Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1
This commit is contained in:
@@ -70,6 +70,7 @@ from B05_Profile.B05_Profile_Engine_RouteInitial_Pool import (
|
||||
Pool,
|
||||
build_pool,
|
||||
pick,
|
||||
settle,
|
||||
similar,
|
||||
weighted,
|
||||
)
|
||||
@@ -345,11 +346,24 @@ def generate_initial_route(
|
||||
run = np.maximum(chain[span:] - chain[:-span], 1e-9)
|
||||
return max(0.0, float((np.abs(z[span:] - z[:-span]) / run).max()) - max_grade)
|
||||
|
||||
def fit(outline: Any, follow: bool = False) -> Any:
|
||||
# R = 일반측점(383-1) — 갈래는 옮긴 조각이 원래보다 더 가파르면 그 곡선만 그 자리(추적은
|
||||
# 제약을 안 따짐 · 새 초기 노선 `snapped_initial` 과 같은 잣대).
|
||||
moved = snap_curves_to_stations(
|
||||
outline, min_radius_m, excess=None if follow else grade_excess
|
||||
)
|
||||
return outline if moved is None else moved
|
||||
|
||||
unsnapped: dict[int, Any] = {}
|
||||
|
||||
def follow_line() -> tuple[Any, dict[str, Any]]:
|
||||
# 예상노선 추적 = 전처리 초기값(`planned_route_initial.csv`)과 같은 규칙 · 같은 입력 →
|
||||
# 같은 선(`_write_planned_polyline` 기본값). 제약은 따지지 않고 넘은 곳만 목록에 뜸.
|
||||
# 예상노선 추적 = 전처리 초기값(`planned_route_initial.csv` · 383-1 측점 맞춤)과 같은
|
||||
# 규칙 · 같은 입력 → 같은 선. 제약은 따지지 않고 넘은 곳만 목록에 뜸.
|
||||
outline = build_planned_polyline(expected, min_radius_m=min_radius_m)
|
||||
return outline, measure(outline, bound=None, follow=True)
|
||||
route = (outline, measure(outline, bound=None, follow=True))
|
||||
return settle(
|
||||
route, lambda line: fit(line, True), measure, unsnapped, bound=None, follow=True
|
||||
)
|
||||
|
||||
def profiled(route: Any, exact: bool = True) -> None:
|
||||
if route is not None:
|
||||
@@ -385,7 +399,7 @@ def generate_initial_route(
|
||||
graph = RouteGraph(grid, expected, limit=max_grade, min_radius_m=min_radius_m)
|
||||
while len(_POOLS) >= POOL_CACHE_SIZE:
|
||||
_POOLS.pop(next(iter(_POOLS)))
|
||||
_POOLS[key] = build_pool(graph, follow_line(), max_grade, min_radius_m, measure)
|
||||
_POOLS[key] = build_pool(graph, follow_line(), max_grade, min_radius_m, measure, fit)
|
||||
pool = _POOLS[key]
|
||||
bound = pool.bound
|
||||
if target == "weighted":
|
||||
@@ -395,26 +409,9 @@ def generate_initial_route(
|
||||
for route in [*pool.candidates, pool.follow]:
|
||||
profiled(route, exact=False) # 줄 세우기 — 고른 하나만 아래에서 바로 셈
|
||||
outline, metrics = pick(target, pool.candidates, with_profile)
|
||||
# 측점 맞춤(351-2 · 브레인 확정) — 고른 선의 곡선 MC 를 일반측점에 놓은 선을 늘 함께 냄(모달
|
||||
# 「측점 맞춤」 켬 = 이 선 · 끔 = 고른 선 그대로). 갈래는 옮긴 조각이 원래보다 더 가파르면 그
|
||||
# 곡선만 그 자리(추적은 제약을 안 따짐). 비교값은 맞춘 선으로 다시 셈.
|
||||
raw = (outline, metrics)
|
||||
moved = snap_curves_to_stations(
|
||||
outline, min_radius_m, excess=None if target == "follow" else grade_excess
|
||||
)
|
||||
snapped = (
|
||||
raw
|
||||
if moved is None or moved is outline
|
||||
else (
|
||||
moved,
|
||||
measure(
|
||||
moved,
|
||||
bound=bound,
|
||||
follow=target == "follow",
|
||||
weighted=target == "weighted",
|
||||
),
|
||||
)
|
||||
)
|
||||
# 후보는 이미 측점에 맞춘 선(383-1 · `..._Pool.settle`) — 모달 「측점 맞춤」 끔 = 맞추기 전 선
|
||||
snapped = (outline, metrics)
|
||||
raw = unsnapped.get(id(outline)) or (pool.raw.get(id(outline)) if pool else None)
|
||||
|
||||
def finish(route: tuple[Any, dict[str, Any]]) -> dict[str, Any]:
|
||||
line, values = route
|
||||
@@ -443,7 +440,7 @@ def generate_initial_route(
|
||||
"terrain_bound_pct": None if bound is None else round(bound * 100.0, 2),
|
||||
**shown,
|
||||
# 측점 맞춤 끔 = 고른 선 그대로(맞출 곡선이 없거나 못 맞췄으면 None — 위와 같음)
|
||||
"unsnapped": None if snapped is raw else finish(raw),
|
||||
"unsnapped": None if raw is None else finish(raw),
|
||||
# 10 m 안인 다른 갈래(같은 노선 알림) · 제약 안 띠 폭 어림 — 추적은 묶음이 이미 있을 때만
|
||||
"similar": None if pool is None else similar(pool, target, outline, with_profile),
|
||||
"with_profile": with_profile,
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
갈래가 그 사이 어느 비중보다 제 목표에서 좋게(토공 최소 ≤ 쓸기 절성토 · 기울기 순한 ≤
|
||||
쓸기 최대 기울기).
|
||||
· 못 지킨 곳 수가 기준(엄격: 후보 최소 · 풂: 센 벌점 기준 길)보다 많은 후보는 뺀다.
|
||||
· R = 일반측점이 기본 제약(383-1) — 후보마다 곡선 MC 를 일반측점에 맞춘 선(`snap`)으로 재고
|
||||
고르고 거름. 못 맞춘 곡선은 「못 지킨 구간」(station) · 고르기는 측점 밖 0 후보를 앞세움.
|
||||
|
||||
갈래는 **비교값으로** 고른다(`pick` · `GOALS`) — 토공 최소 = 절성토 합 · 기울기 순한 = 최대(같으면
|
||||
평균) · 영선 노선 = 목표 기울기 편차 · 안전 우선 = 위험 통과 길이(옆경사 35° 이상 · 계류 버퍼 안) ·
|
||||
@@ -44,6 +46,7 @@ from B05_Profile.B05_Profile_Engine_RouteInitial_Search import (
|
||||
NoRoute,
|
||||
RouteGraph,
|
||||
)
|
||||
from B05_Profile.B05_Profile_Engine_RouteInitial_Station import with_station_misses
|
||||
from common_util.common_util_route_geometry import densify_route
|
||||
from common_util.common_util_route_polyline import build_planned_polyline
|
||||
|
||||
@@ -77,6 +80,8 @@ TRACE_DENSIFY_M = 2.0
|
||||
|
||||
Measure = Callable[..., dict[str, Any]]
|
||||
Route = tuple[Any, dict[str, Any]]
|
||||
# 선 → 곡선 MC 를 일반측점에 맞춘 선(못 맞추면 그 선 그대로)
|
||||
Snap = Callable[[Any], Any]
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -84,9 +89,10 @@ class Pool:
|
||||
"""같은 입력의 후보 묶음 — 갈래 고르기 · 비중 탐색이 같이 쓴다."""
|
||||
|
||||
graph: RouteGraph
|
||||
candidates: list[Route]
|
||||
bound: float | None
|
||||
limit: int # 후보로 남길 못 지킨 곳 수 상한
|
||||
snap: Snap
|
||||
candidates: list[Route] = field(default_factory=list)
|
||||
bound: float | None = None
|
||||
limit: int = 0 # 후보로 남길 못 지킨 곳 수 상한
|
||||
# 비중 몫 크기(1 m 당) — 이격 · 토공 · 기울기 · 넘은 기울기 · 계류 나란히
|
||||
scale: np.ndarray = field(default_factory=lambda: np.ones(5))
|
||||
# 비중 탐색 상한 — 예상 0 이면 갈래 상한(`base_cap`) · 예상 100 이면 추적 노선이 지나는
|
||||
@@ -95,23 +101,32 @@ class Pool:
|
||||
follow_cap: float = np.inf
|
||||
searched: dict[tuple[float, ...], Route] = field(default_factory=dict)
|
||||
traced: dict[tuple, Route] = field(default_factory=dict) # 노드 → 잰 노선(`_trace`)
|
||||
raw: dict[int, Route] = field(default_factory=dict) # 맞춘 선 id → 맞추기 전(「측점 맞춤」 끔)
|
||||
follow: Route | None = None
|
||||
# 제약 안에서 갈 수 있는 띠 폭 어림 — 후보끼리 가장 먼 거리(m)
|
||||
band_width_m: float = 0.0
|
||||
|
||||
|
||||
def settle(route: Route, snap: Snap, measure: Measure, raw: dict[int, Route], **kw: Any) -> Route:
|
||||
"""측점에 맞춘 선 + 잰 값(측점 밖 곡선 = 못 지킨 구간 · 383-1) · 맞추기 전 선은 `raw` 에."""
|
||||
outline, metrics = route
|
||||
moved = snap(outline)
|
||||
if moved is not outline:
|
||||
raw[id(moved)] = (outline, with_station_misses(metrics, outline))
|
||||
outline, metrics = moved, measure(moved, **kw)
|
||||
return outline, with_station_misses(metrics, outline)
|
||||
|
||||
|
||||
def _trace(
|
||||
nodes: list[tuple[float, float]],
|
||||
min_radius_m: float,
|
||||
measure: Measure,
|
||||
traced: dict[tuple, Route],
|
||||
**kw: Any,
|
||||
pool: Pool, nodes: list[tuple[float, float]], min_radius_m: float, measure: Measure, **kw: Any
|
||||
) -> Route:
|
||||
# 큰 R(PLAN 328) — 2단 판(허용 이탈 안 가장 큰 R · `..._Radius.widen`) → 2단을 최소 R 판
|
||||
# 노드에 바로 건 판(단순화가 기울기 위반을 늘리는 급한 지형 · 검증_sub8 실측) → 1단 판(탐색
|
||||
# 경로를 예정노선 규칙으로 단순화 · 묶음 · R 맞춤) 차례로, 위반이 최소 R 판(노드마다 곡선 ·
|
||||
# 옛 규칙)보다 많지 않은 첫 판. 모두 화면이 [확인] 때 같은 선으로 다시 짜는 꼴.
|
||||
# 같은 노드 · 같은 잼이면 다시 재지 않음(쓸기는 이웃 비중이 같은 길을 자주 냄 · 묶음마다 따로).
|
||||
# 고른 판을 측점에 맞춤(`settle` · 383-1).
|
||||
traced = pool.traced
|
||||
key = (tuple(nodes), tuple(sorted(kw.items())))
|
||||
if key not in traced:
|
||||
floor = build_planned_polyline(
|
||||
@@ -121,7 +136,7 @@ def _trace(
|
||||
path = densify_route(nodes, TRACE_DENSIFY_M)
|
||||
fitted = build_planned_polyline(path, min_radius_m=min_radius_m)
|
||||
larger = (widen(fitted, path, min_radius_m), widen(floor, path, min_radius_m), fitted)
|
||||
traced[key] = next(
|
||||
chosen = next(
|
||||
(
|
||||
route
|
||||
for route in ((outline, measure(outline, **kw)) for outline in larger)
|
||||
@@ -129,6 +144,7 @@ def _trace(
|
||||
),
|
||||
least,
|
||||
)
|
||||
traced[key] = settle(chosen, pool.snap, measure, pool.raw, **kw)
|
||||
return traced[key]
|
||||
|
||||
|
||||
@@ -150,17 +166,14 @@ def _solve(graph: RouteGraph, target: str, **constraint: Any) -> list[tuple[floa
|
||||
|
||||
|
||||
def _candidates(
|
||||
graph: RouteGraph,
|
||||
max_grade: float,
|
||||
min_radius_m: float,
|
||||
measure: Measure,
|
||||
traced: dict[tuple, Route],
|
||||
pool: Pool, max_grade: float, min_radius_m: float, measure: Measure
|
||||
) -> tuple[list[Route], float | None, int]:
|
||||
"""제약 탐색 후보들 · 지형상 불가능 때 막은 기울기(아니면 None) · 후보로 남길 못 지킨 곳 상한.
|
||||
|
||||
제약을 지키는 길이 있으면 목표마다 탐색 상한을 `GRADE_CAP_STEPS` 차례로 낮춰 찾은 노선들 ·
|
||||
없으면 막은 기울기 안에서 벌점 크기 · 목표를 바꿔 찾은 노선들.
|
||||
"""
|
||||
graph = pool.graph
|
||||
found: list[Route] = []
|
||||
for target in TARGETS:
|
||||
for step in GRADE_CAP_STEPS:
|
||||
@@ -168,7 +181,7 @@ def _candidates(
|
||||
nodes = _solve(graph, target, cap=max_grade * step, strict_stream=True)
|
||||
except NoRoute:
|
||||
break
|
||||
found.append(_trace(nodes, min_radius_m, measure, traced, bound=None))
|
||||
found.append(_trace(pool, nodes, min_radius_m, measure, bound=None))
|
||||
if found[-1][1]["over_limit"]["total"] == 0:
|
||||
break
|
||||
if not found: # 제약을 지키는 길 없음 — 목표와 무관하니 더 볼 것 없음
|
||||
@@ -182,6 +195,7 @@ def _candidates(
|
||||
bound = graph.terrain_bound()
|
||||
cap = bound + 1e-9
|
||||
seed = _trace(
|
||||
pool,
|
||||
graph.solve(
|
||||
{"expected": 50.0, "earthwork": 25.0, "grade": 25.0},
|
||||
cap=cap,
|
||||
@@ -190,14 +204,13 @@ def _candidates(
|
||||
),
|
||||
min_radius_m,
|
||||
measure,
|
||||
traced,
|
||||
bound=bound,
|
||||
)
|
||||
found = [seed]
|
||||
for target in TARGETS:
|
||||
for cost in RELAXED_GRADE_COSTS:
|
||||
nodes = _solve(graph, target, cap=cap, strict_stream=False, grade_cost=cost)
|
||||
found.append(_trace(nodes, min_radius_m, measure, traced, bound=bound))
|
||||
found.append(_trace(pool, nodes, min_radius_m, measure, bound=bound))
|
||||
most = seed[1]["over_limit"]["total"]
|
||||
return [item for item in found if item[1]["over_limit"]["total"] <= most], bound, most
|
||||
|
||||
@@ -208,11 +221,12 @@ def build_pool(
|
||||
max_grade: float,
|
||||
min_radius_m: float,
|
||||
measure: Measure,
|
||||
snap: Snap,
|
||||
) -> Pool:
|
||||
"""후보 묶음 — 제약 탐색 후보 → 끝 노선 셋으로 비중 크기 맞춤 → 토공 ↔ 기울기 쓸기를 더함."""
|
||||
traced: dict[tuple, Route] = {}
|
||||
candidates, bound, limit = _candidates(graph, max_grade, min_radius_m, measure, traced)
|
||||
pool = Pool(graph, candidates, bound, limit, traced=traced, follow=follow)
|
||||
pool = Pool(graph, snap, follow=follow)
|
||||
pool.candidates, pool.bound, pool.limit = _candidates(pool, max_grade, min_radius_m, measure)
|
||||
candidates, bound, limit = pool.candidates, pool.bound, pool.limit
|
||||
ends = [follow[0], pick("earthwork", candidates)[0], pick("gentle", candidates)[0]]
|
||||
pool.scale, steepest = _scale(graph, [outline.vertices for outline in ends], max_grade)
|
||||
pool.base_cap = (max_grade if bound is None else bound) + 1e-9
|
||||
@@ -282,7 +296,7 @@ def weighted(pool: Pool, weights: dict[str, float], min_radius_m: float, measure
|
||||
scale=np.concatenate([pool.scale[:3], np.ones(len(OBJECTIVES) - 3)]),
|
||||
)
|
||||
pool.searched[key] = _trace(
|
||||
nodes, min_radius_m, measure, pool.traced, bound=pool.bound, weighted=True
|
||||
pool, nodes, min_radius_m, measure, bound=pool.bound, weighted=True
|
||||
)
|
||||
return pool.searched[key]
|
||||
|
||||
@@ -306,8 +320,13 @@ GOALS: dict[str, Callable[[dict[str, Any]], tuple[float, ...]]] = {
|
||||
|
||||
|
||||
def pick(mode: str, candidates: list[Route], with_profile: bool = True) -> Route:
|
||||
"""갈래 목표 비교값이 가장 좋은 후보. 같으면 앞 후보. `with_profile` 끔 = 원지반 가늠 점수."""
|
||||
"""갈래 목표 비교값이 가장 좋은 후보 — 측점 밖 곡선 없는 후보 먼저(383-1). 같으면 앞 후보.
|
||||
`with_profile` 끔 = 원지반 가늠 점수."""
|
||||
goal = GOALS[mode]
|
||||
return min(
|
||||
candidates, key=lambda item: goal(item[1] if with_profile else plain_metrics(item[1]))
|
||||
candidates,
|
||||
key=lambda item: (
|
||||
item[1]["over_limit"].get("station", 0) > 0,
|
||||
*goal(item[1] if with_profile else plain_metrics(item[1])),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
"""추천 노선 곡선 MC → 일반측점(PLAN 351-2 · 요구 235-3).
|
||||
|
||||
`B05_Profile_Engine_RouteInitial.generate_initial_route` 끝이 고른 선(다섯 갈래 · 큰 R 뒤)에
|
||||
부름 — 맞춘 선 · 맞추기 전 선 두 벌을 냄(모달 「측점 맞춤」 켬끔 · 브레인 확정). 전처리 초기
|
||||
노선 · 손 편집 · [측점 스냅]은 안 거침.
|
||||
R = 일반측점이 기본 제약(383-1 · 요구 241-13) — 추천 후보마다(`..._Pool._trace` · 고르기 · 쓸기 ·
|
||||
거름이 맞춘 선으로) · 새 초기 노선(`snapped_initial` · `_ensure_planned_initial`)이 부름. 맞추기 전
|
||||
선은 모달 「측점 맞춤」 끔. 못 맞춘 곡선 = 「못 지킨 구간」(`with_station_misses`). 손 편집 ·
|
||||
[측점 스냅]은 TS 짝 꼴(`logic/B05_Profile_RouteEdit_StationFit.ts`).
|
||||
|
||||
「MC = 서로 다른 일반측점」은 사용자 규칙(지식DB 근거 없음 — 원전 노선측량은 MC = 보조측점).
|
||||
|
||||
일반측점 = 누가거리 20 m 격자(No.N+0). 곡선 가운데 MC(BC · EC 누가거리 가운데 = 곡선 중심과
|
||||
마주치는 법선 자리)를 일반측점 위에 둔다 → 곡선 수 ≤ 내부 일반측점 수. 앞 곡선이 뒤 누가거리를
|
||||
@@ -32,6 +35,7 @@ import numpy as np
|
||||
import shapely
|
||||
from shapely.geometry import LineString, Point
|
||||
|
||||
from B05_Profile.B05_Profile_Engine_RouteInitial_Metrics import _item
|
||||
from B05_Profile.B05_Profile_Engine_RouteInitial_Radius import _flat
|
||||
from common_util.common_util_route_polyline import (
|
||||
STRAIGHT_INNER_ANGLE_DEG,
|
||||
@@ -134,6 +138,31 @@ def off_station_count(outline: PlannedPolyline) -> int:
|
||||
return len(outline.curves) - station_hits(outline)
|
||||
|
||||
|
||||
REASON_STATION = "곡선 MC 측점 밖 — 이웃 곡선 · 이탈 한도 · 지반 기울기가 막아 일반측점에 못 놓음"
|
||||
|
||||
|
||||
def with_station_misses(metrics: dict, outline: PlannedPolyline) -> dict:
|
||||
"""측점 밖 곡선(MC 가 일반측점 밖 · 앞 곡선과 같은 측점)을 「못 지킨 구간」에 더한 잰 값(383-1).
|
||||
|
||||
값 = 가까운 일반측점까지(m). `over_limit` 에 `station` 으로 셈 — 후보 거름이 측점 밖 0 을
|
||||
앞세움.
|
||||
"""
|
||||
taken: set[int] = set()
|
||||
misses = []
|
||||
for mc in curve_mc_chainages(outline):
|
||||
n = round(mc / STATION_M)
|
||||
if n >= 1 and abs(mc - n * STATION_M) <= MC_TOL_M and n not in taken:
|
||||
taken.add(n)
|
||||
continue
|
||||
misses.append(_item("station", mc, mc, abs(mc - n * STATION_M), 0.0, REASON_STATION))
|
||||
counts = {**metrics["over_limit"], "station": len(misses)}
|
||||
counts["total"] = metrics["over_limit"]["total"] - metrics["over_limit"].get("station", 0)
|
||||
counts["total"] += len(misses)
|
||||
kept = [item for item in metrics["violations"] if item["kind"] != "station"]
|
||||
violations = sorted(kept + misses, key=lambda item: item["from_m"])
|
||||
return {**metrics, "over_limit": counts, "violations": violations}
|
||||
|
||||
|
||||
def _unit(a: Xy, b: Xy) -> Xy:
|
||||
length = math.dist(a, b)
|
||||
return ((b[0] - a[0]) / length, (b[1] - a[1]) / length)
|
||||
@@ -462,6 +491,17 @@ class _Snapper:
|
||||
return None
|
||||
|
||||
|
||||
def snapped_initial(
|
||||
points: list[Xy], min_radius_m: float
|
||||
) -> tuple[list[Xy], list[bool], list[float | None]] | None:
|
||||
"""새 초기 노선(383-1) — 예상노선 폴리라인(기본 규칙)의 곡선 MC 를 일반측점에 맞춘 노드 · 곡선
|
||||
켬 · R(기울기 잣대 없음 = 추천 「예상노선 추적」 켬과 같은 선). 못 맞추면 None(지금 선)."""
|
||||
moved = snap_curves_to_stations(
|
||||
build_planned_polyline(points, min_radius_m=min_radius_m), min_radius_m
|
||||
)
|
||||
return None if moved is None else _flat(moved)
|
||||
|
||||
|
||||
def snap_curves_to_stations(
|
||||
outline: PlannedPolyline,
|
||||
min_radius_m: float,
|
||||
|
||||
@@ -32,7 +32,8 @@ from B05_Profile.B05_Profile_Engine_RouteInitial import (
|
||||
)
|
||||
from common_util.common_util_json import atomic_write_json
|
||||
|
||||
ALGO_VERSION = 4 # 3 = 곡선 MC 일반측점(351-2) · 4 = 다투는 곡선 끔(364-5)
|
||||
# 3 = 곡선 MC 일반측점(351-2) · 4 = 다투는 곡선 끔(364-5) · 5 = 후보마다 맞춤(383-1)
|
||||
ALGO_VERSION = 5
|
||||
STORE_DIR = "B05_Profile/route_recommend"
|
||||
# 종류(갈래 · 지형) 마다 남길 벌 수 — 꺼낼 때 수정시각을 새로 해 최근에 쓴 것이 남음
|
||||
KEEP_PER_KIND = 2
|
||||
|
||||
@@ -40,6 +40,7 @@ from fastapi.responses import JSONResponse
|
||||
|
||||
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
|
||||
from B05_Profile.B05_Profile_Engine_RouteInitial import read_initial_choice, write_initial_choice
|
||||
from B05_Profile.B05_Profile_Engine_RouteInitial_Station import snapped_initial
|
||||
from B05_Profile.B05_Profile_Engine_Sections import load_station_snap
|
||||
from B05_Profile.B05_Profile_Route_Save import RouteSaveRequest
|
||||
from B05_Profile.B05_Profile_Schema import ( # noqa: F401 — 부르는 곳이 `replan.` 으로 씀
|
||||
@@ -299,7 +300,12 @@ def _ensure_planned_initial(project_root: Path, radius_m: float) -> dict | None:
|
||||
points = [(x, y) for x, y in _vertices_of(source)]
|
||||
if len(points) < 2:
|
||||
return None
|
||||
summary = _write_planned_polyline(target, points, radius_m)
|
||||
# 383-1 — 곡선 MC = 일반측점(기본 제약) + [측점 스냅] 켬 · 못 맞추면 지금 선 + 켬
|
||||
fit = snapped_initial(points, radius_m)
|
||||
shape = {"simplify": False, "curve_flags": fit[1], "radii": fit[2]} if fit else {}
|
||||
summary = _write_planned_polyline(
|
||||
target, fit[0] if fit else points, radius_m, station_snap=True, **shape
|
||||
)
|
||||
logger.info(
|
||||
"계획노선 초기 폴리라인 생성: %s (노드 %d · 곡선 %d · 위반 %d · 정점 %d)",
|
||||
target,
|
||||
|
||||
@@ -175,7 +175,8 @@ export async function saveRecommendedRoute(
|
||||
showLoadingOverlay();
|
||||
try {
|
||||
await saveRouteDraft(projectId, {
|
||||
draft: routeDraftOf(nodes, settings),
|
||||
// 383-1 — 추천 선은 곡선 MC 를 일반측점에 맞춘 선 → [측점 스냅] 켬으로 저장
|
||||
draft: routeDraftOf(nodes, { ...settings, stationSnap: true }),
|
||||
initial,
|
||||
balanced_profile: balancedProfile,
|
||||
as_initial: true,
|
||||
|
||||
@@ -549,7 +549,9 @@ export function violationText(item: InitialViolation): string {
|
||||
? `R ${fixed(item.value, 1, "m")}`
|
||||
: item.kind === "stream"
|
||||
? `계류 ${fixed(item.value, 1, "m")}`
|
||||
: fixed(item.value, 1, "");
|
||||
: item.kind === "station"
|
||||
? `측점 밖 ${fixed(item.value, 1, "m")}`
|
||||
: fixed(item.value, 1, "");
|
||||
const head = item.reason.split(" — ")[0].trim();
|
||||
return `${item.station} ${value}${head ? `(${head})` : ""}`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_RouteEdit_Keys.ts
|
||||
* 계획노선 편집 평면의 **교각점 방향키**(383-2 · 요구 241-16) — 고른 IP 를 화면 축으로 0.1 m
|
||||
* (Shift 1 m)씩. 누르는 동안 옮기기만 · 뗄 때 되돌리기 한 걸음(측점 스냅 켬이면 그때 MC 맞춤).
|
||||
*
|
||||
* 받는 때 = 마지막 누름이 평면 편집 창(`.b05-routeedit`) 안 · 입력칸 아님. 그때 방향키는 평면이
|
||||
* 삼킴(IP 를 안 골랐어도) — 종단 계획고 ▲▼ · 횡단 조정창으로 새지 않음. 하한 검사 · 놓을 때 할
|
||||
* 일은 끌기(`_Pointer`)와 같음. 걸음 · 결과 한 줄 셈은 `logic/B05_Profile_RouteEdit_KeyNudge`.
|
||||
* ========================================================================== */
|
||||
|
||||
import { lastPointerWithin } from "../B06_Section/B06_Section_UI_Cross_Panel_Base";
|
||||
import { HOLD_DELAY_MS, HOLD_INTERVAL_MS } from "./B05_Profile_UI_Profile_Edit";
|
||||
import type { RouteEditPointerParams } from "./B05_Profile_UI_RouteEdit_Pointer";
|
||||
import {
|
||||
arrowStep,
|
||||
isArrowKey,
|
||||
nudgeMessage,
|
||||
nudgeReport,
|
||||
type ArrowKey,
|
||||
} from "./logic/B05_Profile_RouteEdit_KeyNudge";
|
||||
import type { Vertex } from "./logic/B05_Profile_UI_RouteEdit_Curve";
|
||||
import { curveShortfalls, shortfallCrossed } from "./logic/B05_Profile_UI_RouteEdit_Edits";
|
||||
|
||||
/** 누르는 한 번(누름 ~ 뗌) — 뗄 때 결과 한 줄을 셈. */
|
||||
interface Hold {
|
||||
key: ArrowKey;
|
||||
shift: boolean;
|
||||
index: number;
|
||||
before: Vertex[];
|
||||
start: Vertex;
|
||||
moved: boolean;
|
||||
}
|
||||
|
||||
export function bindRouteEditKeys(params: RouteEditPointerParams, dragging: () => boolean): void {
|
||||
const { canvas, status } = params;
|
||||
let hold: Hold | null = null;
|
||||
let timer = 0;
|
||||
|
||||
const metricAt = (x: number, y: number): Vertex => params.toMetric(...params.unrotate(x, y));
|
||||
|
||||
/** 한 걸음 — 하한을 새로 깨면 그 걸음만 되돌림(끌기와 같은 검사). */
|
||||
function step(now: Hold): void {
|
||||
const state = params.state();
|
||||
const node = state.planned[now.index];
|
||||
if (!node) return;
|
||||
const [dx, dy] = arrowStep(now.key, now.shift, metricAt);
|
||||
const before = curveShortfalls(state.nodeInfo, state.limitRadiusM, state.limitArcM);
|
||||
state.planned[now.index] = [node[0] + dx, node[1] + dy];
|
||||
params.markEdited();
|
||||
const after = params.state();
|
||||
if (
|
||||
shortfallCrossed(before, curveShortfalls(after.nodeInfo, after.limitRadiusM, after.limitArcM))
|
||||
) {
|
||||
state.planned[now.index] = node;
|
||||
params.markEdited();
|
||||
status.textContent =
|
||||
`${params.routeHead()} — 하한에 걸려 더 못 옮깁니다` +
|
||||
`(곡선반지름 ${state.limitRadiusM}m${state.limitArcM > 0 ? ` · 곡선 길이 ${state.limitArcM}m` : ""}).`;
|
||||
params.draw();
|
||||
return;
|
||||
}
|
||||
now.moved = true;
|
||||
status.textContent = `${params.routeHead()} — 방향키로 옮기는 중(뗄 때 한 걸음). ${params.curveHint()}`;
|
||||
params.draw();
|
||||
}
|
||||
|
||||
/** 뗌 — 옮겼으면 되돌리기 한 걸음(스냅 켬이면 맞춤) · 횡단 · 기울기 다시 · 결과 한 줄. */
|
||||
function release(): void {
|
||||
window.clearTimeout(timer);
|
||||
window.clearInterval(timer);
|
||||
const done = hold;
|
||||
hold = null;
|
||||
if (!done?.moved) return;
|
||||
const end = params.state().planned[done.index] ?? done.start;
|
||||
params.commit();
|
||||
void params.crossPreview.refresh();
|
||||
params.slopeRefresh();
|
||||
const report = nudgeReport(done.before, params.state().planned, done.index, done.start, end);
|
||||
const message = nudgeMessage(report, params.stations() !== null);
|
||||
status.textContent = `${params.routeHead()} — ${message} ${params.curveHint()}`;
|
||||
params.draw();
|
||||
}
|
||||
|
||||
const detach = (): void => {
|
||||
window.removeEventListener("keydown", onKeyDown, true);
|
||||
window.removeEventListener("keyup", onKeyUp, true);
|
||||
window.removeEventListener("blur", release);
|
||||
};
|
||||
|
||||
function onKeyDown(event: KeyboardEvent): void {
|
||||
if (!canvas.isConnected) return detach();
|
||||
if (!isArrowKey(event.key) || !lastPointerWithin(".b05-routeedit")) return;
|
||||
const target = event.target as HTMLElement | null;
|
||||
if (target?.closest?.("input, select, textarea, [contenteditable='true']")) return;
|
||||
// 평면이 삼킴 — 종단도(▲▼ 계획고 · `defaultPrevented` 면 비킴) · 횡단 조정창으로 안 감
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (event.repeat || hold || dragging() || params.measureMode()) return; // 반복은 아래 타이머
|
||||
const state = params.state();
|
||||
const index = state.picked;
|
||||
const node = state.planned[index];
|
||||
if (!node || state.apexLock[index] === true) return;
|
||||
const now: Hold = {
|
||||
key: event.key,
|
||||
shift: event.shiftKey,
|
||||
index,
|
||||
before: state.planned.map(([x, y]): Vertex => [x, y]),
|
||||
start: [node[0], node[1]],
|
||||
moved: false,
|
||||
};
|
||||
hold = now;
|
||||
step(now);
|
||||
// 누르고 있으면 종단 편집과 같은 속도(0.5초 뒤 초당 10회) — OS 키 반복은 기기마다 갈림
|
||||
timer = window.setTimeout(() => {
|
||||
timer = window.setInterval(() => hold === now && step(now), HOLD_INTERVAL_MS);
|
||||
}, HOLD_DELAY_MS);
|
||||
}
|
||||
|
||||
function onKeyUp(event: KeyboardEvent): void {
|
||||
if (!canvas.isConnected) return detach();
|
||||
if (!hold || event.key !== hold.key) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
release();
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", onKeyDown, true);
|
||||
window.addEventListener("keyup", onKeyUp, true);
|
||||
window.addEventListener("blur", release);
|
||||
}
|
||||
@@ -11,6 +11,7 @@
|
||||
import { pickPx } from "../A00_Common/touch_bridge";
|
||||
import { hitPreparedLayer, type ViewState } from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
|
||||
import type { RouteEditContours } from "./B05_Profile_UI_RouteEdit_Contour";
|
||||
import { bindRouteEditKeys } from "./B05_Profile_UI_RouteEdit_Keys";
|
||||
import {
|
||||
dragHandleTo as curveDragTo,
|
||||
type EditedCurve,
|
||||
@@ -372,6 +373,7 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP
|
||||
};
|
||||
canvas.addEventListener("pointerup", endDrag);
|
||||
canvas.addEventListener("pointercancel", endDrag);
|
||||
bindRouteEditKeys(params, () => dragNode >= 0 || dragHandle !== null); // 교각점 방향키(383-2)
|
||||
|
||||
return { dragging: () => dragNode >= 0 || dragHandle !== null, nodeAt, segmentAt };
|
||||
}
|
||||
|
||||
@@ -198,7 +198,7 @@ export function openRecommendModal(params: RecommendParams): void {
|
||||
<label class="b05-routerec__target" data-target-row hidden
|
||||
title="영선 노선이 따라갈 종단기울기(%) — 비우면 위 종단기울기 상한">목표 기울기
|
||||
<span data-slot="target-input"></span>%</label>
|
||||
<button type="button" class="ui-btn ui-btn--glass" data-act="rerun" hidden>재계산</button>
|
||||
<div class="b05-routerec__row"><button type="button" class="ui-btn ui-btn--glass" data-act="rerun">재계산</button></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="b05-routerec__legend">
|
||||
@@ -397,8 +397,8 @@ export function openRecommendModal(params: RecommendParams): void {
|
||||
input.disabled = busy;
|
||||
});
|
||||
q<HTMLElement>("[data-target-row]").hidden = logic !== "gradeline";
|
||||
rerun.hidden = !dirty;
|
||||
rerun.disabled = busy || invalid.size > 0;
|
||||
// 측점 맞춤 오른쪽 · 늘 보임 — 바꾼 것이 있을 때만 켜짐(383-3)
|
||||
rerun.disabled = busy || !dirty || invalid.size > 0;
|
||||
const view = shownChoice();
|
||||
applyButton.disabled = busy || dirty || !view || invalid.size > 0;
|
||||
const shown = metricsLines(view?.metrics ?? null);
|
||||
|
||||
@@ -409,6 +409,12 @@
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
/* 측점 맞춤 · 재계산 한 줄(383-3) */
|
||||
.b05-routerec__row {
|
||||
display: flex;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.b05-routerec__lines {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile/logic/B05_Profile_RouteEdit_KeyNudge.ts
|
||||
* 교각점 방향키(383-2 · 요구 241-16) — 화면 축 걸음 · 뗄 때 맞춘 결과 한 줄.
|
||||
*
|
||||
* · 한 번 0.1 m · Shift ×10 · 위 = 화면 위(지도를 돌려도 화면 기준).
|
||||
* · 측점 스냅 켬이면 뗄 때 MC 를 일반측점에 맞춤 → 실제 이동 · 함께 밀린 IP 를 알림.
|
||||
* 파이썬 짝 아님. 순수 계산 — 화면 · fetch · sessionStorage 없음. 부르는 쪽은 `_UI_RouteEdit_Keys`.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { Vertex } from "./B05_Profile_UI_RouteEdit_Curve";
|
||||
|
||||
/** 방향키 한 번(m) · Shift 배수. */
|
||||
export const KEY_STEP_M = 0.1;
|
||||
export const KEY_SHIFT_TIMES = 10;
|
||||
/** 다른 IP 가 이보다 더 움직였으면 「함께 밀림」(m). */
|
||||
const SAME_M = 0.005;
|
||||
/** 고른 IP 가 이보다 덜 움직였으면 「제자리」(m) — MC 측점 허용(`MC_TOL_M`)만큼. */
|
||||
const STAY_M = 0.05;
|
||||
|
||||
export type ArrowKey = "ArrowUp" | "ArrowDown" | "ArrowLeft" | "ArrowRight";
|
||||
|
||||
export function isArrowKey(key: string): key is ArrowKey {
|
||||
return key === "ArrowUp" || key === "ArrowDown" || key === "ArrowLeft" || key === "ArrowRight";
|
||||
}
|
||||
|
||||
/**
|
||||
* 방향키 한 번의 도면 좌표 걸음 — `metricAt(화면 x, y)` 로 화면 위 · 오른쪽 1 px 를 도면 좌표로 바꿔
|
||||
* 그 방향 단위 벡터 × 걸음. 돌린 지도 · 확대와 무관하게 화면 축.
|
||||
*/
|
||||
export function arrowStep(
|
||||
key: ArrowKey,
|
||||
shift: boolean,
|
||||
metricAt: (x: number, y: number) => Vertex,
|
||||
): Vertex {
|
||||
const [x0, y0] = metricAt(0, 0);
|
||||
const [dx, dy] =
|
||||
key === "ArrowUp"
|
||||
? [0, -1]
|
||||
: key === "ArrowDown"
|
||||
? [0, 1]
|
||||
: key === "ArrowLeft"
|
||||
? [-1, 0]
|
||||
: [1, 0];
|
||||
const [x1, y1] = metricAt(dx, dy);
|
||||
const length = Math.hypot(x1 - x0, y1 - y0);
|
||||
if (!(length > 0)) return [0, 0];
|
||||
const step = KEY_STEP_M * (shift ? KEY_SHIFT_TIMES : 1);
|
||||
return [((x1 - x0) / length) * step, ((y1 - y0) / length) * step];
|
||||
}
|
||||
|
||||
export interface NudgeReport {
|
||||
/** 누르기 전 자리에서 뗀 뒤(맞춘 뒤) 자리까지(m). */
|
||||
movedM: number;
|
||||
/** 고른 IP 말고 자리가 바뀐 IP 수(맞춤이 함께 민 것). */
|
||||
pushed: number;
|
||||
}
|
||||
|
||||
/** 뗀 뒤 결과 — `before` = 누르기 전 노드 · `after` = 맞춘 뒤 노드 · `start` = 고른 IP 처음 자리 ·
|
||||
* `end` = 키로 옮긴 자리(맞추기 전). 노드 수가 바뀌었으면(합침) `end` 에 가장 가까운 노드를 고른 IP 로. */
|
||||
export function nudgeReport(
|
||||
before: ReadonlyArray<Vertex>,
|
||||
after: ReadonlyArray<Vertex>,
|
||||
picked: number,
|
||||
start: Vertex,
|
||||
end: Vertex,
|
||||
): NudgeReport {
|
||||
const same = before.length === after.length;
|
||||
let index = picked;
|
||||
if (!same || !after[index]) {
|
||||
index = 0;
|
||||
after.forEach((node, i) => {
|
||||
if (
|
||||
Math.hypot(node[0] - end[0], node[1] - end[1]) <
|
||||
Math.hypot(after[index][0] - end[0], after[index][1] - end[1])
|
||||
)
|
||||
index = i;
|
||||
});
|
||||
}
|
||||
const node = after[index] ?? start;
|
||||
const moved = (a: Vertex, b: Vertex): boolean => Math.hypot(a[0] - b[0], a[1] - b[1]) > SAME_M;
|
||||
const pushed = same
|
||||
? after.filter((xy, i) => i !== index && moved(xy, before[i])).length
|
||||
: after.filter((xy, i) => i !== index && before.every((old) => moved(xy, old))).length;
|
||||
return { movedM: Math.hypot(node[0] - start[0], node[1] - start[1]), pushed };
|
||||
}
|
||||
|
||||
/** 상태줄 한 마디 — 스냅 켬이면 실제 이동 · 함께 밀린 IP · 제자리면 미세 조정 안내. */
|
||||
export function nudgeMessage(report: NudgeReport, snapping: boolean): string {
|
||||
if (!snapping)
|
||||
return `교각점을 ${report.movedM.toFixed(1)} m 옮겼습니다(방향키 0.1 m · Shift 1 m).`;
|
||||
if (report.movedM < STAY_M) return "같은 측점 — 스냅 끄고 미세 조정하세요.";
|
||||
return `측점에 맞춰 실제 이동 ${report.movedM.toFixed(1)} m · 함께 밀린 IP ${report.pushed}곳.`;
|
||||
}
|
||||
@@ -87,6 +87,11 @@ if (typeof document !== "undefined")
|
||||
true,
|
||||
);
|
||||
|
||||
/** 마지막으로 누른 자리가 `selector` 안인가 — 평면 노선 편집 방향키(B05 383-2)도 이것으로 가림. */
|
||||
export function lastPointerWithin(selector: string): boolean {
|
||||
return !!lastPointer?.closest(selector);
|
||||
}
|
||||
|
||||
/** 방향키가 종단도 몫인가 — 마지막으로 누른 자리가 종단도 안이면 참. */
|
||||
export function arrowsBelongToProfile(): boolean {
|
||||
// 새 창으로 뗀 유토 · 배수 판도 종단도 몫(325-2).
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
/* 383-2 교각점 방향키 — `logic/B05_Profile_RouteEdit_KeyNudge.ts` 를 Node 로 돌려 JSON 한 줄을 냄
|
||||
* (`test_383_2_route_keys.py` 가 판정). `.cjs` = 루트 package.json 이 "type": "module" 이라서.
|
||||
*/
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "..");
|
||||
const cache = new Map();
|
||||
|
||||
function loadTs(file) {
|
||||
const full = path.resolve(file);
|
||||
if (cache.has(full)) return cache.get(full);
|
||||
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
cache.set(full, box.exports);
|
||||
const localRequire = (request) =>
|
||||
request.startsWith(".")
|
||||
? loadTs(path.join(path.dirname(full), `${request}.ts`))
|
||||
: require(request);
|
||||
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
|
||||
cache.set(full, box.exports);
|
||||
return box.exports;
|
||||
}
|
||||
|
||||
const nudge = loadTs(path.join(ROOT, "B05_Profile", "logic", "B05_Profile_RouteEdit_KeyNudge.ts"));
|
||||
|
||||
// 화면 → 도면: 1 px = 0.5 m · 화면 y 아래 = 도면 y 위로 뒤집힘 · 지도 30° 돌림
|
||||
const turn = (Math.PI / 180) * 30;
|
||||
const plain = (x, y) => [x * 0.5, -y * 0.5];
|
||||
const rotated = (x, y) => {
|
||||
const [mx, my] = plain(x, y);
|
||||
return [mx * Math.cos(turn) - my * Math.sin(turn), mx * Math.sin(turn) + my * Math.cos(turn)];
|
||||
};
|
||||
const keys = ["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"];
|
||||
const steps = (metricAt, shift) =>
|
||||
Object.fromEntries(keys.map((key) => [key, nudge.arrowStep(key, shift, metricAt)]));
|
||||
|
||||
const before = [
|
||||
[0, 0],
|
||||
[50, 0],
|
||||
[100, 20],
|
||||
[150, 0],
|
||||
];
|
||||
const snapped = [
|
||||
[0, 0],
|
||||
[50.8, 0],
|
||||
[100, 20.6],
|
||||
[150, 0],
|
||||
];
|
||||
const stayed = [
|
||||
[0, 0],
|
||||
[50.02, 0],
|
||||
[100, 20],
|
||||
[150, 0],
|
||||
];
|
||||
const merged = [
|
||||
[0, 0],
|
||||
[50.4, 0],
|
||||
[150, 0],
|
||||
];
|
||||
const out = {
|
||||
plain: steps(plain, false),
|
||||
shift: steps(plain, true),
|
||||
rotated: steps(rotated, false),
|
||||
arrows: keys.map(nudge.isArrowKey).concat(nudge.isArrowKey("Enter")),
|
||||
snapped: nudge.nudgeReport(before, snapped, 1, [50, 0], [50.3, 0]),
|
||||
stayed: nudge.nudgeReport(before, stayed, 1, [50, 0], [50.3, 0]),
|
||||
merged: nudge.nudgeReport(before, merged, 1, [50, 0], [50.3, 0]),
|
||||
messages: {
|
||||
snapped: nudge.nudgeMessage({ movedM: 0.8, pushed: 1 }, true),
|
||||
stayed: nudge.nudgeMessage({ movedM: 0.02, pushed: 0 }, true),
|
||||
free: nudge.nudgeMessage({ movedM: 0.3, pushed: 0 }, false),
|
||||
},
|
||||
};
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
@@ -40,9 +40,9 @@ def runs() -> dict:
|
||||
caught: dict = {}
|
||||
real = engine.build_pool
|
||||
|
||||
def spy(graph, follow, max_grade, min_radius_m, measure):
|
||||
def spy(graph, follow, max_grade, min_radius_m, measure, snap):
|
||||
caught["measure"] = measure
|
||||
caught["pool"] = real(graph, follow, max_grade, min_radius_m, measure)
|
||||
caught["pool"] = real(graph, follow, max_grade, min_radius_m, measure, snap)
|
||||
return caught["pool"]
|
||||
|
||||
engine._POOLS.clear()
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
"""383-1 R = 일반측점 기본 제약 — 새 초기 노선 맞춤 + [측점 스냅] 켬 · 추천 후보 맞춤 · [적용] 켬.
|
||||
|
||||
./venv/Scripts/python.exe -m pytest resources/tester/test_383_1_route_station_default.py -q
|
||||
|
||||
새 사본(예상노선만) → 초기 노선 곡선 MC 가 일반측점 위(≈ 0 밖) · 곡선표 `station_snap` 켬 ·
|
||||
추천 「예상노선 추적」(켬) 노드 = 초기 노선 노드 · 측점 밖 곡선 = 「못 지킨 구간」(station) ·
|
||||
옛 사본(초기 노선 이미 있음)은 파일 · 깃발 그대로(말없이 측점 안 바뀜) · 추천 [적용] 은 스냅 켬.
|
||||
첫 셈(검증_sub1 · 14 % · R12 · 묶음 + 영선) 74.6 s → 42.6 s(10-17 실측 · 같은 기계).
|
||||
"""
|
||||
|
||||
import json
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from shapely.geometry import LineString, Point
|
||||
|
||||
from B05_Profile.B05_Profile_Engine_RouteInitial import generate_initial_route
|
||||
from B05_Profile.B05_Profile_Engine_RouteInitial_Station import MC_TOL_M, STATION_M
|
||||
from B05_Profile.B05_Profile_Engine_Sections import load_station_snap
|
||||
from B05_Profile.B05_Profile_Router_Replan import (
|
||||
_curves_path,
|
||||
_ensure_planned_initial,
|
||||
_nodes_path,
|
||||
_write_planned_polyline,
|
||||
)
|
||||
from common_util.common_util_route_geometry import (
|
||||
expected_route_csv_path,
|
||||
planned_route_initial_path,
|
||||
read_planned_route_csv,
|
||||
)
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
PROJECT = ROOT / "storage/1/3/7e420ed6-f940-4d12-a1a5-39cdcf70a391" # 검증_sub1
|
||||
RADIUS = 12.0
|
||||
|
||||
|
||||
def _copy_expected(tmp_path: Path) -> Path:
|
||||
source = expected_route_csv_path(PROJECT)
|
||||
if not source.is_file():
|
||||
pytest.skip("검증_sub1 storage 없음")
|
||||
target = expected_route_csv_path(tmp_path)
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copyfile(source, target)
|
||||
return tmp_path
|
||||
|
||||
|
||||
def _off_station(path: Path) -> tuple[int, int]:
|
||||
"""(측점 밖 곡선 수, 곡선 수) — 저장 파일에서 다시 잼(MC = BC · EC 누가거리 가운데)."""
|
||||
line = LineString([(v.x, v.y) for v in read_planned_route_csv(path).vertices])
|
||||
curves = json.loads(_curves_path(path).read_text(encoding="utf-8"))["curves"]
|
||||
taken, off = set(), 0
|
||||
for curve in curves:
|
||||
mc = (line.project(Point(curve["start"])) + line.project(Point(curve["end"]))) / 2
|
||||
n = round(mc / STATION_M)
|
||||
if n >= 1 and abs(mc - n * STATION_M) <= MC_TOL_M and n not in taken:
|
||||
taken.add(n)
|
||||
else:
|
||||
off += 1
|
||||
return off, len(curves)
|
||||
|
||||
|
||||
def test_new_copy_initial_on_stations_and_flag_on(tmp_path: Path) -> None:
|
||||
root = _copy_expected(tmp_path)
|
||||
assert _ensure_planned_initial(root, RADIUS) is not None
|
||||
initial = planned_route_initial_path(root)
|
||||
off, total = _off_station(initial)
|
||||
print(f"새 초기 노선 곡선 {total} · 측점 밖 {off}")
|
||||
assert total and off <= 1
|
||||
assert load_station_snap(root) is True
|
||||
|
||||
|
||||
def test_follow_nodes_equal_initial(tmp_path: Path) -> None:
|
||||
"""추천 「예상노선 추적」(측점 맞춤 켬) = 새 초기 노선 · 측점 밖 곡선 수 = station 목록 수."""
|
||||
surface = PROJECT / "B05_Profile/route/cost_surface_classification_dtm_smooth.npz"
|
||||
if not surface.is_file():
|
||||
pytest.skip("검증_sub1 storage 없음")
|
||||
import test_351_2_curve_stations as t351
|
||||
|
||||
root = _copy_expected(tmp_path)
|
||||
_ensure_planned_initial(root, RADIUS)
|
||||
terrain, streams = t351._project_terrain()
|
||||
expected = [(v.x, v.y) for v in read_planned_route_csv(expected_route_csv_path(root)).vertices]
|
||||
result = generate_initial_route(
|
||||
expected,
|
||||
terrain,
|
||||
streams,
|
||||
mode="follow",
|
||||
weights=None,
|
||||
max_grade=0.14,
|
||||
min_radius_m=RADIUS,
|
||||
with_profile=False,
|
||||
)
|
||||
saved = [
|
||||
(v.x, v.y)
|
||||
for v in read_planned_route_csv(_nodes_path(planned_route_initial_path(root))).vertices
|
||||
]
|
||||
nodes = [(node["x"], node["y"]) for node in result["nodes"]]
|
||||
assert len(nodes) == len(saved)
|
||||
assert all(abs(a[0] - b[0]) <= 1e-3 and abs(a[1] - b[1]) <= 1e-3 for a, b in zip(nodes, saved))
|
||||
misses = [v for v in result["metrics"]["violations"] if v["kind"] == "station"]
|
||||
assert len(misses) == result["metrics"]["over_limit"]["station"] == result["off_station_curves"]
|
||||
assert result["unsnapped"] is not None # 「측점 맞춤」 끔 = 맞추기 전 선
|
||||
|
||||
|
||||
def test_old_copy_flag_and_line_unchanged(tmp_path: Path) -> None:
|
||||
"""옛 사본 — 초기 노선(깃발 칸 없음)이 이미 있으면 다시 안 씀 · 스냅 끔 그대로."""
|
||||
root = _copy_expected(tmp_path)
|
||||
initial = planned_route_initial_path(root)
|
||||
points = [(v.x, v.y) for v in read_planned_route_csv(expected_route_csv_path(root)).vertices]
|
||||
_write_planned_polyline(initial, points, RADIUS) # 옛 규칙(맞춤 없음 · 칸 없음)
|
||||
before = {p: p.read_bytes() for p in (initial, _curves_path(initial), _nodes_path(initial))}
|
||||
assert _ensure_planned_initial(root, RADIUS) is None
|
||||
assert {p: p.read_bytes() for p in before} == before
|
||||
assert load_station_snap(root) is False
|
||||
|
||||
|
||||
def test_apply_saves_station_snap_on() -> None:
|
||||
text = (ROOT / "B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts").read_text(encoding="utf-8")
|
||||
assert "routeDraftOf(nodes, { ...settings, stationSnap: true })" in text
|
||||
@@ -0,0 +1,80 @@
|
||||
"""383-2 교각점 방향키 — `logic/B05_Profile_RouteEdit_KeyNudge.ts` 를
|
||||
Node(`helper_383_2_key_nudge.cjs`)로 돌려 판정(화면 축 0.1 m · Shift ×10 · 돌린 지도 · 스냅 뒤
|
||||
실제 이동 · 함께 밀린 IP · 제자리 안내) + 이음(`_RouteEdit_Keys` · 끌기 걸기 한 줄 · 평면이
|
||||
삼킴 → 종단 ▲▼ 비킴).
|
||||
|
||||
./venv/Scripts/python.exe -m pytest resources/tester/test_383_2_route_keys.py -q
|
||||
"""
|
||||
|
||||
import json
|
||||
import math
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
B05 = ROOT / "B05_Profile"
|
||||
HELPER = Path(__file__).with_name("helper_383_2_key_nudge.cjs")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def got() -> dict:
|
||||
run = subprocess.run( # noqa: S603 — 고정 실행 파일
|
||||
["node", str(HELPER)], capture_output=True, text=True, encoding="utf-8", timeout=60
|
||||
)
|
||||
assert run.returncode == 0, run.stderr
|
||||
return json.loads(run.stdout)
|
||||
|
||||
|
||||
def _read(name: str) -> str:
|
||||
return (B05 / name).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_화면_축_0_1m_Shift_10배(got) -> None:
|
||||
plain = got["plain"]
|
||||
assert plain["ArrowUp"] == pytest.approx([0, 0.1]) # 화면 위 = 도면 북쪽(뒤집힌 y)
|
||||
assert plain["ArrowDown"] == pytest.approx([0, -0.1])
|
||||
assert plain["ArrowLeft"] == pytest.approx([-0.1, 0])
|
||||
assert plain["ArrowRight"] == pytest.approx([0.1, 0])
|
||||
assert got["shift"]["ArrowRight"] == pytest.approx([1.0, 0])
|
||||
assert got["arrows"] == [True, True, True, True, False]
|
||||
|
||||
|
||||
def test_돌린_지도도_화면_위(got) -> None:
|
||||
up = got["rotated"]["ArrowUp"]
|
||||
turn = math.radians(30)
|
||||
assert math.hypot(*up) == pytest.approx(0.1)
|
||||
assert up == pytest.approx([-0.1 * math.sin(turn), 0.1 * math.cos(turn)])
|
||||
|
||||
|
||||
def test_스냅_뒤_실제_이동_함께_밀린_IP(got) -> None:
|
||||
assert got["snapped"]["movedM"] == pytest.approx(0.8)
|
||||
assert got["snapped"]["pushed"] == 1
|
||||
assert got["stayed"]["movedM"] == pytest.approx(0.02) and got["stayed"]["pushed"] == 0
|
||||
assert got["merged"]["movedM"] == pytest.approx(0.4) and got["merged"]["pushed"] == 0
|
||||
messages = got["messages"]
|
||||
assert "실제 이동 0.8 m" in messages["snapped"] and "함께 밀린 IP 1곳" in messages["snapped"]
|
||||
assert "같은 측점 — 스냅 끄고 미세 조정" in messages["stayed"]
|
||||
assert "0.3 m 옮겼습니다" in messages["free"]
|
||||
|
||||
|
||||
def test_이음_끌기_걸기_한_줄_창_가로채기() -> None:
|
||||
pointer = _read("B05_Profile_UI_RouteEdit_Pointer.ts")
|
||||
assert pointer.count("bindRouteEditKeys(") == 1
|
||||
keys = _read("B05_Profile_UI_RouteEdit_Keys.ts")
|
||||
assert 'window.addEventListener("keydown", onKeyDown, true)' in keys # 앞서 받음(capture)
|
||||
assert 'lastPointerWithin(".b05-routeedit")' in keys
|
||||
assert "event.preventDefault();\n event.stopPropagation();" in keys
|
||||
assert "state.apexLock[index] === true" in keys # 고정 IP 는 안 옮김
|
||||
assert "shortfallCrossed(" in keys # 하한 검사 = 끌기와 같음
|
||||
assert "params.commit();" in keys # 뗄 때 한 걸음(스냅이면 그때 맞춤)
|
||||
# 종단 ▲▼ 는 앞선 preventDefault 를 보고 비킴
|
||||
tools = _read("B05_Profile_UI_Profile_Panel_Tools.ts")
|
||||
assert "if (event.defaultPrevented) return;" in tools
|
||||
base = (ROOT / "B06_Section/B06_Section_UI_Cross_Panel_Base.ts").read_text(encoding="utf-8")
|
||||
assert "export function lastPointerWithin(selector: string)" in base
|
||||
|
||||
|
||||
def test_RouteEdit_본문_줄_안_늘림() -> None:
|
||||
assert len(_read("B05_Profile_UI_RouteEdit.ts").splitlines()) <= 700
|
||||
@@ -0,0 +1,20 @@
|
||||
"""383-3 노선 추천 모달 [재계산] — 측점 맞춤 오른쪽 같은 줄 · 늘 보임 · 바꾼 것 있을 때만 켜짐.
|
||||
|
||||
./venv/Scripts/python.exe -m pytest resources/tester/test_383_3_rerun_row.py -q
|
||||
"""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
B05 = ROOT / "B05_Profile"
|
||||
|
||||
|
||||
def test_재계산_한_줄_늘_보임_바꿨을_때만_켜짐() -> None:
|
||||
ts = (B05 / "B05_Profile_UI_RouteEdit_Recommend.ts").read_text(encoding="utf-8")
|
||||
assert re.search(r'<div class="b05-routerec__row"><button[^>]*data-act="rerun">재계산', ts)
|
||||
assert 'data-act="rerun" hidden' not in ts and "rerun.hidden" not in ts
|
||||
assert "rerun.before(snapToggle)" in ts # 토글이 같은 줄 왼쪽
|
||||
assert "rerun.disabled = busy || !dirty || invalid.size > 0;" in ts
|
||||
css = (B05 / "B05_Profile_UI_Style_RouteRecommend.css").read_text(encoding="utf-8")
|
||||
assert re.search(r"\.b05-routerec__row \{\s*display: flex;", css)
|
||||
@@ -120,19 +120,17 @@ def _earth(result: dict) -> float:
|
||||
return result["metrics"]["cut_m3"] + result["metrics"]["fill_m3"]
|
||||
|
||||
|
||||
def _raw(result: dict) -> dict:
|
||||
"""측점 맞추기 전 선(351-2) — 갈래 고르기 · `similar` 는 이 선으로 함."""
|
||||
return {**result, **(result.get("unsnapped") or {})}
|
||||
|
||||
|
||||
def _check_branch_goals(runs: dict) -> None:
|
||||
"""갈래마다 제 목표 비교값(`GOALS` — 절성토 · 최대 기울기 · 목표 기울기 편차 · 위험 길이 ·
|
||||
계류 횡단 · 공사비)이 갈래(같은 후보 묶음) 가운데 1 등 · 새 비교값이 서로 맞음(PLAN 23-5).
|
||||
고르기는 측점 맞추기 전 선으로 함(351-2)."""
|
||||
runs = {mode: _raw(result) for mode, result in runs.items()}
|
||||
고르기는 측점에 맞춘 선 · 측점 밖 곡선 없는 후보 먼저(383-1)."""
|
||||
branches = [runs[mode]["metrics"] for mode in BRANCHES]
|
||||
|
||||
def rank(goal, m: dict) -> tuple:
|
||||
return (m["over_limit"].get("station", 0) > 0, *goal(m))
|
||||
|
||||
for mode, goal in GOALS.items():
|
||||
assert all(goal(runs[mode]["metrics"]) <= goal(m) for m in branches), mode
|
||||
assert all(rank(goal, runs[mode]["metrics"]) <= rank(goal, m) for m in branches), mode
|
||||
for result in runs.values():
|
||||
m = result["metrics"]
|
||||
assert abs(m["surplus_m3"] - (m["cut_m3"] - m["fill_m3"])) <= 0.11
|
||||
@@ -242,7 +240,10 @@ def test_terrain_impossible_is_listed() -> None:
|
||||
assert result["relaxed"] is True
|
||||
assert result["terrain_bound_pct"] > GRADE * 100.0
|
||||
grades = [v for v in result["metrics"]["violations"] if v["kind"] == "grade"]
|
||||
assert grades and all("지형상 불가능" in v["reason"] for v in grades)
|
||||
# 측점 밖 0 후보 먼저(383-1) — 쓸기 후보(비중 계산 사유)가 뽑힐 수 있음
|
||||
assert grades and all(
|
||||
("지형상 불가능" in v["reason"]) or ("비중 계산" in v["reason"]) for v in grades
|
||||
)
|
||||
assert all(v["station"].startswith("No.") for v in grades)
|
||||
|
||||
|
||||
@@ -454,7 +455,7 @@ def test_project_similar_and_band(project_runs: dict) -> None:
|
||||
earthwork = project_runs["earthwork"]
|
||||
assert earthwork["band_width_m"] < 20.0
|
||||
near = {item["logic"]: item["max_gap_m"] for item in earthwork["similar"]}
|
||||
assert near["gentle"] == round(_gap(_raw(earthwork), _raw(project_runs["gentle"])), 1)
|
||||
assert near["gentle"] == round(_gap(earthwork, project_runs["gentle"]), 1)
|
||||
assert all(gap < 10.0 for gap in near.values()) and "earthwork" not in near
|
||||
assert "follow" not in near # 추적은 40 m 넘게 떨어짐
|
||||
|
||||
|
||||
Reference in New Issue
Block a user