Files
Aislo/B06_Section/logic/B06_Section_Cross_Refresh_Calc.ts
T

304 lines
16 KiB
TypeScript

/* =============================================================================
* B06_Section_Cross_Refresh_Calc.ts
* 저장 노선 횡단 다시 셈의 셈 몫(PLAN 511-B B-4) — `B06_Section_Cross_Refresh.ts` 가 세션 · 가게에서 모은
* 입력(`RefreshInputs` · JSON 꼴)으로 전 측점 설계를 제자리에서 다시 셈. 브라우저와 서버 Node
* (`cross.refresh` · 계산 자리 cross = server)가 이 함수 하나를 씀 — 같은 입력 = 같은 값.
* 계산 = `computeCrossDesign`(파이썬 `B06_Section_Engine_Design.py` 짝) · 미교차 넓힘 = `extendUnclosed`.
* 순수 — 화면 · fetch · 세션 없음(세션 읽기는 부르는 쪽).
* ========================================================================== */
import { bermSpecAt, type BermSessionSpec, type BermSpan } from "@util/common_util_berm_spans";
import {
BERM_DEFAULT_DEPTH_M,
BERM_DEFAULT_INTERVAL_M,
BERM_DEFAULT_SLOPE_DEG,
BERM_DEFAULT_WIDTH_M,
type BermSpec,
} from "@util/common_util_cross_berm";
import { computeCrossDesign } from "@util/common_util_cross_design";
import type { CrossDesignOptions, StandardCrossSectionSpec } from "@util/common_util_cross_design";
import type { DtmGrid } from "@util/common_util_surface_sampler";
import type { CrossSection, SectionDetailResponse } from "../B06_Section_Api_Fetch";
import type { CrossDesignChoice } from "../B06_Section_Cross_Design_Session";
import {
planElevationAt,
type ProfileAlignment,
} from "../../B05_Profile/logic/B05_Profile_UI_Profile_Alignment";
import { toDesignProfile } from "../../B05_Profile/logic/B05_Profile_UI_Profile_Data";
import { extendUnclosed } from "../../B05_Profile/logic/B05_Profile_Sections_Ground";
import { USER_TOUCHED_KEYS } from "./B06_Section_Design_Keys";
type Side = "left" | "right";
const SIDES: Side[] = ["left", "right"];
type SideValues = Partial<Record<Side, number>>;
/** 모은 입력 — 열쇠 = 누가거리(0.01 m 숫자 · `rockKey`) · Map 은 목록(JSON 으로 서버에 감). */
export interface RefreshInputs {
/** 화면이 쥔 계획선(측점 한 벌 · 342-1). */
alignment: ProfileAlignment;
standard: StandardCrossSectionSpec;
/** 암 경계 세션 오프셋 · 기본 오프셋(config). */
rockOffsets: Array<[number, number]>;
rockDefault: number | null;
/** 측점별 암 절토 경사(0 = 표준값). */
cutSlopes: Array<[number, number]>;
/** 소단 사본(세션 `berm` · 없으면 null = 저장분 `design.berm`). */
bermSpans: BermSpan[] | null;
bermDepths: Array<[number, SideValues]>;
/** 카드 선택(지반 · 측구 · 2단 경사 …). */
choices: Array<[number, CrossDesignChoice]>;
shoulderExtends: Array<[number, SideValues]>;
/** 저장 정본 설계(세션 고름 ?? 정본). */
canon: Array<[number, unknown]>;
}
/** 측점별 앞 계산 — 브라우저만(측점 객체가 바뀌면 저절로 비워짐). */
export type RefreshMemo = WeakMap<
CrossSection,
{ samples: CrossSection["samples"]; fingerprint: string; design: CrossSection["design"] }
>;
/** 다시 계산해도 살려 두는 값 — 사용자 값에 **상태를 나르는 둘**을 더한 것. */
const PRESERVED_KEYS = ["status", "pavement_suggested", ...USER_TOUCHED_KEYS] as const;
/** 누가거리 열쇠(0.01 m) — 자릿수에 안 휘둘리게 숫자로. */
export function rockKey(chainageM: number): number {
return Math.round(chainageM * 100) / 100;
}
/**
* 포장 = 노면공 · 물넘이 범위 안만(138-1) — 측점에 얹힌 제원(`pavement` · `ford_pavement`)이 곧 범위다.
* 서버 짝 = `B06_Section_Router_Design.paved_at` · 저장값 · 카드 선택은 쓰지 않는다.
*/
export function pavedBySpecs(section: Pick<CrossSection, "pavement" | "ford_pavement">): boolean {
return Boolean(section.pavement?.length) || Boolean(section.ford_pavement);
}
function preserveUserFields(
next: NonNullable<CrossSection["design"]>,
previous: CrossSection["design"],
): NonNullable<CrossSection["design"]> {
if (!previous) return next;
const merged = { ...next } as Record<string, unknown>;
const source = previous as unknown as Record<string, unknown>;
for (const key of PRESERVED_KEYS) {
if (source[key] !== undefined) merged[key] = source[key];
}
return merged as unknown as NonNullable<CrossSection["design"]>;
}
/** 저장분 소단 제원(`design.berm`) — 짝: 파이썬 `berm_from`(빈 칸 = 기본값 · 깊이 0 미만 = 0 · 못 읽으면 없음). */
export function storedBermSpec(berm: unknown): BermSessionSpec | null {
if (!berm || typeof berm !== "object" || Array.isArray(berm)) return null;
const spec = berm as Record<string, unknown>;
const value = (key: string, fallback: number): number => {
const raw = spec[key];
if (raw === undefined) return fallback;
return raw === null || raw === "" || typeof raw === "object" ? Number.NaN : Number(raw);
};
const out = {
width_m: value("width_m", BERM_DEFAULT_WIDTH_M),
interval_m: value("interval_m", BERM_DEFAULT_INTERVAL_M),
slope_deg: value("slope_deg", BERM_DEFAULT_SLOPE_DEG),
depth_m: Math.max(value("depth_m", BERM_DEFAULT_DEPTH_M), 0),
};
return Object.values(out).every(Number.isFinite) ? out : null;
}
/** 소단 쪽마다 옮긴 깊이(194-1) — 세션 → 저장분 순 · 없으면 null(구간 깊이를 씀). */
function bermDepthReader(session: Map<number, SideValues>) {
return (chainageM: number, design: Record<string, unknown>, side: Side): number | null => {
const own = session.get(rockKey(chainageM))?.[side];
const stored = design[`berm_depth_${side}_m`];
const value = own ?? (typeof stored === "number" ? stored : null);
return value === null ? null : Math.max(value, 0);
};
}
/**
* 전 측점 설계를 모은 입력으로 다시 셈 · `detail` 제자리 고침 · 다시 센 측점 누가거리.
* 계획고는 화면 선(`inputs.alignment`)에서 — `design_profiles` 도 그 선으로 얹음(낡음 판정 기준 한 벌).
*/
export function refreshDesigns(
detail: SectionDetailResponse,
inputs: RefreshInputs,
grid: DtmGrid | null,
memos?: RefreshMemo,
): number[] {
const { alignment, standard } = inputs;
const standardKey = JSON.stringify(standard);
// 방금 푼 계획선을 **공유 캐시에도 얹는다**. 여기서 만드는 횡단 설계는 편집이 반영된
// 계획고 기준인데 `design_profiles` 만 저장분으로 남으면 두 값의 기준이 어긋나, 낡음
// 판정이 영원히 참이 되어 유토곡선이 빈 채로 남는다(2026-09-03 사용자 보고: B05 편집 중
// 문구만 뜸 → B06 유토곡선 영역 누락). `profile_alignment`(base_pvi)는 **건드리지 않는다**
// — 편집 델타의 기준선이라 편집분을 구워 넣으면 다음 편집에서 이중 적용된다.
detail.longitudinal.design_profiles = [
toDesignProfile(alignment, detail.longitudinal.design_profiles?.[0]),
];
const rockOffsets = new Map(inputs.rockOffsets);
const rockDefault = inputs.rockDefault;
// 측점별 암 절토 경사도 세션에만 있는 값이다 — **계산 전에** 실어야 설계선이 새 경사로
// 그려진다. 계산 뒤에 값만 베껴 붙이면 그림은 옛 경사, 숫자만 새것이 된다(2026-09-07).
const cutSlopeRatios = new Map(inputs.cutSlopes);
// 소단도 같은 성격 — 세션에만 있는 값이라 여기서 실어 주지 않으면 계획선을 고치는
// 순간 계단이 사라진다(계획서 3-9). 사본 없음(시설 줄이 구조물을 받기 전) = 저장분 `design.berm`
// (파이썬 `load_berm_spans` 못 읽음 → 저장분 · `stored_berm` 짝 · 467).
const bermSpans = inputs.bermSpans;
const bermDepthAt = bermDepthReader(new Map(inputs.bermDepths));
const bermAt = (chainageM: number, design: Record<string, unknown>): BermSpec | null => {
const spec = bermSpans ? bermSpecAt(bermSpans, chainageM) : storedBermSpec(design.berm);
return spec
? {
widthM: spec.width_m,
intervalM: spec.interval_m,
slopeDeg: spec.slope_deg,
depthM: spec.depth_m,
// 쪽마다 옮긴 깊이(194-1) — 세션 → 저장분
depthLeftM: bermDepthAt(chainageM, design, "left"),
depthRightM: bermDepthAt(chainageM, design, "right"),
}
: null;
};
// 카드 버튼 선택은 세션 초안이 정본보다 새것이다 — 새로고침 뒤에도 고른 값이 남는다
// (2026-09-06 사용자 확정: 조작은 캐시, 저장은 [저장]·[확정]).
/** 세션 → 저장분 순. 없거나 0(되돌림)이면 null 을 줘 표준값을 쓰게 한다. */
const cutSlopeAt = (chainageM: number, design: Record<string, unknown>): number | null => {
const session = cutSlopeRatios.get(rockKey(chainageM));
if (session !== undefined) return session > 0 ? session : null;
const stored = design.cut_slope_ratio_user;
return typeof stored === "number" && stored > 0 ? stored : null;
};
const choices = new Map(inputs.choices);
const choiceAt = (chainageM: number) => choices.get(Math.round(chainageM * 100) / 100);
// 노견 확장(139-6) — 세션 → 저장분 순 · 기하 입력이라 계산에 넣고 결과 칸도 그 값으로
const shoulderExtends = new Map(inputs.shoulderExtends);
const extendAt = (chainageM: number, design: Record<string, unknown>, side: "left" | "right") => {
const session = shoulderExtends.get(Math.round(chainageM * 100) / 100)?.[side];
const stored = design[`shoulder_extend_${side}_m`];
return Math.max(session ?? (typeof stored === "number" ? stored : 0), 0);
};
// 미교차 넓힘 격자 — 받기 전이면 null(받은 뒤 다시 셈이 넓힘 · 지문에 실음)
// 입력 물러섬 = 저장 정본 설계(세션 고름 ?? 정본) — 다시 셈 결과가 메모리 설계에 앞 고름을 남겨도 ↶ 가 정본으로
// (467 덧) · 정본에 없는 측점(새 측점 · 옮긴 시설)만 지금 설계
const canon = new Map(inputs.canon);
const updated: number[] = [];
for (const section of detail.cross_sections) {
const previous = section.design;
if (!previous) continue; // 설계가 없는 측점은 서버 기본 설계가 붙을 때까지 둔다.
const design = (canon.get(rockKey(section.chainage_m)) ?? previous) as unknown as Record<
string,
unknown
>;
// 암 경계는 세션 조정값 → 저장분 → config 기본값 순 — 서버
// `recompute_designs_for_alignment` 의 우선순위와 같다.
const sessionOffset = rockOffsets.get(rockKey(section.chainage_m));
const storedOffset = design.rock_boundary_offset_m;
const rockBoundaryOffsetM =
typeof sessionOffset === "number"
? sessionOffset
: typeof storedOffset === "number"
? storedOffset
: rockDefault;
const choice = choiceAt(section.chainage_m);
const extendLeft = extendAt(section.chainage_m, design, "left");
const extendRight = extendAt(section.chainage_m, design, "right");
const elevation = planElevationAt(alignment, section.chainage_m);
const options: CrossDesignOptions = {
groundType:
choice?.ground_type ??
(typeof design.ground_type === "string" ? design.ground_type : "ripping_rock"),
sectionMode:
choice?.section_mode ??
(typeof design.section_mode === "string" ? design.section_mode : "left_cut"),
ditchSide:
choice?.ditch_side ?? (typeof design.ditch_side === "string" ? design.ditch_side : null),
// 저장분은 측구가 없으면 `ditch_type: null` 이다 — 서버와 같이 기본형으로 되돌린다.
ditchType:
choice?.ditch_type ??
(typeof design.ditch_type === "string" ? design.ditch_type : "standard"),
paved: pavedBySpecs(section),
// 물넘이 자리 = 측구 없음 · 지대 낮은 쪽 한 경사(286-2 · 287-4 · 서버 `tilt_ranges` 짝 = 얹힌 제원)
tilted: Boolean(section.ford_pavement || section.underdrain),
tiltPct: section.ford_pavement?.slope_pct ?? null, // 맹암거 = 표준 경사(371-3)
standard,
berm: bermAt(section.chainage_m, design),
rockBoundaryOffsetM,
cutSlopeRatio: cutSlopeAt(section.chainage_m, design),
twoStageSlope: choice?.two_stage_slope ?? Boolean(design.two_stage_slope),
// ⚠ **선택과 결과를 갈라 넘긴다**(2026-09-09). 선택이 있으면 그것을 따르고,
// 없으면 옛 저장분의 결과를 **자동값과 다를 때만** 선택으로 살린다.
ditchChoice: choice
? (choice.ditch_choice ?? null)
: typeof design.ditch_choice === "boolean"
? design.ditch_choice
: null,
ditchEnabled: typeof design.ditch_enabled === "boolean" ? design.ditch_enabled : null,
// 세월교 월류 하강 = 그 측점에 얹힌 세월교 제원(초안 · 저장분 같은 자리 · 서버 `ford_drop_at` 짝 · 314)
surfaceDropM:
Number(section.ford?.overflow_depth_m) > 0 ? Number(section.ford?.overflow_depth_m) : 0,
// 곡선부 확폭 입력 — 측점 기록에 실려 온다(서버 엔진과 같은 값, 2026-09-06).
planRadiusM: section.plan_radius_m ?? null,
curveOuterSide:
section.curve_outer_side === "left" || section.curve_outer_side === "right"
? section.curve_outer_side
: null,
curveWideningM: section.curve_widening_m ?? null,
shoulderExtendLeftM: extendLeft,
shoulderExtendRightM: extendRight,
};
// 국부 연산(346-2) — 입력 지문(지반 표본 · 계획고 · 횡단 입력 · 구조물 맥락 · 표준값)이 같고
// 설계가 그때 낸 그 객체면 앞 결과 그대로. 계획고 ▲ 한 번에 다시 세는 측점은 곡선 둘레 몇 곳뿐.
const samples = section.samples ?? [];
const fingerprint = `${standardKey}|${elevation}|${samples.length}|${grid ? "grid" : ""}|${JSON.stringify({ ...options, standard: null })}`;
const memo = memos?.get(section);
if (
memo?.samples === section.samples &&
memo.fingerprint === fingerprint &&
memo.design === previous
) {
continue;
}
let next;
try {
next = computeCrossDesign(samples, elevation, options);
// 미교차 넓힘 — 서버 [저장] `extend_unclosed_sections` 짝 · 넓힌 표본을 측점에 남김(428 · 묶음 cross 와 같음)
const wide =
grid && next.slope_unclosed
? extendUnclosed(
samples,
[section.center_x, section.center_y],
section.frame.left_xy,
(more) => computeCrossDesign(more as typeof samples, elevation, options),
grid,
)
: null;
if (wide && (wide.info.left_m || wide.info.right_m)) {
section.samples = wide.samples as typeof samples;
next = computeCrossDesign(section.samples, elevation, options);
}
} catch {
continue; // 샘플 부족·값 손상 측점은 건너뛴다(서버 엔진과 같은 태도).
}
section.design = preserveUserFields(
next as unknown as NonNullable<CrossSection["design"]>,
previous,
);
section.design.shoulder_extend_left_m = extendLeft;
section.design.shoulder_extend_right_m = extendRight;
// 옮긴 소단 깊이 = 새 계산이 되실은 값(옛 값 승계보다 세션이 새것 · 194-1) · 없으면 칸 없음(↶ 뒤 앞 값이
// 메모리에 안 남음 · 467 덧) · 소단 제원도 이번 원천 그대로(지운 소단이 남지 않음)
for (const side of SIDES) {
const own = next[`berm_depth_${side}_m`];
if (typeof own === "number") section.design[`berm_depth_${side}_m`] = own;
else delete section.design[`berm_depth_${side}_m`];
}
if (next.berm) section.design.berm = next.berm;
else delete section.design.berm;
memos?.set(section, { samples: section.samples, fingerprint, design: section.design });
updated.push(section.chainage_m);
}
return updated;
}