Files
Aislo/A00_Common/design/design_pipeline_cross.ts
T
eomsangdonandClaude Opus 5.5 e304f4c8c3 fix(B05·B06 한 페이지): 평면 바꾼 저장 때 B06 조절 · 사면 쉬프트 · 소단 깊이 새 노선 측점으로 · 평면 초안 횡단 입력에 사면 쉬프트 · 조절 · 소단 깊이 (417-1 M2 · M3 · L4)
- 측점 열쇠 이월에 사면 쉬프트 · 소단 깊이 더함(CARRY_WHOLE)
- 측점 옮김 목록 ROUTE_CARRY_STATES — 옹벽 · 세월 · BOX · 집수 · 덧 옹벽 · 경간 · 잇기 · 측점 폭 · 메모 · 상세 표를 노선 저장 때 새 노선 측점으로(구조물 조절 = 관 · 구조물 열쇠만) · 옮긴 값을 새 상세 설계 칸에 얹어 같은 [저장] 횡단 패치로
- 서버 이월 열쇠 짝 ROUTE_CARRY_KEYS — 노선 저장 이월이 측점마다 넘김 · 기본 설계가 사면 쉬프트로 셈
- 평면 초안 횡단 입력에 사면 쉬프트 · 저장분 사면 쉬프트 · 소단 깊이 · 초안 상세에 조절 얹음
- 시험 test_417_route_carry(거울 시험 포함) · 344-3 · 346-3 기대값 고침

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Se7ytqbohmBQnfYpwYFnYf
2026-10-18 12:30:48 +09:00

207 lines
11 KiB
TypeScript

/* =============================================================================
* A00_Common/design/design_pipeline_cross.ts
* 계산 묶음 cross 입력 모음(PLAN 346-2 붙임) — 저장 노선 상세의 측점마다 횡단 입력 · 얹힌 시설 제원을
* **측점 열쇠**로 모음. 노선이 바뀌어도 같은 열쇠 측점(격자 번호 · 관 id · 구조물 id)이 그 입력을 받음.
* 열쇠 = 저장 노선을 stations 단계와 같은 함수로 짚은 측점(`buildStations`)과 누가거리로 짝지음 —
* 묶음 측점과 같은 열쇠 규칙(관 = 저장 관 id).
*
* 넘기는 칸 = 서버 노선 저장(`CARRY_INPUT_KEYS`)과 같은 일곱(지반 유형 · 측구 종류 · 측구 선택 · 2단 경사 ·
* 암 경계 · 암 경사 · 소단) + 사면 쉬프트(노견 확장) · 소단 깊이(417 · 서버 `ROUTE_CARRY_KEYS`) + 얹힌 시설
* 맥락(포장 · 물넘이 기울임 · 세월교 노면 하강). 단면 유형 · 측구 방향은 새 지형이 정함
* (`logic/B05_Profile_Pipeline_Detail.ts` `crossWithUphill`). 값 = 상세에 얹힌 설계 위에 세션 고침(측점 열쇠 칸 ·
* 지반 유형 · 측구 · 2단 경사 · 암 경계 · 암 경사 · 사면 쉬프트 · 소단 깊이) — 노선 초안 동안 B06 고침은 저장 상세에
* 안 얹혀도 입력에 들어감(365-2 · 365-5 · 417). 부를 때마다 모음.
* ========================================================================== */
import { readState } from "../b_page_state";
import { savedDesigns } from "../../B06_Section/B06_Section_Section_Canon";
import type { CrossSection, SectionDetailResponse } from "../../B06_Section/B06_Section_Api_Fetch";
import {
effectiveStandardCross,
readRockBoundaryDefault,
} from "../../B06_Section/B06_Section_UI_Standard_Panel";
import type { CrossInput, SectionSpecs } from "../../B05_Profile/logic/B05_Profile_Pipeline_Detail";
import type { PipelineStation } from "./logic/design_pipeline_types";
export interface PipelineCrossSource {
input: CrossInput;
/** 측점 열쇠 → 얹힌 시설 제원. */
specs: Map<string, SectionSpecs>;
}
const SPEC_KEYS = [
"structure",
"culvert",
"ford",
"box",
"ford_pavement",
"revetment",
"pavement",
"underdrain",
] as const;
/** 같은 측점으로 보는 누가거리 차(m) — 측점 열쇠 글 자릿수(0.01 m). */
const SAME_M = 0.01;
/** 짧은 지문(FNV-1a) — 제원 묶음이 같은지만 견줌. */
function digest(text: string): string {
let hash = 0x811c9dc5;
for (let i = 0; i < text.length; i += 1) hash = Math.imul(hash ^ text.charCodeAt(i), 0x01000193);
return `${text.length}:${hash >>> 0}`;
}
/** 다시 셈 부탁 수(387-4) — 입력 칸에 없는 세션 고침(노견 · 소단 구간 · 조정창 4축 · 단면 유형)을 고친 화면이
* 올림 · cross 입력 지문에 들어가 같은 입력이어도 묶음 cross 가 한 번 더 돎(다시 셈 창구 한 곳). */
let forced = 0;
export const forceCross = (): void => void (forced += 1);
const finite = (value: unknown): number | null =>
typeof value === "number" && Number.isFinite(value) ? value : null;
/** 측점 하나의 넘기는 입력(cross 단계 `CrossStationInput` 꼴). */
function carried(section: CrossSection, saved?: CrossSection["design"]): Record<string, unknown> {
const design = (saved ?? section.design ?? {}) as unknown as Record<string, unknown>;
const berm = design.berm as
{ width_m: number; interval_m: number; slope_deg: number; depth_m?: number } | undefined;
const cutSlope = finite(design.cut_slope_ratio_user);
const drop = Number(section.ford?.overflow_depth_m);
return {
...(typeof design.ground_type === "string" ? { groundType: design.ground_type } : {}),
...(typeof design.ditch_type === "string" ? { ditchType: design.ditch_type } : {}),
ditchChoice: typeof design.ditch_choice === "boolean" ? design.ditch_choice : null,
// 옛 저장분 측구 결과 — 다시 셈 창구와 같은 규칙(자동과 다를 때만 선택 · 들어온 화면 무관 · 365-5)
ditchEnabled: typeof design.ditch_enabled === "boolean" ? design.ditch_enabled : null,
twoStageSlope: Boolean(design.two_stage_slope),
rockBoundaryOffsetM: finite(design.rock_boundary_offset_m),
cutSlopeRatio: cutSlope !== null && cutSlope > 0 ? cutSlope : null,
berm: berm
? {
widthM: berm.width_m,
intervalM: berm.interval_m,
slopeDeg: berm.slope_deg,
depthM: berm.depth_m,
}
: null,
paved: Boolean(section.pavement?.length) || Boolean(section.ford_pavement),
tilted: Boolean(section.ford_pavement || section.underdrain),
tiltPct: section.ford_pavement?.slope_pct ?? null, // 맹암거 = 표준 경사(371-3)
surfaceDropM: drop > 0 ? drop : 0,
// 저장분 사면 쉬프트 · 소단 깊이(417 L4) — 세션 고침이 없어도 저장 뒤 그대로
...sides(design, "shoulder_extend", "shoulderExtend"),
...sides(design, "berm_depth", "bermDepth"),
};
}
/** 저장분 쪽마다 값(`{field}_{left|right}_m`) → 입력 칸(`{name}LeftM` · `{name}RightM`) — 있는 쪽만. */
function sides(design: Record<string, unknown>, field: string, name: string) {
const out: Record<string, number> = {};
for (const [side, tail] of [
["left", "LeftM"],
["right", "RightM"],
] as const) {
const value = finite(design[`${field}_${side}_m`]);
if (value !== null) out[`${name}${tail}`] = value;
}
return out;
}
/** 세션 칸 하나를 측점 열쇠마다 — 옛 열쇠(누가거리 · `at:`)는 읽는 쪽이 옮긴 뒤에 들어옴(빼고 봄). */
function sessionCells(
name: "crossdesign" | "rockb" | "cutslope" | "bermdepth" | "shoulderext",
projectId: string,
routeId: number,
): Array<[string, unknown]> {
const raw = readState<Record<string, unknown>>(name, projectId, routeId);
if (!raw || typeof raw !== "object") return [];
return Object.entries(raw).filter(([key]) => key.includes(":") && !key.startsWith("at:"));
}
/** 세션 횡단 고침 → 측점 열쇠마다 넘기는 입력 조각(`carried` 와 같은 칸 이름). */
function sessionCarried(projectId: string, routeId: number): CrossInput["byKey"] {
const out: CrossInput["byKey"] = {};
for (const [key, value] of sessionCells("crossdesign", projectId, routeId)) {
const choice = (value ?? {}) as Record<string, unknown>;
const entry = (out[key] ??= {});
if (typeof choice.ground_type === "string") entry.groundType = choice.ground_type;
if (typeof choice.ditch_type === "string") entry.ditchType = choice.ditch_type;
if ("ditch_choice" in choice)
entry.ditchChoice = typeof choice.ditch_choice === "boolean" ? choice.ditch_choice : null;
if ("two_stage_slope" in choice) entry.twoStageSlope = Boolean(choice.two_stage_slope);
}
for (const [key, value] of sessionCells("rockb", projectId, routeId))
if (finite(value) !== null) (out[key] ??= {}).rockBoundaryOffsetM = value;
for (const [key, value] of sessionCells("cutslope", projectId, routeId)) {
const ratio = finite(value);
if (ratio !== null) (out[key] ??= {}).cutSlopeRatio = ratio > 0 ? ratio : null;
}
// 소단 쪽 깊이(194-1) — 소단 제원은 구조물 구간이 정함(`crossWithBerm` · 365-5) · 사면 쉬프트(139-6 · 417 M3)
for (const [state, name] of [
["bermdepth", "bermDepth"],
["shoulderext", "shoulderExtend"],
] as const)
for (const [key, value] of sessionCells(state, projectId, routeId)) {
const sides = (value ?? {}) as Record<string, unknown>;
if (finite(sides.left) !== null) (out[key] ??= {})[`${name}LeftM`] = sides.left;
if (finite(sides.right) !== null) (out[key] ??= {})[`${name}RightM`] = sides.right;
}
return out;
}
/** 저장 상세 + 저장 노선 측점(열쇠) + 세션 고침(`routeId` 칸) → cross 입력 · 시설 제원 — 표준단면 ·
* 상세가 없으면 null. */
export function pipelineCrossSource(
projectId: string,
detail: SectionDetailResponse | null,
savedStations: ReadonlyArray<PipelineStation>,
routeId: number | null = null,
): PipelineCrossSource | null {
const standard = detail && effectiveStandardCross(projectId);
if (!detail || !standard) return null;
const byKey: CrossInput["byKey"] = {};
const specs = new Map<string, SectionSpecs>();
// 설계 칸 = 세션에 남긴 저장 상세(초안 · 다시 셈이 안 고친 정본 — 들어온 화면 · 고친 차례 무관 · 365-5) ·
// 시설 제원 = 화면 상세(초안 형상을 얹은 것)
const stored = routeId === null ? null : savedDesigns(projectId, routeId); // 세션 다시 안 풂(387-7)
const storedAt = (chainageM: number) =>
stored?.find((row) => Math.abs(row.chainage_m - chainageM) < SAME_M)?.design;
const put = (key: string, section: CrossSection, at = section.chainage_m): void => {
byKey[key] = carried(section, storedAt(at));
const own: Record<string, unknown> = {};
for (const name of SPEC_KEYS) if (section[name] !== undefined) own[name] = section[name];
specs.set(key, own as SectionSpecs);
};
const strays: CrossSection[] = [];
for (const section of detail.cross_sections) {
let best: PipelineStation | null = null;
for (const station of savedStations) {
const gap = Math.abs(station.chainageM - section.chainage_m);
if (gap < SAME_M && (!best || gap < Math.abs(best.chainageM - section.chainage_m)))
best = station;
}
if (best && !byKey[best.key]) put(best.key, section);
else if (!best && section.kind === "irregular") strays.push(section);
}
// 초안 자리로 미리 뜬 관 · 구조물 측점(초안 둔 채 열기 — 상세가 초안 누가거리) = 남은 저장 시설 측점과 차례로 짝 ·
// 수가 다르면 안 짝지음(349-0d 와 같은 규칙 · 들어온 화면 무관 같은 제원 · 365-5)
const left = savedStations.filter(
(station) => (station.kind === "pipe" || station.kind === "structure") && !byKey[station.key],
);
left.sort((a, b) => a.chainageM - b.chainageM);
if (strays.length === left.length)
strays.forEach((section, i) => put(left[i].key, section, left[i].chainageM));
if (routeId !== null)
for (const [key, entry] of Object.entries(sessionCarried(projectId, routeId)))
byKey[key] = { ...byKey[key], ...entry };
return {
// 암 경계 기본값 = 서버 config(화면이 문맥에서 기억) — 입력 없는 측점(초안 새 측점)도 서버처럼(349-0p)
// 제원 지문 — 보기가 나중에 얹은 시설 제원도 입력 차이로(고리가 다시 셈 · 들어온 화면 무관 같은 면적 · 365-5)
input: {
standard,
byKey,
rockBoundaryDefaultM: readRockBoundaryDefault(projectId),
specsKey: `${digest(JSON.stringify([...specs]))}|${forced}`,
},
specs,
};
}