feat(344-2): 첫 계획선 · 관 자리 투영 · 구조물 좌표 따라감 · 계획고 옮김 짝
- 첫 계획선(계획고 = 지반고) base_pvi TS 짝 = B05_Profile/logic/B05_Profile_Grade_Ground.ts(1순위만 · 못 서는 땅은 null = 서버로 다시 셈) - 관 자리 투영 TS 짝 · 구조물 따라감 py · ts 짝 = common_util_route_follow(정수 m · 구간 끝 밂 · 0 ~ 연장 자름 · 20 m 넘으면 멀어짐 표시 · 상수 STRUCTURE_ROUTE_DRIFT_MAX_M) - 구조물 x · y 저장(저장 때 노선 위 자리 · 자리가 그대로면 옛 좌표 지킴 · 옛 자료는 읽을 때 채움) · 노선 [확인] 때 구조물 정본이 좌표를 따라감 - 노선 바뀜 때 계획고 옮김 = 바뀐 구간만 새 지반 첫 계획선 · 앞 그대로 · 뒤 길이 차만큼 밂(B05_Profile_Engine_Grade_Carry.py ↔ logic/B05_Profile_Grade_Carry.ts) - 거울 시험 test_344_2_route_pairs(+ helper cjs) · buildCurves · evaluateAt 내보냄 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
"""노선이 바뀔 때 계획고 옮김 — 바뀐 구간만 지반에 다시 맞추고 그 밖 편집은 길이 차만큼 민다(344-2).
|
||||
|
||||
사용자 확정(10-16 · 341 결정 1): 노선 끝쪽을 고쳤는데 앞쪽 계획고가 날아가지 않게 한다.
|
||||
|
||||
- 바뀐 구간 = 옛 · 새 노선 정점열의 같은 앞머리 · 같은 꼬리를 뺀 가운데(`changed_span`) — 바뀐
|
||||
노드 앞 · 뒤 접선점 사이. 원호 정점까지 같은지 보므로 곡선이 바뀐 이웃 노드 범위도 들어간다.
|
||||
- 구간 앞 = 같은 땅 · 같은 누가거리 → 그대로. 구간 뒤 = 같은 땅 · 누가거리만 길이 차만큼 밀림.
|
||||
- 구간 안 = 새 지반 첫 계획선(`design_ground_following_profile` 의 base_pvi) 꼭짓점으로
|
||||
갈아 끼움 · 그 안 편집(계획고 · 종단곡선 R)은 버림.
|
||||
- 옮기는 것 = base_pvi · station_offsets · curve_radii(누가거리 글자 키).
|
||||
|
||||
짝 = TS `B05_Profile/logic/B05_Profile_Grade_Carry.ts`(같은 규칙) · 거울 시험 =
|
||||
`resources/tester/test_344_2_route_pairs.py`. 서버 정본은 이쪽(346-3 [저장]이 부름).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import Any
|
||||
|
||||
from B05_Profile.B05_Profile_Engine_Grade_Alignment import chainage_key
|
||||
|
||||
# 같은 정점으로 볼 거리(m) — 저장 좌표 0.0001 m 자리 · 원호 다시 뽑기 잡음보다 크게.
|
||||
SAME_VERTEX_M = 1e-3
|
||||
_EPS = 1e-6
|
||||
|
||||
|
||||
def _cumulative(xy: list[tuple[float, float]]) -> list[float]:
|
||||
total = [0.0]
|
||||
for (x0, y0), (x1, y1) in zip(xy, xy[1:]):
|
||||
total.append(total[-1] + math.hypot(x1 - x0, y1 - y0))
|
||||
return total
|
||||
|
||||
|
||||
def _same(a: tuple[float, float], b: tuple[float, float]) -> bool:
|
||||
return abs(a[0] - b[0]) <= SAME_VERTEX_M and abs(a[1] - b[1]) <= SAME_VERTEX_M
|
||||
|
||||
|
||||
def changed_span(
|
||||
old_xy: list[tuple[float, float]], new_xy: list[tuple[float, float]]
|
||||
) -> dict[str, Any] | None:
|
||||
"""옛 · 새 노선 정점열 → 바뀐 누가거리 구간. 같으면 None.
|
||||
|
||||
`start_m` = 같은 앞머리 끝(옛 = 새) · `old_end_m` · `new_end_m` = 같은 꼬리 시작 ·
|
||||
`delta_m` = 새 연장 − 옛 연장 · `start_fixed` · `end_fixed` = 앞머리 · 꼬리가 한 점이라도
|
||||
같은가.
|
||||
"""
|
||||
old_xy = [(float(x), float(y)) for x, y in old_xy]
|
||||
new_xy = [(float(x), float(y)) for x, y in new_xy]
|
||||
limit = min(len(old_xy), len(new_xy))
|
||||
prefix = 0
|
||||
while prefix < limit and _same(old_xy[prefix], new_xy[prefix]):
|
||||
prefix += 1
|
||||
if prefix == len(old_xy) == len(new_xy):
|
||||
return None
|
||||
suffix = 0
|
||||
while suffix < limit - prefix and _same(old_xy[-1 - suffix], new_xy[-1 - suffix]):
|
||||
suffix += 1
|
||||
old_cum, new_cum = _cumulative(old_xy), _cumulative(new_xy)
|
||||
return {
|
||||
"start_m": old_cum[prefix - 1] if prefix else 0.0,
|
||||
"old_end_m": old_cum[len(old_xy) - suffix] if suffix else old_cum[-1],
|
||||
"new_end_m": new_cum[len(new_xy) - suffix] if suffix else new_cum[-1],
|
||||
"delta_m": new_cum[-1] - old_cum[-1],
|
||||
"start_fixed": prefix > 0,
|
||||
"end_fixed": suffix > 0,
|
||||
}
|
||||
|
||||
|
||||
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 _after(span: dict[str, Any], s: float, end: float) -> bool:
|
||||
"""구간 뒤(밂) — 꼬리가 같으면 그 시작점부터."""
|
||||
return s > end + _EPS or (bool(span["end_fixed"]) and s >= end - _EPS)
|
||||
|
||||
|
||||
def carry_profile_edits(
|
||||
base_pvi: list[dict[str, float]],
|
||||
edits: dict[str, Any] | None,
|
||||
span: dict[str, Any],
|
||||
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"])}
|
||||
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"])}
|
||||
)
|
||||
|
||||
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
|
||||
@@ -19,6 +19,11 @@
|
||||
- `rebuild_alignment_profile()`: 사용자 편집 확정 시. **저장된 자동 선형(base_pvi)과
|
||||
정책을 그대로 재사용**하고 편집 델타만 다시 얹는다. DP를 다시 돌리면 기준선이
|
||||
흔들려 "원복" 이 원래 위치로 돌아가지 않기 때문이다.
|
||||
|
||||
짝(344-2): `design_ground_following_profile` 의 base_pvi 몫(`ground_profile` · `pass_through`) ↔ TS
|
||||
`B05_Profile/logic/B05_Profile_Grade_Ground.ts` `groundFollowingBasePvi` · 노선 바뀜 때 계획고 옮김은
|
||||
`B05_Profile_Engine_Grade_Carry.py` ↔ `logic/B05_Profile_Grade_Carry.ts` · 거울 시험 =
|
||||
`resources/tester/test_344_2_route_pairs.py`.
|
||||
"""
|
||||
|
||||
from collections import Counter
|
||||
|
||||
@@ -420,6 +420,15 @@ async def _recompute(
|
||||
|
||||
marks.append(("재계산 준비(관 삭제·노선 읽기·stage 기록)", time.perf_counter()))
|
||||
|
||||
# 구조물은 땅 위 자리(좌표)를 따라감(344-2) — 옛 노선 경로 파일은 체인 전이라 아직 옛 것
|
||||
from B05_Profile.B05_Profile_Structures_Router import follow_structures_to_route
|
||||
|
||||
try:
|
||||
old_path = (latest or {}).get("route_data_path")
|
||||
await asyncio.to_thread(follow_structures_to_route, project_root, old_path, points)
|
||||
except Exception: # 따라감 실패 = 옛 누가거리 그대로(지금까지와 같음)
|
||||
logger.exception("구조물 따라감 실패(옛 누가거리 유지): project_id=%s", project_id)
|
||||
|
||||
target = None
|
||||
if balanced is not None:
|
||||
from B05_Profile.B05_Profile_Router_RouteInitial import balanced_target
|
||||
|
||||
@@ -42,6 +42,7 @@ from common_util.common_util_drainage_pipes import (
|
||||
pipe_points_path_in,
|
||||
read_pipe_points_file,
|
||||
)
|
||||
from common_util.common_util_route_follow import fill_structure_coordinates, follow_structures
|
||||
from common_util.common_util_storage import resolve_stored_project_path
|
||||
from common_util.common_util_structure_station import plan_stations
|
||||
from config.config_db import get_db_pool
|
||||
@@ -96,6 +97,61 @@ async def _route_length(project_id: UUID) -> float | None:
|
||||
return None
|
||||
|
||||
|
||||
async def _route_xy(project_id: UUID, root: str) -> list[list[float]] | None:
|
||||
"""최신 노선 정점(x · y) — 구조물 좌표를 채울 선. 없으면 None(좌표 없이 진행)."""
|
||||
from B05_Profile.B05_Profile_Engine_Sections import _load_route_polyline
|
||||
|
||||
try:
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection:
|
||||
latest = await get_latest_route(connection, project_id)
|
||||
path = (latest or {}).get("route_data_path")
|
||||
return (
|
||||
await asyncio.to_thread(_load_route_polyline, Path(root), str(path)) if path else None
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"B05 구조물 좌표: 노선을 못 읽음(좌표 없이 진행): project_id=%s", project_id
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def follow_structures_to_route(
|
||||
project_root: Path, old_route_data_path: str | None, new_xy: list[dict[str, float]]
|
||||
) -> dict[str, int]:
|
||||
"""노선이 바뀔 때 구조물이 땅 위 자리를 따라감(344-2) → 정본 저장. {옮김, 멀어짐} 수.
|
||||
|
||||
좌표가 없는 옛 자료는 옛 노선(아직 안 바뀐 경로 파일)으로 먼저 채운다. 실패는 비치명 —
|
||||
구조물은 옛 누가거리 그대로 남는다(지금까지와 같음).
|
||||
"""
|
||||
from B05_Profile.B05_Profile_Engine_Sections import _load_route_polyline
|
||||
|
||||
root = str(project_root)
|
||||
revision, items = load_structures(root)
|
||||
if not items:
|
||||
return {"moved": 0, "far": 0}
|
||||
if old_route_data_path:
|
||||
try:
|
||||
fill_structure_coordinates(
|
||||
items, _load_route_polyline(project_root, old_route_data_path), only_missing=True
|
||||
)
|
||||
except (OSError, ValueError):
|
||||
logger.warning("B05 구조물 따라감: 옛 노선을 못 읽어 좌표 없는 구조물은 그대로")
|
||||
before = [(item.chainage_m, item.start_m, item.end_m) for item in items]
|
||||
result = follow_structures(items, [(point["x"], point["y"]) for point in new_xy])
|
||||
moved = sum(
|
||||
before[index] != (item.chainage_m, item.start_m, item.end_m)
|
||||
for index, item in enumerate(items)
|
||||
)
|
||||
far = sum(1 for _, flag in result if flag)
|
||||
if moved:
|
||||
# 범위는 따라감이 0 ~ 새 연장 안으로 이미 자름 — DB 연장(옛 노선)으로 재지 않음
|
||||
save_structures(root, items, base_revision=revision)
|
||||
if far:
|
||||
logger.warning("B05 구조물 %d건이 새 노선에서 멀어짐(버리지 않음)", far)
|
||||
return {"moved": moved, "far": far}
|
||||
|
||||
|
||||
def _migrate_pipe_facilities(
|
||||
root: str, facilities: list[PipePoint], migrated_before: set[str]
|
||||
) -> tuple[int, set[str]]:
|
||||
@@ -160,6 +216,11 @@ async def read_structures(project_id: UUID) -> StructureListResponse | JSONRespo
|
||||
if root is None:
|
||||
return JSONResponse(status_code=404, content=_PROJECT_PATH_MISSING)
|
||||
revision, structures = load_structures(root)
|
||||
# 옛 자료(좌표 없음)는 지금 노선 위 기준 자리로 채워 보냄 — 파일은 다음 [저장] 때(344-2)
|
||||
if any(item.x is None for item in structures):
|
||||
xy = await _route_xy(project_id, root)
|
||||
if xy:
|
||||
fill_structure_coordinates(structures, xy, only_missing=True)
|
||||
return StructureListResponse(
|
||||
project_id=str(project_id),
|
||||
revision=revision,
|
||||
@@ -287,6 +348,14 @@ async def write_structures(
|
||||
if root is None:
|
||||
return JSONResponse(status_code=404, content=_PROJECT_PATH_MISSING)
|
||||
_, previous = load_structures(root)
|
||||
# 땅 위 자리(344-2) — 기준 측점의 노선 위 좌표 · 자리가 그대로면 옛 좌표 지킴
|
||||
xy = await _route_xy(project_id, root)
|
||||
if xy:
|
||||
fill_structure_coordinates(
|
||||
payload.structures,
|
||||
xy,
|
||||
previous={item.structure_id: item for item in previous if item.structure_id},
|
||||
)
|
||||
revision = save_structures(
|
||||
root,
|
||||
payload.structures,
|
||||
|
||||
@@ -197,6 +197,10 @@ class StructureInstance(BaseModel):
|
||||
summary_item: dict[str, Any] | None = None
|
||||
# 옵션 그룹(M02 PLAN 46)마다 고른 집계 항목 `{그룹 id: summary_item}` — 본체는 `summary_item`
|
||||
group_items: dict[str, Any] | None = None
|
||||
# 땅 위 자리(344-2) — 기준 측점(`chainage_m`)의 노선 위 좌표 · 서버가 저장 때 채움 ·
|
||||
# 노선이 바뀌면 새 노선에 투영해 누가거리 다시(`common_util_route_follow`) · 옛 파일 = 읽을 때
|
||||
x: float | None = None
|
||||
y: float | None = None
|
||||
|
||||
@field_validator("summary_item")
|
||||
@classmethod
|
||||
|
||||
@@ -281,13 +281,13 @@ function resolvePvi(
|
||||
return [...nodes.values()].sort((a, b) => a.chainage_m - b.chainage_m);
|
||||
}
|
||||
|
||||
interface WorkingCurve extends AlignmentCurve {
|
||||
export interface WorkingCurve extends AlignmentCurve {
|
||||
grade_in: number;
|
||||
grade_out: number;
|
||||
}
|
||||
|
||||
/** 각 변화점에 대칭 종단곡선을 삽입한다 (좌우 직선이 남도록 반쪽 길이를 제한). */
|
||||
function buildCurves(
|
||||
export function buildCurves(
|
||||
pvi: AlignmentNode[],
|
||||
policy: AlignmentPolicy,
|
||||
curveRadii: Record<string, number>,
|
||||
@@ -352,7 +352,7 @@ function buildCurves(
|
||||
}
|
||||
|
||||
/** 직선 + 종단곡선으로 구성된 계획선을 임의 chainage에서 평가한다. */
|
||||
function evaluateAt(
|
||||
export function evaluateAt(
|
||||
pviS: number[],
|
||||
pviZ: number[],
|
||||
curves: WorkingCurve[],
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile/logic/B05_Profile_Grade_Carry.ts
|
||||
* 노선이 바뀔 때 계획고 옮김 — 바뀐 구간만 지반에 다시 맞추고 그 밖 편집은 길이 차만큼 밂(344-2).
|
||||
*
|
||||
* 짝 = 파이썬 `B05_Profile_Engine_Grade_Carry.py`(서버 정본 · 같은 규칙) · 거울 시험 =
|
||||
* resources/tester/test_344_2_route_pairs.py.
|
||||
* - 바뀐 구간 = 옛 · 새 노선 정점열의 같은 앞머리 · 꼬리를 뺀 가운데(바뀐 노드 앞 · 뒤 접선점 사이).
|
||||
* - 앞 = 그대로 · 뒤 = 누가거리만 길이 차만큼 밂 · 안 = 새 지반 첫 계획선 꼭짓점(`groundFollowingBasePvi`)
|
||||
* · 안의 편집(계획고 · 종단곡선 R)은 버림.
|
||||
* - 옮기는 것 = base_pvi · station_offsets · curve_radii(누가거리 글자 키).
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
chainageKey,
|
||||
type AlignmentEdits,
|
||||
type AlignmentNode,
|
||||
} from "../B05_Profile_UI_Profile_Alignment";
|
||||
|
||||
/** 같은 정점으로 볼 거리(m) — 파이썬 `SAME_VERTEX_M` 짝. */
|
||||
export const SAME_VERTEX_M = 1e-3;
|
||||
const EPS = 1e-6;
|
||||
|
||||
export interface XY {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface ChangedSpan {
|
||||
start_m: number;
|
||||
old_end_m: number;
|
||||
new_end_m: number;
|
||||
delta_m: number;
|
||||
start_fixed: boolean;
|
||||
end_fixed: boolean;
|
||||
}
|
||||
|
||||
const roundTo = (value: number, digits: number): number => Number(value.toFixed(digits));
|
||||
|
||||
function cumulative(xy: ReadonlyArray<XY>): number[] {
|
||||
const total = [0];
|
||||
for (let index = 1; index < xy.length; index += 1) {
|
||||
const prev = xy[index - 1];
|
||||
total.push(total[index - 1] + Math.hypot(xy[index].x - prev.x, xy[index].y - prev.y));
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
const same = (a: XY, b: XY): boolean =>
|
||||
Math.abs(a.x - b.x) <= SAME_VERTEX_M && Math.abs(a.y - b.y) <= SAME_VERTEX_M;
|
||||
|
||||
/** 옛 · 새 노선 정점열 → 바뀐 누가거리 구간 · 같으면 null(파이썬 `changed_span` 짝). */
|
||||
export function changedSpan(
|
||||
oldXy: ReadonlyArray<XY>,
|
||||
newXy: ReadonlyArray<XY>,
|
||||
): ChangedSpan | null {
|
||||
const limit = Math.min(oldXy.length, newXy.length);
|
||||
let prefix = 0;
|
||||
while (prefix < limit && same(oldXy[prefix], newXy[prefix])) prefix += 1;
|
||||
if (prefix === oldXy.length && prefix === newXy.length) return null;
|
||||
let suffix = 0;
|
||||
while (
|
||||
suffix < limit - prefix &&
|
||||
same(oldXy[oldXy.length - 1 - suffix], newXy[newXy.length - 1 - suffix])
|
||||
)
|
||||
suffix += 1;
|
||||
const oldCum = cumulative(oldXy);
|
||||
const newCum = cumulative(newXy);
|
||||
return {
|
||||
start_m: prefix ? oldCum[prefix - 1] : 0,
|
||||
old_end_m: suffix ? oldCum[oldXy.length - suffix] : oldCum[oldCum.length - 1],
|
||||
new_end_m: suffix ? newCum[newXy.length - suffix] : newCum[newCum.length - 1],
|
||||
delta_m: newCum[newCum.length - 1] - oldCum[oldCum.length - 1],
|
||||
start_fixed: prefix > 0,
|
||||
end_fixed: suffix > 0,
|
||||
};
|
||||
}
|
||||
|
||||
const before = (span: ChangedSpan, s: number): boolean =>
|
||||
s < span.start_m - EPS || (span.start_fixed && s <= span.start_m + EPS);
|
||||
|
||||
const after = (span: ChangedSpan, s: number, end: number): boolean =>
|
||||
s > end + EPS || (span.end_fixed && s >= end - EPS);
|
||||
|
||||
/** 옛 꼭짓점 · 편집을 새 노선으로 옮김 — `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; // 바뀐 구간 안 — 버림
|
||||
};
|
||||
|
||||
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),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile/logic/B05_Profile_Grade_Ground.ts
|
||||
* 첫 계획선(계획고 = 지반고) 꼭짓점 — 종단 지반 표본 · 측점 → base_pvi (344-2).
|
||||
*
|
||||
* 짝 = 파이썬 `B05_Profile_Engine_Grade_Profile.design_ground_following_profile` 의 base_pvi 몫
|
||||
* (1순위만 · 관 정착 · 직선 분할 폴백은 짝 없음). 그 위 선형은 이미 짝인 `buildAlignment`
|
||||
* (`B05_Profile_UI_Profile_Alignment.ts`). 거울 시험 = resources/tester/test_344_2_route_pairs.py.
|
||||
* 1순위가 못 서는 땅(유효 지반 둘 미만 · 연장 0)이면 null — 부르는 쪽이 「서버로 다시 셈」 표시만.
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
buildCurves,
|
||||
evaluateAt,
|
||||
type AlignmentNode,
|
||||
type AlignmentPolicy,
|
||||
} from "../B05_Profile_UI_Profile_Alignment";
|
||||
|
||||
/** 종단 지반 표본 한 점 — 저장 종단 `samples[]` 꼴. */
|
||||
export interface GroundSample {
|
||||
chainage_m: number;
|
||||
elevation_m?: number | null;
|
||||
valid?: boolean;
|
||||
}
|
||||
|
||||
export interface GroundFollowingInput {
|
||||
samples: ReadonlyArray<GroundSample>;
|
||||
/** 측점 누가거리 — 모든 측점이 꼭짓점. */
|
||||
stations: ReadonlyArray<number>;
|
||||
policy: AlignmentPolicy;
|
||||
startOffsetM?: number;
|
||||
endOffsetM?: number;
|
||||
}
|
||||
|
||||
/** 파이썬 `round(value, digits)` 와 같은 값(이진 값 그대로 십진 반올림). */
|
||||
const roundTo = (value: number, digits: number): number => Number(value.toFixed(digits));
|
||||
|
||||
/** `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;
|
||||
}
|
||||
|
||||
/** 파이썬 `ground_profile` 짝 — 누가거리 순 · 빈 지반은 선형 보간. 유효 둘 미만이면 null. */
|
||||
export function groundProfile(
|
||||
samples: ReadonlyArray<GroundSample>,
|
||||
): { chainage: number[]; ground: number[] } | null {
|
||||
const rows = samples
|
||||
.map((sample) => ({
|
||||
s: Number(sample.chainage_m ?? 0),
|
||||
z: (sample.valid ?? true) && sample.elevation_m != null ? Number(sample.elevation_m) : NaN,
|
||||
}))
|
||||
.sort((a, b) => a.s - b.s);
|
||||
const finite = rows.filter((row) => Number.isFinite(row.z));
|
||||
if (finite.length < 2 || rows.length < 2) return null;
|
||||
const fs = finite.map((row) => row.s);
|
||||
const fz = finite.map((row) => row.z);
|
||||
return { chainage: rows.map((row) => row.s), ground: rows.map((row) => interp(fs, fz, row.s)) };
|
||||
}
|
||||
|
||||
/** 종단곡선을 넣어도 측점마다 `target` 을 지나게 하는 꼭짓점 표고 — 파이썬 `pass_through` 짝. */
|
||||
export function passThrough(baseS: number[], target: number[], policy: AlignmentPolicy): number[] {
|
||||
let baseZ = [...target];
|
||||
for (let round = 0; round < 12; round += 1) {
|
||||
const pvi = baseS.map((s, index) => ({ chainage_m: s, elevation_m: baseZ[index] }));
|
||||
const curves = buildCurves(pvi, policy, {}, []);
|
||||
const error = baseS.map((s, index) => target[index] - evaluateAt(baseS, baseZ, curves, s));
|
||||
error[0] = 0;
|
||||
error[error.length - 1] = 0;
|
||||
if (Math.max(...error.map(Math.abs)) < 1e-4) break;
|
||||
baseZ = baseZ.map((z, index) => z + error[index]);
|
||||
}
|
||||
return baseZ;
|
||||
}
|
||||
|
||||
/** 첫 계획선 꼭짓점(base_pvi · 0.000001 m) — 못 서는 땅이면 null(서버로 다시 셈). */
|
||||
export function groundFollowingBasePvi(input: GroundFollowingInput): AlignmentNode[] | null {
|
||||
const profile = groundProfile(input.samples);
|
||||
if (!profile) return null;
|
||||
const { chainage, ground } = profile;
|
||||
const total = chainage[chainage.length - 1];
|
||||
if (!(total > 0)) return null;
|
||||
|
||||
let nodeS = [
|
||||
...new Set(
|
||||
input.stations
|
||||
.filter((value) => Number.isFinite(value) && value >= 0 && value <= total)
|
||||
.map((value) => roundTo(value, 3)),
|
||||
),
|
||||
].sort((a, b) => a - b);
|
||||
if (nodeS.length < 2) nodeS = chainage.map((value) => roundTo(value, 3));
|
||||
if (nodeS[0] > 0) nodeS.unshift(0);
|
||||
if (nodeS[nodeS.length - 1] < total) nodeS.push(roundTo(total, 3));
|
||||
|
||||
const target = nodeS.map((s) => interp(chainage, ground, s));
|
||||
target[0] += input.startOffsetM ?? 0;
|
||||
target[target.length - 1] += input.endOffsetM ?? 0;
|
||||
const baseZ = passThrough(nodeS, target, input.policy);
|
||||
return nodeS.map((s, index) => ({
|
||||
chainage_m: roundTo(s, 6),
|
||||
elevation_m: roundTo(baseZ[index], 6),
|
||||
}));
|
||||
}
|
||||
Reference in New Issue
Block a user