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
+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" }> {