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
@@ -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);