Merge remote-tracking branch 'origin/sub_laptop_3' into main_laptop_1

This commit is contained in:
2026-10-18 22:01:18 +09:00
22 changed files with 625 additions and 54 deletions
+1
View File
@@ -280,6 +280,7 @@ export function createDesignProfile(ctx: DesignProfileContext) {
base: () => base,
alignment: () => alignment,
edits: () => store.edits(),
savedRadii: () => store.savedRadii(),
applyEdits: (next) => applyEdits(next),
selectedStationId: () => selectedStationId,
irregularStations: () => irregularStations,
+1 -1
View File
@@ -44,7 +44,7 @@ export function listedBase(
);
return {
...storedBase,
basePvi: seatBornNodes(storedBase, moved.nodes, moved.seat),
basePvi: seatBornNodes(storedBase, moved.nodes, moved.seat, moved.held),
stations: list.map((station) => ({
station_id: station.station_id,
chainage_m: station.chainage_m,
+47 -15
View File
@@ -28,6 +28,7 @@
from collections import Counter
from collections.abc import Callable
from dataclasses import replace
from typing import Any
import numpy as np
@@ -367,11 +368,13 @@ def design_pipe_anchored_profile(
# 높이로, 정본 산식은 `common_util_drainage_pipes.facility_clearance_m`이다.
# 시·종점만 오프셋을 얹는다.
target = np.interp(base_s, chainage, ground)
lifted = np.zeros(len(base_s), dtype=bool)
if pipe_clearances:
for index, value in enumerate(base_s):
clearance = _clearance_at(pipe_clearances, float(value))
if clearance > 0:
target[index] += clearance
lifted[index] = True
target[0] = fixed[0]
target[-1] = fixed[1]
@@ -380,12 +383,15 @@ def design_pipe_anchored_profile(
edits = dict(edits or {})
user_radii = dict(edits.get("curve_radii") or {})
def radii_for(nodes_z: np.ndarray) -> dict[str, float]:
"""현 표고에서의 대수차로 R = L / A 를 역산한다(사용자 지정 R이 있으면 그쪽이 이긴다)."""
def radii_for(nodes_z: np.ndarray, held: np.ndarray) -> dict[str, float]:
"""현 표고에서의 대수차로 R = L / A 를 역산한다(사용자 지정 R이 있으면 그쪽이 이긴다) ·
직선 위로 옮긴(`held`) 꼭짓점은 R 없음."""
spans = base_s[1:] - base_s[:-1]
grades = (nodes_z[1:] - nodes_z[:-1]) / np.where(spans > 0, spans, 1.0)
resolved: dict[str, float] = {}
for index in range(1, len(base_s) - 1):
if held[index]:
continue
delta = abs(float(grades[index] - grades[index - 1]))
radius = target_length / delta if delta > 1e-9 else min_radius
resolved[chainage_key(base_s[index])] = max(radius, min_radius)
@@ -394,19 +400,28 @@ def design_pipe_anchored_profile(
# 대칭 종단곡선은 꼭짓점(변화점)을 지나지 않는다 — 곡선이 배관 지반고를 통과하도록
# 변화점 표고를 반복 보정한다(호가 안쪽으로 파고드는 중앙종거만큼 꼭짓점을 밀어낸다).
# 표고가 움직이면 대수차도 변하므로 R도 매 회 다시 역산한다.
base_z = target.copy()
radii = radii_for(base_z)
for _ in range(12):
radii = radii_for(base_z)
curves, _curve_warnings = build_curves(base_s, base_z, policy, radii)
plan_at = evaluate(base_s, base_z, curves, base_s)
error = target - plan_at
error[0] = 0.0
error[-1] = 0.0
if float(np.max(np.abs(error))) < 1e-4:
# 초기값 직선화(438 · 433-13②) — 기본 L 기준 R > 500 m 관 꼭짓점 묶음은 앞뒤 꺾임을 잇는
# 직선 위(높이 한도 없음 · 요구 269-2) · 경계가 새로 R > 500 m 가 되면 묶음을 늘려 다시 ·
# 시설 여유로 들린 관 꼭짓점은 그대로(직선화 경계).
held = np.zeros(len(base_s), dtype=bool)
for _ in range(len(base_s)):
base_z = straighten_held(base_s, target, held)
for _ in range(12):
radii = radii_for(base_z, held)
curves, _curve_warnings = build_curves(base_s, base_z, policy, radii)
plan_at = evaluate(base_s, base_z, curves, base_s)
error = target - plan_at
error[0] = 0.0
error[-1] = 0.0
error[held] = 0.0
if float(np.max(np.abs(error))) < 1e-4:
break
base_z = straighten_held(base_s, base_z + error, held)
flat = flat_bends(base_s, base_z, policy.default_curve_length_m) & ~held & ~lifted
if not flat.any():
break
base_z = base_z + error
edits["curve_radii"] = radii
held = held | flat
edits["curve_radii"] = radii_for(base_z, held)
alignment = build_alignment(
base_s=base_s,
@@ -498,6 +513,14 @@ def design_alignment_profile(
down_limit,
balance_tolerance_percent=policy.balance_tolerance_percent,
)
# 초기값 직선화(438 · 433-13②) — 기본 L 기준 R > 500 m 꼭짓점 묶음은 앞뒤 꺾임을 잇는 직선 위
held = np.zeros(len(base_s), dtype=bool)
flat = flat_bends(base_s, base_z, policy.default_curve_length_m)
while flat.any():
held = held | flat
straight = straighten_held(base_s, base_z, held)
flat = flat_bends(base_s, straight, policy.default_curve_length_m) & ~held
base_z = straighten_held(base_s, base_z, held)
alignment = build_alignment(
base_s=base_s,
base_z=base_z,
@@ -531,6 +554,16 @@ def rebuild_alignment_profile(
policy = AlignmentPolicy.from_dict(stored.get("policy") or {})
previous = (longitudinal.get("design_profiles") or [{}])[0]
criteria = previous.get("criteria") or {}
# 옛 정책엔 법정 최소 R · L 이 없음 — 첫 계획선 설계 기준(criteria = 설계속도 법정값)으로
# 채움(433-13 · TS `readAlignment` 짝)
policy = replace(
policy,
min_vertical_radius_m=policy.min_vertical_radius_m
or float(criteria.get("min_vertical_radius_m") or 0.0),
min_curve_length_m=policy.min_curve_length_m
or float(criteria.get("min_curve_length_m") or 0.0),
)
alignment = build_alignment(
base_s=base_s,
base_z=base_z,
@@ -540,7 +573,6 @@ def rebuild_alignment_profile(
policy=policy,
edits=edits,
)
criteria = previous.get("criteria") or {}
options = GradeDesignOptions(
max_grade_pct=float(criteria.get("max_grade_pct") or policy.max_grade_pct),
max_reverse_grade_pct=float(criteria.get("max_reverse_grade_pct") or 5.0),
+42 -13
View File
@@ -213,14 +213,21 @@ async def sync_uphill_overrides_into_designs(
_SAME_STATION_M = 0.006
#: 지반 종단으로 볼 꼭짓점 자리 계획선 · 지반 어긋남 가운데값(m) — 새 꼭짓점을 지반에 세울지(438).
_SEAT_GROUND_M = 0.01
#: 새 꼭짓점 이웃이 옮기기 전 계획고에서 뜨면 맞출 한도(m · 433-5).
_SEAT_KEEP_M = 0.001
def _seat_born_nodes(
stored: dict[str, Any], base: list[dict[str, Any]], born: list[float]
stored: dict[str, Any],
base: list[dict[str, Any]],
born: list[float],
held: list[float] = (),
) -> list[dict[str, Any]]:
"""새 측점(`born`)에 자동 계획선 규칙(원지반 지남 · `pass_through`)으로 꼭짓점을 세움(427).
다른 꼭짓점 높이는 그대로 · 양 끝 밖 · 이미 꼭짓점 있는(0.006m 안) 자리는 건너뜀. 지반 = 저장
이웃 꼭짓점은 곡선 모양이 바뀌어 옮기기 전 계획고에서 1mm 넘게 뜨면 그 높이로 맞춤(433-5 ·
바로 앞뒤부터 번지는 만큼) · 높이 고친 꼭짓점(`held` = 옮긴 뒤 `station_offsets` 측점)과 그
밖 꼭짓점은 그대로 · 양 끝 밖 · 이미 꼭짓점 있는(0.006m 안) 자리는 건너뜀. 지반 = 저장
선형 표본(`samples`) · 곡선 = 편집 없이(`pass_through` 와 같음). 옮기기 전 계획선이 꼭짓점
대부분에서 지반을 안 지나면(균형 종단) 지반 대신 옮기기 전 계획선 높이(438). TS 짝 =
`seatBornNodes`(B05_Profile/logic/B05_Profile_Grade_Seat.ts) · 거울 시험 =
@@ -258,20 +265,41 @@ def _seat_born_nodes(
key=lambda row: row[0],
)
base_s = np.array([row[0] for row in rows], dtype=np.float64)
target = np.array([row[1] for row in rows], dtype=np.float64)
free = np.array([row[2] is None for row in rows])
base_z = target.copy()
for _ in range(20):
born_rows = np.array([row[2] is None for row in rows])
# 다른 꼭짓점 목표 = 옮기기 전 계획선 그 자리 높이(433-5) · 맞출 수 있는 = 높이 안 고친 것
target = np.where(
born_rows,
np.array([row[1] for row in rows], dtype=np.float64),
evaluate(pre_s, pre_z, pre_curves, base_s),
)
can = np.array(
[
row[2] is not None and all(abs(row[0] - value) >= _SAME_STATION_M for value in held)
for row in rows
]
)
free = born_rows.copy()
base_z = np.array([row[1] for row in rows], dtype=np.float64)
for _ in range(len(rows)):
for _ in range(20):
curves, _warnings = build_curves(base_s, base_z, policy)
error = np.where(free, target - evaluate(base_s, base_z, curves, base_s), 0.0)
if float(np.max(np.abs(error))) < 1e-6:
break
base_z = base_z + error
# 이웃 곡선 모양이 바뀌어 뜬 꼭짓점 — 그 꼭짓점도 맞춤(번지는 만큼)
curves, _warnings = build_curves(base_s, base_z, policy)
error = np.where(free, target - evaluate(base_s, base_z, curves, base_s), 0.0)
if float(np.max(np.abs(error))) < 1e-6:
drift = (
can & ~free & (np.abs(target - evaluate(base_s, base_z, curves, base_s)) > _SEAT_KEEP_M)
)
if not drift.any():
break
base_z = base_z + error
free |= drift
return [
node
if node is not None
else {"chainage_m": round(float(s), 6), "elevation_m": round(float(z), 6)}
for (s, _target, node), z in zip(rows, base_z.tolist())
if node is not None and not keep
else {**(node or {}), "chainage_m": round(float(s), 6), "elevation_m": round(float(z), 6)}
for (s, _z, node), z, keep in zip(rows, base_z.tolist(), free.tolist())
]
@@ -367,7 +395,8 @@ def _follow_moved_stations(
changed = True
if new_key is not None and new_key not in reseat:
base.append({**node, "chainage_m": float(new_key)})
seated = _seat_born_nodes(stored, base, seat)
held = [float(key) for key in next_edits["station_offsets"]]
seated = _seat_born_nodes(stored, base, seat, held)
changed = changed or len(seated) != len(base)
base = seated
# 측점 목록만 바뀌어도 다시 셈 — 계획선 측점 줄(표 · ▲▼)이 옛 측점을 들고 남지 않게(272) ·
@@ -70,6 +70,8 @@ function createHoldRepeater(node: Node): {
export interface ProfileEditStore {
edits(): AlignmentEdits;
/** 서버 저장분 곡선반경 — ▲▼ 가 자동 R 로 보고 지울 기준(433-13). */
savedRadii(): Readonly<Record<string, number>>;
replace(next: AlignmentEdits): void;
/** 서버 저장이 끝났음을 표시한다. 편집 델타는 그대로 두고 초안만 지운다.
* 이때의 값이 [초기화]의 기준점이 된다. */
@@ -209,6 +211,7 @@ function handleOf(
return {
edits: () => state.current,
savedRadii: () => state.saved.curve_radii ?? {},
replace: commit,
markSaved() {
state.unsaved = false;
@@ -47,6 +47,8 @@ export interface PanelToolsContext {
base: () => AlignmentBase | null;
alignment: () => ProfileAlignment | null;
edits: () => AlignmentEdits;
/** 서버 저장분 곡선반경 — ▲▼ 자동 R 기준(433-13). */
savedRadii: () => Readonly<Record<string, number>>;
/** false = 막힘(기울기 · 흙덮기). */
applyEdits: (next: AlignmentEdits) => boolean;
/** 고른 측점 id(없으면 null). */
@@ -219,7 +221,7 @@ export function createPanelTools(ctx: PanelToolsContext): PanelTools {
if (tools.mode() === "shift") return tools.nudge(delta);
const chainage = selectedChainage();
if (chainage === null) return false;
ctx.applyEdits(adjustStation(base, ctx.edits(), chainage, delta));
ctx.applyEdits(adjustStation(base, ctx.edits(), chainage, delta, ctx.savedRadii()));
return true;
}
// 좌우 이동 = 마지막 누름이 종단도 안일 때만 — 횡단 카드 · 3D 를 누른 뒤 방향키가 관을 몰래 옮겼음(219-1)
+4 -2
View File
@@ -79,6 +79,7 @@ export interface ProfileRenderContext {
};
store: {
edits: () => AlignmentEdits;
savedRadii: () => Readonly<Record<string, number>>;
resetStation: (chainageM: number) => void;
};
detail: () => SectionDetailResponse | null;
@@ -203,7 +204,8 @@ export function renderProfile(ctx: ProfileRenderContext): void {
ctx.applyEdits(setCurveRadius(store.edits(), curve, radius ?? 0)),
// 값 열 계획고 직접 입력 → 규칙 측점과 동일한 station_offset 파이프라인.
onAdjustStation: (chainage, delta) =>
base && ctx.applyEdits(adjustStation(base, store.edits(), chainage, delta)),
base &&
ctx.applyEdits(adjustStation(base, store.edits(), chainage, delta, store.savedRadii())),
})
: null;
@@ -411,7 +413,7 @@ export function renderProfile(ctx: ProfileRenderContext): void {
// 다른 편집 경로(직선화·쉬프트·틸팅·방향키)와 규칙이 갈린다(2026-09-02).
onStation: (chainage, delta) => {
if (!base) return;
ctx.applyEdits(adjustStation(base, store.edits(), chainage, delta));
ctx.applyEdits(adjustStation(base, store.edits(), chainage, delta, store.savedRadii()));
},
}),
);
+38 -15
View File
@@ -1,6 +1,7 @@
/* =============================================================================
* B05_Profile/logic/B05_Profile_Grade_Seat.ts
* 옮긴 관 · 구조물의 새 측점에 계획선 꼭짓점 세움(427) — 원지반 지남(`passThrough` 규칙) · 다른 꼭짓점 그대로.
* 옮긴 관 · 구조물의 새 측점에 계획선 꼭짓점 세움(427) — 원지반 지남(`passThrough` 규칙) · 이웃 꼭짓점은 곡선 모양이
* 바뀌어 옮기기 전 계획고에서 1mm 넘게 뜨면 그 높이로 맞춤(433-5 · 바로 앞뒤부터 번지는 만큼) · 높이 고친 꼭짓점은 그대로.
* 옮기기 전 계획선이 꼭짓점 대부분에서 지반을 안 지나면(균형 종단) 지반 대신 옮기기 전 계획선 높이(438).
*
* 짝 = 파이썬 `B05_Profile_Router_Confirm._seat_born_nodes` · 세울 측점은 공용 `followMovedNodes`
@@ -16,6 +17,8 @@ import {
/** 지반 종단으로 볼 꼭짓점 자리 계획선 · 지반 어긋남 가운데값(m) — 파이썬 `_SEAT_GROUND_M`. */
const SEAT_GROUND_M = 0.01;
/** 새 꼭짓점 이웃이 옮기기 전 계획고에서 뜨면 맞출 한도(m · 433-5) — 파이썬 `_SEAT_KEEP_M`. */
const SEAT_KEEP_M = 0.001;
/** `np.median` 짝. */
function median(values: number[]): number {
@@ -24,11 +27,13 @@ function median(values: number[]): number {
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
}
/** 새 측점(`born`)에 원지반을 지나는 꼭짓점 — 양 끝 밖 · 이미 꼭짓점 있는(0.006m 안) 자리는 건너뜀. */
/** 새 측점(`born`)에 원지반을 지나는 꼭짓점 — 양 끝 밖 · 이미 꼭짓점 있는(0.006m 안) 자리는 건너뜀 ·
* `held` = 옮긴 뒤 높이 고친 꼭짓점 측점(`followMovedNodes` 의 `held`). */
export function seatBornNodes(
base: Pick<AlignmentBase, "chainage" | "ground" | "policy" | "basePvi">,
nodes: AlignmentNode[],
born: ReadonlyArray<number>,
held: ReadonlyArray<number> = [],
): AlignmentNode[] {
if (!nodes.length || base.chainage.length < 2) return nodes;
const first = nodes[0].chainage_m;
@@ -59,22 +64,40 @@ export function seatBornNodes(
...fresh.map((s) => ({ s, z: seatZ(s), node: null })),
].sort((a, b) => a.s - b.s);
const baseS = rows.map((row) => row.s);
const target = rows.map((row) => row.z);
let baseZ = [...target];
for (let round = 0; round < 20; round += 1) {
// 다른 꼭짓점 목표 = 옮기기 전 계획선 그 자리 높이(433-5) · 맞출 수 있는 = 높이 안 고친 것
const target = rows.map((row) => (row.node ? evaluateAt(preS, preZ, preCurves, row.s) : row.z));
const can = rows.map(
(row) => row.node !== null && held.every((value) => Math.abs(row.s - value) >= 0.006),
);
const free = rows.map((row) => row.node === null);
let baseZ = rows.map((row) => row.z);
const errorOf = (): number[] => {
const pvi = baseS.map((s, index) => ({ chainage_m: s, elevation_m: baseZ[index] }));
const curves = buildCurves(pvi, base.policy, {}, []);
const error = rows.map((row, index) =>
row.node ? 0 : target[index] - evaluateAt(baseS, baseZ, curves, baseS[index]),
return baseS.map((s, index) => target[index] - evaluateAt(baseS, baseZ, curves, s));
};
for (let grow = 0; grow < rows.length; grow += 1) {
for (let round = 0; round < 20; round += 1) {
const error = errorOf().map((value, index) => (free[index] ? value : 0));
if (Math.max(...error.map(Math.abs)) < 1e-6) break;
baseZ = baseZ.map((z, index) => z + error[index]);
}
// 이웃 곡선 모양이 바뀌어 뜬 꼭짓점 — 그 꼭짓점도 맞춤(번지는 만큼)
const drift = errorOf().map(
(value, index) => can[index] && !free[index] && Math.abs(value) > SEAT_KEEP_M,
);
if (Math.max(...error.map(Math.abs)) < 1e-6) break;
baseZ = baseZ.map((z, index) => z + error[index]);
if (!drift.includes(true)) break;
drift.forEach((hit, index) => {
if (hit) free[index] = true;
});
}
return rows.map(
(row, index) =>
row.node ?? {
chainage_m: Number(row.s.toFixed(6)),
elevation_m: Number(baseZ[index].toFixed(6)),
},
return rows.map((row, index) =>
row.node && !free[index]
? row.node
: {
...row.node,
chainage_m: Number(row.s.toFixed(6)),
elevation_m: Number(baseZ[index].toFixed(6)),
},
);
}
@@ -561,12 +561,14 @@ const FEEDBACK_TOLERANCE_M = 1e-4;
* 버튼 1클릭이 정확히 0.1m가 되지 않는다. **화면에 보이는 계획고**가 정확히 delta만큼
* 움직이도록 중앙종거 변화분을 몇 번 되먹여 보정한다.
* 누른 점 좌우 다음 꺾임(`pivotAxes`)을 축으로 사이 측점이 새 직선 위로 따라 돈다(355-1) · 직선 안 점도 그 점이 새 꺾임(368-1).
* 누른 점 R 이 자동 R(`autoRadii` = 저장분 · 관 정착 등 엔진이 역산한 값)과 같으면 지워 기본 L(433-13) · 사용자 R 은 둠.
*/
export function adjustStation(
base: AlignmentBase,
edits: AlignmentEdits,
chainageM: number,
delta: number,
autoRadii: Readonly<Record<string, number>> = {},
): AlignmentEdits {
const key = chainageKey(chainageM);
const current = buildAlignment(base, edits);
@@ -580,6 +582,7 @@ export function adjustStation(
const withOffset = (value: number): AlignmentEdits => {
const offsets = { ...edits.station_offsets, [key]: Number(value.toFixed(6)) };
const radii = { ...edits.curve_radii };
if (radii[key] !== undefined && radii[key] === autoRadii[key]) delete radii[key];
axes?.inner.forEach((chainage) => {
const [axisM, axisZ] = chainage < chainageM ? [axes.fromM, axes.fromZ] : [axes.toM, axes.toZ];
const z =
@@ -17,6 +17,16 @@ export function readAlignment(data: LongitudinalSection): ProfileAlignment | nul
station_offsets: candidate.edits?.station_offsets ?? {},
curve_radii: candidate.edits?.curve_radii ?? {},
};
// 옛 정책엔 법정 최소 R · L 이 없음 — 첫 계획선 설계 기준(criteria = 설계속도 법정값)으로 채움
// (433-13 · 서버 `rebuild_alignment_profile` 짝)
const criteria = data.design_profiles?.[0]?.criteria;
const policy = candidate.policy;
if (criteria && (!policy.min_vertical_radius_m || !policy.min_curve_length_m))
candidate.policy = {
...policy,
min_vertical_radius_m: policy.min_vertical_radius_m || criteria.min_vertical_radius_m || 0,
min_curve_length_m: policy.min_curve_length_m || criteria.min_curve_length_m || 0,
};
return candidate;
}
+2
View File
@@ -180,6 +180,8 @@ export interface DesignProfile {
samples: DesignProfileSample[];
balance_segments: DesignProfileSegment[];
summary: DesignProfileSummary;
/** 첫 계획선 설계 기준(서버 `GradeDesignOptions.as_dict`) — 옛 정책 빈 법정 최소 R · L 채움(433-13). */
criteria?: { min_vertical_radius_m?: number | null; min_curve_length_m?: number | null };
}
export interface LongitudinalSection {
+9 -3
View File
@@ -344,14 +344,15 @@ export function oneStationList<S extends ListedStation, P extends { chainage_m:
* 정수 m 맞춤 자리(272)면 높이 그대로 옮김. 옮긴 관 · 구조물(427) — 짝 못 찾은 옛 측점과 새 측점이 같은
* 수면 차례대로 짝 · 높이 고친 꼭짓점(`edited` = 저장 `station_offsets` 키)은 높이째 · 나머지 새 측점은
* `seat`(세움은 부르는 쪽이 `seatBornNodes` · B05_Profile/logic/B05_Profile_Grade_Seat.ts) · 짝 없는 옛
* 꼭짓점은 지움. 거울 시험 = `resources/tester/test_427_1_follow_seat.py`.
* 꼭짓점은 지움 · `held` = 옮긴 뒤 높이 고친 꼭짓점 측점(서버 옮긴 `station_offsets` 키 · 433-5).
* 거울 시험 = `resources/tester/test_427_1_follow_seat.py`.
*/
export function followMovedNodes<N extends { chainage_m: number }>(
nodes: ReadonlyArray<N>,
gone: ReadonlyArray<number>,
born: ReadonlyArray<number>,
edited: ReadonlyArray<string> = [],
): { nodes: N[]; seat: number[] } {
): { nodes: N[]; seat: number[]; held: number[] } {
const target = (old: number): number | null => {
const near = born.find((value) => Math.abs(value - old) < 0.006);
if (near !== undefined) return near;
@@ -379,5 +380,10 @@ export function followMovedNodes<N extends { chainage_m: number }>(
const to = moves.get(old) ?? null;
return to === null || seat.includes(to) ? [] : [{ ...node, chainage_m: to }];
});
return { nodes: kept, seat };
const held = edited.flatMap((key) => {
const old = gone.find((value) => Math.abs(value - Number(key)) < 0.006);
const to = old === undefined ? Number(key) : (moves.get(old) ?? null);
return to === null ? [] : [to];
});
return { nodes: kept, seat, held };
}
@@ -89,6 +89,7 @@ tools.createPanelTools({
base: () => ({}),
alignment: () => null,
edits: () => ({}),
savedRadii: () => ({}),
applyEdits: () => undefined,
selectedStationId: () => "irregular:p1",
irregularStations: () => [pipe],
@@ -28,6 +28,6 @@ const station = loadTs("common_util/common_util_structure_station.ts");
const seat = loadTs("B05_Profile/logic/B05_Profile_Grade_Seat.ts");
const out = cases.map((item) => {
const moved = station.followMovedNodes(item.base.basePvi, item.gone, item.born, item.edited);
return seat.seatBornNodes(item.base, moved.nodes, moved.seat);
return seat.seatBornNodes(item.base, moved.nodes, moved.seat, moved.held);
});
process.stdout.write(JSON.stringify(out));
@@ -0,0 +1,77 @@
/* 455 (433-13 ①) 종단 ▲▼ 자동 R — `B05_Profile_UI_Profile_Alignment.ts` adjustStation 을 Node 로 돌림.
* ① 누른 꼭짓점 R = 저장 자동 R → 지움 · 곡선 L = 기본 L ② 사용자 R(저장분과 다름) → 그대로
* ③ 자동 R 기준을 안 넘기면(옛 부름) → 그대로. 표준출력 = JSON.
*/
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const ROOT = path.join(__dirname, "..", "..");
const cache = new Map();
function loadTs(file) {
const full = path.resolve(file);
if (cache.has(full)) return cache.get(full);
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
cache.set(full, box.exports);
const localRequire = (request) => {
if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`));
return require(request);
};
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
cache.set(full, box.exports);
return box.exports;
}
const align = loadTs(
path.join(ROOT, "B05_Profile", "logic", "B05_Profile_UI_Profile_Alignment.ts"),
);
// 20m 측점 · 100m 에 꺾임 하나(대수차 0.5 %)
const stations = Array.from({ length: 11 }, (_, i) => i * 20);
const ground = stations.map((s) => 100 + 0.02 * s + (s > 100 ? 0.005 * (s - 100) : 0));
const base = {
policy: {
station_interval_m: 20,
curve_radius_ratio: 1,
curve_tangent_max_ratio: 0.5,
curve_skip_legal_exception: false,
default_curve_length_m: 15,
default_curve_radius_m: 300,
balance_tolerance_percent: 10,
edit_step_m: 0.1,
grade_violation_policy: "warn",
max_grade_pct: 14,
curve_skip_delta_pct: 0,
paved: true,
},
onlyExplicitCurves: false,
basePvi: stations.map((s, i) => ({ chainage_m: s, elevation_m: ground[i] })),
chainage: stations,
ground,
stations: stations.map((s) => ({ station_id: `S${s}`, chainage_m: s })),
};
const KEY = "100.000";
const AUTO = 15 / 0.005; // 관 정착이 역산한 꼴(R = L / A)
function press(radius, autoRadii) {
const edits = { station_offsets: {}, curve_radii: { [KEY]: radius } };
const next =
autoRadii === undefined
? align.adjustStation(base, edits, 100, 0.1)
: align.adjustStation(base, edits, 100, 0.1, autoRadii);
const curve = align
.buildAlignment(base, next)
.curves.find((c) => Math.abs(c.chainage_m - 100) < 1e-6);
return { radius: next.curve_radii[KEY] ?? null, length: curve ? curve.l_m : null };
}
process.stdout.write(
JSON.stringify({
auto: press(AUTO, { [KEY]: AUTO }),
user: press(777, { [KEY]: AUTO }),
legacy: press(AUTO),
}),
);
@@ -0,0 +1,38 @@
/* 455 (433-13 ③) 옛 정책 경고 — 진짜 `B05_Profile_UI_Profile_Data.ts` readAlignment +
* `B05_Profile_UI_Profile_Alignment.ts` buildAlignment 를 Node 로 돌림.
* 입력 argv[2] = 종단 정본 JSON · 표준출력 = { warnings: [법정 미달 글], policy }.
*/
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const ROOT = path.join(__dirname, "..", "..");
const cache = new Map();
function loadTs(file) {
const full = path.resolve(file);
if (cache.has(full)) return cache.get(full);
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
cache.set(full, box.exports);
const localRequire = (request) => {
if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`));
return require(request);
};
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
return box.exports;
}
const LOGIC = path.join(ROOT, "B05_Profile", "logic");
const data = loadTs(path.join(LOGIC, "B05_Profile_UI_Profile_Data.ts"));
const align = loadTs(path.join(LOGIC, "B05_Profile_UI_Profile_Alignment.ts"));
const longitudinal = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
const stored = data.readAlignment(longitudinal);
const built = align.buildAlignment(align.toAlignmentBase(stored), align.emptyEdits());
process.stdout.write(
JSON.stringify({
warnings: built.warnings.filter((text) => text.includes("법정 최소")),
policy: stored.policy,
}),
);
+3 -1
View File
@@ -80,7 +80,9 @@ def test_종단도는_막힌_방향키를_건너뜀() -> None:
encoding="utf-8"
)
assert "if (event.defaultPrevented) return;" in src
assert "ctx.applyEdits(adjustStation(base, ctx.edits(), chainage, delta))" in src
assert (
"ctx.applyEdits(adjustStation(base, ctx.edits(), chainage, delta, ctx.savedRadii()))" in src
)
def _ground(level: float) -> list[dict]:
+3 -2
View File
@@ -69,10 +69,11 @@ def test_직선_안_점은_새_꺾임_축은_좌우_다음_꺾임(out, case):
def test_부르는_곳_셋이_같은_함수():
assert "adjustStation(base, store.edits(), chainage, delta)" in _read(
# 455 — 자동 R 기준(저장 곡선반경)을 함께 넘김
assert "adjustStation(base, store.edits(), chainage, delta, store.savedRadii())" in _read(
"B05_Profile/B05_Profile_UI_Profile_Render.ts"
)
assert "ctx.applyEdits(adjustStation(base, ctx.edits(), chainage, delta))" in _read(
assert "adjustStation(base, ctx.edits(), chainage, delta, ctx.savedRadii())" in _read(
"B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts"
)
assert "pivotAxes(current, chainageM)" in _read(
@@ -195,3 +195,63 @@ def test_화면_짝과_같은_꼭짓점(tmp_path: Path, offsets, to) -> None:
assert [n["chainage_m"] for n in nodes] == [n["chainage_m"] for n in server["base_pvi"]]
for mine, theirs in zip(nodes, server["base_pvi"]):
assert mine["elevation_m"] == pytest.approx(theirs["elevation_m"], abs=1e-6)
def _balanced() -> dict:
"""지반 종단을 1 m 내린 판 — 계획선이 지반을 안 지남(균형 종단 꼴 · 438 시험과 같은 꼴)."""
data = _longitudinal()
for node in data["profile_alignment"]["base_pvi"]:
node["elevation_m"] -= 1.0
alignment, profile = rebuild_alignment_profile(data, {})
data["profile_alignment"], data["design_profiles"] = alignment, [profile]
return data
@pytest.mark.parametrize("make", [_longitudinal, _balanced])
def test_433_5_관_옮김_뒤_이웃_측점_계획고_그대로(tmp_path: Path, make) -> None:
"""관 351 → 356 — 이웃 340 · 360 곡선이 바뀌어 0.2 m 뜨던 것(433-5) → 옮기기 전 계획고."""
first = make()
before = _rows(first, 0, 400)
_write(tmp_path, first)
after = _rows(_move(tmp_path, 356.0), 0, 400)
for at, value in before.items():
if at != PIPE:
assert abs(after[at] - value) < 0.01, at
if make is _longitudinal:
assert after[356.0] == pytest.approx(_ground(356.0), abs=1e-4)
# 화면 짝 같은 꼭짓점
stored = first["profile_alignment"]
base = {
"basePvi": stored["base_pvi"],
"chainage": [s["chainage_m"] for s in stored["samples"]],
"ground": [s["ground_elevation_m"] for s in stored["samples"]],
"policy": stored["policy"],
}
server = json.loads((tmp_path / REL).read_text(encoding="utf-8"))["profile_alignment"]
[nodes] = _ts(tmp_path, [{"base": base, "gone": [PIPE], "born": [356.0], "edited": []}])
assert [n["chainage_m"] for n in nodes] == [n["chainage_m"] for n in server["base_pvi"]]
for mine, theirs in zip(nodes, server["base_pvi"], strict=True):
assert mine["elevation_m"] == pytest.approx(theirs["elevation_m"], abs=1e-6)
def test_433_5_높이_고친_이웃은_그대로(tmp_path: Path) -> None:
"""340 높이 고침(+0.3) · 관 351 → 356 — 340 꼭짓점 그대로 · 360 은 맞춤 · 화면 짝."""
first = _longitudinal({"340.000": 0.3})
stored = copy.deepcopy(first["profile_alignment"])
_write(tmp_path, first)
server = _move(tmp_path, 356.0)["profile_alignment"]
old = {n["chainage_m"]: n["elevation_m"] for n in stored["base_pvi"]}
new = {n["chainage_m"]: n["elevation_m"] for n in server["base_pvi"]}
assert new[340.0] == old[340.0]
assert new[360.0] != old[360.0]
base = {
"basePvi": stored["base_pvi"],
"chainage": [s["chainage_m"] for s in stored["samples"]],
"ground": [s["ground_elevation_m"] for s in stored["samples"]],
"policy": stored["policy"],
}
[nodes] = _ts(
tmp_path, [{"base": base, "gone": [PIPE], "born": [356.0], "edited": ["340.000"]}]
)
for mine, theirs in zip(nodes, server["base_pvi"], strict=True):
assert mine["elevation_m"] == pytest.approx(theirs["elevation_m"], abs=1e-6)
@@ -0,0 +1,52 @@
"""455 (433-13 ①) 종단 ▲▼ — 누른 꼭짓점의 자동 R 을 지워 기본 L.
까닭: ▲▼ 가 누른 꼭짓점 자신의 R 을 남겨 거의 직선이 된 꼭짓점에 옛 자동 R(관 정착 역산값)로
짧은 곡선이 생김. 자동 R = 서버 저장분 곡선반경과 같은 값 · 사용자가 넣은 R 은 둠.
계산은 `helper_455_arrow_auto_radius.cjs` 가 진짜 TS 를 Node 로 돌림 · 부르는 곳 셋은 소스로 봄.
"""
import json
import subprocess
from pathlib import Path
import pytest
HERE = Path(__file__).resolve().parent
ROOT = HERE.parent.parent
@pytest.fixture(scope="module")
def out() -> dict:
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", str(HERE / "helper_455_arrow_auto_radius.cjs")],
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
check=False,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def test_자동_R_은_지워_기본_L(out) -> None:
assert out["auto"]["radius"] is None
assert out["auto"]["length"] == pytest.approx(15.0, abs=1e-6)
# 옛 판(지우지 않음)은 같은 R 로 짧은 곡선 — 까닭 근거
assert out["legacy"]["radius"] == pytest.approx(3000.0)
assert out["legacy"]["length"] < 14.0
def test_사용자_R_은_그대로(out) -> None:
assert out["user"]["radius"] == 777
def test_부르는_곳_셋이_저장_R_을_넘김() -> None:
def read(rel: str) -> str:
return (ROOT / rel).read_text(encoding="utf-8")
render = read("B05_Profile/B05_Profile_UI_Profile_Render.ts")
assert render.count("chainage, delta, store.savedRadii())") == 2
tools = read("B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts")
assert "adjustStation(base, ctx.edits(), chainage, delta, ctx.savedRadii())" in tools
assert "savedRadii: () => store.savedRadii()," in read("A00_Common/design/design_profile.ts")
@@ -0,0 +1,122 @@
"""455 (433-13 ②) 폴백 첫 종단 직선화 — 관 정착 · 직선 분할도 R > 500 m 꼭짓점 없음.
까닭: 초기값 직선화(438)가 1순위 지반 추종 선형에만 걸려, 1순위가 실패해 2 · 3순위(관 정착 ·
직선 분할)로 내려가면 R > 500 m 꼭짓점이 남음. 사용자 확정(269-2) — 직선화 높이 한도 없음.
"""
from __future__ import annotations
import math
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 import GradeDesignOptions # noqa: E402
from B05_Profile.B05_Profile_Engine_Grade_Alignment import ( # noqa: E402
AlignmentPolicy,
build_curves,
is_straight_bend,
)
from B05_Profile.B05_Profile_Engine_Grade_CurveLaw import flat_bends # noqa: E402
from B05_Profile.B05_Profile_Engine_Grade_Profile import ( # noqa: E402
design_alignment_profile,
design_pipe_anchored_profile,
)
PIPES = [60.0, 125.0, 190.0, 260.0, 330.0]
def _ground(s: float) -> float:
return 100.0 + 0.06 * s + 2.5 * math.sin(s / 45.0) + 0.05 * math.sin(s / 6.0)
def _longitudinal() -> dict:
samples = [
{"chainage_m": float(s), "elevation_m": round(_ground(s), 6), "valid": True}
for s in range(0, 401)
]
stations = [{"chainage_m": float(s), "kind": "regular"} for s in range(0, 401, 20)]
return {"samples": samples, "stations": stations}
OPTIONS = GradeDesignOptions(
max_grade_pct=14.0,
max_reverse_grade_pct=5.0,
min_vertical_radius_m=100.0,
min_curve_length_m=20.0,
min_tangent_length_m=20.0,
vertical_curve_skip_delta_pct=0.5,
design_speed_kph=20,
)
def _flat(alignment: dict) -> tuple[list[float], int]:
"""(R > 500 m 꺾임 측점 · 직선 위로 옮긴 꼭짓점 수) — 438 시험과 같은 판정."""
s = np.array([n["chainage_m"] for n in alignment["base_pvi"]], dtype=np.float64)
z = np.array([n["elevation_m"] for n in alignment["base_pvi"]], dtype=np.float64)
policy = AlignmentPolicy.from_dict(alignment["policy"])
grades = np.diff(z) / np.diff(s)
straight = np.zeros(len(s), dtype=bool)
for i in range(1, len(s) - 1):
straight[i] = is_straight_bend(grades[i] - grades[i - 1], s[i] - s[i - 1], s[i + 1] - s[i])
flat = flat_bends(s, z, policy.default_curve_length_m) & ~straight
assert all(float(c["r_m"]) <= 500.0 for c in alignment["curves"])
return s[flat].tolist(), int(straight.sum())
def test_관_정착_선형_R500_넘는_꼭짓점_없음() -> None:
alignment, _profile = design_pipe_anchored_profile(_longitudinal(), OPTIONS, PIPES)
flat, straight = _flat(alignment)
assert flat == [] and 0 < straight < len(PIPES)
# 직선 위로 옮긴 관 꼭짓점은 자동 R 을 안 남김 · 남은 꺾임은 곡선이 관 자리를 지남
radii = alignment["edits"]["curve_radii"]
kinks = {n["chainage_m"] for n in alignment["base_pvi"][1:-1]}
assert set(map(float, radii)) <= kinks
s = np.array([n["chainage_m"] for n in alignment["base_pvi"]], dtype=np.float64)
z = np.array([n["elevation_m"] for n in alignment["base_pvi"]], dtype=np.float64)
curves, _ = build_curves(s, z, AlignmentPolicy.from_dict(alignment["policy"]), radii)
assert all(float(c["chainage_m"]) in kinks for c in curves)
def test_직선_분할_선형_R500_넘는_꼭짓점_없음(monkeypatch) -> None:
alignment, _profile = design_alignment_profile(_longitudinal(), OPTIONS)
assert _flat(alignment)[0] == []
# 풀이가 R > 500 m 꺾임(100 m · 대수차 0.5 %)을 낸 꼴 — 직선 위로 · 200 m 꺾임은 그대로
import B05_Profile.B05_Profile_Engine_Grade_Profile as engine
s = np.array([0.0, 100.0, 200.0, 400.0])
z = np.array([100.0, 103.0, 106.5, 124.5])
monkeypatch.setattr(engine, "station_breakpoints", lambda *_a, **_k: s)
monkeypatch.setattr(engine, "solve_alignment_elevations", lambda *_a, **_k: (z.copy(), True))
alignment, _profile = engine.design_alignment_profile(_longitudinal(), OPTIONS)
flat, straight = _flat(alignment)
assert flat == [] and straight == 1
nodes = {n["chainage_m"]: n["elevation_m"] for n in alignment["base_pvi"]}
assert nodes[100.0] == pytest.approx(103.25)
assert nodes[200.0] == pytest.approx(106.5)
def test_부르는_곳() -> None:
src = (ROOT / "B05_Profile" / "B05_Profile_Engine_Grade_Profile.py").read_text(encoding="utf-8")
assert src.count("flat_bends(base_s, ") >= 3 # 지반 추종(straight_pass_through 안) 밖 둘
def test_여유로_들린_관_꼭짓점은_그대로() -> None:
"""관 여유 강제(+1.5 m)로 들린 관 꼭짓점 = 직선화 경계 · 여유 없는 관 꼭짓점만 직선 위."""
clearances = {125.0: 1.5}
alignment, _profile = design_pipe_anchored_profile(
_longitudinal(), OPTIONS, PIPES, pipe_clearances=clearances
)
flat, straight = _flat(alignment)
assert straight > 0
assert flat in ([], [125.0]) # 들린 꼭짓점만 R > 500 이 남을 수 있음
rows = {r["chainage_m"]: r["plan_elevation_m"] for r in alignment["stations"]}
plan = np.interp(125.0, list(rows), list(rows.values()))
assert plan > _ground(125.0) + 1.0
@@ -0,0 +1,105 @@
"""455 (433-13 ③) 옛 정책 종단 — 법정 최소 R · L 미달 경고가 초안 · 저장 뒤 같음.
까닭: 옛 저장 정책엔 `min_vertical_radius_m` · `min_curve_length_m` 가 없어 화면 초안(저장
정책 그대로) 경고 0 · 노선 [저장](새 정책)에서야 뜸. 고침: 빈 값은 첫 계획선 설계 기준(criteria =
설계속도 법정값)
— TS `readAlignment` ↔ 서버 `rebuild_alignment_profile` 같은 규칙(`helper_455_old_policy.cjs`).
"""
from __future__ import annotations
import copy
import json
import math
import subprocess
import sys
from pathlib import Path
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 import GradeDesignOptions # noqa: E402
from B05_Profile.B05_Profile_Engine_Grade_Profile import ( # noqa: E402
design_ground_following_profile,
rebuild_alignment_profile,
)
# 설계속도 40 — 법정 최소 R 450 · L 40 > 기본 L 20 → 곡선마다 미달
OPTIONS = GradeDesignOptions(
max_grade_pct=14.0,
max_reverse_grade_pct=5.0,
min_vertical_radius_m=450.0,
min_curve_length_m=40.0,
min_tangent_length_m=20.0,
vertical_curve_skip_delta_pct=0.5,
design_speed_kph=40,
)
def _ground(s: float) -> float:
return 100.0 + 0.05 * s + 2.0 * math.sin(s / 30.0)
def _fresh() -> dict:
samples = [
{"chainage_m": float(s), "elevation_m": round(_ground(s), 6), "valid": True}
for s in range(0, 401)
]
stations = [{"chainage_m": float(s), "kind": "regular"} for s in range(0, 401, 20)]
data = {"length_m": 400.0, "samples": samples, "stations": stations}
alignment, profile = design_ground_following_profile(data, OPTIONS, station_interval_m=20.0)
data["profile_alignment"], data["design_profiles"] = alignment, [profile]
return data
def _old() -> dict:
data = _fresh()
for key in ("min_vertical_radius_m", "min_curve_length_m"):
data["profile_alignment"]["policy"].pop(key)
return data
def _law(warnings: list[str]) -> list[str]:
return [text for text in warnings if "법정 최소" in text]
def _ts(tmp_path: Path, data: dict) -> dict:
source = tmp_path / "in455.json"
source.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", str(HERE / "helper_455_old_policy.cjs"), str(source)],
capture_output=True,
text=True,
encoding="utf-8",
cwd=str(ROOT),
check=False,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def test_새_정책_경고_기준(tmp_path: Path) -> None:
fresh = _fresh()
law = _law(fresh["profile_alignment"]["warnings"])
assert law
assert _ts(tmp_path, fresh)["warnings"] == law
def test_옛_정책_초안_경고_저장_뒤와_같음(tmp_path: Path) -> None:
expected = _law(_fresh()["profile_alignment"]["warnings"])
old = _old()
ts = _ts(tmp_path, copy.deepcopy(old))
assert ts["policy"]["min_vertical_radius_m"] == 450.0
assert ts["policy"]["min_curve_length_m"] == 40.0
assert ts["warnings"] == expected
alignment, _profile = rebuild_alignment_profile(old, {})
assert _law(alignment["warnings"]) == expected
assert alignment["policy"]["min_curve_length_m"] == 40.0
def test_criteria_없는_판은_그대로(tmp_path: Path) -> None:
old = _old()
old["design_profiles"][0].pop("criteria")
assert _ts(tmp_path, copy.deepcopy(old))["warnings"] == []