From 61a86d806e751001cab9732c800bb1272c06d4dd Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 23 Jul 2026 18:54:32 +0900 Subject: [PATCH] =?UTF-8?q?260722=5FB05=5F=EC=A2=85=EB=8B=A8=20=EC=84=A4?= =?UTF-8?q?=EA=B3=84=20=EB=B0=98=EC=98=81=20=EC=B4=88=EC=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts | 33 ++ B05_wf2_Route/B05_wf2_Route_Engine_Grade.py | 8 +- .../B05_wf2_Route_Engine_Grade_Alignment.py | 469 +++++++++++++++++ .../B05_wf2_Route_Engine_Grade_Profile.py | 244 +++++++++ .../B05_wf2_Route_Engine_Grade_Solver.py | 184 +++++++ .../B05_wf2_Route_Engine_Sections.py | 28 +- B05_wf2_Route/B05_wf2_Route_Repository.py | 33 ++ B05_wf2_Route/B05_wf2_Route_Router.py | 82 +++ B05_wf2_Route/B05_wf2_Route_Schema.py | 37 ++ B05_wf2_Route/B05_wf2_Route_UI_Page.ts | 10 +- .../B05_wf2_Route_UI_Profile_Alignment.ts | 496 ++++++++++++++++++ .../B05_wf2_Route_UI_Profile_Edit.ts | 192 +++++++ .../B05_wf2_Route_UI_Profile_Panel.ts | 324 +++++++++--- .../B05_wf2_Route_UI_Profile_Table.ts | 186 +++++++ B05_wf2_Route/B05_wf2_Route_UI_Style.css | 237 ++++++++- .../B06_wf3_ProfileCross_Api_Fetch.ts | 6 + config/config_system.py | 40 ++ 17 files changed, 2528 insertions(+), 81 deletions(-) create mode 100644 B05_wf2_Route/B05_wf2_Route_Engine_Grade_Alignment.py create mode 100644 B05_wf2_Route/B05_wf2_Route_Engine_Grade_Profile.py create mode 100644 B05_wf2_Route/B05_wf2_Route_UI_Profile_Alignment.ts create mode 100644 B05_wf2_Route/B05_wf2_Route_UI_Profile_Edit.ts create mode 100644 B05_wf2_Route/B05_wf2_Route_UI_Profile_Table.ts diff --git a/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts b/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts index f109e05c..d0ead034 100644 --- a/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts +++ b/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts @@ -187,6 +187,39 @@ export async function updateContourInterval( ); } +/** 종단 계획선 편집 델타 (자동 선형 대비 측점 계획고 델타 + 종단곡선 길이). */ +export interface ProfileAlignmentEdits { + station_offsets: Record; + curve_lengths: Record; +} + +export interface ProfileAlignmentSaveResponse { + status: string; + project_id: string; + route_id: number; + profile_alignment: unknown; + grade_summary: RouteGradeSummary | null; +} + +/** + * 종단 계획선 사용자 편집을 영속화한다. + * 화면은 즉시 계산해 보여주고, 여기서 **편집 델타만** 보내면 서버가 저장된 자동 + * 선형에 다시 얹어 정본(longitudinal.json)을 만든다. + */ +export async function saveProfileAlignment( + projectId: string, + routeId: number, + edits: ProfileAlignmentEdits, +): Promise { + return requestJson( + `/projects/${projectId}/route/profile-alignment`, + { + method: "PUT", + body: JSON.stringify({ route_id: routeId, ...edits }), + }, + ); +} + /** 프로젝트의 최신 경로를 확정한다. */ export async function confirmRoute(projectId: string): Promise { return requestJson(`/projects/${projectId}/route/confirm`, { diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Grade.py b/B05_wf2_Route/B05_wf2_Route_Engine_Grade.py index 356f783d..18270964 100644 --- a/B05_wf2_Route/B05_wf2_Route_Engine_Grade.py +++ b/B05_wf2_Route/B05_wf2_Route_Engine_Grade.py @@ -197,7 +197,7 @@ def resolve_grade_options( ) -def _ground_profile(longitudinal: dict[str, Any]) -> tuple[np.ndarray, np.ndarray]: +def ground_profile(longitudinal: dict[str, Any]) -> tuple[np.ndarray, np.ndarray]: """종단 샘플에서 (chainage, 지반고) 배열을 만든다. 결측은 선형 보간한다.""" samples = longitudinal.get("samples") or [] chainage = np.array([float(s.get("chainage_m", 0.0)) for s in samples], dtype=np.float64) @@ -219,7 +219,7 @@ def _ground_profile(longitudinal: dict[str, Any]) -> tuple[np.ndarray, np.ndarra return chainage, ground -def _detect_main_direction(ground: np.ndarray, rise: float) -> tuple[str, str | None]: +def detect_main_direction(ground: np.ndarray, rise: float) -> tuple[str, str | None]: """지반 종단 형상에서 역기울기 판정 기준이 되는 주 진행방향을 정한다. 시·종점 고도차만으로 부호를 보면 V자(계곡 횡단)·Λ자(능선 통과) 노선에서 @@ -386,7 +386,7 @@ def design_grade_line(longitudinal: dict[str, Any], options: GradeDesignOptions) 반환 dict는 longitudinal.json의 `design_profiles` 배열에 그대로 넣는다. """ options.validate() - chainage, ground = _ground_profile(longitudinal) + chainage, ground = ground_profile(longitudinal) total = float(chainage[-1]) if total <= 0: raise ValueError("종단 연장이 0이어서 계획선을 만들 수 없습니다.") @@ -400,7 +400,7 @@ def design_grade_line(longitudinal: dict[str, Any], options: GradeDesignOptions) rise = fixed[1] - fixed[0] warnings = list(options.warnings) direction, direction_note = ( - _detect_main_direction(ground, rise) + detect_main_direction(ground, rise) if options.main_direction == "auto" else (options.main_direction, None) ) diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Grade_Alignment.py b/B05_wf2_Route/B05_wf2_Route_Engine_Grade_Alignment.py new file mode 100644 index 00000000..a59cefde --- /dev/null +++ b/B05_wf2_Route/B05_wf2_Route_Engine_Grade_Alignment.py @@ -0,0 +1,469 @@ +"""B05 종단 계획선 선형(직선 + 측점 위 종단곡선) 파생 모듈. + +계획선을 "폴리라인 샘플"이 아니라 **변화점(PVI) 구조**로 1차 표현하고, 도면 +테이블(구배·절토고·성토고·계획고·지반고·누가거리·거리·측점·곡선)에 필요한 값을 +전부 여기서 파생시킨다. 폴리라인 샘플은 이 구조에서 만들어지므로 B06/B07은 +기존 `design_profiles[].samples` 계약을 그대로 쓴다. + +기하 규칙 (사용자 확정 사항): + - 변화점은 **기준 측점 위에만** 놓인다 → 곡선 중심이 측점 수직선상에 있다. + - 종단곡선은 변화점 대칭 배치이며 좌우에 직선 구간이 반드시 남는다. + - 곡선 기본 길이 L = 측점간격 × `curve_length_ratio`, 반경 R = L / |A| 로 파생. + +수치 최적화(직선 분할 DP·표고 결정)는 [[B05_wf2_Route_Engine_Grade_Solver]]가, +기준 해석과 오케스트레이션은 [[B05_wf2_Route_Engine_Grade]]가 담당한다. 이 모듈은 +scipy에 의존하지 않는 순수 기하 계산만 두어 프론트엔드 구현과 1:1로 대응시킨다. +""" + +from dataclasses import dataclass +from typing import Any + +import numpy as np + +from config.config_system import FOREST_ROAD_PROFILE_ALIGNMENT + +ALIGNMENT_SCHEMA_VERSION = 1 +# chainage를 dict 키로 쓸 때의 표기. 프론트엔드(`toFixed(3)`)와 반드시 같아야 한다. +_KEY_DECIMALS = 3 + + +def chainage_key(value: float) -> str: + """chainage를 편집 델타 dict의 키 문자열로 바꾼다(프론트와 동일 규칙).""" + return f"{float(value):.{_KEY_DECIMALS}f}" + + +@dataclass(frozen=True) +class AlignmentPolicy: + """계획선 선형·편집 정책. config 기본값 위에 노선별 해석값을 얹는다.""" + + station_interval_m: float + curve_length_ratio: float + curve_length_min_m: float + curve_tangent_max_ratio: float + curve_skip_legal_exception: bool + balance_tolerance_percent: float + pvi_penalty_m2: float + min_segment_stations: int + edit_step_m: float + grade_violation_policy: str + max_grade_pct: float + curve_skip_delta_pct: float + paved: bool + + @classmethod + def from_config( + cls, + *, + station_interval_m: float, + max_grade_pct: float, + curve_skip_delta_pct: float, + paved: bool, + ) -> "AlignmentPolicy": + config = FOREST_ROAD_PROFILE_ALIGNMENT + return cls( + station_interval_m=float(station_interval_m), + curve_length_ratio=float(config["curve_length_ratio"]), + curve_length_min_m=float(config["curve_length_min_m"]), + curve_tangent_max_ratio=float(config["curve_tangent_max_ratio"]), + curve_skip_legal_exception=bool(config["curve_skip_legal_exception"]), + balance_tolerance_percent=float(config["balance_tolerance_percent"]), + pvi_penalty_m2=float(config["pvi_penalty_m2"]), + min_segment_stations=int(config["min_segment_stations"]), + edit_step_m=float(config["edit_step_m"]), + grade_violation_policy=str(config["grade_violation_policy"]), + max_grade_pct=float(max_grade_pct), + curve_skip_delta_pct=float(curve_skip_delta_pct), + paved=bool(paved), + ) + + @classmethod + def from_dict(cls, payload: dict[str, Any]) -> "AlignmentPolicy": + """저장된 정책 스냅샷을 복원한다(편집 저장 시 기준을 흔들지 않기 위함).""" + config = FOREST_ROAD_PROFILE_ALIGNMENT + return cls( + station_interval_m=float(payload.get("station_interval_m") or 20.0), + curve_length_ratio=float( + payload.get("curve_length_ratio", config["curve_length_ratio"]) + ), + curve_length_min_m=float( + payload.get("curve_length_min_m", config["curve_length_min_m"]) + ), + curve_tangent_max_ratio=float( + payload.get("curve_tangent_max_ratio", config["curve_tangent_max_ratio"]) + ), + curve_skip_legal_exception=bool( + payload.get("curve_skip_legal_exception", config["curve_skip_legal_exception"]) + ), + balance_tolerance_percent=float( + payload.get("balance_tolerance_percent", config["balance_tolerance_percent"]) + ), + pvi_penalty_m2=float(config["pvi_penalty_m2"]), + min_segment_stations=int(config["min_segment_stations"]), + edit_step_m=float(payload.get("edit_step_m", config["edit_step_m"])), + grade_violation_policy=str( + payload.get("grade_violation_policy", config["grade_violation_policy"]) + ), + max_grade_pct=float(payload.get("max_grade_pct") or 9.0), + curve_skip_delta_pct=float(payload.get("curve_skip_delta_pct") or 5.0), + paved=bool(payload.get("paved", False)), + ) + + @property + def default_curve_length_m(self) -> float: + """측점간격 기준 기본 종단곡선 길이.""" + return max(self.curve_length_min_m, self.station_interval_m * self.curve_length_ratio) + + def as_dict(self) -> dict[str, Any]: + """프론트엔드가 동일 기하를 재현하는 데 필요한 상수 묶음.""" + return { + "station_interval_m": self.station_interval_m, + "curve_length_ratio": self.curve_length_ratio, + "curve_length_min_m": self.curve_length_min_m, + "curve_tangent_max_ratio": self.curve_tangent_max_ratio, + "curve_skip_legal_exception": self.curve_skip_legal_exception, + "default_curve_length_m": self.default_curve_length_m, + "balance_tolerance_percent": self.balance_tolerance_percent, + "edit_step_m": self.edit_step_m, + "grade_violation_policy": self.grade_violation_policy, + "max_grade_pct": self.max_grade_pct, + "curve_skip_delta_pct": self.curve_skip_delta_pct, + "paved": self.paved, + } + + +def tangent_elevation(pvi_s: np.ndarray, pvi_z: np.ndarray, targets: np.ndarray) -> np.ndarray: + """종단곡선을 빼고 변화점 직선만으로 본 표고(탄젠트 표고).""" + return np.interp(targets, pvi_s, pvi_z) + + +def resolve_pvi( + base_s: np.ndarray, + base_z: np.ndarray, + station_offsets: dict[str, float], +) -> tuple[np.ndarray, np.ndarray, list[str]]: + """자동 변화점에 사용자 편집 측점을 합쳐 최종 변화점 집합을 만든다. + + 편집 델타는 **자동 선형의 탄젠트 표고 기준**으로 해석한다. 이렇게 해야 다른 + 측점을 나중에 건드려도 이미 편집한 측점의 표고가 흔들리지 않고, 키를 지우면 + 자동 선형으로 정확히 돌아온다(원복). + """ + nodes: dict[float, float] = { + round(float(s), _KEY_DECIMALS): float(z) for s, z in zip(base_s, base_z) + } + sources: dict[float, str] = {key: "auto" for key in nodes} + for key, offset in (station_offsets or {}).items(): + try: + chainage = round(float(key), _KEY_DECIMALS) + delta = float(offset) + except (TypeError, ValueError): + continue + if not np.isfinite(chainage) or not np.isfinite(delta): + continue + if chainage < float(base_s[0]) - 1e-6 or chainage > float(base_s[-1]) + 1e-6: + continue + base_value = float(np.interp(chainage, base_s, base_z)) + nodes[chainage] = base_value + delta + sources[chainage] = "user" + ordered = sorted(nodes.items()) + pvi_s = np.array([item[0] for item in ordered], dtype=np.float64) + pvi_z = np.array([item[1] for item in ordered], dtype=np.float64) + return pvi_s, pvi_z, [sources[value] for value in pvi_s.tolist()] + + +def build_curves( + pvi_s: np.ndarray, + pvi_z: np.ndarray, + policy: AlignmentPolicy, + curve_lengths: dict[str, float] | None = None, +) -> tuple[list[dict[str, Any]], list[str]]: + """각 변화점에 대칭 종단곡선을 삽입하고 곡선 제원을 만든다. + + 곡선 반쪽 길이는 짧은 쪽 인접 직선의 `curve_tangent_max_ratio` 이내로 제한해 + 좌우에 직선이 반드시 남게 한다(사용자가 R을 키워도 곡선끼리 겹치지 않는다). + """ + overrides = curve_lengths or {} + spans = pvi_s[1:] - pvi_s[:-1] + grades = (pvi_z[1:] - pvi_z[:-1]) / np.where(spans > 0, spans, 1.0) + curves: list[dict[str, Any]] = [] + warnings: list[str] = [] + skip_delta = policy.curve_skip_delta_pct / 100.0 + for index in range(1, len(pvi_s) - 1): + grade_in, grade_out = float(grades[index - 1]), float(grades[index]) + delta = grade_out - grade_in + chainage = float(pvi_s[index]) + key = chainage_key(chainage) + if abs(delta) < 1e-9: + continue + # 법정 다-(3)-(다)는 "종단곡선을 두지 않을 수 있다"는 허용 조항이다. + # 실무 도면은 대수차가 작아도 변화점을 원곡선으로 처리하므로, 기본은 곡선을 + # 삽입하고 생략 가능 구간이라는 표시만 남긴다(config로 실제 생략 전환 가능). + skip_allowed = not policy.paved and abs(delta) <= skip_delta + 1e-12 + omitted = skip_allowed and policy.curve_skip_legal_exception + requested = overrides.get(key) + try: + length = float(requested) if requested is not None else policy.default_curve_length_m + except (TypeError, ValueError): + length = policy.default_curve_length_m + if not np.isfinite(length) or length <= 0: + length = policy.default_curve_length_m + half_limit = ( + min(float(spans[index - 1]), float(spans[index])) * policy.curve_tangent_max_ratio + ) + half = min(length / 2.0, half_limit) + if half <= 1e-9: + continue + if length / 2.0 - half > 1e-6: + warnings.append( + f"chainage {chainage:.1f}m: 인접 직선이 짧아 종단곡선 길이를 " + f"{half * 2.0:.1f}m로 줄였습니다." + ) + length = half * 2.0 + curves.append( + { + "pvi_index": index, + "chainage_m": chainage, + "elevation_m": float(pvi_z[index]), + "grade_in": grade_in, + "grade_out": grade_out, + "delta_pct": delta * 100.0, + "bvc_m": chainage - half, + "evc_m": chainage + half, + "length_m": length, + "radius_m": length / abs(delta), + # K = 곡선길이 / 기울기 대수차(%) — 도면 주기 표기와 동일 정의 + "k": length / (abs(delta) * 100.0), + # 중앙종거: 변화점에서 탄젠트와 곡선의 수직 거리 (|A|·L/8) + "middle_ordinate_m": abs(delta) * length / 8.0, + # 포물선 삽입으로 직선 폴리라인 대비 발생하는 면적 차 (A·L²/24) + "area_offset_m2": delta * length * length / 24.0, + "omitted": bool(omitted), + "skip_allowed": bool(skip_allowed), + "omit_reason": ( + f"비포장 대수차 {policy.curve_skip_delta_pct:.0f}% 이하 (법정 생략 가능)" + if skip_allowed + else None + ), + } + ) + return curves, warnings + + +def evaluate( + pvi_s: np.ndarray, + pvi_z: np.ndarray, + curves: list[dict[str, Any]], + targets: np.ndarray, +) -> np.ndarray: + """직선 + 종단곡선으로 구성된 계획선을 임의 chainage에서 평가한다.""" + values = np.interp(targets, pvi_s, pvi_z) + for curve in curves: + if curve["omitted"]: + continue + start, end = curve["bvc_m"], curve["evc_m"] + mask = (targets >= start) & (targets <= end) + if not np.any(mask): + continue + local = targets[mask] - start + half = curve["length_m"] / 2.0 + start_z = curve["elevation_m"] - curve["grade_in"] * half + delta = curve["grade_out"] - curve["grade_in"] + values[mask] = ( + start_z + curve["grade_in"] * local + delta / (2.0 * curve["length_m"]) * local * local + ) + return values + + +def _trapezoid_weights(chainage: np.ndarray) -> np.ndarray: + """사다리꼴 적분용 샘플 가중치(ds).""" + weights = np.zeros_like(chainage) + if len(chainage) < 2: + return weights + weights[1:-1] = (chainage[2:] - chainage[:-2]) / 2.0 + weights[0] = (chainage[1] - chainage[0]) / 2.0 + weights[-1] = (chainage[-1] - chainage[-2]) / 2.0 + return weights + + +def _segments(pvi_s: np.ndarray, pvi_z: np.ndarray) -> list[dict[str, Any]]: + """변화점 사이 직선 구간의 연장·고저차·구배(%).""" + rows: list[dict[str, Any]] = [] + for index in range(len(pvi_s) - 1): + length = float(pvi_s[index + 1] - pvi_s[index]) + height = float(pvi_z[index + 1] - pvi_z[index]) + rows.append( + { + "index": index, + "from_m": round(float(pvi_s[index]), 6), + "to_m": round(float(pvi_s[index + 1]), 6), + "length_m": round(length, 6), + "height_m": round(height, 6), + "grade_percent": round(height / length * 100.0 if length > 0 else 0.0, 6), + } + ) + return rows + + +def _station_rows( + stations: list[dict[str, Any]], + pvi_s: np.ndarray, + pvi_z: np.ndarray, + curves: list[dict[str, Any]], + chainage: np.ndarray, + ground: np.ndarray, +) -> list[dict[str, Any]]: + """도면 테이블 한 열에 해당하는 측점별 값 (거리·누가거리·지반고·계획고·절성토고).""" + if not stations: + return [] + targets = np.array([float(item["chainage_m"]) for item in stations], dtype=np.float64) + plan = evaluate(pvi_s, pvi_z, curves, targets) + natural = np.interp(targets, chainage, ground) + rows: list[dict[str, Any]] = [] + for index, station in enumerate(stations): + difference = float(natural[index] - plan[index]) + rows.append( + { + "station_id": station.get("station_id"), + "chainage_m": round(float(targets[index]), 6), + "distance_m": round( + float(targets[index] - targets[index - 1]) if index else 0.0, 6 + ), + "ground_elevation_m": round(float(natural[index]), 6), + "plan_elevation_m": round(float(plan[index]), 6), + "cut_m": round(max(difference, 0.0), 6), + "fill_m": round(max(-difference, 0.0), 6), + } + ) + return rows + + +def build_alignment( + *, + base_s: np.ndarray, + base_z: np.ndarray, + chainage: np.ndarray, + ground: np.ndarray, + stations: list[dict[str, Any]], + policy: AlignmentPolicy, + edits: dict[str, Any] | None = None, +) -> dict[str, Any]: + """자동 변화점 + 사용자 편집으로 계획선 선형 전체를 파생한다.""" + edits = edits or {} + station_offsets = { + str(key): float(value) + for key, value in (edits.get("station_offsets") or {}).items() + if value is not None + } + curve_lengths = { + str(key): float(value) + for key, value in (edits.get("curve_lengths") or {}).items() + if value is not None + } + + pvi_s, pvi_z, sources = resolve_pvi(base_s, base_z, station_offsets) + curves, warnings = build_curves(pvi_s, pvi_z, policy, curve_lengths) + segments = _segments(pvi_s, pvi_z) + plan = evaluate(pvi_s, pvi_z, curves, chainage) + difference = plan - ground + weights = _trapezoid_weights(chainage) + + cut_area = float(weights[difference < 0] @ -difference[difference < 0]) + fill_area = float(weights[difference > 0] @ difference[difference > 0]) + reference = max(cut_area, fill_area) + imbalance = abs(cut_area - fill_area) / reference * 100.0 if reference > 1e-9 else 0.0 + + curve_by_pvi = {curve["pvi_index"]: curve for curve in curves} + pvi_rows: list[dict[str, Any]] = [] + for index in range(len(pvi_s)): + curve = curve_by_pvi.get(index) + pvi_rows.append( + { + "chainage_m": round(float(pvi_s[index]), 6), + "elevation_m": round(float(pvi_z[index]), 6), + "source": sources[index], + "kind": "bp" if index == 0 else "ep" if index == len(pvi_s) - 1 else "pvi", + "grade_in_pct": round(segments[index - 1]["grade_percent"], 6) if index else None, + "grade_out_pct": ( + round(segments[index]["grade_percent"], 6) if index < len(segments) else None + ), + "curve_l_m": round(float(curve["length_m"]), 6) if curve else None, + "curve_r_m": round(float(curve["radius_m"]), 6) if curve else None, + } + ) + + violations = [ + { + "segment_index": segment["index"], + "type": "grade_over", + "value": segment["grade_percent"], + "limit": policy.max_grade_pct, + } + for segment in segments + if abs(segment["grade_percent"]) > policy.max_grade_pct + 1e-6 + ] + if violations: + worst = max(abs(item["value"]) for item in violations) + warnings.append( + f"종단기울기 {worst:.2f}%가 기준 {policy.max_grade_pct:.2f}%를 초과하는 구간이 " + f"{len(violations)}개 있습니다." + ) + within_tolerance = imbalance <= policy.balance_tolerance_percent + 1e-9 + if not within_tolerance: + warnings.append( + f"절·성토 불균형 {imbalance:.1f}%가 허용치 " + f"{policy.balance_tolerance_percent:.1f}%를 초과합니다." + ) + + return { + "schema_version": ALIGNMENT_SCHEMA_VERSION, + "policy": policy.as_dict(), + "base_pvi": [ + {"chainage_m": round(float(s), 6), "elevation_m": round(float(z), 6)} + for s, z in zip(base_s, base_z) + ], + "edits": {"station_offsets": station_offsets, "curve_lengths": curve_lengths}, + "pvi": pvi_rows, + "segments": segments, + "curves": [ + { + "pvi_index": curve["pvi_index"], + "chainage_m": round(float(curve["chainage_m"]), 6), + "bvc_m": round(float(curve["bvc_m"]), 6), + "evc_m": round(float(curve["evc_m"]), 6), + "bvc_elevation_m": round( + float(evaluate(pvi_s, pvi_z, curves, np.array([curve["bvc_m"]]))[0]), 6 + ), + "evc_elevation_m": round( + float(evaluate(pvi_s, pvi_z, curves, np.array([curve["evc_m"]]))[0]), 6 + ), + "l_m": round(float(curve["length_m"]), 6), + "r_m": round(float(curve["radius_m"]), 6), + "k": round(float(curve["k"]), 6), + "delta_pct": round(float(curve["delta_pct"]), 6), + "middle_ordinate_m": round(float(curve["middle_ordinate_m"]), 6), + "omitted": curve["omitted"], + "skip_allowed": curve["skip_allowed"], + "omit_reason": curve["omit_reason"], + } + for curve in curves + ], + "stations": _station_rows(stations, pvi_s, pvi_z, curves, chainage, ground), + "samples": [ + { + "chainage_m": round(float(chainage[index]), 6), + "elevation_m": round(float(plan[index]), 6), + "ground_elevation_m": round(float(ground[index]), 6), + "difference_m": round(float(difference[index]), 6), + } + for index in range(len(chainage)) + ], + "balance": { + "cut_area_m2": round(cut_area, 6), + "fill_area_m2": round(fill_area, 6), + "net_area_m2": round(fill_area - cut_area, 6), + "imbalance_percent": round(imbalance, 6), + "tolerance_percent": policy.balance_tolerance_percent, + "within_tolerance": bool(within_tolerance), + }, + "violations": violations, + "warnings": warnings, + } diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Grade_Profile.py b/B05_wf2_Route/B05_wf2_Route_Engine_Grade_Profile.py new file mode 100644 index 00000000..38b637e1 --- /dev/null +++ b/B05_wf2_Route/B05_wf2_Route_Engine_Grade_Profile.py @@ -0,0 +1,244 @@ +"""B05 종단 계획선 선형 산출 오케스트레이터 (지반 추종 직선 분할 + 편집 재구성). + +[[B05_wf2_Route_Engine_Grade]] 가 확정한 설계 기준과 +[[B05_wf2_Route_Engine_Grade_Solver]] 의 수치 계산, +[[B05_wf2_Route_Engine_Grade_Alignment]] 의 기하 파생을 묶어 +`design_profiles` 배열에 넣을 계획선 한 벌을 만든다. + +두 진입점이 있다. + - `design_alignment_profile()` : 노선 계산 직후. 직선 분할 DP부터 새로 푼다. + - `rebuild_alignment_profile()`: 사용자 편집 확정 시. **저장된 자동 선형(base_pvi)과 + 정책을 그대로 재사용**하고 편집 델타만 다시 얹는다. DP를 다시 돌리면 기준선이 + 흔들려 "원복" 이 원래 위치로 돌아가지 않기 때문이다. +""" + +from collections import Counter +from typing import Any + +import numpy as np + +from B05_wf2_Route.B05_wf2_Route_Engine_Grade import ( + GradeDesignOptions, + detect_main_direction, + ground_profile, +) +from B05_wf2_Route.B05_wf2_Route_Engine_Grade_Alignment import ( + ALIGNMENT_SCHEMA_VERSION, + AlignmentPolicy, + build_alignment, +) +from B05_wf2_Route.B05_wf2_Route_Engine_Grade_Solver import ( + grade_limits, + integration_weights, + solve_alignment_elevations, + station_breakpoints, +) + +ALIGNMENT_PROFILE_ID = "design_grade_line" +ALIGNMENT_BASIS = "station_alignment" + + +def infer_station_interval(stations: list[dict[str, Any]]) -> float: + """측점 목록에서 가장 흔한 간격을 기준 측점간격으로 본다. + + 사용자가 추가한 비기준 측점(+18, +15 등)이 섞여 있어도 최빈값이 기준 간격이다. + """ + gaps: list[float] = [] + for previous, current in zip(stations[:-1], stations[1:]): + gap = round(float(current["chainage_m"]) - float(previous["chainage_m"]), 1) + if gap > 0: + gaps.append(gap) + if not gaps: + return 20.0 + return float(Counter(gaps).most_common(1)[0][0]) + + +def _profile_entry( + alignment: dict[str, Any], + options: GradeDesignOptions, + direction: str, + balanced: bool, + warnings: list[str], +) -> dict[str, Any]: + """`design_profiles` 배열 계약(기존 스키마)에 맞춰 계획선 한 벌을 만든다. + + B06 횡단 계획고와 B07 CAD 계획선 레이어는 `samples`만 사용하므로, 선형 구조가 + 바뀌어도 하류 단계는 그대로 동작한다. + """ + balance = alignment["balance"] + grades = [abs(segment["grade_percent"]) for segment in alignment["segments"]] + return { + "schema_version": ALIGNMENT_SCHEMA_VERSION, + "id": ALIGNMENT_PROFILE_ID, + "name": "계획선", + "basis": ALIGNMENT_BASIS, + "criteria": {**options.as_dict(), "resolved_main_direction": direction}, + # 구 스키마 호환: 변화점 목록을 pvis 이름으로도 노출한다. + "pvis": alignment["pvi"], + "samples": alignment["samples"], + "stations": alignment["stations"], + "balance_segments": [ + { + "index": 0, + "start_chainage_m": alignment["segments"][0]["from_m"] + if alignment["segments"] + else 0.0, + "end_chainage_m": alignment["segments"][-1]["to_m"] + if alignment["segments"] + else 0.0, + "cut_area_m2": balance["cut_area_m2"], + "fill_area_m2": balance["fill_area_m2"], + "balance_error_m2": balance["net_area_m2"], + } + ], + "summary": { + "cut_area_m2": balance["cut_area_m2"], + "fill_area_m2": balance["fill_area_m2"], + "balance_error_m2": balance["net_area_m2"], + "imbalance_percent": balance["imbalance_percent"], + "tolerance_percent": balance["tolerance_percent"], + "max_grade_pct": round(max(grades), 6) if grades else 0.0, + "vertical_curve_count": sum(1 for curve in alignment["curves"] if not curve["omitted"]), + "pvi_count": len(alignment["pvi"]), + "balance_segment_count": 1, + "balanced": bool(balanced and balance["within_tolerance"]), + "main_direction": direction, + "suggested_elevation_offset_m": None, + "edited_station_count": len(alignment["edits"]["station_offsets"]), + "warnings": warnings, + }, + } + + +def design_alignment_profile( + longitudinal: dict[str, Any], + options: GradeDesignOptions, + *, + station_interval_m: float | None = None, + edits: dict[str, Any] | None = None, +) -> tuple[dict[str, Any], dict[str, Any]]: + """지반 종단을 직선 분할로 근사해 계획선 선형을 새로 만든다. + + 반환값은 (선형 구조, `design_profiles` 항목) 이다. + """ + options.validate() + chainage, ground = ground_profile(longitudinal) + total = float(chainage[-1]) + if total <= 0: + raise ValueError("종단 연장이 0이어서 계획선을 만들 수 없습니다.") + + stations = list(longitudinal.get("stations") or []) + interval = float(station_interval_m or 0) or infer_station_interval(stations) + policy = AlignmentPolicy.from_config( + station_interval_m=interval, + max_grade_pct=options.max_grade_pct, + curve_skip_delta_pct=options.vertical_curve_skip_delta_pct, + paved=options.paved, + ) + + warnings = list(options.warnings) + fixed = ( + float(ground[0]) + options.start_elevation_offset_m, + float(ground[-1]) + options.end_elevation_offset_m, + ) + rise = fixed[1] - fixed[0] + direction, note = ( + detect_main_direction(ground, rise) + if options.main_direction == "auto" + else (options.main_direction, None) + ) + if note: + warnings.append(note) + up_limit, down_limit = grade_limits( + options.max_grade_pct, options.max_reverse_grade_pct, direction + ) + limit = up_limit if rise >= 0 else down_limit + if abs(rise) / total > limit + 1e-9: + raise ValueError( + f"시·종점 고도차({rise:.2f}m)를 연장 {total:.1f}m에서 기준 기울기 " + f"{limit * 100:.1f}% 이내로 연결할 수 없습니다." + ) + + station_chainages = np.array( + [float(station["chainage_m"]) for station in stations], dtype=np.float64 + ) + base_s = station_breakpoints( + chainage, + ground, + station_chainages if len(station_chainages) else chainage, + penalty_m2=policy.pvi_penalty_m2, + min_segment_stations=policy.min_segment_stations, + ) + base_z, balanced = solve_alignment_elevations( + base_s, + chainage, + ground, + integration_weights(chainage), + fixed, + up_limit, + down_limit, + balance_tolerance_percent=policy.balance_tolerance_percent, + ) + alignment = build_alignment( + base_s=base_s, + base_z=base_z, + chainage=chainage, + ground=ground, + stations=stations, + policy=policy, + edits=edits, + ) + warnings.extend(alignment["warnings"]) + return alignment, _profile_entry(alignment, options, direction, balanced, warnings) + + +def rebuild_alignment_profile( + longitudinal: dict[str, Any], + edits: dict[str, Any] | None, +) -> tuple[dict[str, Any], dict[str, Any]]: + """저장된 자동 선형을 기준으로 사용자 편집만 다시 얹는다. + + 직선 분할 DP를 다시 돌리지 않으므로 편집 델타의 기준선(base_pvi)이 고정되고, + 편집 키를 지우면 최초 자동 선형으로 정확히 되돌아간다. + """ + stored = longitudinal.get("profile_alignment") + if not isinstance(stored, dict) or not stored.get("base_pvi"): + raise ValueError("저장된 계획선 선형이 없어 편집을 반영할 수 없습니다.") + + chainage, ground = ground_profile(longitudinal) + base = stored["base_pvi"] + base_s = np.array([float(item["chainage_m"]) for item in base], dtype=np.float64) + base_z = np.array([float(item["elevation_m"]) for item in base], dtype=np.float64) + policy = AlignmentPolicy.from_dict(stored.get("policy") or {}) + + alignment = build_alignment( + base_s=base_s, + base_z=base_z, + chainage=chainage, + ground=ground, + stations=list(longitudinal.get("stations") or []), + policy=policy, + edits=edits, + ) + previous = (longitudinal.get("design_profiles") or [{}])[0] + criteria = previous.get("criteria") or {} + options = GradeDesignOptions( + max_grade_pct=float(criteria.get("max_grade_pct") or policy.max_grade_pct), + max_reverse_grade_pct=float(criteria.get("max_reverse_grade_pct") or 5.0), + min_vertical_radius_m=float(criteria.get("min_vertical_radius_m") or 100.0), + min_curve_length_m=float(criteria.get("min_curve_length_m") or 20.0), + min_tangent_length_m=float(criteria.get("min_tangent_length_m") or 20.0), + vertical_curve_skip_delta_pct=policy.curve_skip_delta_pct, + design_speed_kph=int(criteria.get("design_speed_kph") or 20), + terrain_type=str(criteria.get("terrain_type") or "normal"), + paved=policy.paved, + main_direction=str(criteria.get("main_direction") or "auto"), + ) + direction = str(criteria.get("resolved_main_direction") or "none") + return alignment, _profile_entry( + alignment, + options, + direction, + alignment["balance"]["within_tolerance"], + alignment["warnings"], + ) diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Grade_Solver.py b/B05_wf2_Route/B05_wf2_Route_Engine_Grade_Solver.py index 5ca57da1..ae182cfb 100644 --- a/B05_wf2_Route/B05_wf2_Route_Engine_Grade_Solver.py +++ b/B05_wf2_Route/B05_wf2_Route_Engine_Grade_Solver.py @@ -20,6 +20,9 @@ BALANCE_TOLERANCE_M = 1e-6 _SOFT_BALANCE_WEIGHT = 1.0e4 _TRUST_OPTIONS = {"maxiter": 1000, "gtol": 1e-10, "xtol": 1e-12} _SLSQP_OPTIONS = {"maxiter": 500, "ftol": 1e-12} +# 균형 허용치(비율) 수렴용: 면적 상한을 다시 잡는 최대 횟수와 목표 여유 계수 +_BALANCE_TIGHTEN_PASSES = 4 +_BALANCE_TARGET_MARGIN = 0.8 def integration_weights(chainage: np.ndarray) -> np.ndarray: @@ -276,6 +279,187 @@ def optimize_pvi_elevations( return elevations, False +def _regression_prefix(chainage: np.ndarray, ground: np.ndarray) -> dict[str, np.ndarray]: + """구간 잔차제곱합을 O(1)로 구하기 위한 접두합 묶음.""" + zeros = np.zeros(1, dtype=np.float64) + return { + "n": np.concatenate([zeros, np.cumsum(np.ones_like(chainage))]), + "x": np.concatenate([zeros, np.cumsum(chainage)]), + "xx": np.concatenate([zeros, np.cumsum(chainage * chainage)]), + "y": np.concatenate([zeros, np.cumsum(ground)]), + "yy": np.concatenate([zeros, np.cumsum(ground * ground)]), + "xy": np.concatenate([zeros, np.cumsum(chainage * ground)]), + } + + +def _segment_sse(prefix: dict[str, np.ndarray], start: int, end: int) -> float: + """샘플 [start, end] 구간을 직선으로 최소자승 근사했을 때의 잔차제곱합.""" + count = prefix["n"][end + 1] - prefix["n"][start] + if count < 2: + return 0.0 + sum_x = prefix["x"][end + 1] - prefix["x"][start] + sum_y = prefix["y"][end + 1] - prefix["y"][start] + centered_xx = (prefix["xx"][end + 1] - prefix["xx"][start]) - sum_x * sum_x / count + centered_yy = (prefix["yy"][end + 1] - prefix["yy"][start]) - sum_y * sum_y / count + centered_xy = (prefix["xy"][end + 1] - prefix["xy"][start]) - sum_x * sum_y / count + if centered_xx <= 1e-12: + return float(max(centered_yy, 0.0)) + return float(max(centered_yy - centered_xy * centered_xy / centered_xx, 0.0)) + + +def station_breakpoints( + chainage: np.ndarray, + ground: np.ndarray, + stations: np.ndarray, + *, + penalty_m2: float, + min_segment_stations: int, +) -> np.ndarray: + """지반 종단을 최소 개수의 직선으로 근사하는 변화점 chainage를 고른다. + + 변화점 후보를 **기준 측점으로 한정**했기 때문에 동적계획법으로 정확해를 구할 + 수 있다(구간 잔차제곱합이 접두합으로 O(1)이라 전체 O(n²)). 목적함수는 + ``Σ(지반고 − 직선)² + penalty_m2 × 구간 수`` 로, penalty를 올릴수록 직선이 + 길고 적어진다. 반환값은 시·종점을 포함한 변화점 chainage 배열이다. + """ + nodes = np.unique(np.round(stations.astype(np.float64), 6)) + nodes = nodes[(nodes >= chainage[0] - 1e-6) & (nodes <= chainage[-1] + 1e-6)] + nodes = np.unique(np.concatenate([[float(chainage[0])], nodes, [float(chainage[-1])]])) + if len(nodes) < 3: + return nodes + + prefix = _regression_prefix(chainage, ground) + sample_index = np.clip(np.searchsorted(chainage, nodes), 0, len(chainage) - 1) + step = max(1, int(min_segment_stations)) + count = len(nodes) + best = np.full(count, np.inf, dtype=np.float64) + previous = np.zeros(count, dtype=np.int64) + best[0] = 0.0 + for end in range(1, count): + for start in range(0, end - step + 1): + if not np.isfinite(best[start]): + continue + cost = ( + best[start] + + _segment_sse(prefix, int(sample_index[start]), int(sample_index[end])) + + penalty_m2 + ) + if cost < best[end]: + best[end] = cost + previous[end] = start + if not np.isfinite(best[count - 1]): + # 최소 구간 측점 수를 만족하는 분할이 없으면 시·종점 직선 하나로 둔다. + return np.array([nodes[0], nodes[-1]], dtype=np.float64) + picked = [count - 1] + while picked[-1] != 0: + picked.append(int(previous[picked[-1]])) + return nodes[np.array(sorted(picked), dtype=np.int64)] + + +def solve_alignment_elevations( + node_s: np.ndarray, + chainage: np.ndarray, + ground: np.ndarray, + weights: np.ndarray, + fixed: tuple[float, float], + up_limit: float, + down_limit: float, + *, + balance_tolerance_percent: float, +) -> tuple[np.ndarray, bool]: + """변화점 표고를 "지반 추종 우선 + 균형은 허용 오차 이내" 로 결정한다. + + 기존 [[optimize_pvi_elevations]] 는 구역별 절·성토 균형을 **등식 제약**으로 + 강제해 계획선이 지반 형상에서 멀어지곤 했다. 여기서는 지반 추종을 목적으로 + 두고, 균형은 `|절토−성토| / max(절토,성토) ≤ 허용치` 를 만족할 때까지만 + 순 면적을 부등식으로 조인다. 종단기울기 상한은 법정 기준이라 항상 강제한다. + """ + matrix = _interp_matrix(node_s, chainage) + free = np.arange(1, len(node_s) - 1) + base = np.zeros(len(node_s), dtype=np.float64) + base[0], base[-1] = fixed + if not len(free): + return base, True + + matrix_free = matrix[:, free] + offset = matrix @ base - ground + scale = float(weights.sum()) or 1.0 + normalized = weights / scale + + def objective(x: np.ndarray) -> float: + residual = matrix_free @ x + offset + return float(residual @ (normalized * residual)) + + def objective_jac(x: np.ndarray) -> np.ndarray: + residual = matrix_free @ x + offset + return 2.0 * (matrix_free.T @ (normalized * residual)) + + spans = node_s[1:] - node_s[:-1] + difference = np.zeros((len(spans), len(node_s)), dtype=np.float64) + rows = np.arange(len(spans)) + difference[rows, rows] = -1.0 / spans + difference[rows, rows + 1] = 1.0 / spans + difference_free = difference[:, free] + difference_base = difference @ base + grade_constraint = LinearConstraint( + difference_free, -down_limit - difference_base, up_limit - difference_base + ) + + def solve(constraints: list[LinearConstraint], initial: np.ndarray) -> np.ndarray: + try: + result = minimize( + objective, + initial, + jac=objective_jac, + constraints=constraints, + method="SLSQP", + options=_SLSQP_OPTIONS, + ) + except (ValueError, np.linalg.LinAlgError): + return initial + return np.asarray(result.x, dtype=np.float64) + + def imbalance_of(x: np.ndarray) -> tuple[float, float, float]: + elevations = base.copy() + elevations[free] = x + gap = matrix @ elevations - ground + cut = float(weights[gap < 0] @ -gap[gap < 0]) + fill = float(weights[gap > 0] @ gap[gap > 0]) + reference = max(cut, fill) + return cut, fill, (abs(cut - fill) / reference * 100.0 if reference > 1e-9 else 0.0) + + following = np.interp(node_s[free], chainage, ground) + solution = solve([grade_constraint], following) + + # 허용치를 넘으면 순 면적(∫(계획고−지반고)ds)을 조여 다시 푼다. + # 허용치는 |절토−성토| / max(절토,성토) 라는 **비율**이라 면적 상한을 한 번만 + # 잡으면 재계산 후 분모(max)가 줄어들며 비율이 다시 넘칠 수 있다. 분모를 갱신하며 + # 몇 번 조여 들어가고, 더 못 줄이면 그 시점의 최선을 채택한다. + area = _polyline_area_matrix(node_s, float(node_s[0]), float(node_s[-1])) + ground_area = float(weights @ ground) + span = max(float(node_s[-1] - node_s[0]), 1e-9) + _, _, imbalance = imbalance_of(solution) + for _ in range(_BALANCE_TIGHTEN_PASSES): + if imbalance <= balance_tolerance_percent + 1e-9: + break + cut, fill, _ = imbalance_of(solution) + # 목표를 허용치보다 조금 더 조여, 분모가 줄어도 비율이 상한 안에 남게 한다. + tolerance_area = balance_tolerance_percent / 100.0 * max(cut, fill) * _BALANCE_TARGET_MARGIN + balance_constraint = LinearConstraint( + area[free][None, :] / span, + (ground_area - tolerance_area - float(area @ base)) / span, + (ground_area + tolerance_area - float(area @ base)) / span, + ) + candidate = solve([grade_constraint, balance_constraint], solution) + _, _, candidate_imbalance = imbalance_of(candidate) + if candidate_imbalance >= imbalance - 1e-9: + break + solution, imbalance = candidate, candidate_imbalance + elevations = base.copy() + elevations[free] = solution + return elevations, imbalance <= balance_tolerance_percent + 1e-9 + + def build_vertical_curves( pvi_s: np.ndarray, pvi_z: np.ndarray, diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Sections.py b/B05_wf2_Route/B05_wf2_Route_Engine_Sections.py index 67c56cde..0b3d5661 100644 --- a/B05_wf2_Route/B05_wf2_Route_Engine_Sections.py +++ b/B05_wf2_Route/B05_wf2_Route_Engine_Sections.py @@ -12,6 +12,7 @@ from pathlib import Path from typing import Any from B05_wf2_Route.B05_wf2_Route_Engine_Grade import GradeDesignOptions, design_grade_line +from B05_wf2_Route.B05_wf2_Route_Engine_Grade_Profile import design_alignment_profile from B05_wf2_Route.B05_wf2_Route_Engine_Sections_Core import ( SectionGenerationOptions, generate_sections, @@ -78,21 +79,32 @@ def _cross_summary(cross_section: dict[str, Any]) -> dict[str, Any]: def _append_design_profiles( - longitudinal: dict[str, Any], grade_options: GradeDesignOptions | None + longitudinal: dict[str, Any], + grade_options: GradeDesignOptions | None, + station_interval_m: float | None = None, ) -> dict[str, Any] | None: """종단 계획선을 산출해 longitudinal에 붙이고 요약을 반환한다. - 계획선 산출 실패가 종횡단 생성 자체를 무효화하지 않도록 예외를 격리한다. + 1순위는 측점 제약 직선 분할 선형(`profile_alignment`)이며, 여기서 산출된 + 변화점 구조가 사용자 편집의 기준선이 된다. 산출이 실패하면 구 균형 최적화 + 계획선으로 폴백하고(편집 불가), 그마저 실패해도 종횡단 생성 자체는 유지한다. 횡단 설계 기반 계획선을 나중에 추가할 수 있게 배열로 보관한다. """ longitudinal.setdefault("design_profiles", []) if grade_options is None: return None try: - profile = design_grade_line(longitudinal, grade_options) + alignment, profile = design_alignment_profile( + longitudinal, grade_options, station_interval_m=station_interval_m + ) + longitudinal["profile_alignment"] = alignment except (ValueError, KeyError, ArithmeticError): - logger.exception("B05 종단 계획선 산출 실패 (종횡단은 유지)") - return None + logger.exception("B05 계획선 선형 산출 실패 — 균형 최적화 계획선으로 대체") + try: + profile = design_grade_line(longitudinal, grade_options) + except (ValueError, KeyError, ArithmeticError): + logger.exception("B05 종단 계획선 산출 실패 (종횡단은 유지)") + return None longitudinal["design_profiles"].append(profile) return {"id": profile["id"], **profile["summary"]} @@ -135,7 +147,11 @@ def run_section_generation( cross_dir.mkdir(parents=True, exist_ok=True) # 종단면 저장 (계획선은 저장 직전에 종단 데이터에 붙인다) - grade_summary = _append_design_profiles(result["longitudinal"], grade_options) + grade_summary = _append_design_profiles( + result["longitudinal"], + grade_options, + (result.get("options") or {}).get("station_interval_m"), + ) long_file = long_dir / "longitudinal.json" atomic_write_json(long_file, result["longitudinal"]) long_summary = { diff --git a/B05_wf2_Route/B05_wf2_Route_Repository.py b/B05_wf2_Route/B05_wf2_Route_Repository.py index e9692695..061afea0 100644 --- a/B05_wf2_Route/B05_wf2_Route_Repository.py +++ b/B05_wf2_Route/B05_wf2_Route_Repository.py @@ -208,6 +208,39 @@ async def confirm_route(connection: aiomysql.Connection, route_id: int) -> None: ) +async def update_longitudinal_grade_summary( + connection: aiomysql.Connection, + *, + route_id: int, + grade_summary: dict[str, Any] | None, +) -> None: + """계획선 편집 저장 시 longitudinal_sections.data의 grade_summary만 갱신한다. + + 측점·반폭 등 다른 옵션 스냅샷은 그대로 두어야 하므로 data 전체를 덮어쓰지 않고 + 읽어서 해당 키만 바꿔 다시 쓴다(단일 소스 유지). + """ + async with connection.cursor() as cursor: + await cursor.execute( + """ + SELECT id, data FROM longitudinal_sections + WHERE route_id = %s ORDER BY id DESC LIMIT 1 + """, + (route_id,), + ) + row = await cursor.fetchone() + if not row: + return + stored = row[1] + if isinstance(stored, str): + stored = json.loads(stored) + data = stored if isinstance(stored, dict) else {} + data["grade_summary"] = grade_summary + await cursor.execute( + "UPDATE longitudinal_sections SET data = %s WHERE id = %s", + (json.dumps(data, ensure_ascii=False), int(row[0])), + ) + + async def get_surface_crs_epsg( connection: aiomysql.Connection, project_id: UUID, surface_model_id: int ) -> int | None: diff --git a/B05_wf2_Route/B05_wf2_Route_Router.py b/B05_wf2_Route/B05_wf2_Route_Router.py index 56d143a7..b356bc75 100644 --- a/B05_wf2_Route/B05_wf2_Route_Router.py +++ b/B05_wf2_Route/B05_wf2_Route_Router.py @@ -1,6 +1,7 @@ """B05 경로 설계 FastAPI 라우터.""" import asyncio +import json import logging from pathlib import Path from typing import Any @@ -14,6 +15,7 @@ from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_ from B05_wf2_Route.B05_wf2_Route_Debug import log_b05_debug from B05_wf2_Route.B05_wf2_Route_Engine import run_route_design from B05_wf2_Route.B05_wf2_Route_Engine_Grade import GradeDesignOptions, resolve_grade_options +from B05_wf2_Route.B05_wf2_Route_Engine_Grade_Profile import rebuild_alignment_profile from B05_wf2_Route.B05_wf2_Route_Engine_Sections import run_section_generation from B05_wf2_Route.B05_wf2_Route_Engine_Sections_Core import SectionGenerationOptions from B05_wf2_Route.B05_wf2_Route_Repository import ( @@ -24,11 +26,14 @@ from B05_wf2_Route.B05_wf2_Route_Repository import ( get_route_points, get_surface_crs_epsg, insert_route_points, + update_longitudinal_grade_summary, ) from B05_wf2_Route.B05_wf2_Route_Schema import ( GRADE_PERCENT_FIELDS, ContourIntervalUpdateRequest, ContourIntervalUpdateResponse, + ProfileAlignmentSaveRequest, + ProfileAlignmentSaveResponse, RouteConfirmResponse, RouteLatestResponse, RouteSolveRequest, @@ -40,8 +45,10 @@ from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Repository import ( delete_sections_for_route, get_latest_grade_options, get_latest_section_options, + get_longitudinal_section, insert_cross_sections, ) +from common_util.common_util_json import atomic_write_json from common_util.common_util_storage import resolve_stored_project_path from common_util.common_util_surface_confirmation import ( get_surface_confirmation_params, @@ -376,6 +383,81 @@ async def update_contour_interval( ) +def _apply_alignment_edits( + project_root: Path, longitudinal_file_path: str, edits: dict[str, Any] +) -> tuple[dict[str, Any], dict[str, Any]]: + """저장된 종단 JSON에 계획선 편집을 반영해 정본을 다시 쓰고 (선형, 요약)을 반환한다. + + 화면이 즉시 계산해 보여준 값과 같은 기하식을 서버에서도 다시 적용해, 파일에 + 남는 정본이 항상 한 곳(백엔드)에서 만들어지게 한다. + """ + root = project_root.resolve() + path = (root / longitudinal_file_path).resolve() + if root not in path.parents: + raise ValueError("종단 데이터 경로가 프로젝트 밖을 가리킵니다.") + if not path.is_file(): + raise FileNotFoundError("종단 데이터 파일을 찾을 수 없습니다.") + longitudinal = json.loads(path.read_text(encoding="utf-8")) + alignment, profile = rebuild_alignment_profile(longitudinal, edits) + longitudinal["profile_alignment"] = alignment + profiles = longitudinal.get("design_profiles") + if isinstance(profiles, list) and profiles: + profiles[0] = profile + else: + longitudinal["design_profiles"] = [profile] + atomic_write_json(path, longitudinal) + return alignment, {"id": profile["id"], **profile["summary"]} + + +@router.put("/{project_id}/route/profile-alignment", response_model=ProfileAlignmentSaveResponse) +async def save_profile_alignment( + project_id: UUID, request: ProfileAlignmentSaveRequest +) -> ProfileAlignmentSaveResponse | JSONResponse: + """종단 계획선 사용자 편집(측점 계획고·종단곡선)을 영속화한다.""" + pool = get_db_pool() + try: + async with pool.acquire() as connection: + longitudinal = await get_longitudinal_section(connection, project_id, request.route_id) + if not longitudinal: + return JSONResponse( + status_code=404, + content={"status": "error", "message": "저장된 종단 데이터가 없습니다."}, + ) + stored_path = await get_project_storage_relative_path(connection, project_id) + project_root = Path(resolve_stored_project_path(stored_path)) + alignment, grade_summary = await asyncio.to_thread( + _apply_alignment_edits, + project_root, + str(longitudinal["longitudinal_file_path"]), + request.edits(), + ) + await connection.begin() + try: + await update_longitudinal_grade_summary( + connection, route_id=request.route_id, grade_summary=grade_summary + ) + await connection.commit() + except Exception: + await connection.rollback() + raise + return ProfileAlignmentSaveResponse( + project_id=str(project_id), + route_id=request.route_id, + profile_alignment=alignment, + grade_summary=grade_summary, + ) + except FileNotFoundError as exc: + return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)}) + except (OSError, ValueError, json.JSONDecodeError) as exc: + return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)}) + except Exception: + logger.exception("B05 계획선 편집 저장 실패: project_id=%s", project_id) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "계획선 편집 저장 중 오류가 발생했습니다."}, + ) + + @router.get("/{project_id}/route/latest", response_model=RouteLatestResponse) async def read_latest_route(project_id: UUID) -> RouteLatestResponse | JSONResponse: """최신 경로와 DB 렌더 좌표, WF1/WF2 입력 스냅샷을 반환한다.""" diff --git a/B05_wf2_Route/B05_wf2_Route_Schema.py b/B05_wf2_Route/B05_wf2_Route_Schema.py index 8c8d44ec..61103063 100644 --- a/B05_wf2_Route/B05_wf2_Route_Schema.py +++ b/B05_wf2_Route/B05_wf2_Route_Schema.py @@ -169,6 +169,43 @@ class ContourIntervalUpdateResponse(BaseModel): contour_interval_m: float +class ProfileAlignmentSaveRequest(BaseModel): + """종단 계획선 사용자 편집 저장 요청. + + 화면은 편집 결과를 즉시 계산해 보여주고, 확정 시점에 **편집 델타만** 보낸다. + 서버가 저장된 자동 선형(base_pvi)에 델타를 다시 얹어 정본을 만들기 때문에 + 표고 전체를 주고받지 않아도 되고, 키를 지우면 자동 선형으로 원복된다. + """ + + model_config = ConfigDict(extra="forbid") + + route_id: int = Field(gt=0) + # {chainage 문자열(소수 3자리): 자동 선형 대비 계획고 델타(m)} + station_offsets: dict[str, float] = Field(default_factory=dict) + # {chainage 문자열(소수 3자리): 종단곡선 길이(m)}. 화면의 R 입력에서 역산된 값. + curve_lengths: dict[str, float] = Field(default_factory=dict) + + @model_validator(mode="after") + def validate_edits(self) -> "ProfileAlignmentSaveRequest": + for chainage, length in self.curve_lengths.items(): + if length <= 0: + raise ValueError(f"종단곡선 길이는 0보다 커야 합니다 (chainage {chainage}).") + return self + + def edits(self) -> dict[str, Any]: + return {"station_offsets": self.station_offsets, "curve_lengths": self.curve_lengths} + + +class ProfileAlignmentSaveResponse(BaseModel): + """계획선 편집 저장 결과 (재계산된 정본 선형을 그대로 돌려준다).""" + + status: str = "success" + project_id: str + route_id: int + profile_alignment: dict[str, Any] + grade_summary: dict[str, Any] | None = None + + class RouteSolveResponse(BaseModel): """경로 탐색 실행 결과.""" diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Page.ts b/B05_wf2_Route/B05_wf2_Route_UI_Page.ts index 60005129..1b118c2d 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Page.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Page.ts @@ -107,7 +107,7 @@ export async function renderB05Route(root: HTMLElement): Promise { const activeProjectId: string = projectId; const viewer = createRouteViewer(); - const profilePanel = createRouteProfilePanel((stationId) => + const profilePanel = createRouteProfilePanel(activeProjectId, (stationId) => viewer.markers.selectStation(stationId), ); let confirmedSurface: SurfaceModelSummary | null = null; @@ -186,15 +186,15 @@ export async function renderB05Route(root: HTMLElement): Promise { ); } - function renderSections(detail: SectionDetailResponse): void { + function renderSections(detail: SectionDetailResponse, routeId?: number): void { currentSectionDetail = detail; - profilePanel.render(detail, panel.values().stationInterval ?? undefined); + profilePanel.render(detail, panel.values().stationInterval ?? undefined, routeId); renderStationLines(detail); } async function restoreSections(routeId: number): Promise { try { - renderSections(await fetchSectionDetail(activeProjectId, routeId)); + renderSections(await fetchSectionDetail(activeProjectId, routeId), routeId); } catch { currentSectionDetail = null; profilePanel.clear(); @@ -302,6 +302,8 @@ export async function renderB05Route(root: HTMLElement): Promise { if (!routeReady || stale) return; showLoadingOverlay(); try { + // 종단 계획선 편집은 화면에서만 계산해 두었으므로 확정 직전에 영속화한다. + await profilePanel.save(); await confirmRoute(activeProjectId); renderLatest(await fetchLatestRoute(activeProjectId)); showToast("경로를 확정했습니다.", "success"); diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Profile_Alignment.ts b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Alignment.ts new file mode 100644 index 00000000..d520a5ac --- /dev/null +++ b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Alignment.ts @@ -0,0 +1,496 @@ +/* ============================================================================= + * B05_wf2_Route_UI_Profile_Alignment.ts + * 종단 계획선 선형(직선 + 측점 위 종단곡선) 프론트엔드 기하 계산. + * + * 백엔드 `B05_wf2_Route_Engine_Grade_Alignment.py`와 **같은 식**을 쓴다. 사용자가 + * 0.1m 버튼을 누를 때마다 서버를 오가지 않고 즉시 다시 그리기 위한 계산부이며, + * 확정 시점에는 편집 델타만 서버로 보내 백엔드가 정본을 다시 만든다. + * + * 규칙: + * - 변화점(PVI)은 기준 측점 위에만 놓인다 → 곡선 중심이 측점 수직선상에 있다. + * - 종단곡선은 변화점 대칭이며 좌우에 직선이 반드시 남는다. + * - 편집 델타는 항상 **자동 선형(base_pvi) 기준**이라 키를 지우면 정확히 원복된다. + * ========================================================================== */ + +export interface AlignmentPolicy { + station_interval_m: number; + curve_length_ratio: number; + curve_length_min_m: number; + curve_tangent_max_ratio: number; + curve_skip_legal_exception: boolean; + default_curve_length_m: number; + balance_tolerance_percent: number; + edit_step_m: number; + grade_violation_policy: string; + max_grade_pct: number; + curve_skip_delta_pct: number; + paved: boolean; +} + +export interface AlignmentNode { + chainage_m: number; + elevation_m: number; +} + +export interface AlignmentPvi extends AlignmentNode { + source: "auto" | "user"; + kind: string; + grade_in_pct: number | null; + grade_out_pct: number | null; + curve_l_m: number | null; + curve_r_m: number | null; +} + +export interface AlignmentSegment { + index: number; + from_m: number; + to_m: number; + length_m: number; + height_m: number; + grade_percent: number; +} + +export interface AlignmentCurve { + pvi_index: number; + chainage_m: number; + bvc_m: number; + evc_m: number; + bvc_elevation_m: number; + evc_elevation_m: number; + l_m: number; + r_m: number; + k: number; + delta_pct: number; + middle_ordinate_m: number; + omitted: boolean; + skip_allowed: boolean; + omit_reason: string | null; +} + +export interface AlignmentStationRow { + station_id: string | null; + chainage_m: number; + distance_m: number; + ground_elevation_m: number; + plan_elevation_m: number; + cut_m: number; + fill_m: number; +} + +export interface AlignmentSample { + chainage_m: number; + elevation_m: number; + ground_elevation_m: number; + difference_m: number; +} + +export interface AlignmentBalance { + cut_area_m2: number; + fill_area_m2: number; + net_area_m2: number; + imbalance_percent: number; + tolerance_percent: number; + within_tolerance: boolean; +} + +export interface AlignmentViolation { + segment_index: number; + type: string; + value: number; + limit: number; +} + +export interface AlignmentEdits { + station_offsets: Record; + curve_lengths: Record; +} + +export interface ProfileAlignment { + schema_version: number; + policy: AlignmentPolicy; + base_pvi: AlignmentNode[]; + edits: AlignmentEdits; + pvi: AlignmentPvi[]; + segments: AlignmentSegment[]; + curves: AlignmentCurve[]; + stations: AlignmentStationRow[]; + samples: AlignmentSample[]; + balance: AlignmentBalance; + violations: AlignmentViolation[]; + warnings: string[]; +} + +/** 자동 선형과 지반 종단 — 편집을 얹기 위한 고정 입력. */ +export interface AlignmentBase { + policy: AlignmentPolicy; + basePvi: AlignmentNode[]; + chainage: number[]; + ground: number[]; + stations: Array<{ station_id: string | null; chainage_m: number }>; +} + +/** chainage를 편집 델타 dict의 키로 바꾼다 (백엔드 `chainage_key`와 동일 규칙). */ +export function chainageKey(value: number): string { + return value.toFixed(3); +} + +export function emptyEdits(): AlignmentEdits { + return { station_offsets: {}, curve_lengths: {} }; +} + +/** 오름차순 x 배열 위에서의 선형 보간 (범위 밖은 양 끝값으로 클램프). */ +function interpolate(xs: number[], ys: number[], value: number): number { + if (!xs.length) return 0; + if (value <= xs[0]) return ys[0]; + if (value >= xs[xs.length - 1]) return ys[ys.length - 1]; + let low = 0; + let high = xs.length - 1; + while (high - low > 1) { + const mid = (low + high) >> 1; + if (xs[mid] <= value) low = mid; + else high = mid; + } + const span = xs[high] - xs[low]; + if (span <= 0) return ys[high]; + return ys[low] + ((ys[high] - ys[low]) * (value - xs[low])) / span; +} + +export function toAlignmentBase(alignment: ProfileAlignment): AlignmentBase { + return { + policy: alignment.policy, + basePvi: alignment.base_pvi.map((node) => ({ ...node })), + chainage: alignment.samples.map((sample) => sample.chainage_m), + ground: alignment.samples.map((sample) => sample.ground_elevation_m), + stations: alignment.stations.map((station) => ({ + station_id: station.station_id, + chainage_m: station.chainage_m, + })), + }; +} + +/** 자동 변화점에 사용자 편집 측점을 합쳐 최종 변화점 집합을 만든다. */ +function resolvePvi( + base: AlignmentBase, + offsets: Record, +): Array { + const baseS = base.basePvi.map((node) => node.chainage_m); + const baseZ = base.basePvi.map((node) => node.elevation_m); + const nodes = new Map(); + base.basePvi.forEach((node) => { + nodes.set(Number(node.chainage_m.toFixed(3)), { ...node, source: "auto" }); + }); + Object.entries(offsets).forEach(([key, offset]) => { + const chainage = Number(Number(key).toFixed(3)); + if (!Number.isFinite(chainage) || !Number.isFinite(offset)) return; + if (chainage < baseS[0] - 1e-6 || chainage > baseS[baseS.length - 1] + 1e-6) return; + nodes.set(chainage, { + chainage_m: chainage, + elevation_m: interpolate(baseS, baseZ, chainage) + offset, + source: "user", + }); + }); + return [...nodes.values()].sort((a, b) => a.chainage_m - b.chainage_m); +} + +interface WorkingCurve extends AlignmentCurve { + grade_in: number; + grade_out: number; +} + +/** 각 변화점에 대칭 종단곡선을 삽입한다 (좌우 직선이 남도록 반쪽 길이를 제한). */ +function buildCurves( + pvi: AlignmentNode[], + policy: AlignmentPolicy, + curveLengths: Record, + warnings: string[], +): WorkingCurve[] { + const curves: WorkingCurve[] = []; + const skipDelta = policy.curve_skip_delta_pct / 100; + for (let index = 1; index < pvi.length - 1; index += 1) { + const spanLeft = pvi[index].chainage_m - pvi[index - 1].chainage_m; + const spanRight = pvi[index + 1].chainage_m - pvi[index].chainage_m; + if (spanLeft <= 0 || spanRight <= 0) continue; + const gradeIn = (pvi[index].elevation_m - pvi[index - 1].elevation_m) / spanLeft; + const gradeOut = (pvi[index + 1].elevation_m - pvi[index].elevation_m) / spanRight; + const delta = gradeOut - gradeIn; + if (Math.abs(delta) < 1e-9) continue; + const chainage = pvi[index].chainage_m; + const key = chainageKey(chainage); + const skipAllowed = !policy.paved && Math.abs(delta) <= skipDelta + 1e-12; + const requested = curveLengths[key]; + const desired = + Number.isFinite(requested) && requested > 0 ? requested : policy.default_curve_length_m; + const halfLimit = Math.min(spanLeft, spanRight) * policy.curve_tangent_max_ratio; + const half = Math.min(desired / 2, halfLimit); + if (half <= 1e-9) continue; + if (desired / 2 - half > 1e-6) { + warnings.push( + `${chainage.toFixed(1)}m: 인접 직선이 짧아 종단곡선 길이를 ${(half * 2).toFixed(1)}m로 줄였습니다.`, + ); + } + const length = half * 2; + curves.push({ + pvi_index: index, + chainage_m: chainage, + bvc_m: chainage - half, + evc_m: chainage + half, + bvc_elevation_m: 0, + evc_elevation_m: 0, + l_m: length, + r_m: length / Math.abs(delta), + k: length / (Math.abs(delta) * 100), + delta_pct: delta * 100, + middle_ordinate_m: (Math.abs(delta) * length) / 8, + omitted: skipAllowed && policy.curve_skip_legal_exception, + skip_allowed: skipAllowed, + omit_reason: skipAllowed + ? `비포장 대수차 ${policy.curve_skip_delta_pct.toFixed(0)}% 이하 (법정 생략 가능)` + : null, + grade_in: gradeIn, + grade_out: gradeOut, + }); + } + return curves; +} + +/** 직선 + 종단곡선으로 구성된 계획선을 임의 chainage에서 평가한다. */ +function evaluateAt( + pviS: number[], + pviZ: number[], + curves: WorkingCurve[], + chainage: number, +): number { + for (const curve of curves) { + if (curve.omitted) continue; + if (chainage < curve.bvc_m || chainage > curve.evc_m) continue; + const half = curve.l_m / 2; + const local = chainage - curve.bvc_m; + const startZ = pviZ[curve.pvi_index] - curve.grade_in * half; + const delta = curve.grade_out - curve.grade_in; + return startZ + curve.grade_in * local + (delta / (2 * curve.l_m)) * local * local; + } + return interpolate(pviS, pviZ, chainage); +} + +/** 사다리꼴 적분 가중치(ds). */ +function trapezoidWeights(chainage: number[]): number[] { + const weights = new Array(chainage.length).fill(0); + if (chainage.length < 2) return weights; + for (let index = 1; index < chainage.length - 1; index += 1) { + weights[index] = (chainage[index + 1] - chainage[index - 1]) / 2; + } + weights[0] = (chainage[1] - chainage[0]) / 2; + weights[chainage.length - 1] = + (chainage[chainage.length - 1] - chainage[chainage.length - 2]) / 2; + return weights; +} + +export function buildAlignment(base: AlignmentBase, edits: AlignmentEdits): ProfileAlignment { + const warnings: string[] = []; + const nodes = resolvePvi(base, edits.station_offsets); + const pviS = nodes.map((node) => node.chainage_m); + const pviZ = nodes.map((node) => node.elevation_m); + const curves = buildCurves(nodes, base.policy, edits.curve_lengths, warnings); + + const segments: AlignmentSegment[] = []; + for (let index = 0; index < nodes.length - 1; index += 1) { + const length = pviS[index + 1] - pviS[index]; + const height = pviZ[index + 1] - pviZ[index]; + segments.push({ + index, + from_m: pviS[index], + to_m: pviS[index + 1], + length_m: length, + height_m: height, + grade_percent: length > 0 ? (height / length) * 100 : 0, + }); + } + curves.forEach((curve) => { + curve.bvc_elevation_m = evaluateAt(pviS, pviZ, curves, curve.bvc_m); + curve.evc_elevation_m = evaluateAt(pviS, pviZ, curves, curve.evc_m); + }); + + const samples: AlignmentSample[] = base.chainage.map((chainage, index) => { + const plan = evaluateAt(pviS, pviZ, curves, chainage); + return { + chainage_m: chainage, + elevation_m: plan, + ground_elevation_m: base.ground[index], + difference_m: plan - base.ground[index], + }; + }); + + const weights = trapezoidWeights(base.chainage); + let cutArea = 0; + let fillArea = 0; + samples.forEach((sample, index) => { + if (sample.difference_m < 0) cutArea += weights[index] * -sample.difference_m; + else fillArea += weights[index] * sample.difference_m; + }); + const reference = Math.max(cutArea, fillArea); + const imbalance = reference > 1e-9 ? (Math.abs(cutArea - fillArea) / reference) * 100 : 0; + + const stations: AlignmentStationRow[] = base.stations.map((station, index) => { + const plan = evaluateAt(pviS, pviZ, curves, station.chainage_m); + const ground = interpolate(base.chainage, base.ground, station.chainage_m); + return { + station_id: station.station_id, + chainage_m: station.chainage_m, + distance_m: index ? station.chainage_m - base.stations[index - 1].chainage_m : 0, + ground_elevation_m: ground, + plan_elevation_m: plan, + cut_m: Math.max(ground - plan, 0), + fill_m: Math.max(plan - ground, 0), + }; + }); + + const curveByPvi = new Map(curves.map((curve) => [curve.pvi_index, curve])); + const pviRows: AlignmentPvi[] = nodes.map((node, index) => { + const curve = curveByPvi.get(index); + return { + chainage_m: node.chainage_m, + elevation_m: node.elevation_m, + source: node.source, + kind: index === 0 ? "bp" : index === nodes.length - 1 ? "ep" : "pvi", + grade_in_pct: index ? segments[index - 1].grade_percent : null, + grade_out_pct: index < segments.length ? segments[index].grade_percent : null, + curve_l_m: curve ? curve.l_m : null, + curve_r_m: curve ? curve.r_m : null, + }; + }); + + const violations: AlignmentViolation[] = segments + .filter((segment) => Math.abs(segment.grade_percent) > base.policy.max_grade_pct + 1e-6) + .map((segment) => ({ + segment_index: segment.index, + type: "grade_over", + value: segment.grade_percent, + limit: base.policy.max_grade_pct, + })); + const withinTolerance = imbalance <= base.policy.balance_tolerance_percent + 1e-9; + + return { + schema_version: 1, + policy: base.policy, + base_pvi: base.basePvi, + edits, + pvi: pviRows, + segments, + curves: curves.map(({ grade_in: _in, grade_out: _out, ...rest }) => rest), + stations, + samples, + balance: { + cut_area_m2: cutArea, + fill_area_m2: fillArea, + net_area_m2: fillArea - cutArea, + imbalance_percent: imbalance, + tolerance_percent: base.policy.balance_tolerance_percent, + within_tolerance: withinTolerance, + }, + violations, + warnings, + }; +} + +/** + * 계획선 위 지점의 계획고. + * 측점 위라면 곡선까지 반영해 정확히 계산된 측점 행 값을 쓰고, 그 밖에서는 + * 샘플 보간으로 폴백한다(편집 판정은 항상 측점 위에서 일어난다). + */ +export function planElevationAt(alignment: ProfileAlignment, chainageM: number): number { + const station = alignment.stations.find((row) => Math.abs(row.chainage_m - chainageM) < 1e-6); + if (station) return station.plan_elevation_m; + return interpolate( + alignment.samples.map((sample) => sample.chainage_m), + alignment.samples.map((sample) => sample.elevation_m), + chainageM, + ); +} + +const FEEDBACK_PASSES = 4; +const FEEDBACK_TOLERANCE_M = 1e-4; + +/** + * 측점 계획고를 delta만큼 올리거나 내린 편집 델타를 만든다. + * + * 변화점이 새로 생기면 종단곡선의 중앙종거만큼 계획고가 함께 내려가(또는 올라가) + * 버튼 1클릭이 정확히 0.1m가 되지 않는다. **화면에 보이는 계획고**가 정확히 delta만큼 + * 움직이도록 중앙종거 변화분을 몇 번 되먹여 보정한다. + */ +export function adjustStation( + base: AlignmentBase, + edits: AlignmentEdits, + chainageM: number, + delta: number, +): AlignmentEdits { + const key = chainageKey(chainageM); + const current = buildAlignment(base, edits); + const target = planElevationAt(current, chainageM) + delta; + const baseElevation = interpolate( + base.basePvi.map((node) => node.chainage_m), + base.basePvi.map((node) => node.elevation_m), + chainageM, + ); + let offset = + (edits.station_offsets[key] ?? planElevationAt(current, chainageM) - baseElevation) + delta; + const withOffset = (value: number): AlignmentEdits => ({ + ...edits, + station_offsets: { ...edits.station_offsets, [key]: Number(value.toFixed(6)) }, + }); + let next = withOffset(offset); + for (let pass = 0; pass < FEEDBACK_PASSES; pass += 1) { + const error = target - planElevationAt(buildAlignment(base, next), chainageM); + if (Math.abs(error) < FEEDBACK_TOLERANCE_M) break; + offset += error; + next = withOffset(offset); + } + return next; +} + +/** + * 직선 구간 전체를 평행이동한다 (기울기 유지, 양 끝 변화점 동시 이동). + * + * 측점 버튼은 그 점을 꺾는 조작이라 "구배는 그대로 두고 높이만" 옮길 수 없다. + * 구간 양 끝에 같은 델타를 주면 그 직선의 기울기는 보존되고 인접 직선의 각도만 바뀐다. + */ +export function shiftSegment( + base: AlignmentBase, + edits: AlignmentEdits, + segment: AlignmentSegment, + delta: number, +): AlignmentEdits { + const baseS = base.basePvi.map((node) => node.chainage_m); + const baseZ = base.basePvi.map((node) => node.elevation_m); + const current = buildAlignment(base, edits); + const offsets = { ...edits.station_offsets }; + [segment.from_m, segment.to_m].forEach((chainage) => { + const key = chainageKey(chainage); + const existing = + offsets[key] ?? planElevationAt(current, chainage) - interpolate(baseS, baseZ, chainage); + offsets[key] = Number((existing + delta).toFixed(6)); + }); + return { ...edits, station_offsets: offsets }; +} + +/** 곡선 행에서 R을 고치면 L = R × |A| 로 역산해 곡선 길이 override로 저장한다. */ +export function setCurveRadius( + edits: AlignmentEdits, + curve: AlignmentCurve, + radiusM: number, +): AlignmentEdits { + const key = chainageKey(curve.chainage_m); + const deltaRatio = Math.abs(curve.delta_pct) / 100; + const curveLengths = { ...edits.curve_lengths }; + if (!Number.isFinite(radiusM) || radiusM <= 0 || deltaRatio < 1e-9) { + delete curveLengths[key]; + } else { + curveLengths[key] = Number((radiusM * deltaRatio).toFixed(6)); + } + return { ...edits, curve_lengths: curveLengths }; +} + +export function hasEdits(edits: AlignmentEdits): boolean { + return ( + Object.keys(edits.station_offsets).length > 0 || Object.keys(edits.curve_lengths).length > 0 + ); +} diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Profile_Edit.ts b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Edit.ts new file mode 100644 index 00000000..05b3d88f --- /dev/null +++ b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Edit.ts @@ -0,0 +1,192 @@ +/* ============================================================================= + * B05_wf2_Route_UI_Profile_Edit.ts + * 종단 계획선 편집 상태 보관과 그래프 위 숨김 버튼 오버레이. + * + * 편집 델타는 세션 저장소에 초안으로 남겨 새로고침해도 작업이 날아가지 않게 하고, + * 실제 영속화는 [확정] 시점에 서버로 보낸다. + * + * 버튼 구성 (평시 투명, 패널 hover 시 노출): + * - 측점 ▲ / ▼ : 그 측점을 변화점으로 승격시켜 계획고를 ±step 만큼 꺾는다. + * - 구간 ⇧ / ⇩ : 직선 구간 전체를 평행이동한다(구배 유지, 양 끝 변화점 동시 이동). + * - 원복 ↺ : 그 측점의 편집 델타만 지워 자동 선형으로 되돌린다. + * ========================================================================== */ + +import type { + AlignmentEdits, + AlignmentSegment, + ProfileAlignment, +} from "./B05_wf2_Route_UI_Profile_Alignment"; +import { chainageKey, emptyEdits, hasEdits } from "./B05_wf2_Route_UI_Profile_Alignment"; + +const DRAFT_KEY_PREFIX = "b05-profile-alignment-draft"; + +export interface ProfileEditStore { + edits(): AlignmentEdits; + replace(next: AlignmentEdits): void; + /** 서버 저장이 끝났음을 표시한다. 편집 델타는 그대로 두고 초안만 지운다. */ + markSaved(): void; + resetStation(chainageM: number): void; + resetAll(): void; + /** 아직 서버에 반영되지 않은 변경이 있는가. */ + dirty(): boolean; + /** 자동 선형 대비 편집 델타가 하나라도 있는가. */ + edited(): boolean; +} + +function readDraft(storageKey: string): AlignmentEdits | null { + try { + const raw = sessionStorage.getItem(storageKey); + if (!raw) return null; + const parsed = JSON.parse(raw) as Partial; + return { + station_offsets: parsed.station_offsets ?? {}, + curve_lengths: parsed.curve_lengths ?? {}, + }; + } catch { + return null; + } +} + +/** + * 편집 델타 보관소. + * + * 초기값은 **서버에 저장된 편집분**이고, 세션 초안이 남아 있으면(= 확정 없이 + * 새로고침한 경우) 초안을 우선 채택하고 미저장 상태로 표시한다. routeId가 바뀌면 + * 초안 키도 바뀌어 이전 노선의 편집이 새 노선에 잘못 얹히지 않는다. + */ +export function createProfileEditStore( + routeId: number | null, + saved: AlignmentEdits, + onChange: () => void, +): ProfileEditStore { + const storageKey = `${DRAFT_KEY_PREFIX}:${routeId ?? "none"}`; + const draft = readDraft(storageKey); + let current = draft ?? saved; + let unsaved = draft !== null; + + function dropDraft(): void { + try { + sessionStorage.removeItem(storageKey); + } catch { + // 세션 저장소를 못 쓰는 환경에서도 편집 자체는 계속 동작해야 한다. + } + } + + function commit(next: AlignmentEdits): void { + current = next; + unsaved = true; + try { + sessionStorage.setItem(storageKey, JSON.stringify(current)); + } catch { + // 초안 보관 실패는 편집을 막지 않는다. + } + onChange(); + } + + return { + edits: () => current, + replace: commit, + markSaved() { + unsaved = false; + dropDraft(); + }, + resetStation(chainageM) { + const key = chainageKey(chainageM); + const stationOffsets = { ...current.station_offsets }; + const curveLengths = { ...current.curve_lengths }; + delete stationOffsets[key]; + delete curveLengths[key]; + commit({ station_offsets: stationOffsets, curve_lengths: curveLengths }); + }, + resetAll() { + commit(emptyEdits()); + }, + dirty: () => unsaved, + edited: () => hasEdits(current), + }; +} + +export interface EditOverlayOptions { + alignment: ProfileAlignment; + width: number; + x: (chainageM: number) => number; + step: number; + onStation: (chainageM: number, delta: number) => void; + onSegment: (segment: AlignmentSegment, delta: number) => void; + onResetStation: (chainageM: number) => void; +} + +function overlayButton( + className: string, + glyph: string, + title: string, + onClick: () => void, +): HTMLButtonElement { + const button = document.createElement("button"); + button.type = "button"; + button.className = `b05-profile-edit__btn ${className}`; + button.textContent = glyph; + button.title = title; + button.addEventListener("click", (event) => { + event.stopPropagation(); + onClick(); + }); + return button; +} + +/** 그래프 영역 위에 겹치는 편집 버튼 층을 만든다 (선 자체는 가리지 않는다). */ +export function createEditOverlay(options: EditOverlayOptions): HTMLElement { + const { alignment, width, x, step, onStation, onSegment, onResetStation } = options; + const layer = document.createElement("div"); + layer.className = "b05-profile-edit"; + layer.style.width = `${width}px`; + + const edited = new Set(Object.keys(alignment.edits.station_offsets)); + alignment.stations.forEach((station) => { + const left = x(station.chainage_m); + const isEdited = edited.has(chainageKey(station.chainage_m)); + const label = `${station.chainage_m.toFixed(1)}m 계획고 ${station.plan_elevation_m.toFixed(2)}m`; + + const up = overlayButton("is-station is-up", "▲", `${label} — ${step}m 올림`, () => + onStation(station.chainage_m, step), + ); + up.style.left = `${left - 9}px`; + const down = overlayButton("is-station is-down", "▼", `${label} — ${step}m 내림`, () => + onStation(station.chainage_m, -step), + ); + down.style.left = `${left - 9}px`; + layer.append(up, down); + + if (!isEdited) return; + const offset = alignment.edits.station_offsets[chainageKey(station.chainage_m)]; + const reset = overlayButton( + "is-reset", + "↺", + `${label} — 자동 선형으로 원복 (현재 ${offset >= 0 ? "+" : ""}${offset.toFixed(2)}m)`, + () => onResetStation(station.chainage_m), + ); + reset.style.left = `${left - 9}px`; + layer.append(reset); + }); + + alignment.segments.forEach((segment) => { + const left = x(segment.from_m); + const right = x(segment.to_m); + if (right - left < 36) return; + const center = (left + right) / 2; + const label = + `구간 ${segment.from_m.toFixed(0)}~${segment.to_m.toFixed(0)}m ` + + `(구배 ${segment.grade_percent.toFixed(2)}%) 전체 평행이동`; + const up = overlayButton("is-segment is-up", "⇧", `${label} — ${step}m 올림`, () => + onSegment(segment, step), + ); + up.style.left = `${center - 9}px`; + const down = overlayButton("is-segment is-down", "⇩", `${label} — ${step}m 내림`, () => + onSegment(segment, -step), + ); + down.style.left = `${center - 9}px`; + layer.append(up, down); + }); + + return layer; +} diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts index 20c490cc..f2be583a 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts @@ -1,4 +1,16 @@ +/* ============================================================================= + * B05_wf2_Route_UI_Profile_Panel.ts + * 하단 종단면도 패널 — 그래프 + 도면 테이블 2단, 계획선 직접 편집. + * + * 화면 높이의 40%를 쓰며, 그래프와 9행 도면 테이블이 **하나의 가로 스크롤러** 안에 + * 같은 폭으로 쌓여 X축이 자동으로 맞물린다(스크롤 동기화 코드 불필요). + * + * 편집은 전부 프론트에서 즉시 계산해 다시 그리고, 영속화는 [확정] 시점에 + * `saveProfileAlignment()`로 편집 델타만 보낸다. + * ========================================================================== */ + import type { + DesignProfile, LongitudinalSection, SectionDetailResponse, } from "../B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch"; @@ -6,49 +18,74 @@ import { createLongitudinalProfile, longitudinalMinimumWidth, } from "../B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Longitudinal"; +import { LONG_PAD } from "../B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_Common"; import { createWorkflowPanelHandle } from "@ui/ui_template_overlay"; +import { showToast } from "@ui/ui_template_elements"; +import { saveProfileAlignment } from "./B05_wf2_Route_Api_Fetch"; +import type { + AlignmentBase, + AlignmentEdits, + ProfileAlignment, +} from "./B05_wf2_Route_UI_Profile_Alignment"; +import { + adjustStation, + buildAlignment, + emptyEdits, + setCurveRadius, + shiftSegment, + toAlignmentBase, +} from "./B05_wf2_Route_UI_Profile_Alignment"; +import { createEditOverlay, createProfileEditStore } from "./B05_wf2_Route_UI_Profile_Edit"; +import { createProfileTable } from "./B05_wf2_Route_UI_Profile_Table"; import "../B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style.css"; const COLLAPSED_KEY = "b05-route-profile-collapsed"; const HORIZONTAL_SCROLLBAR_HEIGHT = 16; -const BALANCE_BAR_HEIGHT = 24; +const MIN_CHART_HEIGHT = 120; -/** 계획선 절·성토 균형 결과를 종단도 위에 한 줄 요약으로 보여준다. */ -function renderBalanceSummary(data: LongitudinalSection): HTMLElement | null { - const profile = data.design_profiles?.[0]; - if (!profile) return null; - const bar = document.createElement("div"); - bar.className = "b05-route-profile__balance"; - const summary = profile.summary; - const entries: Array<[string, string, string?]> = [ - ["절토", `${summary.cut_area_m2.toFixed(1)} m²`, "cut"], - ["성토", `${summary.fill_area_m2.toFixed(1)} m²`, "fill"], - ["균형오차", `${summary.balance_error_m2.toFixed(2)} m²${summary.balanced ? "" : " (근사)"}`], - ["최대 종단기울기", `${summary.max_grade_pct.toFixed(2)} %`], - ["종단곡선", `${summary.vertical_curve_count} 개`], - ]; - if (summary.balance_segment_count > 1) { - entries.push(["균형구역", `${summary.balance_segment_count} 개`]); - } - if (summary.suggested_elevation_offset_m !== null) { - entries.push(["시·종점 조정 제안", `${summary.suggested_elevation_offset_m.toFixed(1)} m`]); - } - entries.forEach(([label, value, tone]) => { - const item = document.createElement("span"); - item.className = `b05-route-profile__balance-item${tone ? ` is-${tone}` : ""}`; - const caption = document.createElement("em"); - caption.textContent = label; - item.append(caption, document.createTextNode(value)); - bar.append(item); - }); - if (summary.warnings.length) { - const warning = document.createElement("span"); - warning.className = "b05-route-profile__balance-warning"; - warning.textContent = `⚠ ${summary.warnings[0]}`; - warning.title = summary.warnings.join("\n"); - bar.append(warning); - } - return bar; +function readAlignment(data: LongitudinalSection): ProfileAlignment | null { + const candidate = data.profile_alignment as ProfileAlignment | undefined; + if (!candidate?.base_pvi?.length || !candidate.samples?.length) return null; + return candidate; +} + +/** 편집 결과를 종단면도 렌더러가 받는 계획선 형태로 감싼다. */ +function toDesignProfile( + alignment: ProfileAlignment, + original: DesignProfile | undefined, +): DesignProfile { + const balance = alignment.balance; + return { + id: original?.id ?? "design_grade_line", + name: original?.name ?? "계획선", + basis: original?.basis ?? "station_alignment", + samples: alignment.samples, + balance_segments: [ + { + index: 0, + start_chainage_m: alignment.samples[0]?.chainage_m ?? 0, + end_chainage_m: alignment.samples[alignment.samples.length - 1]?.chainage_m ?? 0, + cut_area_m2: balance.cut_area_m2, + fill_area_m2: balance.fill_area_m2, + balance_error_m2: balance.net_area_m2, + }, + ], + summary: { + ...(original?.summary ?? { + max_grade_pct: 0, + vertical_curve_count: 0, + pvi_count: 0, + balance_segment_count: 1, + main_direction: "none", + suggested_elevation_offset_m: null, + warnings: [], + }), + cut_area_m2: balance.cut_area_m2, + fill_area_m2: balance.fill_area_m2, + balance_error_m2: balance.net_area_m2, + balanced: balance.within_tolerance, + }, + }; } function normalizedLongitudinal(data: LongitudinalSection): LongitudinalSection { @@ -61,50 +98,184 @@ function normalizedLongitudinal(data: LongitudinalSection): LongitudinalSection }; } -export function createRouteProfilePanel(onSelectStation: (stationId: string) => void) { +/** 종단면도 렌더러와 **같은** chainage → x(px) 매핑을 만든다 (테이블·버튼 정렬 기준). */ +function chainageMapper(data: LongitudinalSection, width: number): (chainage: number) => number { + const samples = normalizedLongitudinal(data).samples.filter( + (sample) => sample.valid !== false && Number.isFinite(sample.elevation_m ?? NaN), + ); + const maxChainage = Math.max(data.length_m, samples[samples.length - 1]?.chainage_m ?? 1, 1); + const plotWidth = width - LONG_PAD.left - LONG_PAD.right; + return (chainage: number) => LONG_PAD.left + (chainage / maxChainage) * plotWidth; +} + +export function createRouteProfilePanel( + projectId: string, + onSelectStation: (stationId: string) => void, +) { const root = document.createElement("section"); root.className = "b05-route-profile"; const panelHandle = createWorkflowPanelHandle("bottom"); - const toggle = panelHandle.root; + const balanceBar = document.createElement("div"); + balanceBar.className = "b05-route-profile__balance"; const body = document.createElement("div"); body.className = "b05-route-profile__body"; const empty = document.createElement("p"); empty.className = "b05-route-profile__empty"; empty.textContent = "최적 경로를 계산하면 종단면도가 표시됩니다."; body.append(empty); - root.append(toggle, body); + root.append(panelHandle.root, balanceBar, body); + let detail: SectionDetailResponse | null = null; let selectedStationId: string | null = null; let stationInterval: number | undefined; + let routeId: number | null = null; + let base: AlignmentBase | null = null; + let alignment: ProfileAlignment | null = null; + let store = createProfileEditStore(null, emptyEdits(), () => rebuild()); let resizeTimer = 0; let lastWidth = 0; let lastHeight = 0; + function renderBalance(): void { + balanceBar.replaceChildren(); + if (!alignment) return; + const { balance, policy, violations } = alignment; + const entries: Array<[string, string, string?]> = [ + ["절토", `${balance.cut_area_m2.toFixed(1)} m²`, "cut"], + ["성토", `${balance.fill_area_m2.toFixed(1)} m²`, "fill"], + [ + "불균형", + `${balance.imbalance_percent.toFixed(1)} % / 허용 ${balance.tolerance_percent.toFixed(0)} %`, + balance.within_tolerance ? undefined : "over", + ], + ["변화점", `${alignment.pvi.length} 개`], + ["종단곡선", `${alignment.curves.filter((curve) => !curve.omitted).length} 개`], + ["기준 R", `${policy.default_curve_length_m.toFixed(1)} m 곡선길이`], + ]; + const editedCount = Object.keys(alignment.edits.station_offsets).length; + if (editedCount) entries.push(["편집 측점", `${editedCount} 개`, "edited"]); + entries.forEach(([label, value, tone]) => { + const item = document.createElement("span"); + item.className = `b05-route-profile__balance-item${tone ? ` is-${tone}` : ""}`; + const caption = document.createElement("em"); + caption.textContent = label; + item.append(caption, document.createTextNode(value)); + balanceBar.append(item); + }); + if (violations.length) { + const warning = document.createElement("span"); + warning.className = "b05-route-profile__balance-warning"; + warning.textContent = `⚠ 종단기울기 초과 ${violations.length}개 구간`; + warning.title = violations + .map((item) => `구간 ${item.segment_index + 1}: ${item.value.toFixed(2)}% > ${item.limit}%`) + .join("\n"); + balanceBar.append(warning); + } + if (store.edited()) { + const reset = document.createElement("button"); + reset.type = "button"; + reset.className = "b05-route-profile__balance-reset"; + reset.textContent = "초기선 복원"; + reset.title = "모든 편집을 지우고 자동 산출된 계획선으로 되돌립니다."; + reset.addEventListener("click", () => store.resetAll()); + balanceBar.append(reset); + } + if (store.dirty()) { + const badge = document.createElement("span"); + badge.className = "b05-route-profile__balance-item is-unsaved"; + badge.textContent = "미저장 (확정 시 반영)"; + balanceBar.append(badge); + } + } + + /** 편집을 적용한다. 법정 위반 정책이 block이면 새 위반이 생기는 편집을 막는다. */ + function applyEdits(next: AlignmentEdits): void { + if (!base || !alignment) return; + const candidate = buildAlignment(base, next); + if ( + base.policy.grade_violation_policy === "block" && + candidate.violations.length > alignment.violations.length + ) { + showToast( + `종단기울기 상한 ${base.policy.max_grade_pct.toFixed(1)}%를 넘어 편집을 적용하지 않았습니다.`, + "error", + ); + return; + } + store.replace(next); + } + + function rebuild(): void { + if (base) alignment = buildAlignment(base, store.edits()); + draw(); + } + function draw(): void { if (!detail || body.clientWidth <= 0 || body.clientHeight <= 0) return; - const availableWidth = Math.max(1, body.clientWidth - 30); - const balance = renderBalanceSummary(detail.longitudinal); - const height = Math.max( - 1, - body.clientHeight - HORIZONTAL_SCROLLBAR_HEIGHT - (balance ? BALANCE_BAR_HEIGHT : 0), - ); - const minimumWidth = longitudinalMinimumWidth(detail.longitudinal, stationInterval); - const width = Math.max(availableWidth, minimumWidth); lastWidth = body.clientWidth; - lastHeight = height; - const chart = createLongitudinalProfile( - normalizedLongitudinal(detail.longitudinal), - selectedStationId, - 1, - undefined, - onSelectStation, - stationInterval, - width, - height, - minimumWidth, - detail.longitudinal.design_profiles ?? [], + lastHeight = body.clientHeight; + renderBalance(); + + const longitudinal = detail.longitudinal; + const minimumWidth = longitudinalMinimumWidth(longitudinal, stationInterval); + const width = Math.max(Math.max(1, body.clientWidth - 30), minimumWidth); + const canvas = document.createElement("div"); + canvas.className = "b05-profile__canvas"; + canvas.style.width = `${width}px`; + + // 테이블을 먼저 붙여 실제 높이를 재고, 남는 공간 전부를 그래프에 준다. + const x = chainageMapper(longitudinal, width); + const table = alignment + ? createProfileTable({ + alignment, + stationInterval: stationInterval ?? alignment.policy.station_interval_m, + width, + x, + onCurveRadiusChange: (curve, radius) => + applyEdits(setCurveRadius(store.edits(), curve, radius ?? 0)), + }) + : null; + body.replaceChildren(canvas); + if (table) canvas.append(table); + const available = body.clientHeight - HORIZONTAL_SCROLLBAR_HEIGHT - (table?.offsetHeight ?? 0); + const chartHeight = Math.max(MIN_CHART_HEIGHT, available); + + const chartWrap = document.createElement("div"); + chartWrap.className = "b05-profile__chart"; + chartWrap.style.height = `${chartHeight}px`; + const designProfiles = alignment + ? [toDesignProfile(alignment, longitudinal.design_profiles?.[0])] + : (longitudinal.design_profiles ?? []); + chartWrap.append( + createLongitudinalProfile( + normalizedLongitudinal(longitudinal), + selectedStationId, + 1, + undefined, + onSelectStation, + stationInterval, + width, + chartHeight, + width, + designProfiles, + ), ); - body.replaceChildren(...(balance ? [balance, chart] : [chart])); + if (alignment) { + chartWrap.append( + createEditOverlay({ + alignment, + width, + x, + step: alignment.policy.edit_step_m, + onStation: (chainage, delta) => + base && applyEdits(adjustStation(base, store.edits(), chainage, delta)), + onSegment: (segment, delta) => + base && applyEdits(shiftSegment(base, store.edits(), segment, delta)), + onResetStation: (chainage) => store.resetStation(chainage), + }), + ); + } + canvas.prepend(chartWrap); } const resizeObserver = new ResizeObserver(() => { @@ -126,14 +297,24 @@ export function createRouteProfilePanel(onSelectStation: (stationId: string) => if (!collapsed) requestAnimationFrame(draw); } - toggle.addEventListener("click", () => setCollapsed(!root.classList.contains("is-collapsed"))); + panelHandle.root.addEventListener("click", () => + setCollapsed(!root.classList.contains("is-collapsed")), + ); setCollapsed(sessionStorage.getItem(COLLAPSED_KEY) === "true"); return { root, - render(nextDetail: SectionDetailResponse, nextStationInterval?: number) { + render(nextDetail: SectionDetailResponse, nextStationInterval?: number, nextRouteId?: number) { detail = nextDetail; stationInterval = nextStationInterval; + const stored = readAlignment(nextDetail.longitudinal); + // 서버 저장분을 기준으로 삼되, 남아 있는 세션 초안이 있으면 그쪽을 우선한다. + if (nextRouteId !== routeId || !store.dirty()) { + routeId = nextRouteId ?? routeId; + store = createProfileEditStore(routeId, stored?.edits ?? emptyEdits(), () => rebuild()); + } + base = stored ? toAlignmentBase(stored) : null; + alignment = base ? buildAlignment(base, store.edits()) : null; draw(); requestAnimationFrame(draw); }, @@ -141,9 +322,26 @@ export function createRouteProfilePanel(onSelectStation: (stationId: string) => selectedStationId = stationId; draw(); }, + isDirty: () => store.dirty(), + /** [확정] 직전에 호출한다. 편집이 없으면 아무 것도 하지 않는다. */ + async save(): Promise { + if (!routeId || !store.dirty()) return; + const saved = await saveProfileAlignment(projectId, routeId, store.edits()); + const next = saved.profile_alignment as ProfileAlignment | undefined; + if (next?.base_pvi?.length) { + base = toAlignmentBase(next); + alignment = next; + if (detail) detail.longitudinal.profile_alignment = next; + } + store.markSaved(); + draw(); + }, clear() { detail = null; + base = null; + alignment = null; selectedStationId = null; + balanceBar.replaceChildren(); body.replaceChildren(empty); }, dispose() { diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Profile_Table.ts b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Table.ts new file mode 100644 index 00000000..9e8c5c89 --- /dev/null +++ b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Table.ts @@ -0,0 +1,186 @@ +/* ============================================================================= + * B05_wf2_Route_UI_Profile_Table.ts + * 종단면도 하단 도면 테이블 (구배·절토고·성토고·계획고·지반고·누가거리·거리·측점·곡선). + * + * 실무 종단면도 좌측 하단 표를 그대로 옮긴 9행 구성이다. 셀은 종단면도 그래프와 + * **같은 X 매핑**으로 절대 배치되므로 측점 수직선과 정확히 맞물린다. 마지막 곡선 행의 + * R만 입력 가능하고 나머지는 전부 파생값이다. + * ========================================================================== */ + +import type { + AlignmentCurve, + AlignmentSegment, + ProfileAlignment, +} from "./B05_wf2_Route_UI_Profile_Alignment"; +import { stationLabel } from "../B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_Common"; + +export interface ProfileTableOptions { + alignment: ProfileAlignment; + stationInterval: number; + width: number; + /** 종단면도와 공유하는 chainage → x(px) 매핑. */ + x: (chainageM: number) => number; + onCurveRadiusChange: (curve: AlignmentCurve, radiusM: number | null) => void; +} + +interface RowSpec { + key: string; + label: string; + /** 측점마다 한 칸씩 채우는 행. */ + cell?: (index: number) => string; + modifier?: string; +} + +const CELL_WIDTH = 56; + +function element(tag: string, className: string, text?: string): HTMLElement { + const node = document.createElement(tag); + node.className = className; + if (text !== undefined) node.textContent = text; + return node; +} + +/** 절대 배치 셀: 측점 x를 중심으로 좌우 대칭 배치한다. */ +function placeCell(row: HTMLElement, centerX: number, node: HTMLElement): void { + node.style.left = `${centerX - CELL_WIDTH / 2}px`; + node.style.width = `${CELL_WIDTH}px`; + row.append(node); +} + +function buildStationRows(alignment: ProfileAlignment, interval: number): RowSpec[] { + const stations = alignment.stations; + return [ + { + key: "cut", + label: "절토고", + modifier: "cut", + cell: (index) => (stations[index].cut_m > 0.005 ? stations[index].cut_m.toFixed(2) : ""), + }, + { + key: "fill", + label: "성토고", + modifier: "fill", + cell: (index) => (stations[index].fill_m > 0.005 ? stations[index].fill_m.toFixed(2) : ""), + }, + { + key: "plan", + label: "계획고", + modifier: "plan", + cell: (index) => stations[index].plan_elevation_m.toFixed(2), + }, + { + key: "ground", + label: "지반고", + cell: (index) => stations[index].ground_elevation_m.toFixed(2), + }, + { + key: "cumulative", + label: "누가거리", + cell: (index) => stations[index].chainage_m.toFixed(2), + }, + { + key: "distance", + label: "거리", + cell: (index) => (index ? stations[index].distance_m.toFixed(2) : ""), + }, + { + key: "station", + label: "측점", + cell: (index) => stationLabel(stations[index].chainage_m, interval), + }, + ]; +} + +/** + * 구배 행: 구간 중앙에 `L / H / S`, 변화점에 중앙종거를 얹는다. + * 도면의 구배 행에 찍히는 소수값(0.56, -0.75 …)이 이 중앙종거다 (|A|·L/8). + */ +function buildGradeRow(alignment: ProfileAlignment, x: (chainage: number) => number): HTMLElement { + const row = element("div", "b05-profile-table__row b05-profile-table__row--grade"); + row.append(element("span", "b05-profile-table__label", "구배")); + alignment.segments.forEach((segment: AlignmentSegment) => { + const center = (x(segment.from_m) + x(segment.to_m)) / 2; + const span = Math.abs(x(segment.to_m) - x(segment.from_m)); + if (span < 40) return; + const node = element("span", "b05-profile-table__segment"); + node.append( + element("em", "b05-profile-table__segment-line", `L=${segment.length_m.toFixed(2)}m`), + element( + "em", + `b05-profile-table__segment-line is-${segment.height_m >= 0 ? "up" : "down"}`, + `H=${segment.height_m.toFixed(2)}m S=${segment.grade_percent.toFixed(2)}%`, + ), + ); + node.style.left = `${center - span / 2}px`; + node.style.width = `${span}px`; + if (Math.abs(segment.grade_percent) > alignment.policy.max_grade_pct + 1e-6) { + node.classList.add("is-violation"); + node.title = `종단기울기 ${segment.grade_percent.toFixed(2)}%가 기준 ${alignment.policy.max_grade_pct.toFixed(1)}%를 초과합니다.`; + } + row.append(node); + }); + alignment.curves.forEach((curve) => { + const node = element( + "span", + "b05-profile-table__ordinate", + (curve.delta_pct >= 0 ? "" : "-") + curve.middle_ordinate_m.toFixed(2), + ); + node.title = `중앙종거 (대수차 ${curve.delta_pct.toFixed(2)}% × L ${curve.l_m.toFixed(1)}m / 8)`; + placeCell(row, x(curve.chainage_m), node); + }); + return row; +} + +/** 곡선 행: 변화점마다 `L=` 표기와 R 입력 칸을 둔다 (R을 고치면 L을 역산). */ +function buildCurveRow(options: ProfileTableOptions): HTMLElement { + const { alignment, x, onCurveRadiusChange } = options; + const row = element("div", "b05-profile-table__row b05-profile-table__row--curve"); + row.append(element("span", "b05-profile-table__label", "곡선")); + alignment.curves.forEach((curve) => { + const cell = element("span", "b05-profile-table__curve"); + const length = element("em", "b05-profile-table__curve-length", `L=${curve.l_m.toFixed(2)}`); + const input = document.createElement("input"); + input.type = "number"; + input.step = "1"; + input.min = "1"; + input.className = "b05-profile-table__radius"; + input.value = curve.r_m.toFixed(1); + input.title = + `종단곡선 반경 R (m) — 수정하면 L = R × |A| 로 역산합니다.\n` + + `K=${curve.k.toFixed(2)} BVC=${curve.bvc_m.toFixed(1)}m EVC=${curve.evc_m.toFixed(1)}m` + + (curve.omit_reason ? `\n${curve.omit_reason}` : ""); + input.addEventListener("change", () => { + const parsed = Number.parseFloat(input.value); + onCurveRadiusChange(curve, Number.isFinite(parsed) && parsed > 0 ? parsed : null); + }); + if (curve.skip_allowed) cell.classList.add("is-optional"); + if (curve.omitted) cell.classList.add("is-omitted"); + cell.append(length, input); + placeCell(row, x(curve.chainage_m), cell); + }); + return row; +} + +export function createProfileTable(options: ProfileTableOptions): HTMLElement { + const { alignment, stationInterval, width, x } = options; + const table = element("div", "b05-profile-table"); + table.style.width = `${width}px`; + + table.append(buildGradeRow(alignment, x)); + buildStationRows(alignment, stationInterval).forEach((spec) => { + const row = element( + "div", + `b05-profile-table__row${spec.modifier ? ` is-${spec.modifier}` : ""}`, + ); + row.append(element("span", "b05-profile-table__label", spec.label)); + alignment.stations.forEach((station, index) => { + const value = spec.cell ? spec.cell(index) : ""; + if (!value) return; + const cell = element("span", "b05-profile-table__cell", value); + placeCell(row, x(station.chainage_m), cell); + }); + table.append(row); + }); + table.append(buildCurveRow(options)); + return table; +} diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Style.css b/B05_wf2_Route/B05_wf2_Route_UI_Style.css index 57b25cfd..bfd8a5b0 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Style.css +++ b/B05_wf2_Route/B05_wf2_Route_UI_Style.css @@ -34,10 +34,14 @@ background: var(--color-surface); } +/* 하단 종단 패널: 그래프 + 도면 테이블을 담기 위해 화면 높이의 40%를 차지한다. */ .b05-route-profile { position: relative; - flex: 0 0 250px; - min-height: 250px; + display: flex; + flex: 0 0 40vh; + flex: 0 0 40dvh; + flex-direction: column; + min-height: 0; overflow: visible; border-top: 1px solid var(--color-border); background: var(--color-surface-raised); @@ -51,13 +55,28 @@ .b05-route-profile__body { box-sizing: border-box; - height: 100%; + flex: 1 1 auto; + min-height: 0; overflow-x: auto; overflow-y: hidden; padding-inline: 15px; } -.b05-route-profile.is-collapsed .b05-route-profile__body { +/* 그래프와 테이블을 같은 폭으로 쌓아 X축이 저절로 맞물리게 한다. */ +.b05-profile__canvas { + display: flex; + flex-direction: column; + height: 100%; +} + +.b05-profile__chart { + position: relative; + flex: 0 0 auto; + min-height: 0; +} + +.b05-route-profile.is-collapsed .b05-route-profile__body, +.b05-route-profile.is-collapsed .b05-route-profile__balance { display: none; } @@ -254,6 +273,7 @@ /* 종단면도 상단 절·성토 균형 지표 바 */ .b05-route-profile__balance { display: flex; + flex: 0 0 auto; flex-wrap: nowrap; gap: var(--spacing-16); align-items: center; @@ -284,8 +304,217 @@ color: rgb(37 99 235); } +.b05-route-profile__balance-item.is-over, +.b05-route-profile__balance-item.is-unsaved { + color: rgb(180 83 9); + font-weight: var(--font-weight-medium); +} + +.b05-route-profile__balance-item.is-edited em { + color: var(--color-royal-amethyst, rgb(109 40 217)); +} + .b05-route-profile__balance-warning { overflow: hidden; color: rgb(180 83 9); text-overflow: ellipsis; } + +.b05-route-profile__balance-reset { + flex: 0 0 auto; + padding: 1px var(--spacing-8); + border: 1px solid var(--color-border); + border-radius: var(--radius-inputs); + background: var(--color-surface); + color: var(--color-text-body); + font-size: var(--text-caption); + cursor: pointer; +} + +/* ─── 도면 테이블 (구배 ~ 곡선 9행) ─────────────────────────────────────── + 셀은 종단면도와 같은 X 매핑으로 절대 배치되어 측점 수직선과 맞물린다. + 행 이름표만 sticky로 좌측에 고정되어 가로 스크롤에도 계속 보인다. */ +.b05-profile-table { + position: relative; + flex: 0 0 auto; + border-top: 1px solid var(--color-border); + color: var(--color-text-body); + font-size: 10px; + line-height: 1.1; + user-select: none; +} + +.b05-profile-table__row { + position: relative; + height: 16px; + border-bottom: 1px solid color-mix(in srgb, var(--color-border) 60%, transparent); + white-space: nowrap; +} + +.b05-profile-table__row--grade { + height: 28px; +} + +.b05-profile-table__row--curve { + height: 24px; + border-bottom: 0; +} + +.b05-profile-table__label { + position: sticky; + z-index: 3; + left: 0; + display: inline-flex; + align-items: center; + box-sizing: border-box; + width: 56px; + height: 100%; + padding-inline: var(--spacing-4); + border-right: 1px solid var(--color-border); + background: var(--color-surface-raised); + color: var(--color-text-muted, var(--color-plum-velvet)); + font-weight: var(--font-weight-medium); +} + +.b05-profile-table__cell, +.b05-profile-table__ordinate, +.b05-profile-table__segment, +.b05-profile-table__curve { + position: absolute; + top: 0; + display: flex; + align-items: center; + justify-content: center; + height: 100%; + overflow: hidden; +} + +.b05-profile-table__row.is-cut .b05-profile-table__cell { + color: rgb(220 38 38); +} + +.b05-profile-table__row.is-fill .b05-profile-table__cell { + color: rgb(37 99 235); +} + +.b05-profile-table__row.is-plan .b05-profile-table__cell { + color: var(--color-royal-amethyst, rgb(109 40 217)); + font-weight: var(--font-weight-medium); +} + +.b05-profile-table__segment { + flex-direction: column; + justify-content: center; + gap: 1px; + border-left: 1px solid color-mix(in srgb, var(--color-border) 70%, transparent); + border-right: 1px solid color-mix(in srgb, var(--color-border) 70%, transparent); +} + +.b05-profile-table__segment-line { + font-style: normal; +} + +.b05-profile-table__segment.is-violation { + background: color-mix(in srgb, rgb(220 38 38) 12%, transparent); + color: rgb(180 83 9); +} + +.b05-profile-table__ordinate { + z-index: 2; + background: var(--color-surface-raised); + color: var(--color-text-muted, var(--color-plum-velvet)); +} + +.b05-profile-table__curve { + flex-direction: column; + gap: 1px; +} + +.b05-profile-table__curve.is-optional { + opacity: 0.75; +} + +.b05-profile-table__curve.is-omitted { + text-decoration: line-through; +} + +.b05-profile-table__curve-length { + font-style: normal; +} + +.b05-profile-table__radius { + box-sizing: border-box; + width: 100%; + padding: 0 1px; + border: 1px solid var(--color-border); + border-radius: 2px; + background: var(--color-surface); + color: var(--color-text-body); + font-size: 10px; + text-align: center; +} + +/* ─── 계획고 편집 버튼 (평시 투명, 패널 hover 시 노출) ───────────────────── */ +.b05-profile-edit { + position: absolute; + z-index: 4; + inset: 0; + pointer-events: none; +} + +.b05-profile-edit__btn { + position: absolute; + width: 18px; + height: 15px; + padding: 0; + border: 1px solid transparent; + border-radius: 3px; + background: transparent; + color: transparent; + font-size: 9px; + line-height: 1; + cursor: pointer; + opacity: 0; + pointer-events: auto; + transition: opacity var(--transition-fast); +} + +.b05-route-profile:hover .b05-profile-edit__btn { + border-color: color-mix(in srgb, var(--color-border) 60%, transparent); + background: color-mix(in srgb, var(--color-surface) 70%, transparent); + color: var(--color-text-muted, var(--color-plum-velvet)); + opacity: 0.45; +} + +.b05-route-profile .b05-profile-edit__btn:hover, +.b05-route-profile .b05-profile-edit__btn:focus-visible { + border-color: var(--color-border); + background: var(--color-surface-raised); + color: var(--color-text); + opacity: 1; +} + +.b05-profile-edit__btn.is-up { + top: 2px; +} + +.b05-profile-edit__btn.is-down { + bottom: 2px; +} + +.b05-profile-edit__btn.is-segment.is-up { + top: 19px; +} + +.b05-profile-edit__btn.is-segment.is-down { + bottom: 19px; +} + +.b05-profile-edit__btn.is-reset, +.b05-route-profile:hover .b05-profile-edit__btn.is-reset { + top: 36px; + border-color: color-mix(in srgb, var(--color-royal-amethyst, rgb(109 40 217)) 45%, transparent); + background: var(--color-surface-raised); + color: var(--color-royal-amethyst, rgb(109 40 217)); + opacity: 0.9; +} diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch.ts index cfb37e9d..52a11ec0 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch.ts @@ -113,6 +113,12 @@ export interface LongitudinalSection { samples: SectionSample[]; stations: SectionStation[]; design_profiles?: DesignProfile[]; + /** + * 계획선 변화점(PVI) 구조. B05가 편집 기준선으로 사용하며 `design_profiles`는 + * 여기서 파생된다. 구 데이터에는 없을 수 있어 optional이다. + * 구조는 `B05_wf2_Route_UI_Profile_Alignment.ProfileAlignment`. + */ + profile_alignment?: unknown; } export interface CrossSection extends SectionStation { diff --git a/config/config_system.py b/config/config_system.py index 83f6ab11..3097b928 100644 --- a/config/config_system.py +++ b/config/config_system.py @@ -323,6 +323,46 @@ GRADE_TERRAIN_TYPES = ("normal", "special") GRADE_MAIN_DIRECTIONS = ("auto", "ascending", "descending", "none") +# ───────────────────────────────────────────────────────────────────────── +# 5-6. 종단 계획선 선형(직선 + 측점 위 종단곡선) 및 편집 정책 (B05 WF2) +# +# 위 5-5의 FOREST_ROAD_PROFILE_CRITERIA는 법정 기준값이고, 여기는 계획선을 +# "지반 추종 직선 분할 + 측점 위 종단곡선"으로 만들고 사용자가 0.1m 단위로 +# 편집하는 동작을 제어하는 실무 정책값이다. 서로 혼용하지 않는다. +# +# 변화점(PVI)은 반드시 기준 측점 위에만 놓이며, 종단곡선은 그 측점을 중심으로 +# 대칭 배치되어 곡선 좌우에 직선 구간이 반드시 남는다. +# ───────────────────────────────────────────────────────────────────────── +FOREST_ROAD_PROFILE_ALIGNMENT = { + # 절·성토 균형 허용 오차: |절토 − 성토| / max(절토, 성토) (%) + # 5% 미만이면 지반 추종이 왜곡되어 불필요한 변화점이 늘고, 15%를 넘으면 + # 사토·객토 운반 물량 부담이 커진다. 10%를 실무 균형점으로 둔다. + "balance_tolerance_percent": 10.0, + # 종단곡선 기본 길이 = 측점간격 × 이 비율 (변화점 대칭 배치). + # 측점간격 20m → 곡선 8m가 변화점 좌우 ±4m를 점유하고 나머지 12m는 직선이다. + # 곡선반경 R은 길이 L과 기울기 대수차 A로부터 R = L / |A| 로 파생 표기한다. + "curve_length_ratio": 0.40, + "curve_length_min_m": 2.0, + # 인접 직선 길이 대비 곡선 반쪽이 점유할 수 있는 최대 비율. + # 0.45면 짧은 쪽 직선의 55%가 항상 직선으로 남아 좌우 곡선이 겹치지 않는다. + "curve_tangent_max_ratio": 0.45, + # 법정 예외(비포장 & 대수차 5% 이하)를 실제 기하에서도 곡선 생략으로 적용할지. + # 규정 다-(3)-(다)는 "두지 않을 수 있다"는 허용 조항이며, 임도 실무 도면은 대수차가 + # 작아도 변화점을 원곡선으로 처리한다. 기본값 False = 곡선을 항상 삽입하고 + # 해당 구간에는 "생략 가능" 표시만 남긴다(True로 바꾸면 곡선을 실제로 뺀다). + "curve_skip_legal_exception": False, + # 변화점(PVI) 추가 페널티 (직선 분할 DP 목적함수, 단위 m²). + # 변화점 하나를 늘리려면 잔차제곱합이 이 값 이상 개선되어야 채택된다. + "pvi_penalty_m2": 25.0, + # 직선 분할 시 한 구간이 가질 수 있는 최소 측점 개수 (짧은 토막 방지) + "min_segment_stations": 2, + # 사용자 편집 스텝(m). 그래프 상·하단 버튼 1클릭당 계획고 이동량. + "edit_step_m": 0.1, + # 법정 종단기울기 상한 초과 시 동작: "warn"(경고만) | "block"(편집 차단) + "grade_violation_policy": "warn", +} + + # ───────────────────────────────────────────────────────────────────────── # 6. 저장소 경로 # ─────────────────────────────────────────────────────────────────────────