Merge remote-tracking branches 'origin/sub_laptop_5' and 'origin/sub_laptop_6' into sub_laptop_1
This commit is contained in:
@@ -286,6 +286,20 @@ function createPagePipeline(
|
||||
alignment: savedEdits(),
|
||||
};
|
||||
},
|
||||
// 옮긴 편집이 놓일 새 측점 — stations 단계와 같은 셈(400)
|
||||
stationsAt: (route, patch, inputs) =>
|
||||
buildStations(
|
||||
route.line,
|
||||
route.curves,
|
||||
(patch.pipes ?? inputs.pipes ?? []) as PlanPipe[],
|
||||
(patch.structures ?? inputs.structures ?? []) as PlanStructure[],
|
||||
{
|
||||
intervalM: options.stationIntervalM,
|
||||
types: enabled,
|
||||
stationSnap: inputs.route?.stationSnap ?? false,
|
||||
stationSnapStep: inputs.route?.stationSnapStep,
|
||||
},
|
||||
).map((station) => station.chainageM),
|
||||
});
|
||||
/** 다시 셈 창구가 낸 cross 출력(365-4) — 엔진 재사용에서 뺌. */
|
||||
const savedOut = new WeakSet<object>();
|
||||
|
||||
@@ -1,25 +1,38 @@
|
||||
"""노선이 바뀔 때 계획고 옮김 — 바뀐 구간만 지반에 다시 맞추고 그 밖 편집은 길이 차만큼 민다(344-2).
|
||||
"""노선이 바뀔 때 계획고 옮김 — 같은 자리는 옛 계획고 · 바뀐 자리만 새 지반 첫 계획선(344-2 · 400).
|
||||
|
||||
사용자 확정(10-16 · 341 결정 1): 노선 끝쪽을 고쳤는데 앞쪽 계획고가 날아가지 않게 한다.
|
||||
요구 250-2둘째(400): 평면을 고쳐도 크게 안 바뀐 자리의 종단 편집(직선화 · 자유지정 · 쉬프트 ·
|
||||
▲▼)은 이어받음.
|
||||
|
||||
- 바뀐 구간 = 옛 · 새 노선 정점열의 같은 앞머리 · 같은 꼬리를 뺀 가운데(`changed_span`) — 바뀐
|
||||
노드 앞 · 뒤 접선점 사이. 원호 정점까지 같은지 보므로 곡선이 바뀐 이웃 노드 범위도 들어간다.
|
||||
- 구간 앞 = 같은 땅 · 같은 누가거리 → 그대로. 구간 뒤 = 같은 땅 · 누가거리만 길이 차만큼 밀림.
|
||||
- 구간 안 = 새 지반 첫 계획선(`design_ground_following_profile` 의 base_pvi) 꼭짓점으로
|
||||
갈아 끼움 · 그 안 편집(계획고 · 종단곡선 R)은 버림.
|
||||
- 옮기는 것 = base_pvi · station_offsets · curve_radii(누가거리 글자 키).
|
||||
- `changed_span` = 옛 · 새 노선 정점열의 같은 앞머리 · 같은 꼬리를 뺀 가운데 —
|
||||
「노선 그대로?」 · 범위.
|
||||
- 같은 자리(`line_matcher`) = 새 선 점을 옛 선에 투영 · 옆 `SAME_LINE_TOL_M` 안 · 방향 30° 안
|
||||
(측점 스냅이 뒤 교점을 밀어도 이어받음).
|
||||
- 자동 꼭짓점(`carry_base`) · 편집(`carry_edits`)은 **새 측점 위에** 다시 놓음 — 같은 자리면
|
||||
옛 선 그 자리 높이 · 아니면 새 지반 첫 계획선 · 편집은 옛 사용자 꼭짓점이 닿은 자리만 ·
|
||||
R 은 가까운 측점.
|
||||
|
||||
짝 = TS `B05_Profile/logic/B05_Profile_Grade_Carry.ts`(같은 규칙) · 거울 시험 =
|
||||
`resources/tester/test_344_2_route_pairs.py`. 서버 정본은 이쪽(346-3 [저장]이 부름).
|
||||
`resources/tester/test_400_profile_carry.py` · `test_344_2_route_pairs.py`.
|
||||
서버 정본은 이쪽(346-3 [저장]이 부름).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from B05_Profile.B05_Profile_Engine_Grade_Alignment import chainage_key
|
||||
import numpy as np
|
||||
|
||||
from B05_Profile.B05_Profile_Engine_Grade_Alignment import chainage_key, resolve_pvi
|
||||
|
||||
# 같은 자리로 볼 옆 어긋남(m) — 브레인 정함 10-17 · 바꿀 수 있음 · TS `SAME_LINE_TOL_M` 짝.
|
||||
SAME_LINE_TOL_M = 1.0
|
||||
# 같은 자리로 볼 방향 차(cos 30°) — 엇갈려 지나는 선 · 머리핀 맞은편을 뺌.
|
||||
_SAME_DIRECTION_COS = 0.8660254037844386
|
||||
# 꼭짓점 위로 볼 거리(m) — `SAME_STATION_SNAP_M` 짝.
|
||||
_ON_NODE_M = 0.006
|
||||
# 같은 정점으로 볼 거리(m) — 저장 좌표 0.0001 m 자리 · 원호 다시 뽑기 잡음보다 크게.
|
||||
SAME_VERTEX_M = 1e-3
|
||||
_EPS = 1e-6
|
||||
@@ -67,61 +80,128 @@ def changed_span(
|
||||
}
|
||||
|
||||
|
||||
def _before(span: dict[str, Any], s: float) -> bool:
|
||||
"""구간 앞(그대로 둠) — 앞머리가 같으면 그 끝점까지."""
|
||||
start = float(span["start_m"])
|
||||
return s < start - _EPS or (bool(span["start_fixed"]) and s <= start + _EPS)
|
||||
def _segments(xy: list[tuple[float, float]]) -> np.ndarray:
|
||||
"""토막 표 [x, y, ux, uy, 길이, 시작 누가거리] — 길이 0 토막은 뺌(누가거리는 셈)."""
|
||||
rows: list[tuple[float, ...]] = []
|
||||
start = 0.0
|
||||
for (x0, y0), (x1, y1) in zip(xy, xy[1:]):
|
||||
dx, dy = float(x1) - float(x0), float(y1) - float(y0)
|
||||
length = math.sqrt(dx * dx + dy * dy)
|
||||
if length > _EPS:
|
||||
rows.append((float(x0), float(y0), dx / length, dy / length, length, start))
|
||||
start += length
|
||||
return np.array(rows, dtype=np.float64).reshape(-1, 6)
|
||||
|
||||
|
||||
def _after(span: dict[str, Any], s: float, end: float) -> bool:
|
||||
"""구간 뒤(밂) — 꼬리가 같으면 그 시작점부터."""
|
||||
return s > end + _EPS or (bool(span["end_fixed"]) and s >= end - _EPS)
|
||||
def line_matcher(
|
||||
old_xy: list[tuple[float, float]],
|
||||
new_xy: list[tuple[float, float]],
|
||||
tol_m: float = SAME_LINE_TOL_M,
|
||||
) -> Callable[[float], float | None]:
|
||||
"""새 누가거리 → 같은 자리 옛 누가거리(옆 `tol_m` · 방향 30° 안) · 아니면 None."""
|
||||
olds, news = _segments(old_xy), _segments(new_xy)
|
||||
|
||||
def match(chainage: float) -> float | None:
|
||||
if not len(olds) or not len(news):
|
||||
return None
|
||||
ahead = np.nonzero(chainage <= news[:, 5] + news[:, 4])[0]
|
||||
x, y, ux, uy, length, start = news[ahead[0] if len(ahead) else -1]
|
||||
along = min(max(chainage - start, 0.0), length)
|
||||
qx, qy = x + ux * along, y + uy * along
|
||||
facing = olds[olds[:, 2] * ux + olds[:, 3] * uy >= _SAME_DIRECTION_COS]
|
||||
if not len(facing):
|
||||
return None
|
||||
ox, oy, oux, ouy, olen, ostart = facing.T
|
||||
p = np.minimum(np.maximum((qx - ox) * oux + (qy - oy) * ouy, 0.0), olen)
|
||||
dx, dy = qx - (ox + oux * p), qy - (oy + ouy * p)
|
||||
gap = np.sqrt(dx * dx + dy * dy)
|
||||
best = int(np.argmin(gap))
|
||||
return float(ostart[best] + p[best]) if gap[best] <= tol_m else None
|
||||
|
||||
return match
|
||||
|
||||
|
||||
def carry_profile_edits(
|
||||
base_pvi: list[dict[str, float]],
|
||||
edits: dict[str, Any] | None,
|
||||
span: dict[str, Any],
|
||||
def carry_base(
|
||||
old_xy: list[tuple[float, float]],
|
||||
new_xy: list[tuple[float, float]],
|
||||
old_base: list[dict[str, float]],
|
||||
ground_pvi: list[dict[str, float]],
|
||||
) -> tuple[list[dict[str, float]], dict[str, dict[str, float]]]:
|
||||
"""옛 꼭짓점 · 편집을 새 노선으로 옮긴다 → (새 base_pvi, 새 edits).
|
||||
|
||||
`ground_pvi` = 새 노선 지반 첫 계획선 꼭짓점(전 구간 · `design_ground_following_profile`) —
|
||||
바뀐 구간 안 몫만 쓴다.
|
||||
"""
|
||||
delta = float(span["delta_m"])
|
||||
old_end, new_end = float(span["old_end_m"]), float(span["new_end_m"])
|
||||
|
||||
def moved(s: float) -> float | None:
|
||||
if _before(span, s):
|
||||
return s
|
||||
if _after(span, s, old_end):
|
||||
return s + delta
|
||||
return None # 바뀐 구간 안 — 버림
|
||||
|
||||
nodes: dict[float, dict[str, float]] = {}
|
||||
for node in base_pvi:
|
||||
at = moved(float(node["chainage_m"]))
|
||||
if at is not None:
|
||||
key = round(at, 3)
|
||||
nodes[key] = {"chainage_m": round(at, 6), "elevation_m": float(node["elevation_m"])}
|
||||
tol_m: float = SAME_LINE_TOL_M,
|
||||
) -> list[dict[str, float]]:
|
||||
"""새 자동 꼭짓점 — 지반 첫 계획선 측점마다 같은 자리면 옛 자동선 높이 · 아니면 지반."""
|
||||
if not old_base or not ground_pvi:
|
||||
return [dict(node) for node in old_base]
|
||||
match = line_matcher(old_xy, new_xy, tol_m)
|
||||
base_s = np.array([float(n["chainage_m"]) for n in old_base], dtype=np.float64)
|
||||
base_z = np.array([float(n["elevation_m"]) for n in old_base], dtype=np.float64)
|
||||
out = []
|
||||
for node in ground_pvi:
|
||||
s = float(node["chainage_m"])
|
||||
if _before(span, s) or _after(span, s, new_end):
|
||||
continue
|
||||
nodes.setdefault(
|
||||
round(s, 3), {"chainage_m": round(s, 6), "elevation_m": float(node["elevation_m"])}
|
||||
s = match(float(node["chainage_m"]))
|
||||
z = (
|
||||
float(node["elevation_m"])
|
||||
if s is None
|
||||
else round(float(np.interp(s, base_s, base_z)), 6)
|
||||
)
|
||||
out.append({"chainage_m": float(node["chainage_m"]), "elevation_m": z})
|
||||
return out
|
||||
|
||||
carried: dict[str, dict[str, float]] = {}
|
||||
for name in ("station_offsets", "curve_radii"):
|
||||
values: dict[str, float] = {}
|
||||
for key, value in ((edits or {}).get(name) or {}).items():
|
||||
try:
|
||||
at = moved(float(key))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if at is not None and value is not None:
|
||||
values[chainage_key(at)] = float(value)
|
||||
carried[name] = values
|
||||
return [nodes[key] for key in sorted(nodes)], carried
|
||||
|
||||
def carry_edits(
|
||||
old_xy: list[tuple[float, float]],
|
||||
new_xy: list[tuple[float, float]],
|
||||
old_base: list[dict[str, float]],
|
||||
edits: dict[str, Any] | None,
|
||||
stations: list[float],
|
||||
half_m: float,
|
||||
tol_m: float = SAME_LINE_TOL_M,
|
||||
) -> dict[str, dict[str, float]]:
|
||||
"""새 편집 — 새 측점마다 같은 자리 · 옛 사용자 꼭짓점이 닿은 자리면 옛 계획선 − 옛 자동선.
|
||||
|
||||
닿은 자리 = 꼭짓점 위(그 꼭짓점이 사용자) · 앞뒤 꼭짓점 하나가 사용자. 종단곡선 R = 옛 키에서
|
||||
`half_m` 안 가장 가까운 같은 자리 측점. 새 자동 꼭짓점이 같은 자리에서 옛 자동선
|
||||
높이(`carry_base`)라 계획고가 그대로.
|
||||
"""
|
||||
out: dict[str, dict[str, float]] = {"station_offsets": {}, "curve_radii": {}}
|
||||
if not old_base:
|
||||
return out
|
||||
match = line_matcher(old_xy, new_xy, tol_m)
|
||||
mapped: list[tuple[float, float]] = []
|
||||
for t in sorted({round(float(v), 3) for v in stations if math.isfinite(float(v))}):
|
||||
s = match(t)
|
||||
if s is not None:
|
||||
mapped.append((t, s))
|
||||
base_s = np.array([float(n["chainage_m"]) for n in old_base], dtype=np.float64)
|
||||
base_z = np.array([float(n["elevation_m"]) for n in old_base], dtype=np.float64)
|
||||
pvi_s, pvi_z, sources = resolve_pvi(base_s, base_z, (edits or {}).get("station_offsets") or {})
|
||||
|
||||
def touched(s: float) -> bool:
|
||||
on = np.nonzero(np.abs(pvi_s - s) < _ON_NODE_M)[0]
|
||||
if len(on):
|
||||
return sources[int(on[0])] == "user"
|
||||
ahead = np.nonzero(pvi_s > s)[0]
|
||||
nxt = int(ahead[0]) if len(ahead) else -1
|
||||
return nxt > 0 and (sources[nxt - 1] == "user" or sources[nxt] == "user")
|
||||
|
||||
for t, s in mapped:
|
||||
if touched(s):
|
||||
out["station_offsets"][chainage_key(t)] = round(
|
||||
float(np.interp(s, pvi_s, pvi_z)) - float(np.interp(s, base_s, base_z)), 6
|
||||
)
|
||||
gaps: dict[str, float] = {}
|
||||
for key, value in ((edits or {}).get("curve_radii") or {}).items():
|
||||
try:
|
||||
chainage = float(key)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if not math.isfinite(chainage) or value is None:
|
||||
continue
|
||||
slot, best = None, math.inf
|
||||
for t, s in mapped:
|
||||
gap = round(abs(s - chainage), 3) # mm — 가운데 같은 거리면 앞 측점
|
||||
if gap <= half_m and gap < best:
|
||||
slot, best = chainage_key(t), gap
|
||||
if slot is None or best >= gaps.get(slot, math.inf):
|
||||
continue
|
||||
gaps[slot] = best
|
||||
out["curve_radii"][slot] = float(value)
|
||||
return out
|
||||
|
||||
@@ -19,9 +19,9 @@ API — `POST /api/projects/{id}/route/replan`(`B05_Profile_Router_Replan.replan
|
||||
규칙:
|
||||
- 관 정본을 지우지 않음 — 좌표를 따라감 · 새 세류선 교차점 기본 관만 더함
|
||||
(`B05_Profile_Route_Save_Parts`) · 배수유역 저장분은 노선 지문이 달라 「낡음」(B04 `_resolve`).
|
||||
- 계획고 = 바뀐 구간만 새 지반 첫 계획선 · 그 밖 사용자 계획고 지킴
|
||||
(`B05_Profile_Engine_Grade_Carry` · 바뀐 구간 = 옛 · 새 **계획 폴리라인** — 화면 planline 과
|
||||
같은 선) · 균형 종단(`apply_profile_target`)은 추천 [적용](`as_initial`)만.
|
||||
- 계획고 = 바뀐 자리만 새 지반 첫 계획선 · 같은 자리(옆 1 m 안) 사용자 계획고 지킴
|
||||
(`B05_Profile_Engine_Grade_Carry` · 옛 · 새 **계획 폴리라인** — 화면 planline 과 같은 선) ·
|
||||
균형 종단(`apply_profile_target`)은 추천 [적용](`as_initial`)만.
|
||||
- 초기값은 여기서 안 뜸 — 부르는 쪽이 `as_initial` 일 때만(자동설계 체인 직후 + 추천 [적용]).
|
||||
"""
|
||||
|
||||
@@ -199,8 +199,8 @@ async def _carry_profile(
|
||||
old_xy: list[list[float]],
|
||||
new_xy: list[list[float]],
|
||||
) -> None:
|
||||
"""바뀐 구간만 새 지반 첫 계획선 · 그 밖 사용자 계획고 · 종단곡선 R 은 길이 차만큼 밀어 지킴."""
|
||||
from B05_Profile.B05_Profile_Engine_Grade_Carry import carry_profile_edits, changed_span
|
||||
"""같은 자리(옆 1 m 안) 옛 계획고 · R 을 새 측점 위에 · 바뀐 자리만 새 지반 첫 계획선(400)."""
|
||||
from B05_Profile.B05_Profile_Engine_Grade_Carry import carry_base, carry_edits, changed_span
|
||||
from B05_Profile.B05_Profile_Repository import update_longitudinal_grade_summary
|
||||
from B05_Profile.B05_Profile_Router import _apply_alignment_edits
|
||||
from common_util.common_util_json import atomic_write_json
|
||||
@@ -214,9 +214,11 @@ async def _carry_profile(
|
||||
if span is None: # 같은 노선 — 옛 계획선 통째
|
||||
base, edits = old_alignment["base_pvi"], old_alignment.get("edits") or {}
|
||||
else:
|
||||
base, edits = carry_profile_edits(
|
||||
old_alignment["base_pvi"], old_alignment.get("edits"), span, alignment["base_pvi"]
|
||||
)
|
||||
old_base = old_alignment["base_pvi"]
|
||||
stations = [float(row["chainage_m"]) for row in longitudinal.get("stations") or []]
|
||||
half = float((alignment.get("policy") or {}).get("station_interval_m") or 20.0) / 2
|
||||
base = carry_base(old_xy, new_xy, old_base, alignment["base_pvi"])
|
||||
edits = carry_edits(old_xy, new_xy, old_base, old_alignment.get("edits"), stations, half)
|
||||
alignment["base_pvi"] = base
|
||||
await asyncio.to_thread(atomic_write_json, path, longitudinal)
|
||||
_alignment, summary, _dropped = await asyncio.to_thread(
|
||||
|
||||
@@ -1,21 +1,31 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile/logic/B05_Profile_Grade_Carry.ts
|
||||
* 노선이 바뀔 때 계획고 옮김 — 바뀐 구간만 지반에 다시 맞추고 그 밖 편집은 길이 차만큼 밂(344-2).
|
||||
* 노선이 바뀔 때 계획고 옮김 — 같은 자리는 옛 계획고 · 바뀐 자리만 새 지반 첫 계획선(344-2 · 400).
|
||||
*
|
||||
* 짝 = 파이썬 `B05_Profile_Engine_Grade_Carry.py`(서버 정본 · 같은 규칙) · 거울 시험 =
|
||||
* resources/tester/test_344_2_route_pairs.py.
|
||||
* - 바뀐 구간 = 옛 · 새 노선 정점열의 같은 앞머리 · 꼬리를 뺀 가운데(바뀐 노드 앞 · 뒤 접선점 사이).
|
||||
* - 앞 = 그대로 · 뒤 = 누가거리만 길이 차만큼 밂 · 안 = 새 지반 첫 계획선 꼭짓점(`groundFollowingBasePvi`)
|
||||
* · 안의 편집(계획고 · 종단곡선 R)은 버림.
|
||||
* - 옮기는 것 = base_pvi · station_offsets · curve_radii(누가거리 글자 키).
|
||||
* resources/tester/test_400_profile_carry.py · test_344_2_route_pairs.py.
|
||||
* - `changedSpan` = 옛 · 새 정점열의 같은 앞머리 · 꼬리를 뺀 가운데 — 「노선 그대로?」 · 묶음 범위만.
|
||||
* - 같은 자리(`lineMatcher`) = 새 선 점을 옛 선에 투영 · 옆 `SAME_LINE_TOL_M` 안 · 방향 30° 안
|
||||
* (측점 스냅이 뒤 교점을 밀어도 이어받음).
|
||||
* - 자동 꼭짓점(`carryBase`) · 편집(`carryEdits`)은 **새 측점 위에** 다시 놓음 — 같은 자리면 옛 선 그 자리
|
||||
* 높이 · 아니면 새 지반 첫 계획선 · 편집은 옛 사용자 꼭짓점이 닿은 자리만 · R 은 가까운 측점.
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
chainageKey,
|
||||
resolvePvi,
|
||||
type AlignmentBase,
|
||||
type AlignmentEdits,
|
||||
type AlignmentNode,
|
||||
} from "./B05_Profile_UI_Profile_Alignment";
|
||||
|
||||
/** 같은 자리로 볼 옆 어긋남(m) — 브레인 정함 10-17 · 바꿀 수 있음 · 파이썬 `SAME_LINE_TOL_M` 짝. */
|
||||
export const SAME_LINE_TOL_M = 1.0;
|
||||
/** 같은 자리로 볼 방향 차(cos 30°) — 엇갈려 지나는 선 · 머리핀 맞은편을 뺌. */
|
||||
const SAME_DIRECTION_COS = 0.8660254037844386;
|
||||
/** 꼭짓점 위로 볼 거리(m) — 서버 `SAME_STATION_SNAP_M` 짝. */
|
||||
const ON_NODE_M = 0.006;
|
||||
|
||||
/** 같은 정점으로 볼 거리(m) — 파이썬 `SAME_VERTEX_M` 짝. */
|
||||
export const SAME_VERTEX_M = 1e-3;
|
||||
const EPS = 1e-6;
|
||||
@@ -75,55 +85,156 @@ export function changedSpan(
|
||||
};
|
||||
}
|
||||
|
||||
const before = (span: ChangedSpan, s: number): boolean =>
|
||||
s < span.start_m - EPS || (span.start_fixed && s <= span.start_m + EPS);
|
||||
/** `np.interp` — 오름차순 xs · 범위 밖은 양 끝값. */
|
||||
function interp(xs: number[], ys: number[], value: number): number {
|
||||
if (value <= xs[0]) return ys[0];
|
||||
if (value >= xs[xs.length - 1]) return ys[ys.length - 1];
|
||||
let low = 0;
|
||||
let high = xs.length - 1;
|
||||
while (high - low > 1) {
|
||||
const mid = (low + high) >> 1;
|
||||
if (xs[mid] <= value) low = mid;
|
||||
else high = mid;
|
||||
}
|
||||
const span = xs[high] - xs[low];
|
||||
return span <= 0 ? ys[high] : ys[low] + ((ys[high] - ys[low]) * (value - xs[low])) / span;
|
||||
}
|
||||
|
||||
const after = (span: ChangedSpan, s: number, end: number): boolean =>
|
||||
s > end + EPS || (span.end_fixed && s >= end - EPS);
|
||||
interface Segment {
|
||||
x: number;
|
||||
y: number;
|
||||
ux: number;
|
||||
uy: number;
|
||||
length: number;
|
||||
from: number;
|
||||
}
|
||||
|
||||
/** 옛 꼭짓점 · 편집을 새 노선으로 옮김 — `groundPvi` = 새 노선 지반 첫 계획선(전 구간). */
|
||||
export function carryProfileEdits(
|
||||
basePvi: ReadonlyArray<AlignmentNode>,
|
||||
edits: Partial<AlignmentEdits> | null | undefined,
|
||||
span: ChangedSpan,
|
||||
groundPvi: ReadonlyArray<AlignmentNode>,
|
||||
): { basePvi: AlignmentNode[]; edits: AlignmentEdits } {
|
||||
const moved = (s: number): number | null => {
|
||||
if (before(span, s)) return s;
|
||||
if (after(span, s, span.old_end_m)) return s + span.delta_m;
|
||||
return null; // 바뀐 구간 안 — 버림
|
||||
};
|
||||
function segmentsOf(xy: ReadonlyArray<XY>): Segment[] {
|
||||
const out: Segment[] = [];
|
||||
let from = 0;
|
||||
for (let index = 1; index < xy.length; index += 1) {
|
||||
const dx = xy[index].x - xy[index - 1].x;
|
||||
const dy = xy[index].y - xy[index - 1].y;
|
||||
const length = Math.sqrt(dx * dx + dy * dy);
|
||||
if (length > EPS) {
|
||||
const { x, y } = xy[index - 1];
|
||||
out.push({ x, y, ux: dx / length, uy: dy / length, length, from });
|
||||
}
|
||||
from += length;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const nodes = new Map<number, AlignmentNode>();
|
||||
basePvi.forEach((node) => {
|
||||
const at = moved(node.chainage_m);
|
||||
if (at !== null)
|
||||
nodes.set(roundTo(at, 3), { chainage_m: roundTo(at, 6), elevation_m: node.elevation_m });
|
||||
});
|
||||
groundPvi.forEach((node) => {
|
||||
const s = node.chainage_m;
|
||||
if (before(span, s) || after(span, s, span.new_end_m)) return;
|
||||
const slot = roundTo(s, 3);
|
||||
if (!nodes.has(slot))
|
||||
nodes.set(slot, { chainage_m: roundTo(s, 6), elevation_m: node.elevation_m });
|
||||
});
|
||||
|
||||
const carry = (values: Record<string, number> | undefined): Record<string, number> => {
|
||||
const out: Record<string, number> = {};
|
||||
Object.entries(values ?? {}).forEach(([key, value]) => {
|
||||
const chainage = Number(key);
|
||||
if (!Number.isFinite(chainage) || value == null) return;
|
||||
const at = moved(chainage);
|
||||
if (at !== null) out[chainageKey(at)] = Number(value);
|
||||
});
|
||||
return out;
|
||||
};
|
||||
|
||||
return {
|
||||
basePvi: [...nodes.keys()].sort((a, b) => a - b).map((slot) => nodes.get(slot)!),
|
||||
edits: {
|
||||
station_offsets: carry(edits?.station_offsets),
|
||||
curve_radii: carry(edits?.curve_radii),
|
||||
},
|
||||
/** 새 누가거리 → 같은 자리 옛 누가거리(옆 `tolM` · 방향 30° 안) · 아니면 null(파이썬 `line_matcher` 짝). */
|
||||
export function lineMatcher(
|
||||
oldXy: ReadonlyArray<XY>,
|
||||
newXy: ReadonlyArray<XY>,
|
||||
tolM: number = SAME_LINE_TOL_M,
|
||||
): (chainageM: number) => number | null {
|
||||
const olds = segmentsOf(oldXy);
|
||||
const news = segmentsOf(newXy);
|
||||
return (chainageM) => {
|
||||
if (!olds.length || !news.length) return null;
|
||||
const seg = news.find((item) => chainageM <= item.from + item.length) ?? news[news.length - 1];
|
||||
const along = Math.min(Math.max(chainageM - seg.from, 0), seg.length);
|
||||
const qx = seg.x + seg.ux * along;
|
||||
const qy = seg.y + seg.uy * along;
|
||||
let best = Infinity;
|
||||
let at: number | null = null;
|
||||
for (const item of olds) {
|
||||
if (item.ux * seg.ux + item.uy * seg.uy < SAME_DIRECTION_COS) continue;
|
||||
const p = Math.min(
|
||||
Math.max((qx - item.x) * item.ux + (qy - item.y) * item.uy, 0),
|
||||
item.length,
|
||||
);
|
||||
const dx = qx - (item.x + item.ux * p);
|
||||
const dy = qy - (item.y + item.uy * p);
|
||||
const gap = Math.sqrt(dx * dx + dy * dy);
|
||||
if (gap < best) {
|
||||
best = gap;
|
||||
at = item.from + p;
|
||||
}
|
||||
}
|
||||
return best <= tolM ? at : null;
|
||||
};
|
||||
}
|
||||
|
||||
/** 새 자동 꼭짓점 — 지반 첫 계획선 측점마다 같은 자리면 옛 자동선 높이 · 아니면 지반(파이썬 `carry_base` 짝). */
|
||||
export function carryBase(
|
||||
oldXy: ReadonlyArray<XY>,
|
||||
newXy: ReadonlyArray<XY>,
|
||||
oldBase: ReadonlyArray<AlignmentNode>,
|
||||
groundPvi: ReadonlyArray<AlignmentNode>,
|
||||
tolM: number = SAME_LINE_TOL_M,
|
||||
): AlignmentNode[] {
|
||||
if (!oldBase.length || !groundPvi.length) return oldBase.map((node) => ({ ...node }));
|
||||
const match = lineMatcher(oldXy, newXy, tolM);
|
||||
const baseS = oldBase.map((node) => node.chainage_m);
|
||||
const baseZ = oldBase.map((node) => node.elevation_m);
|
||||
return groundPvi.map((node) => {
|
||||
const s = match(node.chainage_m);
|
||||
return {
|
||||
chainage_m: node.chainage_m,
|
||||
elevation_m: s === null ? node.elevation_m : roundTo(interp(baseS, baseZ, s), 6),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 새 편집 — 새 측점마다 같은 자리이고 옛 사용자 꼭짓점이 닿은 자리(꼭짓점 위 · 앞뒤 꼭짓점 하나가 사용자)면
|
||||
* 옛 계획선 높이 − 옛 자동선 높이 · 종단곡선 R = 옛 키에서 `halfM` 안 가장 가까운 같은 자리 측점(파이썬
|
||||
* `carry_edits` 짝). 새 자동 꼭짓점이 같은 자리에서 옛 자동선 높이(`carryBase`)라 계획고가 그대로.
|
||||
*/
|
||||
export function carryEdits(
|
||||
oldXy: ReadonlyArray<XY>,
|
||||
newXy: ReadonlyArray<XY>,
|
||||
oldBase: ReadonlyArray<AlignmentNode>,
|
||||
edits: Partial<AlignmentEdits> | null | undefined,
|
||||
stations: ReadonlyArray<number>,
|
||||
halfM: number,
|
||||
tolM: number = SAME_LINE_TOL_M,
|
||||
): AlignmentEdits {
|
||||
const out: AlignmentEdits = { station_offsets: {}, curve_radii: {} };
|
||||
if (!oldBase.length) return out;
|
||||
const match = lineMatcher(oldXy, newXy, tolM);
|
||||
const mapped: Array<{ t: number; s: number }> = [];
|
||||
[...new Set(stations.filter(Number.isFinite).map((value) => roundTo(value, 3)))]
|
||||
.sort((a, b) => a - b)
|
||||
.forEach((t) => {
|
||||
const s = match(t);
|
||||
if (s !== null) mapped.push({ t, s });
|
||||
});
|
||||
const baseS = oldBase.map((node) => node.chainage_m);
|
||||
const baseZ = oldBase.map((node) => node.elevation_m);
|
||||
const pvi = resolvePvi({ basePvi: [...oldBase] } as AlignmentBase, edits?.station_offsets ?? {});
|
||||
const pviS = pvi.map((node) => node.chainage_m);
|
||||
const pviZ = pvi.map((node) => node.elevation_m);
|
||||
const touched = (s: number): boolean => {
|
||||
const on = pvi.find((node) => Math.abs(node.chainage_m - s) < ON_NODE_M);
|
||||
if (on) return on.source === "user";
|
||||
const next = pvi.findIndex((node) => node.chainage_m > s);
|
||||
return next > 0 && (pvi[next - 1].source === "user" || pvi[next].source === "user");
|
||||
};
|
||||
mapped.forEach(({ t, s }) => {
|
||||
if (touched(s))
|
||||
out.station_offsets[chainageKey(t)] = roundTo(
|
||||
interp(pviS, pviZ, s) - interp(baseS, baseZ, s),
|
||||
6,
|
||||
);
|
||||
});
|
||||
const gaps = new Map<string, number>();
|
||||
Object.entries(edits?.curve_radii ?? {}).forEach(([key, value]) => {
|
||||
const chainage = Number(key);
|
||||
if (!Number.isFinite(chainage) || value == null) return;
|
||||
let slot: string | null = null;
|
||||
let best = Infinity;
|
||||
mapped.forEach(({ t, s }) => {
|
||||
const gap = roundTo(Math.abs(s - chainage), 3); // mm — 가운데 같은 거리면 앞 측점
|
||||
if (gap <= halfM && gap < best) [slot, best] = [chainageKey(t), gap];
|
||||
});
|
||||
if (slot === null || best >= (gaps.get(slot) ?? Infinity)) return;
|
||||
gaps.set(slot, best);
|
||||
out.curve_radii[slot] = Number(value);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
* `A00_Common/design/logic/design_pipeline_types.ts`. 셈은 이미 있는 짝을 부르기만(어댑터):
|
||||
*
|
||||
* follow — 관 · 구조물 좌표 따라감(`common_util_route_follow`) · 계획고 편집 옮김(`Grade_Carry`
|
||||
* `carryProfileEdits` 의 edits 몫 · 옛 노선 `before.route` 가 있을 때만).
|
||||
* planline — 노선 초안이 없으면 저장 종단 기준 그대로 · 있으면 새 지반 첫 계획선(`Grade_Ground`)으로
|
||||
* 바뀐 구간만 다시 맞추고 밖은 저장 꼭짓점을 밂(`carryProfileEdits` 의 basePvi 몫).
|
||||
* `carryEdits` · 옛 노선 `before.route` 가 있을 때만 · 기준 = 마지막 종단 편집 때 한 벌 · 400).
|
||||
* planline — 노선 초안이 없으면 저장 종단 기준 그대로 · 있으면 새 측점마다 같은 자리는 저장 꼭짓점
|
||||
* 높이 · 바뀐 자리만 새 지반 첫 계획선(`Grade_Ground` · `carryBase`).
|
||||
* profile — `buildAlignment`(첫 계획선 + 계획고 편집) — 종단 보기가 쓰는 같은 함수.
|
||||
*
|
||||
* 화면 · fetch · 세션 없음(순수) — 저장 기준 · 저장 노선은 부르는 쪽이 넣음.
|
||||
@@ -16,6 +16,7 @@ import type {
|
||||
FollowPatch,
|
||||
PipelineInputs,
|
||||
RangePart,
|
||||
RouteLine,
|
||||
StageFn,
|
||||
Vertex,
|
||||
} from "../../A00_Common/design/logic/design_pipeline_types";
|
||||
@@ -31,7 +32,7 @@ import {
|
||||
type AlignmentBase,
|
||||
type AlignmentEdits,
|
||||
} from "./B05_Profile_UI_Profile_Alignment";
|
||||
import { carryProfileEdits, changedSpan } from "./B05_Profile_Grade_Carry";
|
||||
import { carryBase, carryEdits, changedSpan, type XY } from "./B05_Profile_Grade_Carry";
|
||||
import { SECTION_STATION_INTERVAL_M } from "./B05_Profile_Sections_Stations";
|
||||
import { groundFollowingBasePvi, groundProfile } from "./B05_Profile_Grade_Ground";
|
||||
|
||||
@@ -74,8 +75,17 @@ export function gradeRange(
|
||||
};
|
||||
}
|
||||
|
||||
/** 계획고 편집 기준 한 벌 — 그 편집이 놓인 선 · 자동 꼭짓점. */
|
||||
interface GradeRef {
|
||||
line: XY[];
|
||||
basePvi: AlignmentBase["basePvi"];
|
||||
edits: AlignmentEdits;
|
||||
}
|
||||
|
||||
/**
|
||||
* follow — 노선이 바뀐 차례에 한 번 · 관 · 구조물 좌표 따라감 · 계획고 편집 옮김. 첫 계산(옛 노선 없음)은
|
||||
* follow — 노선이 바뀐 차례에 한 번 · 관 · 구조물 좌표 따라감 · 계획고 편집 옮김. 편집 기준 = 마지막으로
|
||||
* 편집이 바뀐(종단 조작 · 되돌리기 · 열 때) 뒤 첫 노선 걸음의 옛 선 · 자동 꼭짓점 · 편집 — 노선 걸음이 여럿이어도
|
||||
* 그 한 벌에서 한 번에 옮김(걸음 수와 무관 · 서버 [저장] = 저장 노선에서 한 번 · 400). 첫 계산(옛 노선 없음)은
|
||||
* 저장 노선(`savedLine`)을 옛 노선으로 보고 좌표만 따라감(투영은 몇 번 해도 같음 · 계획고 옮김은 안 함 —
|
||||
* 열 때의 계획고 초안은 이미 그 노선 기준). 노선 초안이 없으면(열 때 · 초안 버림) `saved` 를 그대로 —
|
||||
* 저장 노선의 관 · 구조물 누가거리 · 계획고는 정본이라 다시 투영하지 않음(투영 오차 = 측점 목록 어긋남).
|
||||
@@ -83,7 +93,11 @@ export function gradeRange(
|
||||
export function createFollowStage(deps: {
|
||||
savedLine(): Vertex[] | null;
|
||||
saved?(): FollowPatch;
|
||||
/** 새 노선 측점 누가거리(stations 단계와 같은 셈) — 없으면 일반측점 격자. */
|
||||
stationsAt?(route: RouteLine, patch: FollowPatch, inputs: PipelineInputs): number[];
|
||||
}): StageFn<"follow"> {
|
||||
let ref: GradeRef | null = null;
|
||||
let carried: unknown = null; // 마지막으로 낸 편집 — 입력이 이것이면 기준 그대로
|
||||
return (ctx) => {
|
||||
if (!ctx.inputs.route && deps.saved) return deps.saved();
|
||||
const before = ctx.before.route?.line ?? deps.savedLine();
|
||||
@@ -101,12 +115,31 @@ export function createFollowStage(deps: {
|
||||
undefined,
|
||||
SECTION_STATION_INTERVAL_M, // site = 새 노선 일반 측점(380-3)
|
||||
).map((moved) => moved.item);
|
||||
const span = ctx.before.route ? changedSpan(xy(before), line) : null;
|
||||
if (span && alignment) patch.alignment = carryProfileEdits([], alignment, span, []).edits;
|
||||
const planline = ctx.before.planline;
|
||||
if (alignment !== carried && JSON.stringify(alignment) !== JSON.stringify(carried))
|
||||
ref =
|
||||
alignment && planline
|
||||
? { line: xy(before), basePvi: planline.basePvi, edits: alignment as AlignmentEdits }
|
||||
: null;
|
||||
if (ref && ctx.before.route) {
|
||||
const span = changedSpan(ref.line, line);
|
||||
const half = (planline?.policy.station_interval_m ?? SECTION_STATION_INTERVAL_M) / 2;
|
||||
const stations = span ? (deps.stationsAt?.(now, patch, ctx.inputs) ?? gridOf(now)) : [];
|
||||
carried = patch.alignment = span
|
||||
? carryEdits(ref.line, line, ref.basePvi, ref.edits, stations, half)
|
||||
: ref.edits;
|
||||
}
|
||||
return patch;
|
||||
};
|
||||
}
|
||||
|
||||
/** 일반측점 격자 + 끝 — `stationsAt` 없을 때(시험 · 단독 묶음). */
|
||||
const gridOf = (route: RouteLine): number[] => {
|
||||
const grid: number[] = [];
|
||||
for (let s = 0; s < route.lengthM; s += SECTION_STATION_INTERVAL_M) grid.push(s);
|
||||
return [...grid, route.lengthM];
|
||||
};
|
||||
|
||||
export const followStage = createFollowStage({ savedLine: () => null });
|
||||
|
||||
export interface PlanlineDeps {
|
||||
@@ -144,7 +177,7 @@ export function createPlanlineStage(deps: PlanlineDeps): StageFn<"planline"> {
|
||||
return {
|
||||
policy: saved.policy,
|
||||
onlyExplicitCurves: saved.onlyExplicitCurves,
|
||||
basePvi: span ? carryProfileEdits(savedPvi, null, span, groundPvi).basePvi : savedPvi,
|
||||
basePvi: span ? carryBase(xy(savedLine!), xy(route.line), savedPvi, groundPvi) : savedPvi,
|
||||
chainage: profile.chainage,
|
||||
ground: profile.ground,
|
||||
stations: ground.stations.map((station) => ({
|
||||
|
||||
@@ -260,7 +260,7 @@ function snapToStations(
|
||||
}
|
||||
|
||||
/** 자동 변화점에 사용자 편집 측점을 합쳐 최종 변화점 집합을 만든다. */
|
||||
function resolvePvi(
|
||||
export function resolvePvi(
|
||||
base: AlignmentBase,
|
||||
offsets: Record<string, number>,
|
||||
): Array<AlignmentNode & { source: "auto" | "user" }> {
|
||||
|
||||
@@ -20,6 +20,9 @@
|
||||
쓰는 법
|
||||
./venv/Scripts/python.exe db_management/tools_clone_project.py <원본 UUID> "<새 이름>"
|
||||
[--owner <사용자 id>] [--members <id,id>] (없으면 원본 주인 · 참여자 그대로)
|
||||
storage 만(DB 는 이미 복제됨 — 다른 PC 의 storage 를 맞출 때)
|
||||
./venv/Scripts/python.exe db_management/tools_clone_project.py \\
|
||||
<원본 UUID> <새 UUID> --storage-only
|
||||
"""
|
||||
|
||||
import argparse
|
||||
@@ -42,7 +45,12 @@ from config.config_db import DB_HOST, DB_NAME, DB_PASSWORD, DB_PORT, DB_USER
|
||||
def _args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="프로젝트를 통째로 복제한다(원본은 읽기만).")
|
||||
parser.add_argument("source_id", help="원본 프로젝트 UUID")
|
||||
parser.add_argument("new_name", help="새 프로젝트 이름")
|
||||
parser.add_argument("new_name", help="새 프로젝트 이름(--storage-only 면 새 프로젝트 UUID)")
|
||||
parser.add_argument(
|
||||
"--storage-only",
|
||||
action="store_true",
|
||||
help="DB 는 읽기만 · storage 폴더 복사와 파일 안 id 바꿈만",
|
||||
)
|
||||
parser.add_argument("--owner", type=int, help="새 주인 사용자 id(없으면 원본 주인)")
|
||||
parser.add_argument("--members", help="참여자 사용자 id 쉼표 목록(없으면 원본 참여자)")
|
||||
return parser.parse_args()
|
||||
@@ -65,6 +73,83 @@ async def _insert(cursor, table, row: dict) -> int:
|
||||
return int(cursor.lastrowid)
|
||||
|
||||
|
||||
def _copy_folder(source_root: Path, new_root: Path) -> None:
|
||||
print(f"파일 복사 시작: {source_root.name} -> {new_root.name}")
|
||||
started = time.perf_counter()
|
||||
result = subprocess.run(
|
||||
[
|
||||
"robocopy",
|
||||
str(source_root),
|
||||
str(new_root),
|
||||
"/E",
|
||||
"/NFL",
|
||||
"/NDL",
|
||||
"/NJH",
|
||||
"/NJS",
|
||||
"/MT:8",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode >= 8:
|
||||
raise RuntimeError(f"robocopy 실패 (코드 {result.returncode})")
|
||||
print(f"파일 복사 완료: {time.perf_counter() - started:.0f}초")
|
||||
|
||||
|
||||
def _fix_files(new_root: Path, new_id: str, model_map: dict) -> None:
|
||||
replaced = []
|
||||
for path in new_root.rglob("*.json"):
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeDecodeError):
|
||||
continue
|
||||
if SOURCE_ID not in text:
|
||||
continue
|
||||
path.write_text(text.replace(SOURCE_ID, new_id), encoding="utf-8")
|
||||
replaced.append(str(path.relative_to(new_root)))
|
||||
print("프로젝트 id 치환:", replaced)
|
||||
|
||||
snapshot = new_root / "initial_snapshot" / "db.json"
|
||||
if snapshot.is_file():
|
||||
dump = json.loads(snapshot.read_text(encoding="utf-8"))
|
||||
changed = False
|
||||
for route in dump.get("routes", []):
|
||||
old_model = route.get("surface_model_id")
|
||||
if old_model in model_map:
|
||||
route["surface_model_id"] = model_map[old_model]
|
||||
changed = True
|
||||
if changed:
|
||||
snapshot.write_text(json.dumps(dump, ensure_ascii=False), encoding="utf-8")
|
||||
print("초기값 스냅샷 지표면 id 교정:", changed)
|
||||
|
||||
|
||||
async def storage_only(connection) -> None:
|
||||
"""DB 는 SELECT 만 — 원본 · 새 프로젝트 행(이미 복제됨)으로 폴더 자리 · 지표면 id 짝."""
|
||||
new_id = NEW_NAME
|
||||
paths = {}
|
||||
models = {}
|
||||
async with connection.cursor(aiomysql.DictCursor) as cursor:
|
||||
for project_id in (SOURCE_ID, new_id):
|
||||
row = await _rows(
|
||||
cursor, "SELECT storage_path FROM projects WHERE id = %s", (project_id,)
|
||||
)
|
||||
paths[project_id] = row[0]["storage_path"]
|
||||
rows = await _rows(
|
||||
cursor,
|
||||
"SELECT id FROM surface_models WHERE project_id = %s ORDER BY id",
|
||||
(project_id,),
|
||||
)
|
||||
models[project_id] = [item["id"] for item in rows]
|
||||
# 복제는 지표면 행을 원본 id 차례대로 넣는다 — 차례로 짝 지음.
|
||||
model_map = dict(zip(models[SOURCE_ID], models[new_id], strict=True))
|
||||
new_root = Path(resolve_stored_project_path(paths[new_id]))
|
||||
if any(path.is_file() for path in new_root.rglob("*")):
|
||||
raise RuntimeError(f"새 폴더에 파일이 이미 있음: {new_root}")
|
||||
_copy_folder(Path(resolve_stored_project_path(paths[SOURCE_ID])), new_root)
|
||||
_fix_files(new_root, new_id, model_map)
|
||||
print(f"storage 만 복제 완료: {new_root}")
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
connection = await aiomysql.connect(
|
||||
host=DB_HOST,
|
||||
@@ -75,6 +160,9 @@ async def main() -> None:
|
||||
charset="utf8mb4",
|
||||
)
|
||||
try:
|
||||
if ARGS.storage_only:
|
||||
await storage_only(connection)
|
||||
return
|
||||
async with connection.cursor(aiomysql.DictCursor) as cursor:
|
||||
source = (await _rows(cursor, "SELECT * FROM projects WHERE id = %s", (SOURCE_ID,)))[0]
|
||||
input_files = await _rows(
|
||||
@@ -142,26 +230,7 @@ async def main() -> None:
|
||||
new_root = source_root.parent / new_id
|
||||
|
||||
# 1) 파일 먼저 - 실패하면 DB 를 건드리지 않은 채로 끝난다.
|
||||
print(f"파일 복사 시작: {source_root.name} -> {new_id}")
|
||||
started = time.perf_counter()
|
||||
result = subprocess.run(
|
||||
[
|
||||
"robocopy",
|
||||
str(source_root),
|
||||
str(new_root),
|
||||
"/E",
|
||||
"/NFL",
|
||||
"/NDL",
|
||||
"/NJH",
|
||||
"/NJS",
|
||||
"/MT:8",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode >= 8:
|
||||
raise RuntimeError(f"robocopy 실패 (코드 {result.returncode})")
|
||||
print(f"파일 복사 완료: {time.perf_counter() - started:.0f}초")
|
||||
_copy_folder(source_root, new_root)
|
||||
|
||||
# 2) DB - 한 트랜잭션으로.
|
||||
await connection.begin()
|
||||
@@ -263,30 +332,7 @@ async def main() -> None:
|
||||
)
|
||||
|
||||
# 3) 파일 안에 박힌 옛 프로젝트 id 치환 + 초기값 스냅샷의 지표면 모델 id 교정.
|
||||
replaced = []
|
||||
for path in new_root.rglob("*.json"):
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeDecodeError):
|
||||
continue
|
||||
if SOURCE_ID not in text:
|
||||
continue
|
||||
path.write_text(text.replace(SOURCE_ID, new_id), encoding="utf-8")
|
||||
replaced.append(str(path.relative_to(new_root)))
|
||||
print("프로젝트 id 치환:", replaced)
|
||||
|
||||
snapshot = new_root / "initial_snapshot" / "db.json"
|
||||
if snapshot.is_file():
|
||||
dump = json.loads(snapshot.read_text(encoding="utf-8"))
|
||||
changed = False
|
||||
for route in dump.get("routes", []):
|
||||
old_model = route.get("surface_model_id")
|
||||
if old_model in model_map:
|
||||
route["surface_model_id"] = model_map[old_model]
|
||||
changed = True
|
||||
if changed:
|
||||
snapshot.write_text(json.dumps(dump, ensure_ascii=False), encoding="utf-8")
|
||||
print("초기값 스냅샷 지표면 id 교정:", changed)
|
||||
_fix_files(new_root, new_id, model_map)
|
||||
|
||||
print(f"\n새 프로젝트: {NEW_NAME}\n id = {new_id}\n 저장소 = {new_storage}")
|
||||
finally:
|
||||
|
||||
@@ -46,7 +46,17 @@ const out = {
|
||||
return {
|
||||
span,
|
||||
result: span
|
||||
? carry.carryProfileEdits(item.base_pvi, item.edits, span, item.ground_pvi)
|
||||
? {
|
||||
basePvi: carry.carryBase(item.old_xy, item.new_xy, item.base_pvi, item.ground_pvi),
|
||||
edits: carry.carryEdits(
|
||||
item.old_xy,
|
||||
item.new_xy,
|
||||
item.base_pvi,
|
||||
item.edits,
|
||||
item.stations,
|
||||
10,
|
||||
),
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* 349-0v 바뀐 구간 거울 — 화면 planline 단계 차례 그대로: 새 노선 = `buildEditedPolyline`(노드 초안) ·
|
||||
* 옛 노선 = 저장 계획 폴리라인 · `changedSpan` → `carryProfileEdits`. 입력 argv[2] JSON · 출력 JSON.
|
||||
* 옛 노선 = 저장 계획 폴리라인 · `changedSpan` → `carryBase` · `carryEdits`(400). 입력 argv[2] JSON · 출력 JSON.
|
||||
*/
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
@@ -37,13 +37,18 @@ const built = curve.buildEditedPolyline(
|
||||
input.curveRadius,
|
||||
input.minRadiusM,
|
||||
);
|
||||
const span = carry.changedSpan(xy(input.old_line), xy(built.vertices));
|
||||
const old = xy(input.old_line);
|
||||
const now = xy(built.vertices);
|
||||
const span = carry.changedSpan(old, now);
|
||||
process.stdout.write(
|
||||
JSON.stringify({
|
||||
new_line: built.vertices,
|
||||
span,
|
||||
result: span
|
||||
? carry.carryProfileEdits(input.base_pvi, input.edits, span, input.ground_pvi)
|
||||
? {
|
||||
basePvi: carry.carryBase(old, now, input.base_pvi, input.ground_pvi),
|
||||
edits: carry.carryEdits(old, now, input.base_pvi, input.edits, input.stations, 20),
|
||||
}
|
||||
: null,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
/* 400 계획고 이월 거울 — 진짜 TS(`logic/B05_Profile_Grade_Carry` · `logic/B05_Profile_Pipeline_Grade` follow)를
|
||||
* Node 로 돌림. 입력 argv[2] JSON(파이썬과 같은 값) · 표준출력 JSON 한 벌.
|
||||
*/
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "..");
|
||||
|
||||
function loadTs(file, stubs = {}) {
|
||||
const js = ts.transpileModule(fs.readFileSync(path.join(ROOT, file), "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
const localRequire = (request) => {
|
||||
if (stubs[request]) return stubs[request];
|
||||
throw new Error(`stub 없음: ${request} (${file})`);
|
||||
};
|
||||
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
|
||||
return box.exports;
|
||||
}
|
||||
|
||||
const pivot = loadTs("B05_Profile/logic/B05_Profile_UI_Profile_Pivot.ts");
|
||||
const alignment = loadTs("B05_Profile/logic/B05_Profile_UI_Profile_Alignment.ts", {
|
||||
"./B05_Profile_UI_Profile_Pivot": pivot,
|
||||
});
|
||||
const carry = loadTs("B05_Profile/logic/B05_Profile_Grade_Carry.ts", {
|
||||
"./B05_Profile_UI_Profile_Alignment": alignment,
|
||||
});
|
||||
const ground = loadTs("B05_Profile/logic/B05_Profile_Grade_Ground.ts", {
|
||||
"./B05_Profile_UI_Profile_Alignment": alignment,
|
||||
});
|
||||
const grade = loadTs("B05_Profile/logic/B05_Profile_Pipeline_Grade.ts", {
|
||||
"../../common_util/common_util_route_follow": {},
|
||||
"./B05_Profile_UI_Profile_Alignment": alignment,
|
||||
"./B05_Profile_Grade_Carry": carry,
|
||||
"./B05_Profile_Sections_Stations": { SECTION_STATION_INTERVAL_M: 20 },
|
||||
"./B05_Profile_Grade_Ground": ground,
|
||||
});
|
||||
|
||||
const curve = loadTs("B05_Profile/logic/B05_Profile_UI_RouteEdit_Curve.ts");
|
||||
const edits = loadTs("B05_Profile/logic/B05_Profile_UI_RouteEdit_Edits.ts", {
|
||||
"./B05_Profile_UI_RouteEdit_Curve": curve,
|
||||
});
|
||||
const frame = loadTs("B05_Profile/logic/B05_Profile_UI_RouteEdit_Frame.ts");
|
||||
const fit = loadTs("B05_Profile/logic/B05_Profile_RouteEdit_StationFit.ts", {
|
||||
"./B05_Profile_UI_RouteEdit_Curve": curve,
|
||||
"./B05_Profile_UI_RouteEdit_Edits": edits,
|
||||
"./B05_Profile_UI_RouteEdit_Frame": frame,
|
||||
});
|
||||
|
||||
/** 측점 스냅 켠 노선 — 저장 노선(맞춤) · 교점 하나 옮긴 뒤 맞춤(편집기와 같은 `fitCurveStations`). */
|
||||
function snapFit(item) {
|
||||
const n = item.nodes.length;
|
||||
const cols = (planned) => ({
|
||||
planned,
|
||||
curveOn: Array(n).fill(true),
|
||||
curveRadius: Array(n).fill(null),
|
||||
curveLock: Array(n).fill(null),
|
||||
curveArc: Array(n).fill(null),
|
||||
apexLock: Array(n).fill(false),
|
||||
});
|
||||
const line = (planned) =>
|
||||
curve.buildEditedPolyline(
|
||||
planned,
|
||||
Array(n).fill(true),
|
||||
Array(n).fill(null),
|
||||
item.limits.minRadiusM,
|
||||
).vertices;
|
||||
const old = fit.fitCurveStations(cols(item.nodes), item.limits, 1);
|
||||
const moved = old.map((p, i) => (i === item.index ? item.to : p));
|
||||
const now = fit.fitCurveStations(cols(moved), item.limits, Math.max(1, item.index - 1));
|
||||
return { old_nodes: old, new_nodes: now, old_line: line(old), new_line: line(now) };
|
||||
}
|
||||
|
||||
const input = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
|
||||
const routeOf = (line) => {
|
||||
const chainage = [0];
|
||||
for (let i = 1; i < line.length; i += 1)
|
||||
chainage.push(
|
||||
chainage[i - 1] + Math.hypot(line[i][0] - line[i - 1][0], line[i][1] - line[i - 1][1]),
|
||||
);
|
||||
return { line, curves: [], chainage, lengthM: chainage[chainage.length - 1] };
|
||||
};
|
||||
|
||||
/** follow 단계를 노선 걸음 차례대로 — 각 걸음 뒤 편집(묶음이 입력에 넣는 것과 같음). */
|
||||
function walk(item) {
|
||||
let stations = [];
|
||||
const stage = grade.createFollowStage({ savedLine: () => null, stationsAt: () => stations });
|
||||
const planline = { policy: item.policy, basePvi: item.base_pvi };
|
||||
let alignmentNow = item.edits;
|
||||
let before = routeOf(item.ref_line);
|
||||
const out = [];
|
||||
for (const step of item.steps) {
|
||||
stations = step.stations;
|
||||
const now = routeOf(step.line);
|
||||
const patch = stage({
|
||||
inputs: { route: { nodes: [] }, pipes: null, structures: null, alignment: alignmentNow },
|
||||
out: { route: now },
|
||||
before: { route: before, planline },
|
||||
});
|
||||
if (patch.alignment) alignmentNow = patch.alignment;
|
||||
out.push(alignmentNow);
|
||||
before = now;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const result = {
|
||||
match: input.match.map((item) => {
|
||||
const fn = carry.lineMatcher(item.old_xy, item.new_xy, item.tol);
|
||||
return item.queries.map((t) => fn(t));
|
||||
}),
|
||||
base: input.base.map((item) =>
|
||||
carry.carryBase(item.old_xy, item.new_xy, item.old_base, item.ground_pvi),
|
||||
),
|
||||
edits: input.edits.map((item) =>
|
||||
carry.carryEdits(item.old_xy, item.new_xy, item.old_base, item.edits, item.stations, item.half),
|
||||
),
|
||||
follow: input.follow.map(walk),
|
||||
snap: (input.snap ?? []).map(snapFit),
|
||||
};
|
||||
process.stdout.write(JSON.stringify(result));
|
||||
@@ -7,7 +7,8 @@
|
||||
② 관 자리 투영 — `common_util_drainage_pipes.project_pipe_points` ↔
|
||||
`common_util_route_follow.ts` `projectPipePoints`.
|
||||
③ 구조물 따라감 — `common_util_route_follow.follow_structures` ↔ `followStructures`(20 m 상수 같음).
|
||||
④ 계획고 옮김 — `B05_Profile_Engine_Grade_Carry` ↔ `logic/B05_Profile_Grade_Carry.ts`.
|
||||
④ 계획고 옮김 — `B05_Profile_Engine_Grade_Carry` ↔ `logic/B05_Profile_Grade_Carry.ts`
|
||||
(400 = 같은 자리 · 새 측점 위 · 자세한 규칙은 test_400_profile_carry.py).
|
||||
서버 쪽: 저장 때 좌표 채움(자리가 그대로면 옛 좌표 지킴) · 노선 [확인] 때 구조물 정본이 따라감.
|
||||
"""
|
||||
|
||||
@@ -20,6 +21,7 @@ import sys
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
@@ -29,9 +31,12 @@ if str(ROOT) not in sys.path:
|
||||
|
||||
import B05_Profile.B05_Profile_Structures_Router as structures_router # noqa: E402
|
||||
from B05_Profile.B05_Profile_Engine_Grade import resolve_grade_options # noqa: E402
|
||||
from B05_Profile.B05_Profile_Engine_Grade_Alignment import resolve_pvi # noqa: E402
|
||||
from B05_Profile.B05_Profile_Engine_Grade_Carry import ( # noqa: E402
|
||||
carry_profile_edits,
|
||||
carry_base,
|
||||
carry_edits,
|
||||
changed_span,
|
||||
line_matcher,
|
||||
)
|
||||
from B05_Profile.B05_Profile_Engine_Grade_Profile import ( # noqa: E402
|
||||
design_ground_following_profile,
|
||||
@@ -115,6 +120,7 @@ def _carry_case(new_route: list[tuple[float, float]]) -> dict:
|
||||
"base_pvi": base_pvi,
|
||||
"edits": edits,
|
||||
"ground_pvi": alignment["base_pvi"],
|
||||
"stations": [row["chainage_m"] for row in longitudinal["stations"]],
|
||||
}
|
||||
|
||||
|
||||
@@ -335,18 +341,47 @@ def test_노선_확인_때_구조물_정본이_따라감(tmp_path, monkeypatch):
|
||||
# ── ④ 계획고 옮김 ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _pts(case, key):
|
||||
return [(p["x"], p["y"]) for p in case[key]]
|
||||
|
||||
|
||||
def _py_carry(case):
|
||||
span = changed_span(
|
||||
[(p["x"], p["y"]) for p in case["old_xy"]], [(p["x"], p["y"]) for p in case["new_xy"]]
|
||||
)
|
||||
old, new = _pts(case, "old_xy"), _pts(case, "new_xy")
|
||||
span = changed_span(old, new)
|
||||
result = (
|
||||
carry_profile_edits(case["base_pvi"], case["edits"], span, case["ground_pvi"])
|
||||
(
|
||||
carry_base(old, new, case["base_pvi"], case["ground_pvi"]),
|
||||
carry_edits(old, new, case["base_pvi"], case["edits"], case["stations"], 10.0),
|
||||
)
|
||||
if span
|
||||
else None
|
||||
)
|
||||
return span, result
|
||||
|
||||
|
||||
def _plan_at(base, edits, s):
|
||||
pvi_s, pvi_z, _ = resolve_pvi(
|
||||
np.array([p["chainage_m"] for p in base]),
|
||||
np.array([p["elevation_m"] for p in base]),
|
||||
edits["station_offsets"],
|
||||
)
|
||||
return float(np.interp(s, pvi_s, pvi_z))
|
||||
|
||||
|
||||
def _kept_plan(case, base_pvi, edits):
|
||||
"""같은 자리 새 측점마다 새 계획고 = 옛 계획고 · 바뀐 자리 측점 목록."""
|
||||
match = line_matcher(_pts(case, "old_xy"), _pts(case, "new_xy"))
|
||||
moved = []
|
||||
for t in case["stations"]:
|
||||
s = match(t)
|
||||
if s is None:
|
||||
moved.append(t)
|
||||
continue
|
||||
want = _plan_at(case["base_pvi"], case["edits"], s)
|
||||
assert _plan_at(base_pvi, edits, t) == pytest.approx(want, abs=1e-3), t
|
||||
return moved
|
||||
|
||||
|
||||
@pytest.mark.parametrize("index", range(len(CARRY_CASES)))
|
||||
def test_계획고_옮김_거울(ts, index):
|
||||
span, result = _py_carry(CARRY_CASES[index])
|
||||
@@ -370,33 +405,32 @@ def test_계획고_옮김_거울(ts, index):
|
||||
assert edits == ts_result["edits"]
|
||||
|
||||
|
||||
def test_가운데를_고치면_앞은_그대로_뒤는_밂():
|
||||
span, (base_pvi, edits) = _py_carry(CARRY_CASES[0])
|
||||
def test_가운데를_고치면_앞은_그대로_뒤는_같은자리_계획고():
|
||||
case = CARRY_CASES[0]
|
||||
span, (base_pvi, edits) = _py_carry(case)
|
||||
assert span["start_fixed"] and span["end_fixed"]
|
||||
delta = span["delta_m"]
|
||||
assert span["start_m"] == pytest.approx(math.dist(OLD_ROUTE[0], OLD_ROUTE[1]))
|
||||
# 앞 편집 그대로 · 안(90.5) 버림 · 뒤(180 · 207.6) 길이 차만큼
|
||||
# 앞 편집 그대로 · 안(90.5) 버림 · 앞 R 그대로 · 같은 자리 새 측점 계획고 = 옛 계획고
|
||||
assert edits["station_offsets"]["20.000"] == 0.5
|
||||
assert "90.500" not in edits["station_offsets"]
|
||||
assert edits["station_offsets"][f"{180.0 + delta:.3f}"] == 0.2
|
||||
assert edits["curve_radii"] == {"40.000": 300.0, f"{150.0 + delta:.3f}": 250.0}
|
||||
old = {round(p["chainage_m"], 3): p["elevation_m"] for p in CARRY_CASES[0]["base_pvi"]}
|
||||
assert edits["curve_radii"]["40.000"] == 300.0
|
||||
moved = _kept_plan(case, base_pvi, edits)
|
||||
# 바뀐 자리는 새 지반 첫 계획선 꼭짓점 · 편집 없음
|
||||
ground = {round(p["chainage_m"], 3): p["elevation_m"] for p in case["ground_pvi"]}
|
||||
got = {round(p["chainage_m"], 3): p["elevation_m"] for p in base_pvi}
|
||||
assert got[20.0] == old[20.0] and got[round(180.0 + delta, 3)] == old[180.0]
|
||||
# 바뀐 구간 안은 새 지반 첫 계획선 꼭짓점
|
||||
ground = {round(p["chainage_m"], 3): p["elevation_m"] for p in CARRY_CASES[0]["ground_pvi"]}
|
||||
inside = [s for s in got if span["start_m"] + 1e-6 < s < span["new_end_m"] - 1e-6]
|
||||
assert inside and all(got[s] == ground[s] for s in inside)
|
||||
assert min(got) == 0.0 and max(got) == pytest.approx(L_OLD + delta, abs=1e-3)
|
||||
assert moved and all(got[round(t, 3)] == ground[round(t, 3)] for t in moved)
|
||||
assert not {f"{t:.3f}" for t in moved} & set(edits["station_offsets"])
|
||||
assert min(got) == 0.0 and max(got) == pytest.approx(L_OLD + span["delta_m"], abs=1e-3)
|
||||
|
||||
|
||||
def test_끝을_고치면_끝_꼭짓점은_새_지반():
|
||||
span, (base_pvi, edits) = _py_carry(CARRY_CASES[1])
|
||||
case = CARRY_CASES[1]
|
||||
span, (base_pvi, edits) = _py_carry(case)
|
||||
assert not span["end_fixed"]
|
||||
assert f"{L_OLD:.3f}" not in edits["station_offsets"] # 바뀐 끝 편집은 버림
|
||||
assert edits["station_offsets"]["90.500"] == -0.3 # 앞(바뀐 구간 앞) 편집 그대로
|
||||
ground_end = CARRY_CASES[1]["ground_pvi"][-1]
|
||||
_kept_plan(case, base_pvi, edits) # 앞(90.5 편집 둘레) 계획고 그대로
|
||||
ground_end = case["ground_pvi"][-1]
|
||||
assert base_pvi[-1] == {
|
||||
"chainage_m": round(ground_end["chainage_m"], 6),
|
||||
"chainage_m": ground_end["chainage_m"],
|
||||
"elevation_m": ground_end["elevation_m"],
|
||||
}
|
||||
|
||||
@@ -522,7 +522,7 @@ def _longitudinal(total: float) -> dict:
|
||||
return {**longitudinal, "profile_alignment": alignment, "design_profiles": [profile]}
|
||||
|
||||
|
||||
def test_계획고_옮김은_앞_그대로_뒤_밂(tmp_path, monkeypatch):
|
||||
def test_계획고_옮김은_앞_그대로_뒤_같은자리(tmp_path, monkeypatch):
|
||||
new_xy = [list(p) for p in NEW]
|
||||
total = float(vertices_of(NEW)[-1].chainage_m)
|
||||
old = _longitudinal(400.0)
|
||||
@@ -548,10 +548,12 @@ def test_계획고_옮김은_앞_그대로_뒤_밂(tmp_path, monkeypatch):
|
||||
)
|
||||
)
|
||||
edits = json.loads(path.read_text("utf-8"))["profile_alignment"]["edits"]["station_offsets"]
|
||||
delta = total - 400.0
|
||||
assert edits["40.000"] == 0.7 # 바뀐 구간 앞 — 그대로
|
||||
assert "200.000" not in edits # 바뀐 구간 안 — 새 지반
|
||||
assert edits[f"{380.0 + delta:.3f}"] == 0.3 # 뒤 — 길이 차만큼 밂
|
||||
# 뒤(옛 380 둘레) — 같은 자리 새 측점 위 편집으로 이어받음(400 · 길이 차만큼 민 글자 키 아님)
|
||||
stations = {f"{row['chainage_m']:.3f}" for row in _longitudinal(total)["stations"]}
|
||||
behind = [key for key in edits if float(key) > 300.0]
|
||||
assert behind and set(behind) <= stations
|
||||
|
||||
|
||||
def test_저장_확정이_옛_횡단_파일을_지움(tmp_path):
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
"""349-0v 손으로 고친 노선 [저장] — 미리보기 계획고 = 저장 뒤 계획고(바뀐 구간 거울).
|
||||
|
||||
화면 planline 단계: 옛 = 저장 계획 폴리라인(`read_route_plan` planned) · 새 = `buildEditedPolyline`(노드
|
||||
초안) → `changedSpan` → `carryProfileEdits`. 서버 노선 저장: 옛 = 같은 계획 폴리라인 CSV · 새 =
|
||||
`_write_planned_polyline` 이 쓴 CSV → `changed_span` → `carry_profile_edits`. 두 길이 같은 구간 · 같은
|
||||
계획고를 내야 함(풀린 노선 geojson 으로 가르면 점 간격이 달라 앞머리가 일찍 갈렸음).
|
||||
초안) → `changedSpan` → `carryBase` · `carryEdits`. 서버 노선 저장: 옛 = 같은 계획 폴리라인
|
||||
CSV · 새 = `_write_planned_polyline` 이 쓴 CSV → `changed_span` → `carry_base` · `carry_edits`(400).
|
||||
두 길이 같은 구간 · 같은 계획고를 내야 함(풀린 노선 geojson 으로 가르면 점 간격이 달라
|
||||
앞머리가 일찍 갈렸음).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -24,7 +25,8 @@ if str(ROOT) not in sys.path:
|
||||
|
||||
from B05_Profile import B05_Profile_Router_Replan as replan # noqa: E402
|
||||
from B05_Profile.B05_Profile_Engine_Grade_Carry import ( # noqa: E402
|
||||
carry_profile_edits,
|
||||
carry_base,
|
||||
carry_edits,
|
||||
changed_span,
|
||||
)
|
||||
|
||||
@@ -80,6 +82,7 @@ def world(tmp_path_factory):
|
||||
"base_pvi": base_pvi,
|
||||
"edits": edits,
|
||||
"ground_pvi": ground_pvi,
|
||||
"stations": [p["chainage_m"] for p in ground_pvi],
|
||||
}
|
||||
source = tmp / "input.json"
|
||||
source.write_text(json.dumps(payload), encoding="utf-8")
|
||||
@@ -119,11 +122,18 @@ def test_옮긴_계획고_화면_서버_같음(world):
|
||||
span = changed_span(
|
||||
[tuple(p) for p in world["old_line"]], [tuple(p) for p in world["new_line_py"]]
|
||||
)
|
||||
base, edits = carry_profile_edits(world["base_pvi"], world["edits"], span, world["ground_pvi"])
|
||||
old, new = world["old_line"], world["new_line_py"]
|
||||
base = carry_base(old, new, world["base_pvi"], world["ground_pvi"])
|
||||
edits = carry_edits(old, new, world["base_pvi"], world["edits"], world["stations"], 20.0)
|
||||
assert span is not None
|
||||
ts = world["ts"]["result"]
|
||||
flat = [v for n in base for v in (n["chainage_m"], n["elevation_m"])]
|
||||
assert flat == pytest.approx(
|
||||
[v for n in ts["basePvi"] for v in (n["chainage_m"], n["elevation_m"])], abs=1e-3
|
||||
) # 서버 새 선 = CSV 0.1 mm 반올림 · 화면 = 원값
|
||||
assert edits == ts["edits"]
|
||||
assert edits["station_offsets"]["150.000"] == 0.8 # 바뀐 구간 앞 사용자 계획고 그대로
|
||||
for field in ("station_offsets", "curve_radii"):
|
||||
assert edits[field].keys() == ts["edits"][field].keys()
|
||||
for key, value in edits[field].items():
|
||||
assert value == pytest.approx(ts["edits"][field][key], abs=1e-3)
|
||||
# 바뀐 구간 앞 사용자 계획고(150) = 둘레 새 측점(120 · 160) 편집으로 그대로
|
||||
assert {"120.000", "160.000"} <= set(edits["station_offsets"])
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""400 평면 고친 뒤 종단 편집 이어받기 — 같은 자리(옆 1 m · 방향 30° 안)는 옛 계획고를 새 측점 위에.
|
||||
|
||||
거울(파이썬 = 서버 [저장] 정본 · TS = 브라우저 즉시 · 같은 값):
|
||||
`B05_Profile_Engine_Grade_Carry` line_matcher · carry_base · carry_edits ↔
|
||||
`logic/B05_Profile_Grade_Carry.ts` lineMatcher · carryBase · carryEdits.
|
||||
묶음 follow(`logic/B05_Profile_Pipeline_Grade.ts`) — 걸음 수와 무관(기준 한 벌에서 한 번) ·
|
||||
되돌린 노선 = 옛 편집.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
ROOT = HERE.parents[1]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from B05_Profile.B05_Profile_Engine_Grade_Alignment import resolve_pvi # noqa: E402
|
||||
from B05_Profile.B05_Profile_Engine_Grade_Carry import ( # noqa: E402
|
||||
SAME_LINE_TOL_M,
|
||||
carry_base,
|
||||
carry_edits,
|
||||
line_matcher,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not (ROOT / "config" / "node_modules" / "typescript").is_dir(),
|
||||
reason="프론트엔드 의존성 미설치",
|
||||
)
|
||||
|
||||
INTERVAL = 20.0
|
||||
HALF = INTERVAL / 2
|
||||
|
||||
|
||||
# ── 입력 만들기 ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _dense(nodes: list[tuple[float, float]], step: float = 2.0) -> list[list[float]]:
|
||||
"""꺾은선을 `step` m 안팎 점으로(그린 노선처럼 정점이 많게)."""
|
||||
out = [list(nodes[0])]
|
||||
for (x0, y0), (x1, y1) in zip(nodes, nodes[1:]):
|
||||
n = max(1, int(math.ceil(math.dist((x0, y0), (x1, y1)) / step)))
|
||||
out += [[x0 + (x1 - x0) * k / n, y0 + (y1 - y0) * k / n] for k in range(1, n + 1)]
|
||||
return out
|
||||
|
||||
|
||||
def _length(line) -> float:
|
||||
return sum(math.dist(a, b) for a, b in zip(line, line[1:]))
|
||||
|
||||
|
||||
def _stations(line) -> list[float]:
|
||||
total = _length(line)
|
||||
grid = [round(k * INTERVAL, 3) for k in range(int(total // INTERVAL) + 1)]
|
||||
return grid + ([round(total, 3)] if total - grid[-1] > 1e-6 else [])
|
||||
|
||||
|
||||
def _ground(s: float) -> float:
|
||||
return 100.0 + 0.05 * s + 2.0 * math.sin(s / 37.0)
|
||||
|
||||
|
||||
def _ground_pvi(line) -> list[dict]:
|
||||
"""새 노선 지반 첫 계획선 꼴(측점마다 지반 · 옛 계획고와 다르게 +3 m)."""
|
||||
return [{"chainage_m": s, "elevation_m": round(_ground(s) + 3.0, 6)} for s in _stations(line)]
|
||||
|
||||
|
||||
OLD_NODES = [(0.0, 0.0), (200.0, 0.0), (400.0, 100.0), (600.0, 100.0), (800.0, 0.0)]
|
||||
OLD = _dense(OLD_NODES)
|
||||
OLD_BASE = [{"chainage_m": s, "elevation_m": round(_ground(s), 6)} for s in _stations(OLD)]
|
||||
|
||||
|
||||
def _old_edits() -> dict:
|
||||
"""직선화(40 ~ 120 사이 측점) · ▲ 1 m(300) · 종단곡선 R(300) · 자유지정 꺾임(520 · 540)."""
|
||||
base = {n["chainage_m"]: n["elevation_m"] for n in OLD_BASE}
|
||||
z40, z120 = base[40.0], base[120.0]
|
||||
offsets = {}
|
||||
for s in (60.0, 80.0, 100.0):
|
||||
offsets[f"{s:.3f}"] = round(z40 + (z120 - z40) * (s - 40) / 80 - base[s], 6)
|
||||
offsets["300.000"] = 1.0
|
||||
offsets["520.000"] = -0.8
|
||||
offsets["540.000"] = -1.2
|
||||
return {"station_offsets": offsets, "curve_radii": {"300.000": 400.0, "520.000": 250.0}}
|
||||
|
||||
|
||||
OLD_EDITS = _old_edits()
|
||||
|
||||
|
||||
def _moved(index: int, to: tuple[float, float]) -> list[list[float]]:
|
||||
nodes = list(OLD_NODES)
|
||||
nodes[index] = to
|
||||
return _dense(nodes)
|
||||
|
||||
|
||||
CASES = {
|
||||
# 먼 교점(끝 앞) 하나 옮김 — 앞쪽 편집 그대로
|
||||
"far": _moved(3, (620.0, 130.0)),
|
||||
# 시점을 뒤로 15 m 늘림 — 같은 선 · 누가거리만 +15(새 측점은 옛 측점 사이)
|
||||
"start": _dense([(-15.0, 0.0)] + OLD_NODES[1:]),
|
||||
# 끝을 같은 방향으로 늘림 — 옛 끝까지 그대로 · 넘는 자리만 지반
|
||||
"end": _dense(OLD_NODES[:-1] + [(840.0, -20.0)]),
|
||||
# 측점 스냅처럼 뒤 교점이 옆으로 0.6 m(같은 자리) · 1.6 m(바뀐 자리) 밀림
|
||||
"snap": _dense(OLD_NODES[:2] + [(400.0, 100.6), (600.0, 101.6), (800.0, 1.6)]),
|
||||
"same": OLD,
|
||||
}
|
||||
|
||||
|
||||
def _xy(line):
|
||||
return [{"x": x, "y": y} for x, y in line]
|
||||
|
||||
|
||||
def _plan(base: list[dict], edits: dict) -> tuple[np.ndarray, np.ndarray]:
|
||||
s = np.array([n["chainage_m"] for n in base], dtype=np.float64)
|
||||
z = np.array([n["elevation_m"] for n in base], dtype=np.float64)
|
||||
pvi_s, pvi_z, _ = resolve_pvi(s, z, edits["station_offsets"])
|
||||
return pvi_s, pvi_z
|
||||
|
||||
|
||||
def _py(name: str) -> tuple[list[dict], dict]:
|
||||
new = CASES[name]
|
||||
base = carry_base(OLD, new, OLD_BASE, _ground_pvi(new))
|
||||
edits = carry_edits(OLD, new, OLD_BASE, OLD_EDITS, _stations(new), HALF)
|
||||
return base, edits
|
||||
|
||||
|
||||
# ── TS 한 번 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
FOLLOW_STEPS = [
|
||||
# 기준 → 먼 교점 옮김 → 더 옮김 · 한 번에 옮김과 같아야
|
||||
[("far1", _moved(3, (610.0, 115.0))), ("far2", CASES["far"])],
|
||||
[("far2", CASES["far"])],
|
||||
# 바뀐 자리를 거쳐 되돌림 — 옛 편집 그대로
|
||||
[("mid", _moved(4, (830.0, 30.0))), ("back", OLD)],
|
||||
]
|
||||
|
||||
|
||||
# 측점 스냅 켠 노선 — 앞 교점 하나 옮기면 뒤 교점이 모두 MC 맞춤으로 밀림(StationFit)
|
||||
SNAP = {
|
||||
"nodes": [[0, 0], [150, 30], [300, 0], [450, 40], [600, 10], [750, 50], [900, 20], [1050, 40]],
|
||||
"index": 1,
|
||||
"to": [150.0, 37.0],
|
||||
"limits": {"minRadiusM": 60.0, "limitRadiusM": 0.0, "limitArcM": 0.0, "intervalM": INTERVAL},
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def ts(tmp_path_factory) -> dict:
|
||||
queries = [0.0, 33.3, 210.0, 430.0, 500.0, 700.0, 815.0, 1e6]
|
||||
payload = {
|
||||
"match": [
|
||||
{"old_xy": _xy(OLD), "new_xy": _xy(line), "tol": SAME_LINE_TOL_M, "queries": queries}
|
||||
for line in CASES.values()
|
||||
],
|
||||
"base": [
|
||||
{
|
||||
"old_xy": _xy(OLD),
|
||||
"new_xy": _xy(line),
|
||||
"old_base": OLD_BASE,
|
||||
"ground_pvi": _ground_pvi(line),
|
||||
}
|
||||
for line in CASES.values()
|
||||
],
|
||||
"edits": [
|
||||
{
|
||||
"old_xy": _xy(OLD),
|
||||
"new_xy": _xy(line),
|
||||
"old_base": OLD_BASE,
|
||||
"edits": OLD_EDITS,
|
||||
"stations": _stations(line),
|
||||
"half": HALF,
|
||||
}
|
||||
for line in CASES.values()
|
||||
],
|
||||
"follow": [
|
||||
{
|
||||
"ref_line": OLD,
|
||||
"policy": {"station_interval_m": INTERVAL},
|
||||
"base_pvi": OLD_BASE,
|
||||
"edits": OLD_EDITS,
|
||||
"steps": [{"line": line, "stations": _stations(line)} for _, line in steps],
|
||||
}
|
||||
for steps in FOLLOW_STEPS
|
||||
],
|
||||
"snap": [SNAP],
|
||||
}
|
||||
source = tmp_path_factory.mktemp("s400") / "in.json"
|
||||
source.write_text(json.dumps(payload), encoding="utf-8")
|
||||
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
|
||||
["node", str(HERE / "helper_400_profile_carry.cjs"), str(source)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=120,
|
||||
check=False,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
# ── 거울 ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize("index", range(len(CASES)))
|
||||
def test_거울_같은자리_꼭짓점_편집(ts, index):
|
||||
name, line = list(CASES.items())[index]
|
||||
match = line_matcher(OLD, line)
|
||||
for t, got in zip([0.0, 33.3, 210.0, 430.0, 500.0, 700.0, 815.0, 1e6], ts["match"][index]):
|
||||
want = match(t)
|
||||
assert (want is None) == (got is None), (name, t)
|
||||
if want is not None:
|
||||
assert want == pytest.approx(got, abs=1e-9)
|
||||
base, edits = _py(name)
|
||||
assert len(base) == len(ts["base"][index])
|
||||
for a, b in zip(base, ts["base"][index]):
|
||||
assert a["chainage_m"] == pytest.approx(b["chainage_m"], abs=1e-9)
|
||||
assert a["elevation_m"] == pytest.approx(b["elevation_m"], abs=1e-6)
|
||||
got = ts["edits"][index]
|
||||
for field in ("station_offsets", "curve_radii"):
|
||||
assert edits[field].keys() == got[field].keys(), (name, field)
|
||||
for key, value in edits[field].items():
|
||||
assert value == pytest.approx(got[field][key], abs=1e-6)
|
||||
|
||||
|
||||
# ── 규칙 ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _same_place_plan(name: str):
|
||||
"""같은 자리 새 측점마다 (새 계획고, 옛 계획고) · 바뀐 자리 측점."""
|
||||
new = CASES[name]
|
||||
base, edits = _py(name)
|
||||
match = line_matcher(OLD, new)
|
||||
old_s, old_z = _plan(OLD_BASE, OLD_EDITS)
|
||||
new_s, new_z = _plan(base, edits)
|
||||
kept, moved = [], []
|
||||
for t in _stations(new):
|
||||
s = match(t)
|
||||
if s is None:
|
||||
moved.append(t)
|
||||
else:
|
||||
kept.append((t, float(np.interp(t, new_s, new_z)), float(np.interp(s, old_s, old_z))))
|
||||
return kept, moved, base, edits
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", ["far", "start", "end", "snap", "same"])
|
||||
def test_같은자리_계획고_그대로(name):
|
||||
kept, _, _, _ = _same_place_plan(name)
|
||||
assert kept
|
||||
for t, new_z, old_z in kept:
|
||||
# 옛 자동 꼭짓점 0.006 m 안 측점은 그 꼭짓점 위로 봄 — 기울기 차 × 0.006 m 안
|
||||
assert new_z == pytest.approx(old_z, abs=1e-3), (name, t)
|
||||
|
||||
|
||||
def test_먼교점_옮김_앞쪽_편집_누가거리_그대로_바뀐자리만_지반():
|
||||
kept, moved, base, edits = _same_place_plan("far")
|
||||
# 앞쪽(같은 선 · 같은 측점) 편집 글자 그대로
|
||||
for key in ("60.000", "80.000", "100.000", "300.000"):
|
||||
assert edits["station_offsets"][key] == pytest.approx(OLD_EDITS["station_offsets"][key])
|
||||
assert edits["curve_radii"]["300.000"] == 400.0
|
||||
# 바뀐 자리 = 옮긴 교점 둘레 · 그 측점은 지반 첫 계획선 · 편집 없음(옛 520 · 540 자유지정 버림)
|
||||
assert moved and min(moved) > 423.0
|
||||
ground = {n["chainage_m"]: n["elevation_m"] for n in _ground_pvi(CASES["far"])}
|
||||
by = {n["chainage_m"]: n["elevation_m"] for n in base}
|
||||
assert all(by[t] == ground[t] for t in moved)
|
||||
assert not any(float(k) in moved for k in edits["station_offsets"])
|
||||
|
||||
|
||||
def test_시점_늘림_누가거리_투영_새측점_위():
|
||||
kept, moved, _, edits = _same_place_plan("start")
|
||||
assert not moved or max(moved) < 1.0
|
||||
stations = set(_stations(CASES["start"]))
|
||||
assert edits["station_offsets"] and all(float(k) in stations for k in edits["station_offsets"])
|
||||
assert all(float(k) in stations for k in edits["curve_radii"])
|
||||
# ▲(옛 300) 의 R 은 옛 300 = 새 315 에서 가장 가까운 새 측점(320 · 옛 305)
|
||||
assert edits["curve_radii"].get("320.000") == 400.0
|
||||
|
||||
|
||||
def test_끝_늘림_옛끝까지_이어받고_넘는자리만_지반():
|
||||
kept, moved, base, _ = _same_place_plan("end")
|
||||
old_len = _length(OLD)
|
||||
assert moved and min(moved) > old_len
|
||||
assert max(t for t, _, _ in kept) > old_len - INTERVAL
|
||||
|
||||
|
||||
def test_스냅처럼_옆으로_밀림_1m_안_이어받고_넘으면_다시():
|
||||
match = line_matcher(OLD, CASES["snap"])
|
||||
assert match(410.0) is not None # 0.6 m 쪽
|
||||
assert match(650.0) is None # 1.6 m 쪽
|
||||
_, _, _, edits = _same_place_plan("snap")
|
||||
assert "300.000" in edits["station_offsets"]
|
||||
|
||||
|
||||
def test_종단_안_고친_프로젝트는_편집_없음_꼭짓점은_옛것_또는_지반():
|
||||
empty = {"station_offsets": {}, "curve_radii": {}}
|
||||
new = CASES["far"]
|
||||
assert carry_edits(OLD, new, OLD_BASE, empty, _stations(new), HALF) == empty
|
||||
base = carry_base(OLD, new, OLD_BASE, _ground_pvi(new))
|
||||
old_s = [n["chainage_m"] for n in OLD_BASE]
|
||||
old_z = [n["elevation_m"] for n in OLD_BASE]
|
||||
ground = {n["chainage_m"]: n["elevation_m"] for n in _ground_pvi(new)}
|
||||
match = line_matcher(OLD, new)
|
||||
for node in base:
|
||||
t = node["chainage_m"]
|
||||
s = match(t)
|
||||
want = ground[t] if s is None else round(float(np.interp(s, old_s, old_z)), 6)
|
||||
assert node["elevation_m"] == pytest.approx(want, abs=1e-9)
|
||||
|
||||
|
||||
def test_엇갈려_지나는_선은_같은자리_아님():
|
||||
crossing = _dense([(100.0, -50.0), (100.0, 50.0)])
|
||||
assert line_matcher(OLD, crossing)(50.0) is None
|
||||
reverse = OLD[::-1]
|
||||
assert line_matcher(OLD, reverse)(100.0) is None
|
||||
|
||||
|
||||
# ── 묶음 follow ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_follow_걸음수와_무관_서버와_같은값(ts):
|
||||
stepwise, direct, back = ts["follow"]
|
||||
assert stepwise[-1] == direct[-1]
|
||||
_, edits = _py("far")
|
||||
got = direct[-1]
|
||||
assert edits["station_offsets"].keys() == got["station_offsets"].keys()
|
||||
for key, value in edits["station_offsets"].items():
|
||||
assert value == pytest.approx(got["station_offsets"][key], abs=1e-6)
|
||||
assert edits["curve_radii"] == got["curve_radii"]
|
||||
|
||||
|
||||
def test_follow_바뀐자리_거쳐_되돌리면_옛편집(ts):
|
||||
_, _, back = ts["follow"]
|
||||
assert "520.000" in back[0]["station_offsets"] # 끝 교점 옮김 — 앞쪽(먼) 편집은 남음
|
||||
assert back[-1] == OLD_EDITS
|
||||
|
||||
|
||||
def test_측점스냅_켠_노선_뒤_교점_밀려도_같은자리_편집_이어받음(ts):
|
||||
got = ts["snap"][0]
|
||||
old_line, new_line = got["old_line"], got["new_line"]
|
||||
shifts = [math.dist(a, b) for a, b in zip(got["old_nodes"][2:], got["new_nodes"][2:])]
|
||||
assert max(shifts) > 0.5 # 뒤 교점이 정말 밀림 — 1 mm 정점 비교로는 뒤쪽 전부 「바뀐 구간」
|
||||
stations = _stations(old_line)
|
||||
base = [{"chainage_m": t, "elevation_m": round(_ground(t), 6)} for t in stations]
|
||||
far = [t for t in stations if 600.0 <= t <= 900.0]
|
||||
offsets = {f"{t:.3f}": round(0.5 + 0.001 * t, 6) for t in far} # 뒤쪽 직선화 · ▲ 몫
|
||||
edits = {"station_offsets": offsets, "curve_radii": {}}
|
||||
new_stations = _stations(new_line)
|
||||
new_base = carry_base(old_line, new_line, base, _ground_pvi(new_line))
|
||||
new_edits = carry_edits(old_line, new_line, base, edits, new_stations, HALF)
|
||||
match = line_matcher(old_line, new_line)
|
||||
old_s, old_z = _plan(base, edits)
|
||||
new_s, new_z = _plan(new_base, new_edits)
|
||||
carried = [t for t in new_stations if f"{t:.3f}" in new_edits["station_offsets"]]
|
||||
assert (
|
||||
len(carried) >= len(far) // 2
|
||||
) # 뒤쪽 편집 절반 넘게 이어받음(나머지 = 1 m 넘게 밀린 곡선 둘레)
|
||||
for t in carried:
|
||||
s = match(t)
|
||||
assert float(np.interp(t, new_s, new_z)) == pytest.approx(
|
||||
float(np.interp(s, old_s, old_z)), abs=1e-3
|
||||
)
|
||||
Reference in New Issue
Block a user