- 종단 상단 표시줄을 법정 판정 3항목으로 축소: 최대 기울기/상한(별표2 Ⅰ.2.라),
절·성토 불균형(Ⅰ.1.나.(4)(다)), 종단곡선 필요 n곳(Ⅰ.2.마 — 대수차 5% 초과인데
곡선이 빠진 변화점). 변화점·종단곡선 개수·기본 곡선길이 L·중복 경고칩은 제거.
- [초기선 복원] → [편집 되돌리기] — 실제 동작은 화면 편집 델타 삭제이지 저장 지점
복귀가 아니다. 툴팁에 동작과 [초기화]와의 차이를 적었다.
- 3D 뷰포트 안내 라벨 삭제(로딩 완료 후 남던 조작법 문구).
- 배수유역 버튼 정비: 유역 다시 나누기 / 선택한 관 삭제 / 배관 배치 초기화 +
각 툴팁에 실제 동작 명시(선택 삭제는 툴팁 자체가 없었다).
- 유토곡선 요약 12칩 → 5칩(절토·성토·잉여|부족·운반·검산). 토질 3종 내역·다짐
환산·블록 수·장거리 운반·사토는 해당 칩 툴팁으로. B05·B06 공용 함수라 동시 반영.
- 설계속도 축 도입(임도는 속도를 낼 수 없는 노선 — 기본 20km/h):
· 임도 종류는 프로젝트 등록값(projects.road_type)을 읽어 B05가 읽기 전용 표시
(main→간선임도, fire→산불진화임도, 그 외→작업임도). 화면에서 고치지 않는다.
· 설계속도 선택(간선·산불진화 20/30/40, 작업 20 고정) 신설 — 종단기울기 상한과
종단곡선 반경이 등급이 아니라 이 값으로 정해진다(지식DB 설계제원_총괄 §6·§7).
· B02 임도 종류 선택지를 현행 3종으로 정리(지선 폐지·계류보전은 사방 구분).
· 백엔드: ROUTE_GRADE_CLASSES 3종+branch 호환, selectable_design_speeds 신설,
resolve_design_speed 신설, legal_grade_limit_pct·resolve_grade_options에
설계속도 인자, sections/context가 road_type 제공(B06도 저장값 승계).
· 계획선 정책은 그릴 때마다 현재 기준으로 동기화 — 저장분의 옛 상한이 남지 않는다.
- 검증: pytest 125 통과(설계속도 12건 신규), tsc, 헤드 브라우저 — 진입 시 상한 9%,
40km/h 전환 시 7% 즉시 반영, 원복 9%, 안내문 '간선임도 · 설계속도 20km/h ·
일반지형', 유토곡선 5칩, 유역 버튼 새 이름·툴팁, 3D 라벨 빈 문자열 확인.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
299 lines
12 KiB
Python
299 lines
12 KiB
Python
"""B05 경로 설계 요청·응답 검증 모델."""
|
|
|
|
from typing import Any, Literal
|
|
|
|
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
|
|
from config.config_system import (
|
|
GRADE_MAIN_DIRECTIONS,
|
|
GRADE_TERRAIN_TYPES,
|
|
ROUTE_GRADE_CLASSES,
|
|
)
|
|
|
|
# 경사 입력은 화면·API·DB 모두 **퍼센트(%)** 로 통일한다(비율로 쓰던 과거와 다름).
|
|
# 엔진 진입점에서만 100으로 나눠 비율로 바꾼다.
|
|
GRADE_PERCENT_FIELDS = (
|
|
"max_uphill_grade",
|
|
"max_downhill_grade",
|
|
"min_uphill_grade",
|
|
"min_downhill_grade",
|
|
)
|
|
# 과거 버전은 같은 필드를 비율(0.14 = 14%)로 저장했다. 임도 종단기울기를 1% 이하로
|
|
# 두는 경우는 없으므로, 1 이하 값은 레거시 비율로 보고 퍼센트로 환산한다.
|
|
LEGACY_GRADE_RATIO_MAX = 1.0
|
|
|
|
|
|
def normalize_grade_percent(value: Any) -> Any:
|
|
"""경사 값을 퍼센트로 정규화한다(레거시 비율 자동 환산)."""
|
|
if value is None:
|
|
return None
|
|
try:
|
|
number = float(value)
|
|
except (TypeError, ValueError):
|
|
return value
|
|
if 0.0 < number <= LEGACY_GRADE_RATIO_MAX:
|
|
# 0.14 * 100 = 14.000000000000002 같은 잔여 오차를 남기지 않는다.
|
|
return round(number * 100.0, 6)
|
|
return number
|
|
|
|
|
|
class RoutePoint(BaseModel):
|
|
"""경로 제어점 (BP/CP/EP)."""
|
|
|
|
model_config = ConfigDict(extra="forbid")
|
|
|
|
x: float
|
|
y: float
|
|
z: float | None = None
|
|
order: int | None = None
|
|
|
|
|
|
class CirclePoint(BaseModel):
|
|
"""회피/금지 원 (AP/FP)."""
|
|
|
|
model_config = ConfigDict(extra="forbid")
|
|
|
|
x: float
|
|
y: float
|
|
radius_m: float = Field(gt=0)
|
|
|
|
|
|
class RouteSolveRequest(BaseModel):
|
|
"""경로 탐색 실행 요청."""
|
|
|
|
model_config = ConfigDict(extra="forbid")
|
|
|
|
filter_key: str = Field(description="지면 필터 키 (grid_min_z/csf/pmf)")
|
|
method: str = Field(default="dtm", description="지표면 표현 (dtm/tin/nurbs/implicit/meshfree)")
|
|
smooth: bool = Field(default=False)
|
|
surface_model_id: int | None = Field(default=None, description="기반 지표면 모델 id")
|
|
algorithm: str = Field(default="dijkstra", description="경로 알고리즘 (dijkstra/ridge_valley)")
|
|
|
|
bp: RoutePoint
|
|
ep: RoutePoint
|
|
cp: list[RoutePoint] = Field(default_factory=list)
|
|
ap: list[CirclePoint] = Field(default_factory=list)
|
|
fp: list[CirclePoint] = Field(default_factory=list)
|
|
|
|
grade_class: str = Field(default="trunk")
|
|
paved: bool = Field(default=False)
|
|
min_curve_radius_m: float | None = None
|
|
# 경로탐색 경사 제약 (단위: %). 과거 비율 저장분은 검증 단계에서 자동 환산된다.
|
|
max_uphill_grade: float | None = Field(default=None, ge=0, le=100)
|
|
max_downhill_grade: float | None = Field(default=None, ge=0, le=100)
|
|
min_uphill_grade: float | None = Field(default=None, ge=0, le=100)
|
|
min_downhill_grade: float | None = Field(default=None, ge=0, le=100)
|
|
weights: dict[str, float] | None = None
|
|
allow_avoid_pass_through: bool = Field(default=False)
|
|
station_interval_m: float | None = Field(default=None, gt=0)
|
|
cross_half_width_m: float | None = Field(default=None, gt=0)
|
|
cross_sample_interval_m: float | None = Field(default=None, gt=0)
|
|
long_sample_interval_m: float | None = Field(default=None, gt=0)
|
|
|
|
# 종단 계획선(계획고) 설계 옵션. 빈 값은 null로 두어 config 기본값을 쓴다.
|
|
terrain_type: str = Field(default="normal", description="지형 구분 (normal/special)")
|
|
# 설계속도(km/h) — 법정 종단 기준을 정하는 축이다. 임도는 속도를 낼 수 없어 20이
|
|
# 기본이며, 간선·산불진화만 30·40을 고를 수 있다(2026-08-19 사용자 확정).
|
|
# 미지정(None)이면 종류별 기본 설계속도(grade_to_design_speed)를 쓴다.
|
|
design_speed_kph: int | None = Field(default=None, description="설계속도 (20/30/40)")
|
|
main_direction: str = Field(
|
|
default="auto", description="주 진행방향 (auto/ascending/descending/none)"
|
|
)
|
|
max_grade_pct: float | None = Field(default=None, gt=0)
|
|
min_vertical_radius_m: float | None = Field(default=None, gt=0)
|
|
min_tangent_length_m: float | None = Field(default=None, gt=0)
|
|
balance_segment_length_m: float | None = Field(default=None, gt=0)
|
|
start_elevation_offset_m: float | None = None
|
|
end_elevation_offset_m: float | None = None
|
|
|
|
@model_validator(mode="after")
|
|
def validate_choices(self) -> "RouteSolveRequest":
|
|
if self.grade_class not in ROUTE_GRADE_CLASSES:
|
|
raise ValueError(f"임도 등급은 {ROUTE_GRADE_CLASSES} 중 하나여야 합니다.")
|
|
if self.algorithm not in ("dijkstra", "ridge_valley"):
|
|
raise ValueError("경로 알고리즘은 dijkstra 또는 ridge_valley여야 합니다.")
|
|
if self.design_speed_kph is not None and self.design_speed_kph not in (20, 30, 40):
|
|
raise ValueError("설계속도는 20/30/40 중 하나여야 합니다.")
|
|
if self.terrain_type not in GRADE_TERRAIN_TYPES:
|
|
raise ValueError(f"지형 구분은 {GRADE_TERRAIN_TYPES} 중 하나여야 합니다.")
|
|
if self.main_direction not in GRADE_MAIN_DIRECTIONS:
|
|
raise ValueError(f"주 진행방향은 {GRADE_MAIN_DIRECTIONS} 중 하나여야 합니다.")
|
|
# 화면·DB에 남아 있던 비율 표기를 퍼센트로 맞춰 이후 단계를 단일 단위로 만든다.
|
|
for field_name in GRADE_PERCENT_FIELDS:
|
|
setattr(self, field_name, normalize_grade_percent(getattr(self, field_name)))
|
|
return self
|
|
|
|
def grade_options(self) -> dict[str, Any]:
|
|
"""계획선 엔진에 넘길 요청 측 재정의 값(사용자가 비우면 None)."""
|
|
return {
|
|
"max_grade_pct": self.max_grade_pct,
|
|
"min_vertical_radius_m": self.min_vertical_radius_m,
|
|
"min_tangent_length_m": self.min_tangent_length_m,
|
|
"balance_segment_length_m": self.balance_segment_length_m,
|
|
"start_elevation_offset_m": self.start_elevation_offset_m,
|
|
"end_elevation_offset_m": self.end_elevation_offset_m,
|
|
}
|
|
|
|
def points_data(self) -> dict[str, Any]:
|
|
return {
|
|
"bp": self.bp.model_dump(),
|
|
"ep": self.ep.model_dump(),
|
|
"cp": [p.model_dump() for p in self.cp],
|
|
"ap": [p.model_dump() for p in self.ap],
|
|
"fp": [p.model_dump() for p in self.fp],
|
|
}
|
|
|
|
def options(self) -> dict[str, Any]:
|
|
return {
|
|
"grade_class": self.grade_class,
|
|
"paved": self.paved,
|
|
"terrain_type": self.terrain_type,
|
|
"design_speed_kph": self.design_speed_kph,
|
|
"main_direction": self.main_direction,
|
|
"min_curve_radius_m": self.min_curve_radius_m,
|
|
"max_uphill_grade": self.max_uphill_grade,
|
|
"max_downhill_grade": self.max_downhill_grade,
|
|
"min_uphill_grade": self.min_uphill_grade,
|
|
"min_downhill_grade": self.min_downhill_grade,
|
|
"weights": self.weights,
|
|
"allow_avoid_pass_through": self.allow_avoid_pass_through,
|
|
}
|
|
|
|
|
|
class ContourIntervalUpdateRequest(BaseModel):
|
|
"""등고선 간격 재적용 영속화 요청."""
|
|
|
|
model_config = ConfigDict(extra="forbid")
|
|
|
|
contour_interval_m: float = Field(gt=0)
|
|
|
|
|
|
class ContourIntervalUpdateResponse(BaseModel):
|
|
"""등고선 간격 영속화 결과."""
|
|
|
|
status: str = "success"
|
|
project_id: str
|
|
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)}. 길이 L은 R × |대수차| 로 파생된다.
|
|
curve_radii: dict[str, float] = Field(default_factory=dict)
|
|
|
|
@model_validator(mode="after")
|
|
def validate_edits(self) -> "ProfileAlignmentSaveRequest":
|
|
for chainage, radius in self.curve_radii.items():
|
|
if radius <= 0:
|
|
raise ValueError(f"종단곡선 반경은 0보다 커야 합니다 (chainage {chainage}).")
|
|
return self
|
|
|
|
def edits(self) -> dict[str, Any]:
|
|
return {"station_offsets": self.station_offsets, "curve_radii": self.curve_radii}
|
|
|
|
|
|
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):
|
|
"""경로 탐색 실행 결과."""
|
|
|
|
status: str = "success"
|
|
project_id: str
|
|
route_id: int
|
|
total_length_m: float
|
|
metrics: dict[str, Any]
|
|
required_points_ok: bool
|
|
route_data_path: str
|
|
# 종횡단 생성 실패 시 None (경로 자체는 저장됨)
|
|
longitudinal_length_m: float | None = None
|
|
cross_section_count: int | None = None
|
|
# 계획선 산출 실패 시 None (종횡단·경로는 저장됨)
|
|
grade_summary: dict[str, Any] | None = None
|
|
|
|
|
|
class IrregularStationInput(BaseModel):
|
|
"""비정규 측점(구조물). 규칙 격자 밖 chainage 위치에 구조물 정보를 담는다."""
|
|
|
|
model_config = ConfigDict(extra="forbid")
|
|
|
|
chainage_m: float = Field(ge=0)
|
|
structure: str = Field(default="")
|
|
|
|
|
|
class UphillSideOverride(BaseModel):
|
|
"""측점 상단측(=측구 방향) 사용자 변경값. 3D 원형 램프 클릭으로 지정된다."""
|
|
|
|
model_config = ConfigDict(extra="forbid")
|
|
|
|
chainage_m: float = Field(ge=0)
|
|
side: Literal["left", "right"]
|
|
|
|
|
|
class RouteConfirmRequest(BaseModel):
|
|
"""경로 확정 요청.
|
|
|
|
확정 시 비정규 측점의 **횡단을 함께 생성**하기 위해, 지표 샘플러 재구성에 필요한 값
|
|
(solve 때 쓰던 필터/방법/모델)을 함께 받는다. 비정규 측점이 없으면 재생성 없이 확정만 한다.
|
|
모든 필드가 선택이라 빈 본문(`{}`)이면 기존 확정 동작과 동일하다.
|
|
"""
|
|
|
|
model_config = ConfigDict(extra="forbid")
|
|
|
|
filter_key: str | None = None
|
|
method: str | None = None
|
|
smooth: bool = False
|
|
surface_model_id: int | None = None
|
|
irregular_stations: list[IrregularStationInput] = Field(default_factory=list)
|
|
# 측점 상단측(측구 방향) 사용자 변경분 — solve 자동 판정을 확정 시 덮어쓴다.
|
|
uphill_overrides: list[UphillSideOverride] = Field(default_factory=list)
|
|
|
|
def extra_stations(self) -> tuple[tuple[float, str], ...]:
|
|
return tuple((item.chainage_m, item.structure) for item in self.irregular_stations)
|
|
|
|
def can_regenerate(self) -> bool:
|
|
return bool(
|
|
self.irregular_stations
|
|
and self.filter_key
|
|
and self.method
|
|
and self.surface_model_id is not None
|
|
)
|
|
|
|
|
|
class RouteConfirmResponse(BaseModel):
|
|
"""경로 확정 결과."""
|
|
|
|
status: str = "success"
|
|
project_id: str
|
|
route_id: int
|
|
confirmed: bool = True
|
|
|
|
|
|
class RouteLatestResponse(BaseModel):
|
|
"""새로고침 복원을 위한 최신 경로·입력·통계 응답."""
|
|
|
|
status: str = "success"
|
|
project_id: str
|
|
route: dict[str, Any] | None = None
|
|
route_points: list[dict[str, Any]] = Field(default_factory=list)
|
|
surface_params: dict[str, Any]
|
|
route_params: dict[str, Any] | None = None
|