Merge remote-tracking branches 'origin/sub_laptop_5' and 'origin/sub_laptop_6' into sub_laptop_1

This commit is contained in:
2026-10-17 22:07:09 +09:00
14 changed files with 1047 additions and 211 deletions
+14
View File
@@ -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>();
+139 -59
View File
@@ -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
+10 -8
View File
@@ -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(
+164 -53
View File
@@ -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" }> {
+91 -45
View File
@@ -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:
+11 -1
View File
@@ -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,
};
}),
+8 -3
View File
@@ -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));
+57 -23
View File
@@ -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"],
}
+5 -3
View File
@@ -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"])
+366
View File
@@ -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
)