Merge remote-tracking branches 'origin/sub_laptop_5' and 'origin/sub_laptop_6' into sub_laptop_1
This commit is contained in:
@@ -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" }> {
|
||||
|
||||
Reference in New Issue
Block a user