Files
Aislo/B05_Profile/logic/B05_Profile_Pipeline_Grade.ts
T
eomsangdonandClaude Opus 5.5 0714e9653e feat(B05 종단): 종단곡선 기본 L 20 m · 초기값 R 500 m 넘는 꼭짓점 곡선 없음 · 법정 R · L 미달 경고 (432-1)
- config 기본 L 20 · 초기값 직선 문턱 500 m ↔ TS `logic/B05_Profile_Profile_CurveLaw.ts` 상수 짝
- 초기값(1차 지반 종단 · 추천 [적용])만 정책 `no_curve_keys` 로 얼림 · 손 편집 · R 이면 기본 곡선
- 법정 종단곡선 최소 R · L 미달 = 경고 글(파이썬 · TS 같은 글) + 종단 요약줄 「종단곡선 미달」
- 노선 [저장]은 옛 정책(L · 열쇠) 이어받음 · 바뀐 자리 새 꼭짓점만 새 지반 열쇠(`carry_policy` ↔ `carryNoCurveKeys`)
- 시험 test_432_1 + 헬퍼 스텁 넷

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XWh5wNNycMkbwMpACz1rte
2026-10-18 16:20:03 +09:00

217 lines
9.6 KiB
TypeScript

/* =============================================================================
* B05_Profile_Pipeline_Grade.ts
* 계산 묶음(PLAN 346-1) **follow · planline · profile 단계** + 바뀐 범위 — 계약 =
* `A00_Common/design/logic/design_pipeline_types.ts`. 셈은 이미 있는 짝을 부르기만(어댑터):
*
* follow — 관 · 구조물 좌표 따라감(`common_util_route_follow`) · 계획고 편집 옮김(`Grade_Carry`
* `carryEdits` · 옛 노선 `before.route` 가 있을 때만 · 기준 = 마지막 종단 편집 때 한 벌 · 400).
* planline — 노선 초안이 없으면 저장 종단 기준 그대로 · 있으면 새 측점마다 같은 자리는 저장 꼭짓점
* 높이 · 바뀐 자리만 새 지반 첫 계획선(`Grade_Ground` · `carryBase`).
* profile — `buildAlignment`(첫 계획선 + 계획고 편집) — 종단 보기가 쓰는 같은 함수.
*
* 화면 · fetch · 세션 없음(순수) — 저장 기준 · 저장 노선은 부르는 쪽이 넣음.
* ========================================================================== */
import type {
FollowPatch,
PipelineInputs,
RangePart,
RouteLine,
StageFn,
Vertex,
} from "../../A00_Common/design/logic/design_pipeline_types";
import {
followStructures,
projectPipePoints,
type FollowingPoint,
type FollowingStructure,
} from "../../common_util/common_util_route_follow";
import {
buildAlignment,
emptyEdits,
type AlignmentBase,
type AlignmentEdits,
} from "./B05_Profile_UI_Profile_Alignment";
import {
carryBase,
carryEdits,
carryNoCurveKeys,
changedSpan,
type XY,
} from "./B05_Profile_Grade_Carry";
import { SECTION_STATION_INTERVAL_M } from "./B05_Profile_Sections_Stations";
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 }));
/** 서버 측점 id 꼴(`generate_sections`) — 종단 표 · 고르기가 이 id 로 측점을 짚음. */
export const stationIdOf = (chainageM: number): string =>
`station_${String(Math.round(chainageM * 1000)).padStart(12, "0")}`;
/** 노선 바뀐 범위 — 처음 바뀐 접선점부터 끝(같으면 전 구간). */
export function routeRange(
before: ReadonlyArray<Vertex> | null,
after: ReadonlyArray<Vertex> | null,
): RangePart {
const span = before && after ? changedSpan(xy(before), xy(after)) : null;
return { fromM: span ? span.start_m : 0, toM: Infinity };
}
/** 계획고 바뀐 범위 — 바뀐 편집 키 앞뒤 이웃 꼭짓점 사이(`nodes` = 계획선 꼭짓점 누가거리). 같으면 null. */
export function gradeRange(
before: AlignmentEdits | null,
after: AlignmentEdits | null,
nodes: ReadonlyArray<number>,
): RangePart | null {
const changed: number[] = [];
for (const field of ["station_offsets", "curve_radii"] as const) {
const a = before?.[field] ?? {};
const b = after?.[field] ?? {};
for (const key of new Set([...Object.keys(a), ...Object.keys(b)]))
if (a[key] !== b[key]) changed.push(Number(key));
}
if (!changed.length) return null;
const low = Math.min(...changed);
const high = Math.max(...changed);
const ahead = nodes.filter((node) => node > high + 1e-6);
return {
fromM: Math.max(0, ...nodes.filter((node) => node < low - 1e-6)),
toM: ahead.length ? Math.min(...ahead) : Infinity,
};
}
/** 계획고 편집 기준 한 벌 — 그 편집이 놓인 선 · 자동 꼭짓점. */
interface GradeRef {
line: XY[];
basePvi: AlignmentBase["basePvi"];
edits: AlignmentEdits;
}
/**
* follow — 노선이 바뀐 차례에 한 번 · 관 · 구조물 좌표 따라감 · 계획고 편집 옮김. 편집 기준 = 마지막으로
* 편집이 바뀐(종단 조작 · 되돌리기 · 열 때) 뒤 첫 노선 걸음의 옛 선 · 자동 꼭짓점 · 편집 — 노선 걸음이 여럿이어도
* 그 한 벌에서 한 번에 옮김(걸음 수와 무관 · 서버 [저장] = 저장 노선에서 한 번 · 400). 첫 계산(옛 노선 없음)은
* 저장 노선(`savedLine`)을 옛 노선으로 보고 좌표만 따라감(투영은 몇 번 해도 같음 · 계획고 옮김은 안 함 —
* 열 때의 계획고 초안은 이미 그 노선 기준). 노선 초안이 없으면(열 때 · 초안 버림) `saved` 를 그대로 —
* 저장 노선의 관 · 구조물 누가거리 · 계획고는 정본이라 다시 투영하지 않음(투영 오차 = 측점 목록 어긋남).
*/
export function createFollowStage(deps: {
savedLine(): Vertex[] | null;
saved?(): FollowPatch;
/** 새 노선 측점 누가거리(stations 단계와 같은 셈) — 없으면 일반측점 격자. */
stationsAt?(route: RouteLine, patch: FollowPatch, inputs: PipelineInputs): number[];
}): StageFn<"follow"> {
let ref: GradeRef | null = null;
let carried: unknown = null; // 마지막으로 낸 편집 — 입력이 이것이면 기준 그대로
return (ctx) => {
if (!ctx.inputs.route && deps.saved) return deps.saved();
const before = ctx.before.route?.line ?? deps.savedLine();
const now = ctx.out.route;
if (!before || !now) return {};
const line = xy(now.line);
const patch: FollowPatch = {};
const { pipes, structures, alignment } = ctx.inputs;
if (Array.isArray(pipes) && pipes.length)
patch.pipes = projectPipePoints(pipes as FollowingPoint[], line);
if (Array.isArray(structures) && structures.length)
patch.structures = followStructures(
structures as FollowingStructure[],
line,
undefined,
SECTION_STATION_INTERVAL_M, // site = 새 노선 일반 측점(380-3)
).map((moved) => moved.item);
const planline = ctx.before.planline;
if (alignment !== carried && JSON.stringify(alignment) !== JSON.stringify(carried))
ref =
alignment && planline
? { line: xy(before), basePvi: planline.basePvi, edits: alignment as AlignmentEdits }
: null;
if (ref && ctx.before.route) {
const span = changedSpan(ref.line, line);
const half = (planline?.policy.station_interval_m ?? SECTION_STATION_INTERVAL_M) / 2;
const stations = span ? (deps.stationsAt?.(now, patch, ctx.inputs) ?? gridOf(now)) : [];
carried = patch.alignment = span
? carryEdits(ref.line, line, ref.basePvi, ref.edits, stations, half)
: ref.edits;
}
return patch;
};
}
/** 일반측점 격자 + 끝 — `stationsAt` 없을 때(시험 · 단독 묶음). */
const gridOf = (route: RouteLine): number[] => {
const grid: number[] = [];
for (let s = 0; s < route.lengthM; s += SECTION_STATION_INTERVAL_M) grid.push(s);
return [...grid, route.lengthM];
};
export const followStage = createFollowStage({ savedLine: () => null });
export interface PlanlineDeps {
/** 저장 노선의 종단 기준(측점 한 벌 = 지금 입력이 심는 측점 · 386-3) — 없으면 아직 못 셈. */
savedBase(inputs: PipelineInputs): AlignmentBase | null;
/** 저장 노선(그린 선) — 바뀐 구간을 가름. */
savedLine(): Vertex[] | null;
/** 노선 초안 첫 계획선 꼭짓점 = 서버 저장 종단 — 보기가 심은 측점(초안 자리 관 등)에 안 흔들림(365-5) · 없으면 `savedBase`. */
storedPvi?(): AlignmentBase["basePvi"] | null;
}
export function createPlanlineStage(deps: PlanlineDeps): StageFn<"planline"> {
return (ctx) => {
const saved = deps.savedBase(ctx.inputs);
if (!saved) throw new Error("저장 종단 기준이 아직 없습니다.");
const ground = ctx.out.ground;
const route = ctx.out.route;
if (!ctx.inputs.route || !ground || !route) return saved;
const { chainageM, z } = ground.longitudinal;
const samples = chainageM.map((chainage, index) => ({
chainage_m: chainage,
elevation_m: z[index],
}));
const profile = groundProfile(samples);
if (!profile) return saved; // 못 서는 땅 — 저장 기준 그대로(서버가 [저장] 때 다시 셈)
// 바뀐 자리 새 꼭짓점 = 서버 새 지반 첫 계획선(기본 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 {
// 이어받은 구간 = 옛 정책(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,
ground: profile.ground,
stations: ground.stations.map((station) => ({
station_id: stationIdOf(station.chainageM),
chainage_m: station.chainageM,
})),
};
};
}
/** profile — 첫 계획선 + 계획고 편집 → 종단(종단 보기와 같은 `buildAlignment`). */
export const profileStage: StageFn<"profile"> = (ctx) => {
const base = ctx.out.planline;
if (!base) throw new Error("첫 계획선이 없습니다.");
return buildAlignment(base, ctx.inputs.alignment ?? emptyEdits());
};