Merge remote-tracking branch 'origin/dev' into main_desktop_1

This commit is contained in:
2026-09-12 14:51:35 +09:00
22 changed files with 1675 additions and 173 deletions
+59 -1
View File
@@ -56,8 +56,12 @@ export interface RoutePlanResponse {
nodes: RoutePlanNode[];
/** 직선·곡선 성분 — 곡선 시작·끝점과 반지름. 화면이 이것으로 손잡이를 그린다. */
curves: RoutePlanCurve[];
/** 이 프로젝트에 적용한 법정 최소곡선반지름(m). */
/** 이 프로젝트에 적용한 법정 최소곡선반지름(m) — **기본값·위반 표시 기준**. */
min_radius_m: number;
/** **못 넘는** 곡선반지름 하한(m). 0이면 제한 없음(작업임도). 기본값과 다른 값이다. */
limit_radius_m?: number;
/** **못 넘는** 곡선 길이 하한(m). 0이면 제한 없음 — 지금은 전부 0(법에 값이 없음). */
limit_curve_length_m?: number;
curve_count: number;
violation_count: number;
/** 사용자가 고친 계획노선이 저장돼 있으면 true. */
@@ -128,6 +132,60 @@ export async function replanRoute(
);
}
/** 점 묶음의 **지반고**를 묻는다(계획서 0-9 ⑤·⑧).
*
* 새 계산이 아니라 확정된 지표면을 **읽기만** 하므로 편집 중에 불러도 된다 — 다만 끄는 동안
* 프레임마다 부르지는 않는다(찍는 순간에만). 지표면 밖은 `null` 로 온다. */
export async function fetchRouteElevations(
projectId: string,
points: Array<[number, number]>,
): Promise<Array<number | null>> {
const payload = await requestJson<{ z: Array<number | null> }>(
`/projects/${projectId}/route/elevations`,
{ method: "POST", body: JSON.stringify({ points }) },
60000,
);
return payload.z;
}
/** 횡단 미리보기 한 장 — 고치던 노선 그대로 그 측점만 서버가 셈해 준다(계획서 0-9 ⑧). */
export interface CrossPreviewResponse {
status: string;
chainage_m: number;
label: string | null;
uphill_side: string | null;
plan_radius_m: number | null;
curve_widening_m: number | null;
/** 원지반 횡단 샘플. */
samples: Array<{ offset_m?: number; elevation_m?: number | null; valid: boolean }>;
/** 기본 계획 횡단 — B06 `compute_cross_design` 이 낸 것. 계획고를 못 세우면 null. */
design: {
design_line: Array<{ offset_m: number; elevation_m: number }>;
cut_area_m2: number;
fill_area_m2: number;
[key: string]: unknown;
} | null;
}
export interface CrossPreviewRequest {
vertices: Array<{ x: number; y: number; curve: boolean; radius_m: number | null }>;
chainage_m: number;
min_radius_m: number;
station_interval_m: number;
}
/** 한 측점 횡단을 묻는다. 종·횡단을 한 번 돌리므로 **한두 초** 걸린다(사용자 확정: 괜찮음). */
export async function fetchCrossPreview(
projectId: string,
request: CrossPreviewRequest,
): Promise<CrossPreviewResponse> {
return requestJson<CrossPreviewResponse>(
`/projects/${projectId}/route/cross-preview`,
{ method: "POST", body: JSON.stringify(request) },
120000,
);
}
/** 계획노선을 예상노선으로 되돌리고 같은 재계산을 돈다(노선 초기화). */
export async function resetRoutePlan(projectId: string): Promise<RouteReplanResponse> {
return requestJson<RouteReplanResponse>(
+31
View File
@@ -151,6 +151,37 @@ def legal_plan_radius_min_m(design_speed_kph: int, terrain_type: str = "normal")
return float(speeds[terrain])
def plan_radius_limit_m(
grade_class: str,
design_speed_kph: int | None = None,
terrain_type: str = "normal",
) -> float:
"""계획노선 편집 화면이 **못 넘게 막을** 평면 곡선반지름 하한(m). 0이면 제한 없음.
위 `legal_plan_radius_min_m` 은 **기본값·위반 표시 기준**이고 이것은 **제한**이다
(2026-09-12 사용자 확정: 「아예 못 넘게 막음」). 임도 종류별 칸이 비어 있으면(None)
법정 표를 그대로 하한으로 쓰고, 값이 적혀 있으면 그 값을 쓴다 — 작업임도는 별표2에
곡선반지름 규정이 없어 0(제한 없음)으로 열려 있다.
"""
table = FOREST_ROAD_PROFILE_CRITERIA["plan_radius_limit_by_grade_m"]
override = table.get(grade_class)
if override is not None:
return float(override)
return legal_plan_radius_min_m(
resolve_design_speed(grade_class, design_speed_kph), terrain_type
)
def plan_curve_length_limit_m(grade_class: str) -> float:
"""평면 **곡선 길이(L)** 하한(m). 0이면 제한 없음.
법령·교본에 값이 없어 지금은 임도 종류 전부 0이다 — 자리만 열어 둔 칸이라
실무값이 정해지면 `config_system_design` 의 표만 고치면 된다(2026-09-12 사용자 확정).
"""
table = FOREST_ROAD_PROFILE_CRITERIA["plan_curve_length_limit_by_grade_m"]
return float(table.get(grade_class) or 0.0)
def _pick(*candidates: Any) -> Any:
"""요청 → DB 저장값 → config 순으로 처음 나오는 유효값을 고른다."""
for value in candidates:
+38 -1
View File
@@ -37,7 +37,11 @@ from common_util.common_util_drainage_pipes import (
route_signature,
)
from common_util.common_util_json import atomic_write_json
from common_util.common_util_route_geometry import RouteVertex
from common_util.common_util_route_geometry import (
RouteVertex,
planned_route_initial_path,
planned_route_working_path,
)
from common_util.common_util_surface_sampler import build_surface_sampler
from config.config_system import (
DRAINAGE_CACHE_DIRNAME,
@@ -66,6 +70,34 @@ def _load_route_polyline(project_root: Path, route_data_path: str) -> list[list[
return [[float(c[0]), float(c[1]), float(c[2]) if len(c) > 2 else 0.0] for c in coords]
def _load_design_curves(project_root: Path) -> list[dict[str, Any]]:
"""설계가 쓰는 계획노선의 **곡선표** — 없으면 빈 목록.
곡선표는 폴리라인 파일 옆에 같은 이름으로 선다(`…_curves.json`). 정점 목록만으로는
어디부터 어디까지가 한 곡선이고 반지름이 얼마인지 알 수 없어, 곡선부 확폭을 측점마다
반경을 다시 재는 방식으로 매기면 같은 곡선 안에서도 값이 갈린다(2026-09-12 실측).
수정본(`planned_route.csv`)이 있으면 **그 곡선표만** 쓴다 — 노선을 고쳤는데 초기본
곡선표를 읽으면 있지도 않은 자리에 확폭이 붙는다. 곡선표가 없으면 빈 목록을 돌려주고,
받는 쪽이 옛 방식(측점별 실측 반경)으로 물러선다.
"""
route_path = planned_route_working_path(project_root)
if not route_path.is_file():
route_path = planned_route_initial_path(project_root)
target = route_path.with_name(f"{route_path.stem}_curves.json")
if not target.is_file():
return []
try:
data = json.loads(target.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
logger.warning("계획노선 곡선표를 읽지 못했습니다: %s", target)
return []
curves = data.get("curves") if isinstance(data, dict) else None
return (
[curve for curve in curves if isinstance(curve, dict)] if isinstance(curves, list) else []
)
def cross_filename(chainage_m: float) -> str:
"""측점 chainage에 대응하는 횡단면 파일명(단일 규칙)."""
return f"cross_{int(round(float(chainage_m))):05d}m.json"
@@ -361,6 +393,9 @@ def run_section_generation(
replace(options or SectionGenerationOptions(), extra_stations=extras),
source_snapshot={"filter": filter_key, "method": method, "smooth": smooth},
crs=crs,
# 곡선부 확폭의 정본 — 설계 곡선표(시·종점·반지름)를 그대로 쓴다. 없으면 빈 목록이라
# 종전의 측점별 실측 반경으로 물러선다.
design_curves=_load_design_curves(project_root),
)
stage_root = project_root / _STAGE_SUBDIR
@@ -473,6 +508,8 @@ def generate_irregular_sections(
merged_options,
source_snapshot={"filter": filter_key, "method": method, "smooth": smooth},
crs=crs,
# 비정규(구조물) 측점도 같은 곡선표를 봐야 규칙 측점과 확폭이 어긋나지 않는다.
design_curves=_load_design_curves(project_root),
)
irregular_stations = [
station
+111 -5
View File
@@ -213,6 +213,101 @@ def _curve_widenings(
return widenings, sides
#: 곡선표의 시·종점이 노선 폴리라인에서 이만큼 떨어져 있으면 그 노선의 곡선이 아니라고 본다(m).
#: 노선을 잘라 쓰면(지표면 밖 트림) 곡선표에 남은 옛 곡선이 노선 밖에 뜬다.
DESIGN_CURVE_MATCH_TOLERANCE_M = 5.0
def _project_chainage(
points: np.ndarray, route_chainage: np.ndarray, xy: tuple[float, float]
) -> tuple[float, float]:
"""점을 노선 폴리라인 위로 내려 (떨어진 거리 m, 누가거리 m)."""
starts = points[:-1, :2]
vectors = points[1:, :2] - starts
lengths2 = np.einsum("ij,ij->i", vectors, vectors)
safe = np.where(lengths2 > 1e-12, lengths2, 1.0)
target = np.asarray(xy, dtype=np.float64)
ratios = np.clip(np.einsum("ij,ij->i", target - starts, vectors) / safe, 0.0, 1.0)
feet = starts + vectors * ratios[:, None]
distances = np.hypot(feet[:, 0] - target[0], feet[:, 1] - target[1])
index = int(np.argmin(distances))
span = route_chainage[index + 1] - route_chainage[index]
return float(distances[index]), float(route_chainage[index] + span * ratios[index])
def _design_curve_spans(
points: np.ndarray,
route_chainage: np.ndarray,
curves: list[dict[str, Any]],
) -> list[tuple[float, float, float, str]]:
"""설계 곡선표 → [(시점 누가거리, 종점 누가거리, 반지름 m, 곡선 **바깥쪽**)].
바깥쪽은 회전 방향으로 가른다 — 시점→교점→종점의 외적 z가 양수면 좌회전이라 안쪽이
좌측이고 바깥은 우측이다(`_plan_radii` 와 같은 규약). 노선에서 멀리 떨어진 곡선과
방향을 못 재는 곡선은 버린다.
"""
spans: list[tuple[float, float, float, str]] = []
for curve in curves:
start, apex, end = curve.get("start"), curve.get("apex"), curve.get("end")
radius = curve.get("radius_m")
if not (start and apex and end) or not isinstance(radius, (int, float)):
continue
start_gap, start_chainage = _project_chainage(points, route_chainage, tuple(start[:2]))
end_gap, end_chainage = _project_chainage(points, route_chainage, tuple(end[:2]))
if max(start_gap, end_gap) > DESIGN_CURVE_MATCH_TOLERANCE_M:
continue
cross = (apex[0] - start[0]) * (end[1] - apex[1]) - (apex[1] - start[1]) * (
end[0] - apex[0]
)
if abs(cross) < 1e-12:
continue
spans.append(
(
min(start_chainage, end_chainage),
max(start_chainage, end_chainage),
float(radius),
"right" if cross > 0 else "left",
)
)
return sorted(spans)
def _design_curve_widenings(
station_chainage: np.ndarray,
spans: list[tuple[float, float, float, str]],
) -> tuple[list[float | None], list[str | None], list[float]]:
"""설계 곡선표로 측점별 (평면 곡선반경, 바깥쪽, 확폭량)을 낸다.
곡선 **안**은 그 곡선의 설계 반경이 그대로 반경이고 확폭도 표값 한 값이다. 곡선 앞뒤
`CURVE_WIDENING_TAPER_M` 구간은 0 으로 잇고(실무 관행 직선 테이퍼), 그 밖은 확폭이
없다. 곡선이 겹치면 **확폭이 큰 쪽**을 따르고, 반경은 작은 쪽(급한 쪽)을 남긴다.
"""
count = len(station_chainage)
radii: list[float | None] = [None] * count
sides: list[str | None] = [None] * count
widenings: list[float] = [0.0] * count
for start, end, radius, side in spans:
table = curve_widening_m(radius)
for index, value in enumerate(station_chainage):
chainage = float(value)
inside = start - 1e-9 <= chainage <= end + 1e-9
if inside and (radii[index] is None or radius < radii[index]):
radii[index] = round(radius, 3)
if table <= 0.0:
continue
if inside:
amount = table
else:
distance = start - chainage if chainage < start else chainage - end
if distance > CURVE_WIDENING_TAPER_M:
continue
amount = round(table * (1.0 - distance / CURVE_WIDENING_TAPER_M), 4)
if amount > widenings[index]:
widenings[index] = amount
sides[index] = side
return radii, sides, widenings
def generate_sections(
polyline: np.ndarray | list[list[float]],
sampler: SurfaceElevationSampler,
@@ -220,6 +315,7 @@ def generate_sections(
*,
source_snapshot: dict[str, Any] | None = None,
crs: str | None = None,
design_curves: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
"""확정 경로로 CAD 인계 가능한 종단·횡단 원시 데이터를 생성한다."""
options = options or SectionGenerationOptions()
@@ -267,11 +363,21 @@ def generate_sections(
# 측점별 **평면 곡선반경**(m). 곡선부 확폭(별표2 Ⅰ.2.나.(4))과 최소곡선반지름 위반
# 표시가 이 값을 쓴다(2026-09-06). 노선 폴리라인 위에서 앞뒤로 같은 거리를 떨어진 세
# 점의 외접원 반경이며, 직선이면 무한대라 None 으로 낸다.
plan_radii, plan_outer_sides = _plan_radii(points, route_chainage, station_chainage, total)
# 확폭량은 여기서 한 번에 낸다 — 테이퍼가 이웃 측점을 봐야 하므로 측점 단위로는 못 낸다.
plan_widenings, plan_outer_sides = _curve_widenings(
station_chainage, plan_radii, plan_outer_sides
)
# **설계 곡선표가 있으면 그것이 정본**이다(2026-09-12 사용자 지시). 측점마다 반경을
# 다시 재면 같은 곡선 안에서도 확폭이 갈리고, 곡선이 측점 간격보다 짧으면 통째로
# 빠진다(실측: 설계 12m 곡선에 1.50m 이 붙고 곡선 밖 직선까지 흘러나갔다).
# 곡선표가 없는 옛 프로젝트만 종전의 측점별 실측으로 물러선다.
spans = _design_curve_spans(points, route_chainage, design_curves or [])
if spans:
plan_radii, plan_outer_sides, plan_widenings = _design_curve_widenings(
station_chainage, spans
)
else:
plan_radii, plan_outer_sides = _plan_radii(points, route_chainage, station_chainage, total)
# 확폭량은 여기서 한 번에 낸다 — 테이퍼가 이웃 측점을 봐야 하므로 측점 단위로는 못 낸다.
plan_widenings, plan_outer_sides = _curve_widenings(
station_chainage, plan_radii, plan_outer_sides
)
offsets = np.arange(
-options.cross_half_width_m,
+31 -6
View File
@@ -133,7 +133,19 @@ def _ensure_expected_route(project_root: Path) -> str:
async def _min_plan_radius_m(project_id: UUID) -> float:
"""이 프로젝트에 적용할 법정 최소곡선반지름(m) — 임도 종류·설계속도·지형으로 고른다.
"""기본 반지름만 필요한 자리 — 하한까지 필요하면 `_plan_criteria` 를 쓸 것."""
criteria = await _plan_criteria(project_id)
return criteria[0]
async def _plan_criteria(project_id: UUID) -> tuple[float, float, float]:
"""이 프로젝트의 **기본 반지름 · 반지름 하한 · 곡선 길이 하한**(m) 세 값.
기본 반지름은 곡선을 만들 쓰는 값이고, 하한 둘은 **화면이 넘게 막는** 값이다
(2026-09-12 사용자 확정). 둘을 값으로 묶으면 하한 0 반지름 0 되어 곡선이
아예 그려지므로 반드시 갈라 둔다.
기본 반지름은 임도 종류·설계속도·지형으로 고른다.
값의 출처는 지식DB(`01_임도/02_상세설계/평면선형.md`, 별표2 .2.)이고 산식은 이미
`B05_Profile_Engine_Grade.legal_plan_radius_min_m` 있다 여기서 다시 짜지 않는다.
@@ -145,7 +157,12 @@ async def _min_plan_radius_m(project_id: UUID) -> float:
"""
import aiomysql
from B05_Profile.B05_Profile_Engine_Grade import legal_plan_radius_min_m, resolve_design_speed
from B05_Profile.B05_Profile_Engine_Grade import (
legal_plan_radius_min_m,
plan_curve_length_limit_m,
plan_radius_limit_m,
resolve_design_speed,
)
from common_util.common_util_workflow_state import get_workflow_state
grade_class, design_speed, terrain = "work", None, "special"
@@ -172,7 +189,11 @@ async def _min_plan_radius_m(project_id: UUID) -> float:
terrain = str(params["terrain_type"])
except Exception: # noqa: BLE001 — 설정을 못 읽어도 폴리라인화는 이어 간다
logger.exception("최소곡선반지름 설정을 못 읽어 기본값을 씁니다: %s", project_id)
return legal_plan_radius_min_m(resolve_design_speed(grade_class, design_speed), terrain)
return (
legal_plan_radius_min_m(resolve_design_speed(grade_class, design_speed), terrain),
plan_radius_limit_m(grade_class, design_speed, terrain),
plan_curve_length_limit_m(grade_class),
)
def _nodes_path(path: Path) -> Path:
@@ -404,7 +425,7 @@ async def read_route_plan(project_id: UUID) -> dict[str, Any] | JSONResponse:
if not expected:
expected = await asyncio.to_thread(_vertices_of, design_route_csv_path(project_root))
radius_m = await _min_plan_radius_m(project_id)
radius_m, radius_limit_m, arc_limit_m = await _plan_criteria(project_id)
await asyncio.to_thread(_ensure_planned_initial, project_root, radius_m)
working = await asyncio.to_thread(_vertices_of, planned_route_working_path(project_root))
initial = await asyncio.to_thread(_vertices_of, planned_route_initial_path(project_root))
@@ -445,6 +466,10 @@ async def read_route_plan(project_id: UUID) -> dict[str, Any] | JSONResponse:
# (2026-09-07 사용자 확정). 저장분이 있으면 그것을, 없으면 방금 뽑은 것을 준다.
"curves": saved_curves or [curve.as_dict() for curve in outline.curves],
"min_radius_m": round(radius_m, 2),
# **못 넘는 하한** — 기본값(`min_radius_m`)과 다른 값이다. 0이면 제한 없음
# (작업임도는 별표2에 곡선반지름 규정이 없어 0으로 열려 있다, 2026-09-12 확정).
"limit_radius_m": round(radius_limit_m, 2),
"limit_curve_length_m": round(arc_limit_m, 2),
"curve_count": len(saved_curves) if saved_curves else outline.curve_count,
"violation_count": outline.violation_count,
"edited": bool(working),
@@ -468,7 +493,7 @@ async def replan_route(
# 고치기 전에 예상노선(원본)·초기 폴리라인이 서 있는지 본다 — 초기화가 돌아갈 자리다.
await asyncio.to_thread(_ensure_expected_route, project_root)
radius_m = await _min_plan_radius_m(project_id)
radius_m, radius_limit_m, arc_limit_m = await _plan_criteria(project_id)
await asyncio.to_thread(_ensure_planned_initial, project_root, radius_m)
# 화면이 보낸 것은 **노드(꺾임점)** 다 — 같은 R 규칙으로 다시 폴리라인을 만든다.
# 노드만 옮기면 선이 저절로 규칙을 지키는 것이 이 구조의 목적이다(2026-09-06 사용자).
@@ -527,7 +552,7 @@ async def reset_route_plan(project_id: UUID) -> dict[str, Any] | JSONResponse:
return JSONResponse(status_code=404, content=_PROJECT_PATH_MISSING)
project_root, stored_path = paths
await asyncio.to_thread(_ensure_expected_route, project_root)
radius_m = await _min_plan_radius_m(project_id)
radius_m, radius_limit_m, arc_limit_m = await _plan_criteria(project_id)
await asyncio.to_thread(_ensure_planned_initial, project_root, radius_m)
working_path = planned_route_working_path(project_root)
if working_path.is_file():
+221
View File
@@ -0,0 +1,221 @@
"""계획노선 편집 중 **지반고만** 묻는 가벼운 통로.
편집 모달은 [확인] 전까지 아무 계산도 내보내지 않는다(계획서 0-2 확정 7). 다만 점을 찍어
**구간 길이와 종단기울기** (0-9 ) 측점의 **횡단도 미리보기**() 지반고가
있어야 한다. 계산이 아니라 **이미 확정된 지표면을 읽기만** 하는 통로라 규칙과 부딪히지
않는다 노선을 갈아 끼우지도, 정본을 건드리지도 않는다.
표고 조회는 ·횡단 생성기가 쓰는 것과 **같은 sampler**(`build_surface_sampler`) 연다.
화면이 다른 표고를 보면 같은 자리의 기울기가 갈린다.
POST /api/projects/{id}/route/elevations 묶음의 지반고
POST /api/projects/{id}/route/cross-preview 고치던 노선의 측점 횡단 미리보기
"""
import asyncio
import logging
from pathlib import Path
from uuid import UUID
import numpy as np
from fastapi import APIRouter
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
from B05_Profile.B05_Profile_Engine_Sections_Core import (
SectionGenerationOptions,
generate_sections,
)
from B06_Section.B06_Section_Engine_Design import compute_cross_design, curve_widening_args
from common_util.common_util_route_polyline import build_planned_polyline
from common_util.common_util_storage import resolve_stored_project_path
from common_util.common_util_surface_confirmation import get_surface_confirmation_params
from common_util.common_util_surface_sampler import build_surface_sampler
from config.config_db import get_db_pool
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/projects", tags=["B05 Route Terrain"])
_MODELS_SUBDIR = Path("B04_PreProcess") / "models"
#: 한 번에 물을 수 있는 점 수. 구간 재기는 수십 점, 횡단 한 장은 수백 점이면 넉넉하다 —
#: 상한을 두어 실수로 노선 전체를 밀어 넣는 일을 막는다.
MAX_POINTS = 4000
class ElevationRequest(BaseModel):
"""사업지 좌표계(m) 점 묶음 [[x, y], …]."""
points: list[tuple[float, float]] = Field(..., min_length=1, max_length=MAX_POINTS)
def _sample(project_root: Path, params: dict, points: list[tuple[float, float]]):
"""확정 지표면에서 표고를 읽는다. 모델을 못 열면 None."""
try:
sampler = build_surface_sampler(
project_root / _MODELS_SUBDIR,
str(params["source_filter"]),
str(params["method"]),
bool(params["smooth"]),
)
except (FileNotFoundError, KeyError, OSError, ValueError) as exc:
logger.warning("계획노선 편집: 지표면을 열지 못했습니다 — %s", exc)
return None
z, valid = sampler.sample_xy(np.asarray(points, dtype=np.float64))
return z, valid
@router.post("/{project_id}/route/elevations", response_model=None)
async def read_route_elevations(project_id: UUID, request: ElevationRequest) -> dict | JSONResponse:
"""점 묶음의 지반고(m)와 유효 여부를 돌려준다.
지표면 밖이거나 자료가 없는 자리는 `valid=false` 나가고 표고는 `null` 이다
**임의 표고로 메우지 않는다**(sampler 규칙 그대로). 화면은 자리를 모름으로 낸다.
"""
pool = get_db_pool()
async with pool.acquire() as connection:
stored = await get_project_storage_relative_path(connection, project_id)
if not stored:
return JSONResponse(
status_code=404,
content={"status": "error", "message": "프로젝트 저장 경로를 찾을 수 없습니다."},
)
params = await get_surface_confirmation_params(connection, str(project_id))
project_root = Path(resolve_stored_project_path(stored))
sampled = await asyncio.to_thread(_sample, project_root, params, request.points)
if sampled is None:
return JSONResponse(
status_code=409,
content={
"status": "error",
"message": "확정된 지표면이 없어 지반고를 읽을 수 없습니다.",
},
)
z, valid = sampled
return {
"status": "success",
"project_id": str(project_id),
"z": [None if not ok else round(float(value), 3) for value, ok in zip(z, valid)],
"valid": [bool(ok) for ok in valid],
}
class PreviewVertex(BaseModel):
"""편집 중인 꺾임점 하나 — `RouteVertexInput` 과 같은 꼴."""
x: float
y: float
curve: bool = True
radius_m: float | None = None
class CrossPreviewRequest(BaseModel):
"""고치던 노선 그대로 한 측점의 횡단을 미리 본다."""
vertices: list[PreviewVertex] = Field(..., min_length=2)
chainage_m: float = Field(..., ge=0)
#: 법정 최소곡선반지름(m) — 화면이 `/route/plan` 에서 받은 값을 그대로 돌려준다.
min_radius_m: float = Field(12.0, gt=0)
station_interval_m: float | None = None
def _cross_preview(
project_root: Path,
params: dict,
request: CrossPreviewRequest,
) -> dict | None:
"""고치던 노선으로 종·횡단을 한 번 돌려 그 측점 한 장을 뽑는다.
**B05·B06 정본 로직을 그대로 재사용한다**(2026-09-12 사용자 확정 기본 로직은 B06에
존재함. 재사용) `generate_sections` 측점·접선·지반 샘플을, `compute_cross_design`
설계선을 만든다. 여기서 기하를 새로 짜지 않는다.
**계획고는 아직 없다.** 계획고는 [확인] 체인이 낳는 값이라 편집 중에는 존재하지
않는다. 그래서 측점의 **지반고를 그대로 계획고로 놓는다**(지반 추종) ·성토가 사면
기울기만으로 서는 기본 계획 횡단이며, 사용자가 보기로 것도 그것이다.
"""
try:
sampler = build_surface_sampler(
project_root / _MODELS_SUBDIR,
str(params["source_filter"]),
str(params["method"]),
bool(params["smooth"]),
)
except (FileNotFoundError, KeyError, OSError, ValueError) as exc:
logger.warning("횡단 미리보기: 지표면을 열지 못했습니다 — %s", exc)
return None
built = build_planned_polyline(
[(vertex.x, vertex.y) for vertex in request.vertices],
min_radius_m=request.min_radius_m,
# 화면이 준 노드는 이미 꺾임점이다 — 다시 뽑으면 선이 깎인다(`_write_planned_polyline`).
simplify=False,
curve_flags=[vertex.curve for vertex in request.vertices],
radii=[vertex.radius_m for vertex in request.vertices],
)
interval = request.station_interval_m
options = (
SectionGenerationOptions(station_interval_m=float(interval))
if interval and interval > 0
else SectionGenerationOptions()
)
result = generate_sections(built.vertices, sampler, options)
sections = result["cross_sections"]
if not sections:
return None
section = min(sections, key=lambda row: abs(float(row["chainage_m"]) - request.chainage_m))
design = None
center_z = section.get("center_z")
if center_z is not None:
# 단면유형 기본값은 B06 화면과 같다 — 등고가 높은 쪽을 절토로 본다.
section_mode = "right_cut" if section.get("uphill_side") == "right" else "left_cut"
design = compute_cross_design(
section["samples"],
float(center_z),
ground_type="soil",
section_mode=section_mode,
**curve_widening_args(section),
)
return {
"chainage_m": round(float(section["chainage_m"]), 3),
"label": section.get("label"),
"uphill_side": section.get("uphill_side"),
"plan_radius_m": section.get("plan_radius_m"),
"curve_widening_m": section.get("curve_widening_m"),
"samples": section["samples"],
"design": design,
"total_length_m": round(float(result["longitudinal"]["total_length_m"]), 3)
if result.get("longitudinal", {}).get("total_length_m") is not None
else None,
}
@router.post("/{project_id}/route/cross-preview", response_model=None)
async def read_cross_preview(project_id: UUID, request: CrossPreviewRequest) -> dict | JSONResponse:
"""고치던 계획노선의 **한 측점 횡단**을 돌려준다(계획서 0-9 ⑧).
정본을 건드리지 않는다 파일도 DB 쓰지 않고 자리에서 셈해 돌려주기만 한다.
"""
pool = get_db_pool()
async with pool.acquire() as connection:
stored = await get_project_storage_relative_path(connection, project_id)
if not stored:
return JSONResponse(
status_code=404,
content={"status": "error", "message": "프로젝트 저장 경로를 찾을 수 없습니다."},
)
params = await get_surface_confirmation_params(connection, str(project_id))
project_root = Path(resolve_stored_project_path(stored))
preview = await asyncio.to_thread(_cross_preview, project_root, params, request)
if preview is None:
return JSONResponse(
status_code=409,
content={
"status": "error",
"message": "확정된 지표면이 없어 횡단을 미리 볼 수 없습니다.",
},
)
return {"status": "success", "project_id": str(project_id), **preview}
+115 -136
View File
@@ -25,11 +25,11 @@ import {
} from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
import { prepareLayer } from "../B04_PreProcess/B04_PreProcess_UI_MapRender_Prepare";
import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import { clearDrafts, clearResults } from "../A00_Common/b_page_state";
import { showToast } from "@ui/ui_template_elements";
import { loadRouteEditContours, type RouteEditContours } from "./B05_Profile_UI_RouteEdit_Contour";
import { fetchDrainageLayers } from "./B05_Profile_UI_Drainage_Parts";
import { fetchRoutePlan, replanRoute, resetRoutePlan } from "./B05_Profile_Api_Replan";
import { fetchRoutePlan } from "./B05_Profile_Api_Replan";
import { bindRouteApply } from "./B05_Profile_UI_RouteEdit_Apply";
import {
buildEditedPolyline,
dragHandleTo as curveDragTo,
@@ -42,16 +42,18 @@ import {
handleAtScreen,
nodeAtScreen,
segmentAtScreen,
stationAtScreen,
} from "./B05_Profile_UI_RouteEdit_Input";
import {
centerDirectionOf,
createCurveLabel,
deflectionRad,
} from "./B05_Profile_UI_RouteEdit_Label";
import { createCrossPreview } from "./B05_Profile_UI_RouteEdit_Cross";
import { createMeasureTool } from "./B05_Profile_UI_RouteEdit_Measure";
import { createCurveBar } from "./B05_Profile_UI_RouteEdit_CurveBar";
import {
applyArcLocks,
applyCurveLimits,
curveShortfalls,
curveSummary,
flattenServerPlan,
shortfallCrossed,
type CurveLock,
} from "./B05_Profile_UI_RouteEdit_Edits";
import {
@@ -68,6 +70,8 @@ const NODE_HIT_PX = 9;
const SEGMENT_HIT_PX = 12;
/** 등고선을 집었다고 볼 거리(px) — 노드·손잡이보다 **좁게** 둔다(노선 편집이 먼저). */
const CONTOUR_HIT_PX = 6;
/** 측점 눈금을 집었다고 볼 거리(px) — 눈금이 보이는 자리를 누르면 잡히게 넉넉히. */
const STATION_HIT_PX = 11;
type Vertex = [number, number];
@@ -96,7 +100,9 @@ export async function openRouteEditModal(
<strong> </strong>
<span class="b05-routeedit__hint">
= · = R · = ·
= · () = · =
= · <b> = </b> ·
<b>Shift+ = ·</b> ·
() = · =
</span>
<button type="button" class="b05-routeedit__close" aria-label="닫기"></button>
</div>
@@ -134,6 +140,9 @@ export async function openRouteEditModal(
/** 노드마다의 반지름·내각·법정 위반 — 서버가 함께 내려 준다(표시용). */
let nodeInfo: EditedNode[] = [];
let minRadiusM = 0;
/** **못 넘는** 하한 — 0이면 제한 없음. 기본 반지름(`minRadiusM`)과 다른 값이다(계획서 0-9 ④). */
let limitRadiusM = 0;
let limitArcM = 0;
/** 서버가 준 곡선 성분 — 손잡이(곡선 시작·끝점)를 그리는 재료. 편집하면 비운다. */
let curveInfo: EditedCurve[] = [];
/** 꺾임점마다의 편집값 — 곡선을 둘지, 반지름을 못박을지(2026-09-07 사용자 지시). */
@@ -154,6 +163,34 @@ export async function openRouteEditModal(
let otherSheets: PreparedLayer[] = [];
/** 고른 등고선 가닥 — 없으면 -1(계획서 0-9 ⑦). */
let pickedContour = -1;
/** 측점 횡단 미리보기 창 — 측점 눈금을 누르면 뜬다(계획서 0-9 ⑧). */
const crossPreview = createCrossPreview({
projectId,
bounds: () => canvas.getBoundingClientRect(),
request: () => ({
vertices: planned.map(([x, y], index) => ({
x,
y,
curve: curveOn[index] !== false,
radius_m: curveRadius[index] ?? null,
})),
min_radius_m: minRadiusM || 12,
station_interval_m: stationIntervalM,
}),
});
/** 구간 재기 — Shift+클릭으로 두 점을 찍는다. 셈·서버 묻기는 `_Measure` 몫(계획서 0-9 ⑤). */
const measure = createMeasureTool({
projectId,
stationIntervalM,
line: () => (plannedLine.length ? plannedLine : planned),
// 아래에 선언된 것을 감싸 넘긴다 — 부르는 시점은 늘 그 뒤다.
toScreen: (vertex) => toScreen(vertex),
isClosed: () => closed,
onChange: () => {
status.textContent = `${routeHead()}${measure.hint()}`;
draw();
},
});
let view: ViewState = {
width: 0,
height: 0,
@@ -169,6 +206,7 @@ export async function openRouteEditModal(
window.removeEventListener("resize", resize);
historyControls.dispose(); // 단축키는 창(window)에 달려 있어 안 떼면 닫힌 뒤에도 산다.
curveLabelBox.destroy(); // 패널은 `document.body` 에 붙어 있어 스스로 안 사라진다.
crossPreview.destroy();
overlay.remove();
};
overlay.querySelector(".b05-routeedit__close")!.addEventListener("click", close);
@@ -233,6 +271,7 @@ export async function openRouteEditModal(
otherSheets,
pickedContour,
contourStepM: contourStepM(),
measure: measure.points(),
expected,
plannedLine,
planned,
@@ -284,6 +323,8 @@ export async function openRouteEditModal(
function markEdited(): void {
// 길이를 붙든 자리는 교각이 바뀌었을 수 있다 — 그리기 전에 R 부터 다시 잡는다.
applyArcLocks(planned, curveLock, curveArc, curveRadius);
// 지정해 둔 값이 하한을 밑돌면 하한까지 끌어올린다(계획서 0-9 ④).
applyCurveLimits(planned, curveOn, curveRadius, limitRadiusM, limitArcM);
const built = buildEditedPolyline(planned, curveOn, curveRadius, minRadiusM);
plannedLine = built.vertices;
curveInfo = built.curves;
@@ -316,86 +357,26 @@ export async function openRouteEditModal(
fresh: nodeInfo.length === 0,
});
// ── 곡선 라벨 — 고른 꺾임점 옆(곡선 중심 반대쪽)에 뜬다. 그리기는 `_Label` 몫 ──
const curveLabelBox = createCurveLabel({
onRadius: (value) => {
if (picked < 0) return;
curveRadius[picked] = value;
// 반지름만 바꾼 것이라 노드 자리는 그대로지만, 그려진 선은 낡았다.
applyEdit(value === null ? "반지름을 자동으로 되돌렸습니다." : "반지름을 바꿨습니다.");
},
onArcLength: (value) => {
if (picked < 0) return;
// 곡선 길이 L 과 반지름 R 은 L = R·Δ 로 묶여 있다(Δ = 교각, 앞뒤 직선이 정함).
// 그래서 길이를 받으면 반지름으로 바꿔 **한 값만** 들고 간다 — 두 벌로 두면 어긋난다.
const deflection = deflectionRad(nodeInfo[picked]?.inner_angle_deg);
curveArc[picked] = value;
curveRadius[picked] = value !== null && deflection > 1e-9 ? value / deflection : null;
applyEdit(value === null ? "반지름을 자동으로 되돌렸습니다." : "곡선 길이를 바꿨습니다.");
},
onLock: (lock) => {
if (picked < 0) return;
curveLock[picked] = lock;
const deflection = deflectionRad(nodeInfo[picked]?.inner_angle_deg);
const shown = curveRadius[picked] ?? nodeInfo[picked]?.radius_m ?? null;
// 길이를 붙들려면 지금 길이를 적어 둬야 한다 — 뒤에 교각이 바뀌면 이 값으로 R 을 다시 잡는다.
if (lock === "arc") {
curveArc[picked] = shown !== null && deflection > 1e-9 ? shown * deflection : null;
}
// R 을 붙들 때 칸이 비어 있으면 지금 그려진 R 을 적어 둔다(자동 상태를 그대로 못 박음).
if (lock === "radius" && curveRadius[picked] === null) curveRadius[picked] = shown;
applyEdit(
lock === "radius"
? "반지름을 고정했습니다."
: lock === "arc"
? "곡선 길이를 고정했습니다."
: "고정을 풀었습니다.",
);
},
onCurveOn: (on) => {
if (picked < 0) return;
curveOn[picked] = on;
applyEdit(on ? "곡선을 넣었습니다." : "곡선을 지웠습니다.");
},
// ── 곡선 라벨 — 고른 꺾임점 옆에 뜨는 조작 패널. 배선은 `_CurveBar` 몫 ──
const curveBar = createCurveBar({
canvas,
state: () => ({
picked,
planned,
nodeInfo,
curveInfo,
curveOn,
curveRadius,
curveLock,
curveArc,
limitRadiusM,
limitArcM,
}),
toScreen: (vertex) => toScreen(vertex),
applyEdit: (message) => applyEdit(message),
});
/** 고른 자리에 맞춰 라벨을 옮겨 그린다. 끝점은 곡선이 없으므로 라벨을 숨긴다. */
function syncCurveBar(): void {
if (!(picked > 0 && picked < planned.length - 1)) {
curveLabelBox.hide();
return;
}
const pickedCurve = curveInfo.find((entry) => entry.node_first === picked);
const shown = curveRadius[picked] ?? pickedCurve?.radius_m ?? null;
const deflection = deflectionRad(nodeInfo[picked]?.inner_angle_deg);
const rect = canvas.getBoundingClientRect();
const [screenX, screenY] = toScreen(planned[picked]);
curveLabelBox.show({
seat: picked,
// 패널은 `position: fixed` 라 **화면 좌표**로 넘긴다.
at: [screenX + rect.left, screenY + rect.top],
// 넘어가도 되는 테두리 = **지도 칸**(하단 정보행 위까지). 밖으로 나가면 지금 무엇을
// 고치는지 모달 안에서 안 보인다(2026-09-12 사용자 지적 ⑨).
bounds: {
left: rect.left + 8,
top: rect.top + 8,
right: rect.right - 8,
bottom: rect.bottom - 8,
},
centerDirection: pickedCurve
? centerDirectionOf(
toScreen([pickedCurve.apex[0], pickedCurve.apex[1]]),
toScreen(pickedCurve.start),
toScreen(pickedCurve.end),
)
: null,
curveOn: curveOn[picked] !== false,
radiusShown: shown,
arcLengthShown: shown === null || deflection <= 1e-9 ? null : shown * deflection,
lock: curveLock[picked] ?? null,
innerAngleDeg: nodeInfo[picked]?.inner_angle_deg ?? null,
});
}
const curveLabelBox = curveBar.label;
const syncCurveBar = curveBar.sync;
/** 한 번의 편집을 마무리한다 — 다시 그리고, 라벨·상태줄을 맞추고, 되돌리기에 쌓는다. */
function applyEdit(message: string, record = true): void {
@@ -440,6 +421,11 @@ export async function openRouteEditModal(
const rect = canvas.getBoundingClientRect();
const px = event.clientX - rect.left;
const py = event.clientY - rect.top;
if (event.shiftKey) {
// 구간 재기가 먼저다 — 노드 위에서도 재려는 뜻으로 본다(계획서 0-9 ⑤).
void measure.pick(px, py);
return;
}
// **노드가 손잡이보다 먼저다**(2026-09-07 사용자 지적 ④). 반대로 두었더니 헤어핀처럼
// 곡선이 몰린 데서는 손잡이가 늘 먼저 잡혀 **노드를 아예 못 집었다**(실화면에서 격자로
// 훑어 보니 잡히는 것이 전부 손잡이였음). 손잡이는 고른 곡선에만 나오므로 겹침도 적다.
@@ -454,6 +440,23 @@ export async function openRouteEditModal(
picked = dragHandle.node;
syncCurveBar();
draw();
} else if (
// 측점 눈금을 누르면 그 측점 횡단을 따로 띄운다(계획서 0-9 ⑧). 노드·손잡이 다음이다.
(() => {
const chainage = stationAtScreen(
plannedLine.length ? plannedLine : planned,
toScreen,
stationIntervalM,
px,
py,
STATION_HIT_PX,
);
if (chainage === null) return false;
void crossPreview.open(chainage);
return true;
})()
) {
/* 횡단 창이 떴다 — 더 집지 않는다. */
} else if (contours) {
// 노드도 손잡이도 아니면 **등고선**을 집는다 — 노선 편집이 늘 먼저다(계획서 0-9 ⑦).
// 빈 자리를 누르면 -1 이 되어 고른 것이 풀린다.
@@ -477,7 +480,8 @@ export async function openRouteEditModal(
const moved = dragHandleTo(node, dragHandle.end, toMetric(px, py));
if (moved) {
planned[node] = moved.apex;
curveRadius[node] = Math.round(moved.radius * 100) / 100;
// 손으로 끌어도 하한 아래로는 안 내려간다 — 거기서 멈춘다(계획서 0-9 ④).
curveRadius[node] = Math.max(limitRadiusM, Math.round(moved.radius * 100) / 100);
curveOn[node] = true;
picked = node;
// 손잡이 자리는 다시 셈한 곡선에서 나온다 — 접선 자리가 모자라 R 이 눌리면 손이
@@ -491,9 +495,23 @@ export async function openRouteEditModal(
return;
}
if (dragNode >= 0) {
// 옮기기 **전**에 하한을 지키던 자리 — 이미 밑돌던 자리는 그대로 고칠 수 있어야 하므로
// **지키던 자리가 넘어가는 것만** 막는다(계획서 0-9 ④).
const before = curveShortfalls(nodeInfo, limitRadiusM, limitArcM);
const previous = planned[dragNode];
dragMoved = true;
planned[dragNode] = toMetric(px, py);
markEdited(); // 곡선을 그 자리에서 다시 그린다 — 나머지 곡선은 그대로 남는다.
if (shortfallCrossed(before, curveShortfalls(nodeInfo, limitRadiusM, limitArcM))) {
// 접선 자리가 모자라 R 이 하한 아래로 눌리는 자리다 — 그 걸음만 되돌린다.
planned[dragNode] = previous;
markEdited();
status.textContent =
`${routeHead()} — 하한에 걸려 더 못 옮깁니다` +
`(곡선반지름 ${limitRadiusM}m${limitArcM > 0 ? ` · 곡선 길이 ${limitArcM}m` : ""}).`;
draw();
return;
}
// 끄는 동안에도 상태줄이 살아 있어야 한다 — 예전에는 여기서 아무 말이 없어
// 「곡선이 사라졌다」는 인상만 남았다(2026-09-07 사용자 지적 ②).
status.textContent = `${routeHead()} — 옮기는 중. ${curveHint()}`;
@@ -563,54 +581,13 @@ export async function openRouteEditModal(
draw,
});
async function runHeavy(label: string, task: () => Promise<unknown>): Promise<void> {
busy.hidden = false;
// ⚠ 「몇 분」은 옛 값이었다 — 0-11 로 **약 90초**가 됐다(2026-09-09 실측 네 번:
// 87.3 · 90.0 · 93.9 · 95.4초). 중간 취소를 안 만드는 대신, **얼마나 지났는지**를
// 보여 사람이 멈춘 것인지 도는 것인지 알 수 있게 한다(계획서 0-2).
const message = busy.querySelector("span")!;
const started = Date.now();
const tick = (): void => {
const seconds = Math.round((Date.now() - started) / 1000);
message.textContent = `${label} — 배수유역부터 다시 계산 중입니다. 1분 반쯤 걸립니다 (${seconds}초 지남).`;
};
tick();
const timer = window.setInterval(tick, 1000);
try {
await task();
// 노선이 바뀌면 세션 초안·조회 캐시는 옛 노선 것이라 남기지 않는다(PLAN 0-7 확정 5).
clearDrafts(projectId);
clearResults(projectId);
showToast("노선을 다시 계산했습니다.", "success");
close();
await onApplied();
} catch (error) {
busy.hidden = true;
showToast(error instanceof Error ? error.message : "노선 재계산에 실패했습니다.", "error");
} finally {
window.clearInterval(timer); // 성공·실패·닫힘 어느 쪽이든 멈춘다
}
}
overlay.querySelector('[data-act="apply"]')!.addEventListener("click", () => {
if (planned.length < 2) {
showToast("노선은 노드가 2개 이상이어야 합니다.", "error");
return;
}
void runHeavy("계획노선 반영", () =>
replanRoute(
projectId,
planned.map(([x, y], index) => ({
x,
y,
curve: curveOn[index] !== false,
radius_m: curveRadius[index] ?? null,
})),
),
);
});
overlay.querySelector('[data-act="reset"]')!.addEventListener("click", () => {
void runHeavy("예상노선으로 되돌리기", () => resetRoutePlan(projectId));
bindRouteApply({
overlay,
busy,
projectId,
nodes: () => ({ planned, curveOn, curveRadius }),
close,
onApplied,
});
// ── 자료 읽기 — 노선 두 벌 + 등고선 도엽(배수유역도와 같은 것) ──
@@ -626,6 +603,8 @@ export async function openRouteEditModal(
// (2026-09-06 사용자 지시: 노드를 제어해 계획노선을 고친다).
const nodes = plan.nodes ?? [];
minRadiusM = plan.min_radius_m ?? 0;
limitRadiusM = plan.limit_radius_m ?? 0;
limitArcM = plan.limit_curve_length_m ?? 0;
// 곡선 성분을 편집할 수 있는 꼴로 편다 — 셈은 `_Edits` 몫(까닭도 그쪽에 적었다).
const flat = flattenServerPlan(nodes, plan.curves ?? []);
planned = flat.planned;
@@ -0,0 +1,81 @@
/* =============================================================================
* B05_Profile_UI_RouteEdit_Apply.ts
* **[]·[]** .
*
* ·· ( 90).
* ( 0-2, 2026-09-09) ** **
* .
* ========================================================================== */
import { clearDrafts, clearResults } from "../A00_Common/b_page_state";
import { showToast } from "@ui/ui_template_elements";
import { replanRoute, resetRoutePlan } from "./B05_Profile_Api_Replan";
type Vertex = [number, number];
export interface RouteApplyParams {
overlay: HTMLElement;
/** 화면 전체를 덮는 대기 막. 안에 `<span>` 한 개가 글을 받는다. */
busy: HTMLElement;
projectId: string;
/** 지금 편집값 — 누른 순간에 읽는다. */
nodes: () => { planned: Vertex[]; curveOn: boolean[]; curveRadius: Array<number | null> };
/** 성공하면 모달을 닫고 화면을 다시 읽는다. */
close: () => void;
onApplied: () => void | Promise<void>;
}
/** [확인]·[예상노선으로]를 붙인다. 리스너는 모달과 수명이 같다. */
export function bindRouteApply(params: RouteApplyParams): void {
const { overlay, busy, projectId } = params;
async function runHeavy(label: string, task: () => Promise<unknown>): Promise<void> {
busy.hidden = false;
// ⚠ 「몇 분」은 옛 값이었다 — 0-11 로 **약 90초**가 됐다(2026-09-09 실측 네 번:
// 87.3 · 90.0 · 93.9 · 95.4초).
const message = busy.querySelector("span")!;
const started = Date.now();
const tick = (): void => {
const seconds = Math.round((Date.now() - started) / 1000);
message.textContent = `${label} — 배수유역부터 다시 계산 중입니다. 1분 반쯤 걸립니다 (${seconds}초 지남).`;
};
tick();
const timer = window.setInterval(tick, 1000);
try {
await task();
// 노선이 바뀌면 세션 초안·조회 캐시는 옛 노선 것이라 남기지 않는다(PLAN 0-7 확정 5).
clearDrafts(projectId);
clearResults(projectId);
showToast("노선을 다시 계산했습니다.", "success");
params.close();
await params.onApplied();
} catch (error) {
busy.hidden = true;
showToast(error instanceof Error ? error.message : "노선 재계산에 실패했습니다.", "error");
} finally {
window.clearInterval(timer); // 성공·실패·닫힘 어느 쪽이든 멈춘다
}
}
overlay.querySelector('[data-act="apply"]')!.addEventListener("click", () => {
const { planned, curveOn, curveRadius } = params.nodes();
if (planned.length < 2) {
showToast("노선은 노드가 2개 이상이어야 합니다.", "error");
return;
}
void runHeavy("계획노선 반영", () =>
replanRoute(
projectId,
planned.map(([x, y], index) => ({
x,
y,
curve: curveOn[index] !== false,
radius_m: curveRadius[index] ?? null,
})),
),
);
});
overlay.querySelector('[data-act="reset"]')!.addEventListener("click", () => {
void runHeavy("예상노선으로 되돌리기", () => resetRoutePlan(projectId));
});
}
@@ -0,0 +1,234 @@
/* =============================================================================
* B05_Profile_UI_RouteEdit_Cross.ts
* ** ** ( 0-9 ).
*
* (2026-09-12 ) ** · ·
* **. .
*
* ** ** [] .
* ( ) .
* , .
*
* **B05·B06 ** · `generate_sections`,
* `compute_cross_design`(), B06 `fillSlopeLengths`().
* ========================================================================== */
import type { CrossSection } from "./../B06_Section/B06_Section_Api_Fetch";
import { fillSlopeLengths } from "./../B06_Section/B06_Section_UI_Cross_Fit";
import { fetchCrossPreview, type CrossPreviewResponse } from "./B05_Profile_Api_Replan";
/** 그림 가장자리 여백(px). */
const PAD = 28;
export interface CrossPreviewParams {
projectId: string;
/** 창을 처음 띄울 테두리(화면 좌표) — 보통 모달의 지도 칸. */
bounds: () => DOMRect;
/** 지금 편집값 — 누른 순간에 읽어 서버로 보낸다. */
request: () => {
vertices: Array<{ x: number; y: number; curve: boolean; radius_m: number | null }>;
min_radius_m: number;
station_interval_m: number;
};
}
export interface CrossPreviewWindow {
/** 그 측점의 횡단을 띄운다. 이미 떠 있으면 내용만 갈아 끼운다. */
open: (chainageM: number) => Promise<void>;
/** 모달을 닫을 때 — 몸통이 `document.body` 에 붙어 있어 스스로 안 사라진다. */
destroy: () => void;
}
export function createCrossPreview(params: CrossPreviewParams): CrossPreviewWindow {
const root = document.createElement("div");
root.className = "b05-routeedit__cross";
root.hidden = true;
root.innerHTML = `
<div class="b05-routeedit__cross-head">
<strong class="b05-routeedit__cross-title"> </strong>
<button type="button" class="b05-routeedit__cross-close" aria-label="닫기"></button>
</div>
<canvas class="b05-routeedit__cross-canvas" width="420" height="260"></canvas>
<div class="b05-routeedit__cross-foot"></div>`;
document.body.append(root);
const head = root.querySelector<HTMLElement>(".b05-routeedit__cross-head")!;
const title = root.querySelector<HTMLElement>(".b05-routeedit__cross-title")!;
const foot = root.querySelector<HTMLElement>(".b05-routeedit__cross-foot")!;
const canvas = root.querySelector<HTMLCanvasElement>(".b05-routeedit__cross-canvas")!;
const context = canvas.getContext("2d")!;
root.querySelector(".b05-routeedit__cross-close")!.addEventListener("click", () => {
root.hidden = true;
});
// 창 위에서 누른 것이 지도로 새어 나가면 노드가 딸려 움직인다.
for (const type of ["pointerdown", "dblclick", "contextmenu", "wheel"] as const) {
root.addEventListener(type, (event) => event.stopPropagation());
}
// ── 머리를 잡아 옮기기 — 노선을 가리면 손으로 치울 수 있어야 한다 ──
let dragFrom: { x: number; y: number; left: number; top: number } | null = null;
head.addEventListener("pointerdown", (event) => {
if ((event.target as HTMLElement).closest("button")) return;
dragFrom = { x: event.clientX, y: event.clientY, left: root.offsetLeft, top: root.offsetTop };
head.setPointerCapture(event.pointerId);
event.preventDefault();
});
head.addEventListener("pointermove", (event) => {
if (!dragFrom) return;
root.style.left = `${Math.round(dragFrom.left + event.clientX - dragFrom.x)}px`;
root.style.top = `${Math.round(dragFrom.top + event.clientY - dragFrom.y)}px`;
});
const stopDrag = (event: PointerEvent): void => {
if (head.hasPointerCapture(event.pointerId)) head.releasePointerCapture(event.pointerId);
dragFrom = null;
};
head.addEventListener("pointerup", stopDrag);
head.addEventListener("pointercancel", stopDrag);
/** 이번에 물은 측점 — 늦게 온 응답을 옛 자리에 적지 않으려고 들고 있는다. */
let asked = -1;
return {
async open(chainageM) {
asked = chainageM;
root.hidden = false;
if (!root.style.left) {
// 처음 열 때만 자리를 잡는다 — 그 뒤에는 사용자가 옮긴 자리를 지킨다.
// 지도 칸 **오른쪽 아래**에 붙인다 — 모달 머리·하단 정보행을 가리지 않는 자리다.
const box = params.bounds();
root.style.left = `${Math.round(box.right - root.offsetWidth - 16)}px`;
root.style.top = `${Math.round(box.bottom - root.offsetHeight - 16)}px`;
}
title.textContent = "횡단 미리보기 — 읽는 중…";
foot.textContent = "";
context.clearRect(0, 0, canvas.width, canvas.height);
let preview: CrossPreviewResponse;
try {
preview = await fetchCrossPreview(params.projectId, {
...params.request(),
chainage_m: chainageM,
});
} catch (error) {
if (asked !== chainageM) return;
title.textContent = "횡단 미리보기";
foot.textContent = error instanceof Error ? error.message : "횡단을 읽지 못했습니다.";
return;
}
if (asked !== chainageM || root.hidden) return;
title.textContent = `횡단 미리보기 — ${preview.label ?? `${preview.chainage_m}m`}`;
drawCross(context, canvas, preview);
foot.textContent = summarize(preview);
},
destroy() {
root.remove();
},
};
}
/** 성토사면 길이·절성토 면적 한 줄. 계획고가 없다는 것도 여기 적는다. */
function summarize(preview: CrossPreviewResponse): string {
const design = preview.design;
if (!design) return "계획고를 못 세워 계획 횡단을 그리지 못했습니다.";
// 성토사면 길이는 **B06 화면이 쓰는 그 함수**를 그대로 부른다 — 두 화면이 다른 길이를
// 말하면 안 된다. 필요한 것은 `samples` 와 `design` 둘뿐이라 그만 담아 넘긴다.
const lengths = fillSlopeLengths({
samples: preview.samples,
design,
} as unknown as CrossSection);
const sides = (["left", "right"] as const)
.filter((side) => lengths[side] !== null)
.map((side) => {
const value = lengths[side]!;
const label = side === "left" ? "좌" : "우";
// 계산 반폭 안에서 원지반을 못 만난 사면은 거기까지만 잰 하한값이라 「≥」로 구분한다.
return `${label} ${value.open ? "≥" : ""}${value.lengthM.toFixed(2)}m`;
});
const slope = sides.length ? `성토사면 ${sides.join(" · ")}` : "성토측 없음";
return (
`${slope} · 절토 ${design.cut_area_m2.toFixed(2)}㎡ · 성토 ${design.fill_area_m2.toFixed(2)}` +
" · 계획고는 [확인] 뒤에 정해지므로 지반을 따라 세운 기본 계획임"
);
}
/** 원지반선과 기본 계획 횡단선을 한 판에 그린다. 좌(+)가 왼쪽에 오게 눕힌다. */
function drawCross(
context: CanvasRenderingContext2D,
canvas: HTMLCanvasElement,
preview: CrossPreviewResponse,
): void {
const ground = preview.samples
.filter((sample) => sample.valid && sample.elevation_m !== null)
.map((sample) => [Number(sample.offset_m), Number(sample.elevation_m)] as [number, number]);
const design = (preview.design?.design_line ?? []).map(
(point) => [point.offset_m, point.elevation_m] as [number, number],
);
const all = [...ground, ...design];
context.clearRect(0, 0, canvas.width, canvas.height);
if (all.length < 2) return;
const offsets = all.map((point) => point[0]);
const heights = all.map((point) => point[1]);
const minOffset = Math.min(...offsets);
const maxOffset = Math.max(...offsets);
const minZ = Math.min(...heights);
const maxZ = Math.max(...heights);
const spanX = maxOffset - minOffset || 1;
const spanZ = maxZ - minZ || 1;
// **가로·세로를 같은 배율로** 둔다 — 따로 늘리면 사면 기울기가 거짓으로 보인다. 횡단도는
// 기울기를 눈으로 읽는 그림이라 왜곡하면 안 된다(2026-09-12 실화면: 노면이 안 보였다).
const scale = Math.min((canvas.width - PAD * 2) / spanX, (canvas.height - PAD * 2) / spanZ);
const centerOffset = (minOffset + maxOffset) / 2;
const centerZ = (minZ + maxZ) / 2;
// 좌(+offset)가 화면 왼쪽 — 횡단도 규약(generate_sections cad_exchange)과 같은 방향이다.
const toScreen = (point: [number, number]): [number, number] => [
canvas.width / 2 + (centerOffset - point[0]) * scale,
canvas.height / 2 + (centerZ - point[1]) * scale,
];
const stroke = (points: Array<[number, number]>, color: string, width: number): void => {
if (points.length < 2) return;
context.beginPath();
points.forEach((point, index) => {
const [x, y] = toScreen(point);
if (index === 0) context.moveTo(x, y);
else context.lineTo(x, y);
});
context.strokeStyle = color;
context.lineWidth = width;
context.stroke();
};
// 중심선 — 어디가 노선 가운데인지 먼저 보이게.
const [centerX] = toScreen([0, centerZ]);
context.save();
context.setLineDash([4, 4]);
context.strokeStyle = "rgba(148,163,184,0.7)";
context.lineWidth = 1;
context.beginPath();
context.moveTo(centerX, PAD / 2);
context.lineTo(centerX, canvas.height - PAD / 2);
context.stroke();
context.restore();
stroke(ground, "#94a3b8", 1.6); // 원지반
stroke(design, "#f97316", 2.2); // 기본 계획 횡단
context.font = "11px system-ui, sans-serif";
context.textBaseline = "top";
context.fillStyle = "#94a3b8";
context.textAlign = "left";
context.fillText("원지반", PAD, 6);
context.fillStyle = "#f97316";
context.textAlign = "right";
context.fillText("기본 계획 횡단", canvas.width - PAD, 6);
context.fillStyle = "#94a3b8";
context.textAlign = "center";
context.textBaseline = "bottom";
context.fillText(
`${maxOffset.toFixed(0)}m ← 중심 → 우 ${Math.abs(minOffset).toFixed(0)}m` +
` · 표고 ${minZ.toFixed(1)}~${maxZ.toFixed(1)}m`,
canvas.width / 2,
canvas.height - 4,
);
}
@@ -0,0 +1,152 @@
/* =============================================================================
* B05_Profile_UI_RouteEdit_CurveBar.ts
* **** ,
* . `_Label` .
*
* `B05_Profile_UI_RouteEdit.ts` 700 (2026-09-12).
* , `state()` .
*
* **R **(L = R·Δ) ** **
* . (`_Edits.ts` ).
* ========================================================================== */
import type { EditedCurve, EditedNode, Vertex } from "./B05_Profile_UI_RouteEdit_Curve";
import type { CurveLock } from "./B05_Profile_UI_RouteEdit_Edits";
import {
centerDirectionOf,
createCurveLabel,
deflectionRad,
type CurveLabel,
} from "./B05_Profile_UI_RouteEdit_Label";
/** 패널이 만지는 편집값 한 벌 — 모달이 쥔 배열을 그대로 건네받는다. */
export interface CurveBarState {
picked: number;
planned: Vertex[];
nodeInfo: EditedNode[];
curveInfo: EditedCurve[];
curveOn: boolean[];
curveRadius: Array<number | null>;
curveLock: CurveLock[];
curveArc: Array<number | null>;
/** 못 넘는 하한(m). 0이면 제한 없음(계획서 0-9 ④). */
limitRadiusM: number;
limitArcM: number;
}
export interface CurveBarParams {
canvas: HTMLCanvasElement;
state: () => CurveBarState;
toScreen: (vertex: Vertex) => [number, number];
/** 한 번의 편집을 마무리한다 — 다시 그리고 되돌리기에 쌓는다. */
applyEdit: (message: string) => void;
}
export interface CurveBar {
label: CurveLabel;
/** 고른 자리에 맞춰 패널을 옮겨 그린다. */
sync: () => void;
}
export function createCurveBar(params: CurveBarParams): CurveBar {
const label = createCurveLabel({
onRadius: (value) => {
const { picked, curveRadius } = params.state();
if (picked < 0) return;
curveRadius[picked] = value;
// 반지름만 바꾼 것이라 노드 자리는 그대로지만, 그려진 선은 낡았다.
params.applyEdit(value === null ? "반지름을 자동으로 되돌렸습니다." : "반지름을 바꿨습니다.");
},
onArcLength: (value) => {
const { picked, nodeInfo, curveArc, curveRadius } = params.state();
if (picked < 0) return;
// 곡선 길이 L 과 반지름 R 은 L = R·Δ 로 묶여 있다(Δ = 교각, 앞뒤 직선이 정함).
// 그래서 길이를 받으면 반지름으로 바꿔 **한 값만** 들고 간다 — 두 벌로 두면 어긋난다.
const deflection = deflectionRad(nodeInfo[picked]?.inner_angle_deg);
curveArc[picked] = value;
curveRadius[picked] = value !== null && deflection > 1e-9 ? value / deflection : null;
params.applyEdit(
value === null ? "반지름을 자동으로 되돌렸습니다." : "곡선 길이를 바꿨습니다.",
);
},
onLock: (lock) => {
const { picked, nodeInfo, curveArc, curveRadius, curveLock } = params.state();
if (picked < 0) return;
curveLock[picked] = lock;
const deflection = deflectionRad(nodeInfo[picked]?.inner_angle_deg);
const shown = curveRadius[picked] ?? nodeInfo[picked]?.radius_m ?? null;
// 길이를 붙들려면 지금 길이를 적어 둬야 한다 — 뒤에 교각이 바뀌면 이 값으로 R 을 다시 잡는다.
if (lock === "arc") {
curveArc[picked] = shown !== null && deflection > 1e-9 ? shown * deflection : null;
}
// R 을 붙들 때 칸이 비어 있으면 지금 그려진 R 을 적어 둔다(자동 상태를 그대로 못 박음).
if (lock === "radius" && curveRadius[picked] === null) curveRadius[picked] = shown;
params.applyEdit(
lock === "radius"
? "반지름을 고정했습니다."
: lock === "arc"
? "곡선 길이를 고정했습니다."
: "고정을 풀었습니다.",
);
},
onCurveOn: (on) => {
const { picked, curveOn } = params.state();
if (picked < 0) return;
curveOn[picked] = on;
params.applyEdit(on ? "곡선을 넣었습니다." : "곡선을 지웠습니다.");
},
});
/** 고른 자리에 맞춰 라벨을 옮겨 그린다. 끝점은 곡선이 없으므로 라벨을 숨긴다. */
function sync(): void {
const {
picked,
planned,
nodeInfo,
curveInfo,
curveOn,
curveRadius,
curveLock,
limitRadiusM,
limitArcM,
} = params.state();
if (!(picked > 0 && picked < planned.length - 1)) {
label.hide();
return;
}
const pickedCurve = curveInfo.find((entry) => entry.node_first === picked);
const shown = curveRadius[picked] ?? pickedCurve?.radius_m ?? null;
const deflection = deflectionRad(nodeInfo[picked]?.inner_angle_deg);
const rect = params.canvas.getBoundingClientRect();
const [screenX, screenY] = params.toScreen(planned[picked]);
label.show({
seat: picked,
// 패널은 `position: fixed` 라 **화면 좌표**로 넘긴다.
at: [screenX + rect.left, screenY + rect.top],
// 넘어가도 되는 테두리 = **지도 칸**(하단 정보행 위까지). 밖으로 나가면 지금 무엇을
// 고치는지 모달 안에서 안 보인다(2026-09-12 사용자 지적 ⑨).
bounds: {
left: rect.left + 8,
top: rect.top + 8,
right: rect.right - 8,
bottom: rect.bottom - 8,
},
centerDirection: pickedCurve
? centerDirectionOf(
params.toScreen([pickedCurve.apex[0], pickedCurve.apex[1]]),
params.toScreen(pickedCurve.start),
params.toScreen(pickedCurve.end),
)
: null,
curveOn: curveOn[picked] !== false,
radiusShown: shown,
arcLengthShown: shown === null || deflection <= 1e-9 ? null : shown * deflection,
lock: curveLock[picked] ?? null,
innerAngleDeg: nodeInfo[picked]?.inner_angle_deg ?? null,
limitRadiusM,
limitArcM,
});
}
return { label, sync };
}
@@ -48,6 +48,75 @@ export function applyArcLocks(
}
}
/**
* ** **( 0-9 , 2026-09-12 ).
*
* L = R·Δ L/Δ .
* . ** () **
* , R .
*/
export function applyCurveLimits(
planned: Vertex[],
curveOn: ReadonlyArray<boolean>,
curveRadius: Array<number | null>,
limitRadiusM: number,
limitArcM: number,
): void {
if (limitRadiusM <= 0 && limitArcM <= 0) return;
for (let seat = 1; seat < planned.length - 1; seat += 1) {
if (curveOn[seat] === false) continue;
const current = curveRadius[seat];
if (current === null || current === undefined) continue;
const deflection = deflectionRad(
innerAngleDeg(planned[seat - 1], planned[seat], planned[seat + 1]),
);
const byArc = limitArcM > 0 && deflection > 1e-9 ? limitArcM / deflection : 0;
const floor = Math.max(limitRadiusM, byArc);
if (floor > 0 && current < floor) curveRadius[seat] = floor;
}
}
/**
* ** **(m). 0.
*
* R .
* , ** **
* (2026-09-12).
*
* . **
* ** ( 1 2
* ). .
*/
export function curveShortfalls(
nodes: ReadonlyArray<EditedNode>,
limitRadiusM: number,
limitArcM: number,
): number[] {
return nodes.map((node) => {
if (node.radius_m === null || (limitRadiusM <= 0 && limitArcM <= 0)) return 0;
let short = 0;
if (limitRadiusM > 0) short = Math.max(short, limitRadiusM - node.radius_m);
if (limitArcM > 0) {
short = Math.max(short, limitArcM - node.radius_m * deflectionRad(node.inner_angle_deg));
}
return Math.max(0, short);
});
}
/**
* ** **. (·)
* .
*
* ** **
* 1px (2026-09-12
* ). ( ), **
* ** .
*/
export function shortfallCrossed(before: readonly number[], after: readonly number[]): boolean {
if (before.length !== after.length) return false;
return after.some((value, index) => value > 1e-6 && before[index] <= 1e-6);
}
export interface CurveSummaryInput {
nodeCount: number;
curveOn: boolean[];
@@ -181,6 +181,98 @@ export function segmentAtScreen(
return best;
}
/** 노선 위 한 점 — 어디를 짚었나와 그 자리의 누가거리. */
export interface RoutePointHit {
/** 사업지 좌표(m). */
point: [number, number];
/** 시점에서 노선을 따라간 거리(m). */
chainageM: number;
}
/**
* ( ) ** ** . null.
*
* · ( 0-9 )
* . .
*/
export function routePointAtScreen(
line: Array<[number, number]>,
toScreen: ScreenOf,
px: number,
py: number,
maxPx: number,
): RoutePointHit | null {
let best: RoutePointHit | null = null;
let bestDistance = maxPx;
let travelled = 0;
for (let index = 0; index < line.length - 1; index += 1) {
const from = line[index];
const to = line[index + 1];
const segmentM = Math.hypot(to[0] - from[0], to[1] - from[1]);
const [ax, ay] = toScreen(from);
const [bx, by] = toScreen(to);
const dx = bx - ax;
const dy = by - ay;
const lengthSquared = dx * dx + dy * dy || 1;
const t = Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / lengthSquared));
const distance = Math.hypot(ax + t * dx - px, ay + t * dy - py);
if (distance < bestDistance) {
bestDistance = distance;
best = {
point: [from[0] + (to[0] - from[0]) * t, from[1] + (to[1] - from[1]) * t],
chainageM: travelled + segmentM * t,
};
}
travelled += segmentM;
}
return best;
}
/**
* ** ** (m). null( 0-9 ).
*
* `drawStationTicks` ** **
* . .
*/
export function stationAtScreen(
line: Array<[number, number]>,
toScreen: ScreenOf,
intervalM: number,
px: number,
py: number,
maxPx: number,
): number | null {
if (line.length < 2 || !(intervalM > 0)) return null;
const cumulative: number[] = [0];
for (let index = 1; index < line.length; index += 1) {
cumulative.push(
cumulative[index - 1] +
Math.hypot(line[index][0] - line[index - 1][0], line[index][1] - line[index - 1][1]),
);
}
const total = cumulative[cumulative.length - 1];
let best: number | null = null;
let bestDistance = maxPx;
let cursor = 1;
for (let chainage = 0; chainage <= total; chainage += intervalM) {
while (cursor < cumulative.length - 1 && cumulative[cursor] < chainage) cursor += 1;
const back = line[cursor - 1];
const front = line[cursor];
const segment = cumulative[cursor] - cumulative[cursor - 1] || 1;
const ratio = Math.min(1, Math.max(0, (chainage - cumulative[cursor - 1]) / segment));
const [x, y] = toScreen([
back[0] + (front[0] - back[0]) * ratio,
back[1] + (front[1] - back[1]) * ratio,
]);
const distance = Math.hypot(x - px, y - py);
if (distance < bestDistance) {
bestDistance = distance;
best = chainage;
}
}
return best;
}
/** `bandM` . null.
*
* ** ** ·· .
+29 -5
View File
@@ -71,6 +71,9 @@ export interface CurveLabelState {
arcLengthShown: number | null;
lock: CurveLock;
innerAngleDeg: number | null;
/** **못 넘는** 반지름·곡선 길이 하한(m). 0이면 제한 없음(계획서 0-9 ④). */
limitRadiusM?: number;
limitArcM?: number;
}
export interface CurveLabelHandlers {
@@ -148,15 +151,25 @@ export function createCurveLabel(handlers: CurveLabelHandlers): CurveLabel {
/** 손으로 옮긴 자리 — 꺾임점 기준 어긋남(px). 다른 꺾임점을 고르면 지운다. */
let manual: [number, number] | null = null;
let anchor: [number, number] = [0, 0];
/** 지금 자리의 하한 — 칸이 여기서 멈춘다. 0이면 제한 없음. */
let limitRadius = 0;
let limitArc = 0;
/** 마지막으로 받은 테두리 — 손으로 끌 때도 같은 자리를 지키려고 들고 있는다. */
let limit: CurveLabelState["bounds"];
const numberOf = (input: HTMLInputElement): number | null => {
/** . ** , **
* (2026-09-12 ) . */
const numberOf = (input: HTMLInputElement, floor: number): number | null => {
const value = Number(input.value);
return input.value.trim() !== "" && Number.isFinite(value) && value > 0 ? value : null;
if (input.value.trim() === "" || !Number.isFinite(value) || value <= 0) return null;
if (floor > 0 && value < floor) {
input.value = String(floor);
return floor;
}
return value;
};
radius.addEventListener("change", () => handlers.onRadius(numberOf(radius)));
arc.addEventListener("change", () => handlers.onArcLength(numberOf(arc)));
radius.addEventListener("change", () => handlers.onRadius(numberOf(radius, limitRadius)));
arc.addEventListener("change", () => handlers.onArcLength(numberOf(arc, limitArc)));
toggle.addEventListener("click", () => handlers.onCurveOn(!curveOn));
lockRadius.addEventListener("click", () => handlers.onLock(lock === "radius" ? null : "radius"));
lockArc.addEventListener("click", () => handlers.onLock(lock === "arc" ? null : "arc"));
@@ -242,6 +255,11 @@ export function createCurveLabel(handlers: CurveLabelHandlers): CurveLabel {
curveOn = state.curveOn;
lock = state.lock;
limit = state.bounds;
limitRadius = state.limitRadiusM ?? 0;
limitArc = state.limitArcM ?? 0;
// 칸 자체에도 하한을 박아 화살표·스피너가 그 아래로 안 내려가게 한다.
radius.min = limitRadius > 0 ? String(limitRadius) : "1";
arc.min = limitArc > 0 ? String(limitArc) : "1";
root.hidden = false;
seatText.textContent = `${state.seat + 1}번째 꺾임점`;
toggle.textContent = state.curveOn ? "곡선 지우기" : "곡선 넣기";
@@ -258,8 +276,14 @@ export function createCurveLabel(handlers: CurveLabelHandlers): CurveLabel {
const inner = state.innerAngleDeg;
const held =
lock === "radius" ? "반지름 고정" : lock === "arc" ? "곡선 길이 고정" : "고정 없음";
const floors = [
limitRadius > 0 ? `R ≥ ${limitRadius}m` : "",
limitArc > 0 ? `L ≥ ${limitArc}m` : "",
]
.filter(Boolean)
.join(" · ");
info.textContent = state.curveOn
? `${held}${inner ? ` · 내각 ${Math.round(inner)}°` : ""}`
? `${held}${inner ? ` · 내각 ${Math.round(inner)}°` : ""}${floors ? ` · ${floors}` : ""}`
: "곡선 없음 — 직선이 그대로 꺾입니다";
place(state);
// 글자가 바뀌면 상자 높이가 한 박자 늦게 자란다 — 다음 그림 직전에 한 번 더 맞춘다.
@@ -0,0 +1,104 @@
/* =============================================================================
* B05_Profile_UI_RouteEdit_Measure.ts
* ** ** ( 0-9 ).
*
* Shift+ a·b . ·
* , .
*
* ** ** (`/route/elevations`).
* ( 0-2 7)
* .
* ========================================================================== */
import { fetchRouteElevations } from "./B05_Profile_Api_Replan";
import { routePointAtScreen, type RoutePointHit } from "./B05_Profile_UI_RouteEdit_Input";
import { formatStation } from "./B05_Profile_Util_Station";
type Vertex = [number, number];
/** 구간 재기로 노선을 짚었다고 볼 거리(px). */
const MEASURE_HIT_PX = 14;
interface MeasurePoint extends RoutePointHit {
/** 그 자리의 지반고(m). 아직 못 물었거나 지표면 밖이면 null. */
z: number | null;
}
export interface MeasureToolParams {
projectId: string;
/** 규칙 측점 간격(m) — 측점 표기에 쓴다. */
stationIntervalM: number;
/** 지금 그려지는 노선(원호 포함). 편집으로 바뀌므로 함수로 받는다. */
line: () => Vertex[];
toScreen: (vertex: Vertex) => [number, number];
/** 창이 닫혔나 — 늦게 온 응답을 죽은 화면에 적지 않으려고. */
isClosed: () => boolean;
/** 상태가 바뀌었다 — 호출부가 상태줄을 다시 적고 다시 그린다. */
onChange: () => void;
}
export interface MeasureTool {
/** 찍힌 자리(0~2개) — 그리기가 쓴다. */
points: () => Vertex[];
/** 상태줄에 낼 한 줄. */
hint: () => string;
/** Shift+클릭 한 번. 두 점이 차면 지반고를 한 번만 물어 온다. */
pick: (px: number, py: number) => Promise<void>;
}
export function createMeasureTool(params: MeasureToolParams): MeasureTool {
/** 찍은 두 점. 셋째를 찍으면 새 구간의 시작이 된다. */
let picked: MeasurePoint[] = [];
const hint = (): string => {
if (picked.length === 0) return "Shift+클릭으로 두 점을 찍으면 거리와 기울기가 보입니다.";
const first = picked[0];
if (picked.length === 1) {
return `구간 재기 — 시작 ${formatStation(first.chainageM, params.stationIntervalM)}. 한 점 더.`;
}
const second = picked[1];
const span = Math.abs(second.chainageM - first.chainageM);
const head =
`구간 ${formatStation(first.chainageM, params.stationIntervalM)}` +
`${formatStation(second.chainageM, params.stationIntervalM)} · 길이 ${span.toFixed(1)}m`;
if (first.z === null || second.z === null || span <= 1e-6) {
return `${head} · 지반고를 못 읽어 기울기는 못 냅니다.`;
}
// 기울기는 **노선을 따라간 길이** 기준이다 — 직선거리로 나누면 곡선부에서 과대평가된다.
const rise = second.z - first.z;
return (
`${head} · 지반고 ${first.z.toFixed(1)}${second.z.toFixed(1)}m` +
` · 종단기울기 ${((rise / span) * 100).toFixed(1)}%`
);
};
return {
points: () => picked.map((entry) => entry.point),
hint,
async pick(px, py) {
const hit = routePointAtScreen(params.line(), params.toScreen, px, py, MEASURE_HIT_PX);
if (!hit) {
picked = []; // 노선을 빗나가면 재던 것을 접는다.
params.onChange();
return;
}
picked = picked.length >= 2 ? [{ ...hit, z: null }] : [...picked, { ...hit, z: null }];
params.onChange();
if (picked.length < 2) return;
const asked = picked;
try {
const heights = await fetchRouteElevations(
params.projectId,
asked.map((entry) => entry.point),
);
if (params.isClosed() || picked !== asked) return; // 그 사이 다시 찍었으면 버린다.
asked.forEach((entry, index) => {
entry.z = heights[index] ?? null;
});
} catch {
/* 지반고를 못 읽으면 길이만 낸다 — `hint` 가 그렇게 말한다. */
}
params.onChange();
},
};
}
@@ -36,6 +36,8 @@ const CONTOUR_BAND_M = 300;
const CURVE_HANDLE_PX = 5;
/** 시점·종점 이름표를 끝점에서 **노선 바깥으로** 밀어내는 거리(px). */
const OUTWARD_PX = 26;
/** 구간 재기 표시 색 — 노선(주황)·등고선(연보라)·고른 등고선(보라)과 겹치지 않는 초록. */
const MEASURE_COLOR = "#22c55e";
/** 노선을 따라간 길이(m) — 원호가 이미 정점으로 펴져 있어 정점 간 거리의 합이 곧 길이다. */
export function polylineLengthM(points: ReadonlyArray<Vertex>): number {
@@ -77,6 +79,8 @@ export interface RouteEditScene {
picked: number;
/** 규칙 측점 간격(m). */
stationIntervalM: number;
/** 구간 재기로 찍은 점(0~2개) — 노선 위 자리(계획서 0-9 ⑤). */
measure: ReadonlyArray<Vertex>;
}
export function drawRouteEditScene(context: CanvasRenderingContext2D, scene: RouteEditScene): void {
@@ -201,6 +205,67 @@ export function drawRouteEditScene(context: CanvasRenderingContext2D, scene: Rou
context.restore();
drawStationMarks(context, scene, line);
drawMeasureMarks(context, scene, line);
}
/** 구간 재기로 찍은 자리 — a·b 를 동그라미로 찍고 그 사이 노선을 굵게 덧그린다(계획서 0-9 ⑤). */
function drawMeasureMarks(
context: CanvasRenderingContext2D,
scene: RouteEditScene,
line: ReadonlyArray<Vertex>,
): void {
if (scene.measure.length === 0) return;
context.save();
if (scene.measure.length >= 2) {
const span = spanBetween(line, scene.measure[0], scene.measure[1]);
if (span.length >= 2) {
context.strokeStyle = MEASURE_COLOR;
context.lineWidth = 4;
context.beginPath();
span.forEach((vertex, index) => {
const [x, y] = scene.toScreen(vertex);
if (index === 0) context.moveTo(x, y);
else context.lineTo(x, y);
});
context.stroke();
}
}
context.lineWidth = 2.4;
context.strokeStyle = MEASURE_COLOR;
context.font = "bold 11px system-ui, sans-serif";
context.textAlign = "center";
context.textBaseline = "middle";
scene.measure.forEach((vertex, index) => {
const [x, y] = scene.toScreen(vertex);
context.fillStyle = "rgba(255,255,255,0.95)";
context.beginPath();
context.arc(x, y, 7, 0, Math.PI * 2);
context.fill();
context.stroke();
context.fillStyle = "#14532d";
context.fillText(index === 0 ? "a" : "b", x, y);
});
context.restore();
}
/** 두 점 사이의 노선 조각 — 가장 가까운 정점부터 정점까지. 어디를 쟀는지 보이기만 하면 된다. */
function spanBetween(line: ReadonlyArray<Vertex>, from: Vertex, to: Vertex): Vertex[] {
const nearest = (target: Vertex): number => {
let best = 0;
let bestDistance = Infinity;
line.forEach((vertex, index) => {
const distance = Math.hypot(vertex[0] - target[0], vertex[1] - target[1]);
if (distance < bestDistance) {
bestDistance = distance;
best = index;
}
});
return best;
};
const start = nearest(from);
const end = nearest(to);
const [low, high] = start <= end ? [start, end] : [end, start];
return [from, ...line.slice(low, high + 1), to];
}
/** 규칙 측점 눈금·번호와 시점·종점 이름표(계획서 0-9 ②). */
@@ -241,3 +241,53 @@
color: var(--color-text-secondary);
line-height: 1.35;
}
/* 측점 횡단 미리보기 (계획서 0-9 )
곡선 조작 패널과 같은 까닭으로 `document.body` 띄운다 모달이 `overflow: hidden`
이라 안에 두면 가장자리에서 잘린다. 머리를 잡아 옮길 있다. */
.b05-routeedit__cross {
position: fixed;
z-index: calc(var(--z-modal, 1000) + 2);
display: flex;
flex-direction: column;
gap: var(--spacing-8, 8px);
width: 452px;
padding: var(--spacing-8);
border: 1px solid var(--color-border);
border-radius: var(--radius-8, 6px);
background: var(--color-surface-raised);
box-shadow: 0 8px 28px rgb(0 0 0 / 40%);
}
.b05-routeedit__cross-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-8, 8px);
cursor: move;
touch-action: none;
}
.b05-routeedit__cross-close {
padding: 0 6px;
border: 1px solid var(--color-border);
border-radius: var(--radius-4, 4px);
background: transparent;
color: var(--color-text-secondary);
cursor: pointer;
}
.b05-routeedit__cross-canvas {
display: block;
width: 100%;
height: auto;
border: 1px solid var(--color-border);
border-radius: var(--radius-4, 4px);
background: var(--color-surface);
}
.b05-routeedit__cross-foot {
color: var(--color-text-secondary);
font-size: var(--text-caption);
line-height: 1.5;
}
+31 -8
View File
@@ -721,32 +721,55 @@ export function appendCrossDesignOverlay(
}
// 차도·노견 경계 짧은 수직 틱(N-4-2): ±3.6px(기존 ±6의 60%). 노면 단일 기울기라 육안
// 구분이 안 되는 경계를 표시한다. 노견 바깥 끝(road_edges)은 측구·사면 꺾임으로 이미 구분됨.
// 구분이 안 되는 경계를 표시한다.
const edges = design.carriageway_edges;
if (edges) {
for (const edge of [edges.left, edges.right]) {
const cx = x(edge.offset_m);
const cy = toDisplayY(edge.elevation_m);
const tickAt = (offsetM: number, elevationM: number, className: string, half: number) => {
const cx = x(offsetM);
const cy = toDisplayY(elevationM);
const tick = document.createElementNS(SVG_NS, "line");
tick.setAttribute("x1", String(cx));
tick.setAttribute("y1", String(cy - 3.6));
tick.setAttribute("y1", String(cy - half));
tick.setAttribute("x2", String(cx));
tick.setAttribute("y2", String(cy + 3.6));
tick.setAttribute("class", "b06-chart__carriageway-tick");
tick.setAttribute("y2", String(cy + half));
tick.setAttribute("class", className);
svg.append(tick);
};
for (const edge of [edges.left, edges.right]) {
tickAt(edge.offset_m, edge.elevation_m, "b06-chart__carriageway-tick", 3.6);
}
// 노견 바깥 끝에도 틱을 세운다(2026-09-12 사용자) — 종전에는 차도에만 표기가 있어
// **노견이 늘어난 건지 차도가 늘어난 건지 화면에서 못 가렸다**. 노견은 좌·우 0.5m 로
// 고정이고 확폭은 차도에만 붙으므로, 두 틱 사이 간격이 그 사실을 그대로 보여 준다.
const roadEdges = design.road_edges;
if (roadEdges) {
for (const edge of [roadEdges.left, roadEdges.right]) {
tickAt(edge.offset_m, edge.elevation_m, "b06-chart__shoulder-tick", 2.4);
}
}
// 노폭 라벨(2026-09-06 사용자 지시) — 확폭이 걸린 측점인지 눈으로 바로 알게 한다.
// 확폭이 없으면 규격 폭만, 있으면 「4.5m (규격 3.0 + 확폭 1.5)」로 적는다.
const widened = (design.widening_left_m ?? 0) + (design.widening_right_m ?? 0);
const standardWidth = design.carriageway_standard_width_m;
const shoulderLeft = roadEdges ? roadEdges.left.offset_m - edges.left.offset_m : null;
const shoulderRight = roadEdges ? edges.right.offset_m - roadEdges.right.offset_m : null;
const label = document.createElementNS(SVG_NS, "text");
label.setAttribute("x", String((x(edges.left.offset_m) + x(edges.right.offset_m)) / 2));
label.setAttribute("y", String(toDisplayY(edges.left.elevation_m) - 6));
label.setAttribute("class", "b06-chart__carriageway-label");
label.textContent =
const widthText =
widened > 0.001 && typeof standardWidth === "number"
? `노폭 ${design.carriageway_width_m.toFixed(2)}m (규격 ${standardWidth.toFixed(2)} + 확폭 ${widened.toFixed(2)})`
: `노폭 ${design.carriageway_width_m.toFixed(2)}m`;
// 노견은 좌우가 같으면 한 번만 적는다 — 라벨이 길어지면 옆 측점 라벨과 겹친다.
label.textContent =
shoulderLeft != null && shoulderRight != null
? `${widthText} · 노견 ${
Math.abs(shoulderLeft - shoulderRight) < 0.005
? `${shoulderLeft.toFixed(2)}m`
: `${shoulderLeft.toFixed(2)} / 우 ${shoulderRight.toFixed(2)}m`
}`
: widthText;
svg.append(label);
}
}
@@ -152,6 +152,14 @@
stroke-width: 1.2;
}
/* 노견 바깥 (2026-09-12) 차도 틱보다 짧고 옅다. 차도 틱과 사이가 노견이라,
노면이 넓어졌을 차도가 늘었는지 노견이 늘었는지 눈으로 바로 갈린다. */
.b06-chart__shoulder-tick {
stroke: var(--color-royal-amethyst);
stroke-width: 1;
opacity: 0.55;
}
/* 노폭 라벨(2026-09-06) — 차도 위 가운데. 확폭이 걸린 측점을 눈으로 가려내는 표기다. */
.b06-chart__carriageway-label {
fill: var(--color-royal-amethyst);
+23 -11
View File
@@ -16,6 +16,7 @@
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
import { createButton, createInputField, showToast } from "@ui/ui_template_elements";
import { createWorkflowLayout } from "@ui/ui_template_workflow_layout";
import { attachCollapsible } from "@ui/ui_template_collapsible";
import {
drawBaseDataTab,
drawFactorChoices,
@@ -175,6 +176,14 @@ function injectStyles(): void {
style.textContent = `
.b09-panel { display: flex; flex-direction: column; gap: var(--space-md, 12px); }
.b09-panel__group { display: flex; flex-direction: column; gap: var(--space-xs, 4px); }
/* B04~B07 ( .ui-sidebar-section ).
.ui-sidebar-section . */
.b09-panel__group.ui-sidebar-section {
margin: 0;
padding: calc(var(--spacing-8) + var(--spacing-4));
border-radius: var(--radius-cards);
background-color: var(--color-surface-raised);
}
.b09-panel__legend {
font-size: var(--font-size-xs, 12px); letter-spacing: .06em;
color: var(--color-text-secondary); text-transform: uppercase;
@@ -184,7 +193,6 @@ function injectStyles(): void {
display: flex; justify-content: space-between; gap: var(--space-sm, 8px);
border-bottom: 1px solid var(--color-border); padding: 2px 0;
}
.b09-panel__actions { display: flex; gap: var(--space-xs, 4px); margin-top: var(--space-sm, 8px); }
.b09-hint { font-size: var(--font-size-xs, 12px); color: var(--color-text-secondary); }
/* 표본이 얇은 노임 — 막는 것이 아니라 눈에 띄기만 하면 된다. */
.b09-hint--warn { color: var(--color-warning-text, #8a5a00); }
@@ -483,10 +491,10 @@ function buildSidePanel(
legendKey: keyof typeof ui_locales,
fields: Array<[keyof CostFormState, keyof typeof ui_locales]>,
): void => {
const group = document.createElement("div");
group.className = "b09-panel__group";
const group = document.createElement("section");
group.className = "b09-panel__group ui-collapsible ui-sidebar-section";
const legend = document.createElement("span");
legend.className = "b09-panel__legend";
legend.className = "b09-panel__legend ui-collapsible__title";
legend.textContent = L(legendKey);
group.append(legend);
for (const [field, labelKey] of fields) {
@@ -512,10 +520,10 @@ function buildSidePanel(
]);
// 요율 판 — 읽기 전용. 「어느 판으로 계산했나」가 화면에 남아야 재현성이 선다.
const rateGroup = document.createElement("div");
rateGroup.className = "b09-panel__group";
const rateGroup = document.createElement("section");
rateGroup.className = "b09-panel__group ui-collapsible ui-sidebar-section";
const rateLegend = document.createElement("span");
rateLegend.className = "b09-panel__legend";
rateLegend.className = "b09-panel__legend ui-collapsible__title";
rateLegend.textContent = L("B09_Estimation_Group_RateVersion");
const rateVersionBox = document.createElement("div");
rateGroup.append(rateLegend, rateVersionBox);
@@ -532,10 +540,10 @@ function buildSidePanel(
]);
// 수량 — 여러 줄이라 텍스트 영역으로. 비어 있으면 위 직접비 3칸을 그대로 쓴다.
const quantityGroup = document.createElement("div");
quantityGroup.className = "b09-panel__group";
const quantityGroup = document.createElement("section");
quantityGroup.className = "b09-panel__group ui-collapsible ui-sidebar-section";
const quantityLegend = document.createElement("span");
quantityLegend.className = "b09-panel__legend";
quantityLegend.className = "b09-panel__legend ui-collapsible__title";
quantityLegend.textContent = L("B09_Estimation_Group_Quantity");
const quantityLabel = document.createElement("label");
quantityLabel.className = "ui-field__label";
@@ -555,7 +563,8 @@ function buildSidePanel(
root.append(hintBox);
const actions = document.createElement("div");
actions.className = "b09-panel__actions";
// 바닥 고정 액션 줄(공용) — ui_template_overlay 가 이 줄을 스크롤 밖으로 빼낸다.
actions.className = "ui-sidebar-actions";
actions.append(
createButton({
label: L("B09_Estimation_Btn_Recalc"),
@@ -569,6 +578,9 @@ function buildSidePanel(
);
root.append(actions);
// 그룹 제목 행 클릭 시 접기/펼치기(B04~B07 공통). 액션 줄은 collapsible 이 아니다.
attachCollapsible(root);
return { root, rateVersionBox, hintBox };
}
+25
View File
@@ -516,6 +516,31 @@ FOREST_ROAD_PROFILE_CRITERIA = {
# 배향곡선(Hair Pin) 중심선 반지름 하한(m, 별표2 Ⅰ.2.다.(2)). 이보다 급하면 **경고만**
# 낸다 — 자동 보정·차단은 하지 않는다(2026-09-06 사용자 확정).
"hairpin_min_radius_m": 10.0,
# 임도 종류별 **못 넘는 하한**(m) — 계획노선 편집 화면이 값을 막는 기준이다
# (2026-09-12 사용자 확정). 위 `min_plan_radius_m` 은 **기본값·위반 표시 기준**이고
# 여기는 **제한**이라 서로 다르다. 둘을 한 값으로 묶으면 하한 0 이 곧 반지름 0 이 되어
# 곡선이 아예 안 그려진다.
# · None = 위 표(설계속도 × 지형)를 그대로 하한으로 쓴다.
# · 0.0 = 제한 없음. 작업임도는 별표2에 곡선반지름 규정이 없어 열어 둔다 —
# 값이 정해지면 **이 칸만** 고치면 서버·화면이 함께 따라간다.
# ⚠ `projects.road_type` 은 main|fire|work 로 들어온다(B02 스키마) — 계획선 등급 코드
# trunk 와 같은 뜻이라 둘 다 적어 둔다. 없는 키는 None 과 같게(법정 표) 다뤄진다.
"plan_radius_limit_by_grade_m": {
"main": None,
"trunk": None,
"fire": None,
"work": 0.0,
"branch": None,
},
# 평면 **곡선 길이(L)** 하한(m). 법령·교본에 값이 없어 지금은 전부 0(제한 없음)이다.
# 자리만 만들어 두고, 실무값이 정해지면 여기에 적는다(2026-09-12 사용자 확정).
"plan_curve_length_limit_by_grade_m": {
"main": 0.0,
"trunk": 0.0,
"fire": 0.0,
"work": 0.0,
"branch": 0.0,
},
# 임도 종류 → **기본** 설계속도(km/h). 임도는 속도를 낼 수 없는 노선이라 20이
# 기본이다(2026-08-19 사용자 확정). 별표2상 간선·산불진화는 20~40 범위에서
# 설계자가 고르고, 작업임도는 20 이하이므로 20 고정이다. 사용자가 화면에서 고른
@@ -148,3 +148,64 @@ def test_widening_without_curve_stays_zero() -> None:
widenings, sides = _curve_widenings(chainage, [None] * 3, [None] * 3)
assert widenings == [0.0, 0.0, 0.0]
assert sides == [None, None, None]
def test_design_curve_spans_from_curve_table() -> None:
"""설계 곡선표의 시·종점이 노선 누가거리로 바뀌고, 회전 방향으로 바깥쪽이 갈린다."""
from B05_Profile.B05_Profile_Engine_Sections_Core import _design_curve_spans
# ㄱ자 노선 — (0,0) → (100,0) → (100,100). 교점 (100,0) 에서 좌회전.
line = np.array([[0.0, 0.0, 0.0], [100.0, 0.0, 0.0], [100.0, 100.0, 0.0]])
chainage = np.array([0.0, 100.0, 200.0])
curves = [
{
"start": [90.0, 0.0],
"apex": [100.0, 0.0],
"end": [100.0, 10.0],
"radius_m": 12.0,
}
]
spans = _design_curve_spans(line, chainage, curves)
assert len(spans) == 1
start, end, radius, side = spans[0]
assert abs(start - 90.0) < 1e-6 and abs(end - 110.0) < 1e-6
assert radius == 12.0
# 좌회전이면 안쪽이 좌측이라 바깥은 우측이다.
assert side == "right"
# 노선에서 멀리 떨어진 곡선은 버린다.
assert _design_curve_spans(line, chainage, [{**curves[0], "start": [90.0, 500.0]}]) == []
def test_design_curve_widening_is_one_value_inside_the_curve() -> None:
"""같은 곡선 안 측점은 설계 반경의 표값 하나를 쓰고, 앞뒤 10m 만 이어 준다."""
from B05_Profile.B05_Profile_Engine_Sections_Core import _design_curve_widenings
# 곡선 100~140m, 설계 반경 16m(표값 1.5m). 측점 5m 간격.
spans = [(100.0, 140.0, 16.0, "left")]
chainage = np.arange(80.0, 165.0, 5.0)
radii, sides, widenings = _design_curve_widenings(chainage, spans)
table = dict(zip(chainage.tolist(), widenings, strict=True))
# 곡선 안은 어디서나 같은 값 — 종전에는 측점마다 반경을 다시 재 값이 갈렸다.
for station in (100.0, 110.0, 120.0, 130.0, 140.0):
assert table[station] == 1.5, station
# 앞뒤 10m 는 0 으로 잇는다.
assert table[95.0] == 0.75 and table[145.0] == 0.75
assert table[90.0] == 0.0 and table[150.0] == 0.0
# 반경은 곡선 안에서만 남고, 테이퍼·직선 자리는 비어 있다.
by_index = dict(zip(chainage.tolist(), radii, strict=True))
assert by_index[120.0] == 16.0
assert by_index[95.0] is None and by_index[90.0] is None
# 방향은 곡선 것을 따라간다.
assert sides[chainage.tolist().index(95.0)] == "left"
def test_design_curve_widening_takes_the_wider_of_overlapping_curves() -> None:
"""곡선이 겹치거나 붙어 있으면 확폭이 큰 쪽을 따른다."""
from B05_Profile.B05_Profile_Engine_Sections_Core import _design_curve_widenings
spans = [(100.0, 120.0, 35.0, "left"), (118.0, 140.0, 12.0, "right")]
chainage = np.array([110.0, 119.0, 130.0])
_, sides, widenings = _design_curve_widenings(chainage, spans)
assert widenings[0] == 0.5 # R=35 → 0.5m
assert widenings[1] == 2.25 and sides[1] == "right" # 겹친 자리는 급한 곡선 값
assert widenings[2] == 2.25
@@ -0,0 +1,45 @@
"""계획노선 곡선 **하한**(반지름·곡선 길이) 해석 시험.
기본값(`legal_plan_radius_min_m`) ** 넘는 하한**(`plan_radius_limit_m`) 다른 값이다
(2026-09-12 사용자 확정). 둘을 값으로 묶으면 작업임도의 하한 0 반지름 0 되어
곡선이 아예 그려지므로, 갈라져 있다는 자체를 시험으로 못박는다.
"""
from B05_Profile.B05_Profile_Engine_Grade import (
legal_plan_radius_min_m,
plan_curve_length_limit_m,
plan_radius_limit_m,
resolve_design_speed,
)
def _default(grade_class: str, terrain: str) -> float:
return legal_plan_radius_min_m(resolve_design_speed(grade_class, None), terrain)
def test_작업임도는_하한이_없다():
"""별표2에 작업임도 곡선반지름 규정이 없어 하한을 0(제한 없음)으로 열어 둔다."""
assert plan_radius_limit_m("work", None, "normal") == 0.0
assert plan_radius_limit_m("work", None, "special") == 0.0
def test_작업임도도_기본_반지름은_그대로다():
"""하한이 0이어도 **곡선을 만들 때 쓰는 기본값**은 살아 있어야 한다."""
assert _default("work", "normal") > 0
def test_간선_산불진화는_법정표가_곧_하한이다():
for grade_class in ("main", "trunk", "fire"):
for terrain in ("normal", "special"):
assert plan_radius_limit_m(grade_class, None, terrain) == _default(grade_class, terrain)
def test_모르는_임도종류는_법정표로_떨어진다():
"""칸이 없는 값이 와도 막지 않고 법정표를 하한으로 쓴다(가장 보수적인 쪽)."""
assert plan_radius_limit_m("알 수 없는 종류", None, "normal") == _default("work", "normal")
def test_곡선길이_하한은_아직_전부_0이다():
"""법령·교본에 평면 곡선 길이 하한 값이 없다 — 자리만 열어 둔 칸이다."""
for grade_class in ("main", "trunk", "fire", "work", "branch", "없는종류"):
assert plan_curve_length_limit_m(grade_class) == 0.0