338 lines
14 KiB
TypeScript
338 lines
14 KiB
TypeScript
/* =============================================================================
|
|
* B06_Section_Cross_Save.ts
|
|
* [저장] · [확정] 서버 횡단 한 바퀴(PLAN 511-B B-4 · 서버 Node `cross.enforce`) — 저장 입력으로 모든 측점 설계선
|
|
* 다시 냄 → 미교차 넓힘(DTM 격자) → 포장 구간 → 세월교 노면 하강. 차례 · 승계 · 입력 = 파이썬 그대로.
|
|
*
|
|
* ⚠ ① 짝 — `B06_Section_Router_Design.py` `enforce_design_lines` · `enforce_pavement_ranges` ·
|
|
* `enforce_ford_surface_drops` + `B06_Section_Engine_SampleExtend.py` `extend_unclosed_sections`
|
|
* (서버 `_enforce_stored_designs` 차례 · DTM 아닌 지표면은 파이썬 길) · 저장분 입력 읽기
|
|
* (`stored_cut_slope` · `berm_from` · `stored_shoulder_extend` · `stored_tilt` · `curve_widening_args`) ·
|
|
* 계획고 = `design_elevation_from_longitudinal`. 거울 시험 `test_511_b4_cross_save_mirror.py`.
|
|
* crossOptions = 파이썬 `compute_cross_design` 키 → TS 짝 키(Node `cross.design` 도 이것).
|
|
* 순수 — 화면 · fetch · 세션 없음.
|
|
* ========================================================================== */
|
|
|
|
import { bermSpansFromStructures, bermSpecAt } 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, CrossGroundSample } from "@util/common_util_cross_design";
|
|
import type { CulvertPipePoint } from "@util/common_util_culvert_sets";
|
|
import type { DtmGrid } from "@util/common_util_surface_sampler";
|
|
import {
|
|
extendUnclosed,
|
|
type CrossGroundPoint,
|
|
type ExtendInfo,
|
|
type UnclosedDesign,
|
|
} from "../../B05_Profile/logic/B05_Profile_Sections_Ground";
|
|
import { USER_TOUCHED_KEYS } from "./B06_Section_Design_Keys";
|
|
import { facilityAt, facilityRanges, type FacilityStructure } from "./B06_Section_Cross_Enforce";
|
|
|
|
type Raw = Record<string, unknown>;
|
|
|
|
/** 파이썬 `compute_cross_design` 키 → TS 짝 키(거울 시험 test_b06_cross_design_mirror 표와 같음). */
|
|
const CROSS_OPTION_KEYS: Record<string, keyof CrossDesignOptions> = {
|
|
ground_type: "groundType",
|
|
section_mode: "sectionMode",
|
|
ditch_side: "ditchSide",
|
|
ditch_type: "ditchType",
|
|
paved: "paved",
|
|
rock_boundary_offset_m: "rockBoundaryOffsetM",
|
|
two_stage_slope: "twoStageSlope",
|
|
cut_slope_ratio: "cutSlopeRatio",
|
|
ditch_enabled: "ditchEnabled",
|
|
ditch_choice: "ditchChoice",
|
|
surface_drop_m: "surfaceDropM",
|
|
plan_radius_m: "planRadiusM",
|
|
curve_outer_side: "curveOuterSide",
|
|
curve_widening_m: "curveWideningM",
|
|
shoulder_extend_left_m: "shoulderExtendLeftM",
|
|
shoulder_extend_right_m: "shoulderExtendRightM",
|
|
tilted: "tilted",
|
|
tilt_pct: "tiltPct",
|
|
};
|
|
|
|
/** 파이썬 BermSpec(NamedTuple → dict) → TS BermSpec. */
|
|
function bermOf(value: unknown): BermSpec | null {
|
|
if (!value || typeof value !== "object") return null;
|
|
const berm = value as Record<string, number | null>;
|
|
return {
|
|
widthM: berm.width_m as number,
|
|
intervalM: berm.interval_m as number,
|
|
slopeDeg: berm.slope_deg as number,
|
|
depthM: berm.depth_m as number,
|
|
depthLeftM: berm.depth_left_m ?? null,
|
|
depthRightM: berm.depth_right_m ?? null,
|
|
};
|
|
}
|
|
|
|
/** 파이썬 키 입력 → 엔진 입력(모르는 키 = 던짐). */
|
|
export function crossOptions(
|
|
raw: Raw,
|
|
standard: CrossDesignOptions["standard"],
|
|
): CrossDesignOptions {
|
|
const options: Raw = { standard };
|
|
for (const [key, value] of Object.entries(raw)) {
|
|
if (key === "berm") options.berm = bermOf(value);
|
|
else if (key in CROSS_OPTION_KEYS) options[CROSS_OPTION_KEYS[key]] = value;
|
|
else throw new Error(`모르는 횡단 설계 키: ${key}`);
|
|
}
|
|
return options as unknown as CrossDesignOptions;
|
|
}
|
|
|
|
const isNum = (value: unknown): value is number =>
|
|
typeof value === "number" && Number.isFinite(value);
|
|
|
|
/** 파이썬 `stored_cut_slope` — 측점별 암 절토 경사 · 없거나 0 = null. */
|
|
const storedCutSlope = (design: Raw): number | null =>
|
|
isNum(design.cut_slope_ratio_user) && design.cut_slope_ratio_user > 0
|
|
? design.cut_slope_ratio_user
|
|
: null;
|
|
|
|
/** 파이썬 `berm_from` — 소단 제원 dict + 쪽마다 옮긴 깊이 → BermSpec(python 키). */
|
|
function bermFrom(spec: unknown, sides: Raw): Raw | null {
|
|
if (!spec || typeof spec !== "object" || Array.isArray(spec)) return null;
|
|
const own = (side: string): number | null => {
|
|
const value = sides[`berm_depth_${side}_m`];
|
|
return isNum(value) ? Math.max(value, 0) : null;
|
|
};
|
|
const s = spec as Raw;
|
|
const field = (key: string, fallback: number): number => Number(s[key] ?? fallback);
|
|
const out = {
|
|
width_m: field("width_m", BERM_DEFAULT_WIDTH_M),
|
|
interval_m: field("interval_m", BERM_DEFAULT_INTERVAL_M),
|
|
slope_deg: field("slope_deg", BERM_DEFAULT_SLOPE_DEG),
|
|
depth_m: Math.max(field("depth_m", BERM_DEFAULT_DEPTH_M), 0),
|
|
};
|
|
if (!Object.values(out).every(Number.isFinite)) return null; // 파이썬 TypeError · ValueError
|
|
return { ...out, depth_left_m: own("left"), depth_right_m: own("right") };
|
|
}
|
|
|
|
const storedBerm = (design: Raw): Raw | null => bermFrom(design.berm, design);
|
|
|
|
/** 파이썬 `stored_shoulder_extend` — 없거나 음수 = 0. */
|
|
const storedShoulder = (design: Raw): Raw =>
|
|
Object.fromEntries(
|
|
(["left", "right"] as const).map((side) => {
|
|
const value = design[`shoulder_extend_${side}_m`];
|
|
return [`shoulder_extend_${side}_m`, isNum(value) ? Math.max(value, 0) : 0];
|
|
}),
|
|
);
|
|
|
|
/** 파이썬 `stored_tilt`. */
|
|
const storedTilt = (design: Raw): { tilted: boolean; tilt_pct: number | null } => ({
|
|
tilted: Boolean(design.tilted),
|
|
tilt_pct: isNum(design.tilt_pct) ? design.tilt_pct : null,
|
|
});
|
|
|
|
/** 파이썬 `curve_widening_args`. */
|
|
const curveArgs = (section: Raw): Raw => ({
|
|
plan_radius_m: section.plan_radius_m ?? null,
|
|
curve_outer_side: section.curve_outer_side ?? null,
|
|
curve_widening_m: section.curve_widening_m ?? null,
|
|
});
|
|
|
|
/** 파이썬 `design_elevation_from_longitudinal` — 계획선 표본 선형 보간 · 범위 밖 끝값 · 없으면 null. */
|
|
export function elevationAt(
|
|
samples: ReadonlyArray<{ chainage_m?: unknown; elevation_m?: unknown }>,
|
|
chainageM: number,
|
|
): number | null {
|
|
const rows = samples.filter((s) => isNum(s.elevation_m) && isNum(s.chainage_m)) as Array<{
|
|
chainage_m: number;
|
|
elevation_m: number;
|
|
}>;
|
|
if (!rows.length) return null;
|
|
if (chainageM <= rows[0].chainage_m) return rows[0].elevation_m;
|
|
const last = rows[rows.length - 1];
|
|
if (chainageM >= last.chainage_m) return last.elevation_m;
|
|
for (let index = 1; index < rows.length; index += 1) {
|
|
const [previous, current] = [rows[index - 1], rows[index]];
|
|
if (chainageM > current.chainage_m) continue;
|
|
const span = current.chainage_m - previous.chainage_m;
|
|
if (span <= 0) return current.elevation_m;
|
|
const ratio = (chainageM - previous.chainage_m) / span;
|
|
return previous.elevation_m + (current.elevation_m - previous.elevation_m) * ratio;
|
|
}
|
|
return last.elevation_m;
|
|
}
|
|
|
|
/** 저장분 공통 입력(파이썬 넷이 같은 꼴로 모음) — 포장 · 하강 · 기울임은 부르는 자리가 덮음. */
|
|
function storedOptions(design: Raw, section: Raw, rockDefaultM: number): Raw {
|
|
return {
|
|
ground_type: String(design.ground_type || "ripping_rock"),
|
|
section_mode: String(design.section_mode || "left_cut"),
|
|
ditch_side: design.ditch_side ?? null,
|
|
ditch_type: String(design.ditch_type || "standard"),
|
|
paved: Boolean(design.paved),
|
|
rock_boundary_offset_m:
|
|
"rock_boundary_offset_m" in design ? design.rock_boundary_offset_m : rockDefaultM,
|
|
two_stage_slope: Boolean(design.two_stage_slope),
|
|
cut_slope_ratio: storedCutSlope(design),
|
|
ditch_enabled: design.ditch_enabled ?? null,
|
|
ditch_choice: design.ditch_choice ?? null,
|
|
berm: storedBerm(design),
|
|
...curveArgs(section),
|
|
...storedShoulder(design),
|
|
};
|
|
}
|
|
|
|
/** 측점 하나(서버 상세 꼴) — 쓰는 칸만. */
|
|
export interface SaveSection {
|
|
chainage_m: number;
|
|
design?: Raw | null;
|
|
samples?: CrossGroundSample[];
|
|
frame?: { origin?: { x: number; y: number }; left_xy?: [number, number] };
|
|
plan_radius_m?: number | null;
|
|
curve_outer_side?: string | null;
|
|
curve_widening_m?: number | null;
|
|
}
|
|
|
|
export interface SaveCrossInput {
|
|
/** 종단 계획선 표본(`design_profiles[0].samples`). */
|
|
profile: ReadonlyArray<{ chainage_m?: unknown; elevation_m?: unknown }>;
|
|
sections: SaveSection[];
|
|
standard: CrossDesignOptions["standard"];
|
|
rockDefaultM: number;
|
|
pipes: readonly CulvertPipePoint[];
|
|
/** 구조물 정본 — null = 못 읽음(소단 = 저장분 그대로 · 포장 · 맹암거 구간 없음). */
|
|
structures: readonly FacilityStructure[] | null;
|
|
}
|
|
|
|
/** 측점마다 결과 — 설계 없는 측점 null · 넓혔으면 표본 · 넓힘 기록. */
|
|
export interface SaveCrossRow {
|
|
design: Raw;
|
|
samples?: CrossGroundSample[];
|
|
extended?: ExtendInfo;
|
|
}
|
|
|
|
/** [저장] 횡단 한 바퀴 — 파이썬 `_recompute` 의 설계선 · 넓힘 · 포장 · 하강 넷(차례 그대로 · 제자리 아님). */
|
|
export function enforceSavedCross(
|
|
input: SaveCrossInput,
|
|
grid: () => DtmGrid | null,
|
|
): Array<SaveCrossRow | null> {
|
|
const ranges = facilityRanges(input.pipes, input.structures ?? []);
|
|
const spans = input.structures
|
|
? bermSpansFromStructures(input.structures as Parameters<typeof bermSpansFromStructures>[0])
|
|
: null;
|
|
const rows = input.sections.map((section): SaveCrossRow | null =>
|
|
section.design && typeof section.design === "object" ? { design: section.design } : null,
|
|
);
|
|
const elevation = (section: SaveSection) => elevationAt(input.profile, section.chainage_m);
|
|
const compute = (samples: CrossGroundSample[] | undefined, z: number | null, raw: Raw) => {
|
|
try {
|
|
return computeCrossDesign(
|
|
samples ?? [],
|
|
z,
|
|
crossOptions(raw, input.standard),
|
|
) as unknown as Raw;
|
|
} catch {
|
|
return null; // 파이썬 ValueError · Node { error } 자리 — 저장분 그대로
|
|
}
|
|
};
|
|
/** 다시 세운 설계에 사용자 값 · 상태 승계(파이썬 `apply_designs`). */
|
|
const keep = (next: Raw, from: Raw): Raw => {
|
|
for (const key of ["status", "pavement_suggested", ...USER_TOUCHED_KEYS])
|
|
if (from[key] !== undefined && from[key] !== null) next[key] = from[key];
|
|
return next;
|
|
};
|
|
const at = (section: SaveSection) => facilityAt(ranges, section.chainage_m);
|
|
const sectionRaw = (section: SaveSection) => section as unknown as Raw;
|
|
|
|
// ① 모든 측점 설계선(`enforce_design_lines`) — 소단 = 구조물 정본 구간 · 하강 · 기울임 = 지금 시설
|
|
input.sections.forEach((section, index) => {
|
|
const row = rows[index];
|
|
if (!row) return;
|
|
const design = spans
|
|
? { ...row.design, berm: bermSpecAt(spans, section.chainage_m) }
|
|
: row.design;
|
|
const facility = at(section);
|
|
const next = compute(section.samples, elevation(section), {
|
|
...storedOptions(design, sectionRaw(section), input.rockDefaultM),
|
|
surface_drop_m: facility.surfaceDropM,
|
|
tilted: facility.tilted,
|
|
tilt_pct: facility.tiltPct,
|
|
});
|
|
if (next) row.design = keep(next, design);
|
|
});
|
|
// ② 미교차 넓힘(`extend_unclosed_sections`) — 열린 쪽만 +5 m · DTM 격자 · 넓힌 표본으로 다시 셈
|
|
const open = rows.some((row) => row?.design.slope_unclosed);
|
|
const map = open ? grid() : null;
|
|
input.sections.forEach((section, index) => {
|
|
const row = rows[index];
|
|
const origin = section.frame?.origin;
|
|
const left = section.frame?.left_xy;
|
|
if (!map || !row?.design.slope_unclosed || !origin || !left) return;
|
|
const design = row.design;
|
|
const z = elevation(section);
|
|
const raw = {
|
|
...storedOptions(design, sectionRaw(section), input.rockDefaultM),
|
|
surface_drop_m: at(section).surfaceDropM,
|
|
...storedTilt(design),
|
|
};
|
|
let wide;
|
|
try {
|
|
wide = extendUnclosed(
|
|
(section.samples ?? []) as Array<Partial<CrossGroundPoint>>,
|
|
[origin.x, origin.y],
|
|
left,
|
|
(samples) => {
|
|
const next = compute(samples as CrossGroundSample[], z, raw);
|
|
if (!next) throw new Error("넓힘 셈 못 함");
|
|
return next as unknown as UnclosedDesign;
|
|
},
|
|
map,
|
|
);
|
|
} catch {
|
|
return; // 파이썬 ValueError · KeyError — 그 측점 그대로
|
|
}
|
|
if (!wide || (!wide.info.left_m && !wide.info.right_m)) return;
|
|
const samples = wide.samples as CrossGroundSample[];
|
|
const next = compute(samples, z, raw);
|
|
if (!next) return;
|
|
rows[index] = { design: keep(next, design), samples, extended: wide.info };
|
|
});
|
|
const samplesOf = (index: number) => rows[index]?.samples ?? input.sections[index].samples;
|
|
// ③ 포장 구간(`enforce_pavement_ranges`) — 포장 · 기울임이 저장분과 다른 측점만
|
|
input.sections.forEach((section, index) => {
|
|
const row = rows[index];
|
|
if (!row) return;
|
|
const design = row.design;
|
|
const facility = at(section);
|
|
const tilt = storedTilt(design);
|
|
if (
|
|
Boolean(design.paved) === facility.paved &&
|
|
tilt.tilted === facility.tilted &&
|
|
tilt.tilt_pct === facility.tiltPct
|
|
)
|
|
return;
|
|
const next = compute(samplesOf(index), elevation(section), {
|
|
...storedOptions(design, sectionRaw(section), input.rockDefaultM),
|
|
paved: facility.paved,
|
|
surface_drop_m: facility.surfaceDropM,
|
|
tilted: facility.tilted,
|
|
tilt_pct: facility.tiltPct,
|
|
});
|
|
if (next) row.design = keep(next, design);
|
|
});
|
|
// ④ 세월교 노면 하강(`enforce_ford_surface_drops`) — 하강이 저장분과 다른 측점만
|
|
input.sections.forEach((section, index) => {
|
|
const row = rows[index];
|
|
if (!row) return;
|
|
const design = row.design;
|
|
const wanted = at(section).surfaceDropM;
|
|
if (Math.abs(wanted - Number(design.surface_drop_m || 0)) < 1e-6) return;
|
|
const next = compute(samplesOf(index), elevation(section), {
|
|
...storedOptions(design, sectionRaw(section), input.rockDefaultM),
|
|
surface_drop_m: wanted,
|
|
...storedTilt(design),
|
|
});
|
|
if (next) row.design = keep(next, design);
|
|
});
|
|
return rows;
|
|
}
|