/* ============================================================================= * 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; /** 파이썬 `compute_cross_design` 키 → TS 짝 키(거울 시험 test_b06_cross_design_mirror 표와 같음). */ const CROSS_OPTION_KEYS: Record = { 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; 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 { const ranges = facilityRanges(input.pipes, input.structures ?? []); const spans = input.structures ? bermSpansFromStructures(input.structures as Parameters[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>, [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; }