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
+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;
}