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

This commit is contained in:
2026-10-18 16:20:41 +09:00
19 changed files with 689 additions and 15 deletions
@@ -23,6 +23,7 @@ from typing import Any
import numpy as np
from B05_Profile.B05_Profile_Engine_Grade_CurveLaw import vertical_curve_shortfalls
from config.config_system import FOREST_ROAD_PROFILE_ALIGNMENT, FOREST_ROAD_PROFILE_CRITERIA
ALIGNMENT_SCHEMA_VERSION = 1
@@ -69,6 +70,12 @@ class AlignmentPolicy:
min_plan_radius_m: float = 0.0
#: 배향곡선 하한(m) — 이보다 급하면 경고만 낸다.
hairpin_min_radius_m: float = 0.0
#: 설계속도별 종단곡선 최소 R · L(m) — 미달 경고만(432 · 별표2 마.). 0이면 판정 안 함.
min_vertical_radius_m: float = 0.0
min_curve_length_m: float = 0.0
#: 초기값에서 곡선을 두지 않은 꼭짓점 열쇠(432 · `initial_no_curve_keys`) — 손 편집 · R 이면
#: 곡선.
no_curve_keys: tuple[str, ...] = ()
@classmethod
def from_config(
@@ -79,6 +86,8 @@ class AlignmentPolicy:
curve_skip_delta_pct: float,
paved: bool,
min_plan_radius_m: float = 0.0,
min_vertical_radius_m: float = 0.0,
min_curve_length_m: float = 0.0,
) -> "AlignmentPolicy":
config = FOREST_ROAD_PROFILE_ALIGNMENT
return cls(
@@ -97,6 +106,8 @@ class AlignmentPolicy:
paved=bool(paved),
min_plan_radius_m=float(min_plan_radius_m),
hairpin_min_radius_m=float(FOREST_ROAD_PROFILE_CRITERIA["hairpin_min_radius_m"]),
min_vertical_radius_m=float(min_vertical_radius_m),
min_curve_length_m=float(min_curve_length_m),
)
@classmethod
@@ -134,6 +145,9 @@ class AlignmentPolicy:
max_grade_pct=float(payload.get("max_grade_pct") or 9.0),
curve_skip_delta_pct=float(payload.get("curve_skip_delta_pct") or 5.0),
paved=bool(payload.get("paved", False)),
min_vertical_radius_m=float(payload.get("min_vertical_radius_m") or 0.0),
min_curve_length_m=float(payload.get("min_curve_length_m") or 0.0),
no_curve_keys=tuple(str(key) for key in payload.get("no_curve_keys") or ()),
)
@property
@@ -164,6 +178,9 @@ class AlignmentPolicy:
# 평면 곡선 판정값 — 화면 위반 표시가 쓴다(2026-09-06).
"min_plan_radius_m": self.min_plan_radius_m,
"hairpin_min_radius_m": self.hairpin_min_radius_m,
"min_vertical_radius_m": self.min_vertical_radius_m,
"min_curve_length_m": self.min_curve_length_m,
"no_curve_keys": list(self.no_curve_keys),
}
@@ -293,6 +310,7 @@ def build_curves(
curve_radii: dict[str, float] | None = None,
*,
only_explicit: bool = False,
edited: frozenset[str] = frozenset(),
) -> tuple[list[dict[str, Any]], list[str]]:
"""각 변화점에 대칭 종단곡선을 삽입하고 곡선 제원을 만든다.
@@ -321,6 +339,9 @@ def build_curves(
continue # 직선 판정 한 벌(426)
if only_explicit and overrides.get(key) is None:
continue
# 초기값 직선 꼭짓점(432) — 사용자가 고치거나(`edited`) R 을 넣기 전까지 곡선 없음
if key in policy.no_curve_keys and key not in edited and overrides.get(key) is None:
continue
# 법정 다-(3)-(다)는 "종단곡선을 두지 않을 수 있다"는 허용 조항이다.
# 실무 도면은 대수차가 작아도 변화점을 원곡선으로 처리하므로, 기본은 곡선을
# 삽입하고 생략 가능 구간이라는 표시만 남긴다(config로 실제 생략 전환 가능).
@@ -518,8 +539,14 @@ def build_alignment(
station_offsets, curve_radii = snapped["station_offsets"], snapped["curve_radii"]
pvi_s, pvi_z, sources = resolve_pvi(base_s, base_z, station_offsets)
edited = frozenset(
chainage_key(s) for s, source in zip(pvi_s.tolist(), sources) if source == "user"
)
curves, warnings = build_curves(
pvi_s, pvi_z, policy, curve_radii, only_explicit=only_explicit_curves
pvi_s, pvi_z, policy, curve_radii, only_explicit=only_explicit_curves, edited=edited
)
warnings += vertical_curve_shortfalls(
curves, policy.min_vertical_radius_m, policy.min_curve_length_m
)
segments = _segments(pvi_s, pvi_z)
# 샘플 격자에 변화점(PVI)과 종단곡선 시·종점(BVC/EVC)을 합쳐서 평가한다. 격자만 쓰면
@@ -205,3 +205,52 @@ def carry_edits(
gaps[slot] = best
out["curve_radii"][slot] = float(value)
return out
def carry_no_curve_keys(
old_xy: list[tuple[float, float]],
new_xy: list[tuple[float, float]],
old_keys: list[str],
ground_keys: list[str],
ground_pvi: list[dict[str, float]],
tol_m: float = SAME_LINE_TOL_M,
) -> list[str]:
"""새 꼭짓점의 초기값 직선 열쇠(432) — 같은 자리면 옛 꼭짓점 열쇠(꼭짓점 위일 때만) ·
바뀐 자리면 새 지반 첫 계획선 열쇠. 초기값만 · 그 뒤는 사용자 몫(요구 262)."""
match = line_matcher(old_xy, new_xy, tol_m)
old = [float(key) for key in old_keys]
ground = set(ground_keys)
out = []
for node in ground_pvi:
key = chainage_key(float(node["chainage_m"]))
s = match(float(node["chainage_m"]))
if (s is None and key in ground) or (
s is not None and any(abs(s - value) < _ON_NODE_M for value in old)
):
out.append(key)
return out
def carry_policy(
old_xy: list[tuple[float, float]],
new_xy: list[tuple[float, float]],
old_alignment: dict[str, Any],
new_alignment: dict[str, Any],
) -> dict[str, Any]:
"""노선 [저장] 정책(432) — 새 정책에 옛 기본 L · 옮긴 직선 열쇠 · 옛 정책 없으면 새 정책."""
new_policy = dict(new_alignment.get("policy") or {})
old_policy = old_alignment.get("policy") or {}
if not old_policy:
return new_policy
keys = carry_no_curve_keys(
old_xy,
new_xy,
list(old_policy.get("no_curve_keys") or []),
list(new_policy.get("no_curve_keys") or []),
list(new_alignment.get("base_pvi") or []),
)
return {
**new_policy,
"default_curve_length_m": old_policy.get("default_curve_length_m"),
"no_curve_keys": keys,
}
@@ -0,0 +1,56 @@
"""B05 종단곡선 초기값 직선 · 법정 미달 판정(432 · 요구 262).
- `initial_no_curve_keys` : 초기값(자동설계 체인 · 추천 [적용]으로 처음 만드는 종단)에서만 — 기본 L
기준 R 이 `initial_straight_radius_m`(500 m)보다 큰(대수차 L/R 안쪽) 꼭짓점 열쇠. 정책
`no_curve_keys` 로 저장돼 그 꼭짓점엔 종단곡선을 두지 않음 · 사용자가 그 꼭짓점을 고치거나 R 을
넣으면 다시 곡선(`build_curves`). 꼭짓점 높이는 그대로.
- `vertical_curve_shortfalls` : 설계속도별 종단곡선 최소 R · L 미달(지식DB 별표2 마.) — 막지 않고
경고만 · 비포장 대수차 5 % 이하(생략 가능)는 뺌 · 45 % 규칙으로 줄어든 곡선도 같은 잣대.
짝: TS `logic/B05_Profile_Profile_CurveLaw.ts` — 거울 시험 `test_432_1_curve_law.py`.
"""
from typing import Any
import numpy as np
from config.config_system import FOREST_ROAD_PROFILE_ALIGNMENT
# 초기값 직선 문턱 R(m) — TS `INITIAL_STRAIGHT_RADIUS_M` 과 같은 값.
INITIAL_STRAIGHT_RADIUS_M = float(FOREST_ROAD_PROFILE_ALIGNMENT["initial_straight_radius_m"])
def initial_no_curve_keys(
base_s: np.ndarray, base_z: np.ndarray, curve_length_m: float | None
) -> tuple[str, ...]:
"""안쪽 꼭짓점 중 기본 L 기준 R > 500 m(|대수차| < L/500) 인 것의 열쇠(소수 3자리)."""
if not curve_length_m or len(base_s) < 3:
return ()
limit = float(curve_length_m) / INITIAL_STRAIGHT_RADIUS_M
spans = base_s[1:] - base_s[:-1]
grades = (base_z[1:] - base_z[:-1]) / np.where(spans > 0, spans, 1.0)
keys = []
for index in range(1, len(base_s) - 1):
if abs(float(grades[index] - grades[index - 1])) < limit:
keys.append(f"{float(base_s[index]):.3f}")
return tuple(keys)
def vertical_curve_shortfalls(
curves: list[dict[str, Any]], min_radius_m: float, min_length_m: float
) -> list[str]:
"""법정 최소 R · L 을 못 채운 종단곡선 경고 글(누가거리 순) — 기준 0 이면 판정 안 함."""
if min_radius_m <= 0 and min_length_m <= 0:
return []
out = []
for curve in curves:
if curve.get("omitted") or curve.get("skip_allowed"):
continue
radius = float(curve.get("radius_m", curve.get("r_m", 0.0)))
length = float(curve.get("length_m", curve.get("l_m", 0.0)))
if radius < min_radius_m - 1e-6 or length < min_length_m - 1e-6:
out.append(
f"{float(curve['chainage_m']):.1f}m: 종단곡선 R {radius:.0f}m · L {length:.1f}m — "
f"법정 최소 R {min_radius_m:.0f}m · L {min_length_m:.0f}m 미달"
)
return out
@@ -27,6 +27,7 @@
"""
from collections import Counter
from dataclasses import replace
from typing import Any
import numpy as np
@@ -45,6 +46,7 @@ from B05_Profile.B05_Profile_Engine_Grade_Alignment import (
chainage_key,
evaluate,
)
from B05_Profile.B05_Profile_Engine_Grade_CurveLaw import initial_no_curve_keys
from B05_Profile.B05_Profile_Engine_Grade_Solver import (
grade_limits,
integration_weights,
@@ -181,7 +183,8 @@ def design_ground_following_profile(
규칙(2026-09-02 사용자 확정 + 2026-09-03 종단곡선 복원):
- **모든 측점이 변화점**이고 그 자리 계획고는 지반고 그대로다. 절·성토가 거의 0이다.
- **변화점마다 종단곡선을 넣는다.** 직선과 직선 사이에는 반드시 호가 있고, 호는
- **변화점마다 종단곡선을 넣는다**(432: 기본 L 기준 R > 500 m 꼭짓점은 빼고 · 정책
`no_curve_keys`). 직선과 직선 사이에는 반드시 호가 있고, 호는
변화점 대칭이라 **호의 중심이 측점 세로선 위**에 놓이며 좌우 직선과 접선을 이룬다.
- 대칭 종단곡선은 꼭짓점을 지나지 않으므로 **호와 측점 세로선의 교점이 지반고**가
되도록 꼭짓점 표고를 중앙종거만큼 밀어내며 반복 보정한다
@@ -207,6 +210,9 @@ def design_ground_following_profile(
paved=options.paved,
# 평면 최소곡선반지름은 화면 위반 표시에만 쓴다(2026-09-06) — 탐색 제약과 별개.
min_plan_radius_m=legal_plan_radius_min_m(options.design_speed_kph, options.terrain_type),
# 종단곡선 최소 R · L — 미달 경고만(432)
min_vertical_radius_m=options.min_vertical_radius_m,
min_curve_length_m=options.min_curve_length_m,
)
warnings = list(options.warnings)
@@ -234,6 +240,10 @@ def design_ground_following_profile(
if note:
warnings.append(note)
# 초기값 직선(432) — 기본 L 기준 R > 500 m 꼭짓점은 곡선 없이(꼭짓점 = 지반고 그대로)
policy = replace(
policy, no_curve_keys=initial_no_curve_keys(base_s, base_z, policy.default_curve_length_m)
)
base_z = pass_through(base_s, base_z, policy)
alignment = build_alignment(
@@ -289,6 +299,9 @@ def design_pipe_anchored_profile(
paved=options.paved,
# 평면 최소곡선반지름은 화면 위반 표시에만 쓴다(2026-09-06) — 탐색 제약과 별개.
min_plan_radius_m=legal_plan_radius_min_m(options.design_speed_kph, options.terrain_type),
# 종단곡선 최소 R · L — 미달 경고만(432)
min_vertical_radius_m=options.min_vertical_radius_m,
min_curve_length_m=options.min_curve_length_m,
)
warnings = list(options.warnings)
@@ -406,6 +419,9 @@ def design_alignment_profile(
paved=options.paved,
# 평면 최소곡선반지름은 화면 위반 표시에만 쓴다(2026-09-06) — 탐색 제약과 별개.
min_plan_radius_m=legal_plan_radius_min_m(options.design_speed_kph, options.terrain_type),
# 종단곡선 최소 R · L — 미달 경고만(432)
min_vertical_radius_m=options.min_vertical_radius_m,
min_curve_length_m=options.min_curve_length_m,
)
warnings = list(options.warnings)
+7 -1
View File
@@ -200,7 +200,12 @@ async def _carry_profile(
new_xy: list[list[float]],
) -> None:
"""같은 자리(옆 1 m 안) 옛 계획고 · R 을 새 측점 위에 · 바뀐 자리만 새 지반 첫 계획선(400)."""
from B05_Profile.B05_Profile_Engine_Grade_Carry import carry_base, carry_edits, changed_span
from B05_Profile.B05_Profile_Engine_Grade_Carry import (
carry_base,
carry_edits,
carry_policy,
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
@@ -219,6 +224,7 @@ async def _carry_profile(
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["policy"] = carry_policy(old_xy, new_xy, old_alignment, alignment) # 옛 L · 열쇠(432)
alignment["base_pvi"] = base
await asyncio.to_thread(atomic_write_json, path, longitudinal)
_alignment, summary, _dropped = await asyncio.to_thread(
@@ -360,6 +360,7 @@ def _set_base_pvi(path: Path, stations: list[float], plan: list[float]) -> int:
import numpy as np
from B05_Profile.B05_Profile_Engine_Grade_Alignment import AlignmentPolicy
from B05_Profile.B05_Profile_Engine_Grade_CurveLaw import initial_no_curve_keys
from B05_Profile.B05_Profile_Engine_RouteInitial_Profile import held_pass_through
from common_util.common_util_json import atomic_write_json
@@ -367,6 +368,12 @@ def _set_base_pvi(path: Path, stations: list[float], plan: list[float]) -> int:
alignment = longitudinal["profile_alignment"]
base = alignment["base_pvi"]
base_s = np.array([float(item["chainage_m"]) for item in base])
# 초기값 직선(432) — 균형 종단에서 기본 L 기준 R > 500 m 꼭짓점은 곡선 없이
policy = AlignmentPolicy.from_dict(alignment["policy"])
keys = initial_no_curve_keys(
base_s, np.interp(base_s, stations, plan), policy.default_curve_length_m
)
alignment["policy"]["no_curve_keys"] = list(keys)
# 1차 종단과 같은 맞춤 — 종단곡선을 넣어도 측점마다 균형 종단 계획고를 지나게 · 꼭짓점 사이
# 직선은 균형 종단 상한 안(419-3)
base_z = held_pass_through(
@@ -17,6 +17,7 @@
import type { ProfileAlignment } from "./logic/B05_Profile_UI_Profile_Alignment";
import type { MinCoverViolation } from "./B05_Profile_UI_Profile_MinCover";
import { bentEditCount } from "./logic/B05_Profile_Profile_Straight";
import { verticalCurveShortfalls } from "./logic/B05_Profile_Profile_CurveLaw";
export interface BalanceBarParams {
/** 표시줄 컨테이너. 그릴 때마다 통째로 갈아 끼운다. */
@@ -98,6 +99,9 @@ export function renderBalanceBar(params: BalanceBarParams): void {
}
// 필요한 곳이 없으면 적지 않는다 — "0곳"은 화면 폭만 먹는다.
if (curvesNeeded) entries.push(["종단곡선 필요", `${curvesNeeded} 곳`, "over"]);
// 종단곡선 법정 최소 R · L 미달(별표2 마. · 432) — 막지 않고 경고만 · 내역은 툴팁
const shortfalls = verticalCurveShortfalls(alignment.curves, policy);
if (shortfalls.length) entries.push(["종단곡선 미달", `${shortfalls.length} 곳`, "over"]);
// 횡단배수 최소고 표시는 2026-09-02 사용자 지시로 삭제했다. 편집 차단(강제)은
// `B05_Profile_UI_Profile_Render.ts` 에 그대로 남아 있고 기본 해제다.
const editedCount = bentEditCount(alignment); // 꺾인 편집 꼭짓점만(426)
@@ -110,6 +114,7 @@ export function renderBalanceBar(params: BalanceBarParams): void {
item.append(caption, document.createTextNode(value));
// 상한 초과 구간의 내역은 별도 경고 칩 대신 이 항목의 툴팁으로 붙인다 —
// 같은 사실을 두 번 적지 않는다(2026-08-19 재편).
if (label === "종단곡선 미달") item.title = shortfalls.join("\n");
if (label === "곡선반경 부족" && plan) {
item.title = [
`법정 하한 ${plan.limitM.toFixed(1)}m 미만인 측점 ${plan.count}곳`,
@@ -238,3 +238,27 @@ export function carryEdits(
});
return out;
}
/**
* 새 꼭짓점의 초기값 직선 열쇠(432) — 같은 자리면 옛 꼭짓점 열쇠(꼭짓점 위일 때만) · 바뀐 자리면 새
* 지반 첫 계획선 열쇠(파이썬 `carry_no_curve_keys` 짝). 초기값만 · 그 뒤는 사용자 몫(요구 262).
*/
export function carryNoCurveKeys(
oldXy: ReadonlyArray<XY>,
newXy: ReadonlyArray<XY>,
oldKeys: ReadonlyArray<string>,
groundKeys: ReadonlyArray<string>,
groundPvi: ReadonlyArray<AlignmentNode>,
tolM: number = SAME_LINE_TOL_M,
): string[] {
const match = lineMatcher(oldXy, newXy, tolM);
const old = oldKeys.map(Number);
const ground = new Set(groundKeys);
return groundPvi
.filter((node) => {
const s = match(node.chainage_m);
if (s === null) return ground.has(node.chainage_m.toFixed(3));
return old.some((value) => Math.abs(s - value) < ON_NODE_M);
})
.map((node) => node.chainage_m.toFixed(3));
}
+18 -2
View File
@@ -14,6 +14,7 @@ import {
type AlignmentNode,
type AlignmentPolicy,
} from "./B05_Profile_UI_Profile_Alignment";
import { initialNoCurveKeys } from "./B05_Profile_Profile_CurveLaw";
/** 종단 지반 표본 한 점 — 저장 종단 `samples[]` 꼴. */
export interface GroundSample {
@@ -83,6 +84,16 @@ export function passThrough(baseS: number[], target: number[], policy: Alignment
/** 첫 계획선 꼭짓점(base_pvi · 0.000001 m) — 못 서는 땅이면 null(서버로 다시 셈). */
export function groundFollowingBasePvi(input: GroundFollowingInput): AlignmentNode[] | null {
return groundFollowingPlan(input)?.pvi ?? null;
}
/**
* 첫 계획선 꼭짓점 + 초기값 직선 열쇠(432) — 기본 L 기준 R > 500 m 꼭짓점은 곡선 없이(꼭짓점 =
* 지반고 그대로) · 파이썬 `design_ground_following_profile` 의 `no_curve_keys` 짝.
*/
export function groundFollowingPlan(
input: GroundFollowingInput,
): { pvi: AlignmentNode[]; noCurveKeys: string[] } | null {
const profile = groundProfile(input.samples);
if (!profile) return null;
const { chainage, ground } = profile;
@@ -103,9 +114,14 @@ export function groundFollowingBasePvi(input: GroundFollowingInput): AlignmentNo
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) => ({
const noCurveKeys = initialNoCurveKeys(
nodeS.map((s, index) => ({ chainage_m: s, elevation_m: target[index] })),
input.policy.default_curve_length_m,
);
const baseZ = passThrough(nodeS, target, { ...input.policy, no_curve_keys: noCurveKeys });
const pvi = nodeS.map((s, index) => ({
chainage_m: roundTo(s, 6),
elevation_m: roundTo(baseZ[index], 6),
}));
return { pvi, noCurveKeys };
}
@@ -32,9 +32,16 @@ import {
type AlignmentBase,
type AlignmentEdits,
} from "./B05_Profile_UI_Profile_Alignment";
import { carryBase, carryEdits, changedSpan, type XY } from "./B05_Profile_Grade_Carry";
import {
carryBase,
carryEdits,
carryNoCurveKeys,
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";
import { groundFollowingPlan, groundProfile } from "./B05_Profile_Grade_Ground";
import { DEFAULT_CURVE_LENGTH_M } from "./B05_Profile_Profile_CurveLaw";
const xy = (line: ReadonlyArray<Vertex>): Array<{ x: number; y: number }> =>
line.map(([x, y]) => ({ x, y }));
@@ -165,17 +172,30 @@ export function createPlanlineStage(deps: PlanlineDeps): StageFn<"planline"> {
}));
const profile = groundProfile(samples);
if (!profile) return saved; // 못 서는 땅 — 저장 기준 그대로(서버가 [저장] 때 다시 셈)
const groundPvi =
groundFollowingBasePvi({
samples,
stations: ground.stations.map((station) => station.chainageM),
policy: saved.policy,
}) ?? [];
// 바뀐 자리 새 꼭짓점 = 서버 새 지반 첫 계획선(기본 L = config 짝 · 초기값 직선 열쇠 · 432)
const plan = groundFollowingPlan({
samples,
stations: ground.stations.map((station) => station.chainageM),
policy: { ...saved.policy, default_curve_length_m: DEFAULT_CURVE_LENGTH_M },
});
const groundPvi = plan?.pvi ?? [];
const savedLine = deps.savedLine();
const span = savedLine ? changedSpan(xy(savedLine), xy(route.line)) : null;
const savedPvi = deps.storedPvi?.() ?? saved.basePvi;
return {
policy: saved.policy,
// 이어받은 구간 = 옛 정책(L · 직선 열쇠) · 바뀐 자리 새 꼭짓점만 새 열쇠(서버 `carry_policy` 짝)
policy: span
? {
...saved.policy,
no_curve_keys: carryNoCurveKeys(
xy(savedLine!),
xy(route.line),
saved.policy.no_curve_keys ?? [],
plan?.noCurveKeys ?? [],
groundPvi,
),
}
: saved.policy,
onlyExplicitCurves: saved.onlyExplicitCurves,
basePvi: span ? carryBase(xy(savedLine!), xy(route.line), savedPvi, groundPvi) : savedPvi,
chainage: profile.chainage,
@@ -0,0 +1,70 @@
/* =============================================================================
* B05_Profile_Profile_CurveLaw.ts
* 종단곡선 초기값 직선 · 법정 미달 판정(432 · 요구 262).
*
* 짝: 파이썬 `B05_Profile_Engine_Grade_CurveLaw.py`(`initial_no_curve_keys` ·
* `vertical_curve_shortfalls`) · config `FOREST_ROAD_PROFILE_ALIGNMENT` 의 `default_curve_length_m` ·
* `initial_straight_radius_m` — 거울 시험 `test_432_1_curve_law.py`.
*
* - 초기값에서만 기본 L 기준 R > 500 m(|대수차| < L/500) 꼭짓점은 곡선 없음 — 정책 `no_curve_keys`
* 로 얼려 두고, 사용자가 그 꼭짓점을 고치거나(source "user") R 을 넣으면 다시 기본 곡선.
* - 법정 최소 R · L 미달은 막지 않고 경고 글만 · 비포장 대수차 5 % 이하(생략 가능)는 뺌.
* ========================================================================== */
import type {
AlignmentCurve,
AlignmentNode,
AlignmentPolicy,
} from "./B05_Profile_UI_Profile_Alignment";
/** 기본 종단곡선 길이 L(m) — config `default_curve_length_m` 짝(새 정책에 담겨 옴 · 옛 저장분은 저장값). */
export const DEFAULT_CURVE_LENGTH_M = 20;
/** 초기값 직선 문턱 R(m) — config `initial_straight_radius_m` 짝. */
export const INITIAL_STRAIGHT_RADIUS_M = 500;
/** 안쪽 꼭짓점 중 기본 L 기준 R > 500 m 인 것의 열쇠(소수 3자리) — 파이썬 `initial_no_curve_keys`. */
export function initialNoCurveKeys(
nodes: ReadonlyArray<AlignmentNode>,
curveLengthM: number | null | undefined,
): string[] {
if (!curveLengthM || nodes.length < 3) return [];
const limit = curveLengthM / INITIAL_STRAIGHT_RADIUS_M;
const keys: string[] = [];
for (let index = 1; index < nodes.length - 1; index += 1) {
const [a, b, c] = [nodes[index - 1], nodes[index], nodes[index + 1]];
const spanIn = b.chainage_m - a.chainage_m;
const spanOut = c.chainage_m - b.chainage_m;
const gradeIn = (b.elevation_m - a.elevation_m) / (spanIn > 0 ? spanIn : 1);
const gradeOut = (c.elevation_m - b.elevation_m) / (spanOut > 0 ? spanOut : 1);
if (Math.abs(gradeOut - gradeIn) < limit) keys.push(b.chainage_m.toFixed(3));
}
return keys;
}
/** 초기값 직선 꼭짓점이라 곡선을 안 넣는가 — 사용자가 고친 꼭짓점 · R 지정이면 넣음. */
export function skipsInitialCurve(
policy: AlignmentPolicy,
key: string,
node: AlignmentNode & { source?: string },
hasRequested: boolean,
): boolean {
return !hasRequested && node.source !== "user" && (policy.no_curve_keys ?? []).includes(key);
}
/** 법정 최소 R · L 을 못 채운 종단곡선 경고 글 — 파이썬 `vertical_curve_shortfalls` 와 같은 글. */
export function verticalCurveShortfalls(
curves: ReadonlyArray<AlignmentCurve>,
policy: AlignmentPolicy,
): string[] {
const minR = policy.min_vertical_radius_m ?? 0;
const minL = policy.min_curve_length_m ?? 0;
if (minR <= 0 && minL <= 0) return [];
return curves
.filter((curve) => !curve.omitted && !curve.skip_allowed)
.filter((curve) => curve.r_m < minR - 1e-6 || curve.l_m < minL - 1e-6)
.map(
(curve) =>
`${curve.chainage_m.toFixed(1)}m: 종단곡선 R ${curve.r_m.toFixed(0)}m · L ${curve.l_m.toFixed(1)}m — ` +
`법정 최소 R ${minR.toFixed(0)}m · L ${minL.toFixed(0)}m 미달`,
);
}
@@ -15,6 +15,7 @@
import { pivotAxes } from "./B05_Profile_UI_Profile_Pivot";
import { isStraightBend } from "./B05_Profile_Profile_Straight";
import { skipsInitialCurve, verticalCurveShortfalls } from "./B05_Profile_Profile_CurveLaw";
export interface AlignmentPolicy {
station_interval_m: number;
@@ -35,6 +36,10 @@ export interface AlignmentPolicy {
min_plan_radius_m?: number;
/** 배향곡선 하한(m) — 이보다 급하면 경고만 낸다(2026-09-06 사용자 확정). */
hairpin_min_radius_m?: number;
/** 종단곡선 법정 최소 R · L(m) · 초기값 곡선 없는 꼭짓점 열쇠(432 · `_Profile_CurveLaw`). */
min_vertical_radius_m?: number;
min_curve_length_m?: number;
no_curve_keys?: string[];
}
export interface AlignmentNode {
@@ -316,6 +321,7 @@ export function buildCurves(
const hasRequested = Number.isFinite(requested) && requested > 0;
// 전체 측점 폴리라인 — 사용자가 R을 지정한 변화점에만 라운드가 생긴다.
if (onlyExplicit && !hasRequested) continue;
if (skipsInitialCurve(policy, key, pvi[index], hasRequested)) continue; // 초기값 직선(432)
const fallbackLength = Number.isFinite(policy.default_curve_length_m)
? (policy.default_curve_length_m as number)
: policy.default_curve_radius_m * Math.abs(delta);
@@ -423,6 +429,7 @@ export function buildAlignment(raw: AlignmentBase, input: AlignmentEdits): Profi
grade_percent: length > 0 ? (height / length) * 100 : 0,
});
}
warnings.push(...verticalCurveShortfalls(curves, base.policy)); // 법정 R · L 미달(432)
curves.forEach((curve) => {
curve.bvc_elevation_m = evaluateAt(pviS, pviZ, curves, curve.bvc_m);
curve.evc_elevation_m = evaluateAt(pviS, pviZ, curves, curve.evc_m);