260722_B05_종단 설계 반영 초안

This commit is contained in:
2026-07-23 18:54:32 +09:00
parent a8ac3ea66c
commit 61a86d806e
17 changed files with 2528 additions and 81 deletions
+33
View File
@@ -187,6 +187,39 @@ export async function updateContourInterval(
);
}
/** 종단 계획선 편집 델타 (자동 선형 대비 측점 계획고 델타 + 종단곡선 길이). */
export interface ProfileAlignmentEdits {
station_offsets: Record<string, number>;
curve_lengths: Record<string, number>;
}
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<ProfileAlignmentSaveResponse> {
return requestJson<ProfileAlignmentSaveResponse>(
`/projects/${projectId}/route/profile-alignment`,
{
method: "PUT",
body: JSON.stringify({ route_id: routeId, ...edits }),
},
);
}
/** 프로젝트의 최신 경로를 확정한다. */
export async function confirmRoute(projectId: string): Promise<RouteConfirmResponse> {
return requestJson<RouteConfirmResponse>(`/projects/${projectId}/route/confirm`, {
+4 -4
View File
@@ -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)
)
@@ -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,
}
@@ -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"],
)
@@ -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,
+22 -6
View File
@@ -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 = {
+33
View File
@@ -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:
+82
View File
@@ -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 입력 스냅샷을 반환한다."""
+37
View File
@@ -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):
"""경로 탐색 실행 결과."""
+6 -4
View File
@@ -107,7 +107,7 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
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<void> {
);
}
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<void> {
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<void> {
if (!routeReady || stale) return;
showLoadingOverlay();
try {
// 종단 계획선 편집은 화면에서만 계산해 두었으므로 확정 직전에 영속화한다.
await profilePanel.save();
await confirmRoute(activeProjectId);
renderLatest(await fetchLatestRoute(activeProjectId));
showToast("경로를 확정했습니다.", "success");
@@ -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<string, number>;
curve_lengths: Record<string, number>;
}
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<string, number>,
): Array<AlignmentNode & { source: "auto" | "user" }> {
const baseS = base.basePvi.map((node) => node.chainage_m);
const baseZ = base.basePvi.map((node) => node.elevation_m);
const nodes = new Map<number, AlignmentNode & { source: "auto" | "user" }>();
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<string, number>,
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<number>(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
);
}
@@ -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<AlignmentEdits>;
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;
}
+261 -63
View File
@@ -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)}`, "cut"],
["성토", `${summary.fill_area_m2.toFixed(1)}`, "fill"],
["균형오차", `${summary.balance_error_m2.toFixed(2)}${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)}`, "cut"],
["성토", `${balance.fill_area_m2.toFixed(1)}`, "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<void> {
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() {
@@ -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;
}
+233 -4
View File
@@ -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;
}
@@ -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 {
+40
View File
@@ -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. 저장소 경로
# ─────────────────────────────────────────────────────────────────────────