/* ============================================================================= * A00_Common/design/logic/design_calc_node.ts * 계산 등록부(`design_calc_registry.ts`)의 서버 Node 입구 하나(PLAN 472-3) — id → 함수 표. * 등록부에서 실행기가 `node:` 인 줄만 여기 있음(나머지는 472-4 동안 파이썬). * 브라우저가 쓰는 같은 TS 를 그대로 부름 — 계산을 두 벌로 짜지 않음. * * 실행: node <묶음> <입력.json> <출력.json> (package.json `build:design-calc`) * 입력 { run: [id…], inputs: { detail?: 종횡단 상세(API 꼴) · route_points?: 노선 폴리라인 · * context?: { earthwork_conversion · natural_spoil_min_ground_slope · * haul_equipment_limits · 채집석 · 구조물 잔토(B08) } · * excavation?: { pipes: [{ diameter_mm }] · walls: [{ height_m · average_thickness_m · * has_foundation }] } · * cross_design?: { standard: 표준 횡단(config 위에 저장분 덮은 한 벌) · * stations: [{ samples · design_elevation_m · options: 파이썬 `compute_cross_design` 키 }] } · * berm?: { structures: 구조물 정본 목록 · chainages: [측점 m] } } } * 출력 { results: { [id]: 값 }, available: [id…] } * — 차례는 요청 차례가 아니라 기댐 차례(누가토량 앞에 구조물 면적을 상세에 얹음 · * server-calc 묶음과 같은 차례). * 끝 코드: 0 성공 / 2 인자 오류 / 4 모르는 id * ========================================================================== */ import { readFileSync, writeFileSync } from "node:fs"; import { bermSpansFromStructures, bermSpecAt } from "@util/common_util_berm_spans"; import 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 { pipeTrenchWidthM, wallTrenchAreaM2, wallTrenchWidthM } from "@util/common_util_excavation"; import { computeMassHaul, massHaulPayload } from "@util/common_util_mass_haul"; import type { MassHaulResult } from "@util/common_util_mass_haul"; import { computeHaulPlan, haulPlanPayload } from "@util/common_util_mass_haul_balance"; import type { RoutePoint } from "../../../B05_Profile/B05_Profile_Api_Fetch"; import { buildCorridor } from "../../../B05_Profile/B05_Profile_UI_Corridor_Build"; import { corridorHash, serialize } from "../../../B05_Profile/B05_Profile_UI_Corridor_Envelope"; import type { SectionDetailResponse } from "../../../B06_Section/B06_Section_Api_Fetch"; import { applyStructureAreaRows, extraWallRows, structureAreaRows, } from "../../../B06_Section/B06_Section_Structure_Layouts"; import { followRunners, type FollowInputs } from "./design_calc_node_follow"; type HaulOptions = NonNullable[2]>; interface CalcInputs extends FollowInputs { detail?: SectionDetailResponse; route_points?: RoutePoint[]; context?: Partial< Pick< HaulOptions, | "collected_stone_deduction_m3" | "collected_stone_by_ground_m3" | "collected_stone_ground_unknown_m3" | "structure_spoil_m3" | "structure_spoil_points" > > & { earthwork_conversion?: Parameters[1]; natural_spoil_min_ground_slope?: number | null; haul_equipment_limits?: Parameters[1]; }; excavation?: { pipes?: Array<{ diameter_mm?: number | null }>; walls?: Array<{ height_m?: number | null; average_thickness_m?: number | null; has_foundation?: boolean; }>; }; berm?: { structures: Parameters[0]; chainages: number[]; }; cross_design?: { standard: CrossDesignOptions["standard"]; stations?: Array<{ samples: CrossGroundSample[]; design_elevation_m: number | null; options: 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, }; } function crossOptions( raw: Record, standard: CrossDesignOptions["standard"], ): CrossDesignOptions { const options: Record = { 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; } /** 한 번 돌림 — 같은 id 는 한 번만 셈(memo) · 앞 id 는 `need` 로. */ function runDesignCalc(run: readonly string[], inputs: CalcInputs) { const memo = new Map(); const sections = inputs.detail?.cross_sections ?? []; const context = inputs.context ?? {}; let massHaul: MassHaulResult | null = null; const RUNNERS: Record unknown> = { ...followRunners(inputs), // 측점 열쇠 · 따라감 · 꼭짓점 세움(472-4 ①) // 구조물 측점 면적 · 관 길이를 상세에 얹음(자리에서) — 뒤 누가토량이 고친 면적 위에서 쌓임. "structure.layouts": () => { const areas = structureAreaRows(sections); applyStructureAreaRows(sections, areas); return { areas, extra_walls: extraWallRows(sections) }; }, "masshaul.cumulative": () => { need("structure.layouts"); const conversion = context.earthwork_conversion; massHaul = conversion ? computeMassHaul(sections, conversion, context.natural_spoil_min_ground_slope ?? undefined) : null; return massHaul ? massHaulPayload(massHaul) : null; }, // 두 벌 — haul_plan = 거르지 않은 수량 정본(B08) · haul_plan_drawing = 잔진동 거른 그림. "haul.plan": () => { need("masshaul.cumulative"); const result = massHaul; if (!result) return null; const planFor = (drawing: boolean) => computeHaulPlan(result, context.haul_equipment_limits, { collected_stone_deduction_m3: context.collected_stone_deduction_m3 ?? null, collected_stone_by_ground_m3: context.collected_stone_by_ground_m3 ?? null, collected_stone_ground_unknown_m3: context.collected_stone_ground_unknown_m3 ?? null, structure_spoil_m3: context.structure_spoil_m3 ?? null, structure_spoil_points: context.structure_spoil_points ?? null, conversion: context.earthwork_conversion ?? null, drawing, }); const plan = planFor(false); const drawing = planFor(true); return { ...(plan ? { haul_plan: haulPlanPayload(plan) } : {}), ...(drawing ? { haul_plan_drawing: haulPlanPayload(drawing) } : {}), }; }, "excavation.trench": () => ({ pipes: (inputs.excavation?.pipes ?? []).map((pipe) => ({ width_m: pipeTrenchWidthM(pipe.diameter_mm), })), walls: (inputs.excavation?.walls ?? []).map((wall) => ({ width_m: wallTrenchWidthM(wall.average_thickness_m), area_m2: wallTrenchAreaM2( wall.height_m, wall.average_thickness_m, wall.has_foundation !== false, ), })), }), // 소단 구간(C4) — 구조물 정본 → 구간 · 측점마다 제원(겹치면 먼저 놓은 것). "cross.berm_spans": () => { const spans = bermSpansFromStructures(inputs.berm?.structures ?? []); return { spans, at: (inputs.berm?.chainages ?? []).map((c) => bermSpecAt(spans, c)) }; }, // 측점마다 TS 짝(`computeCrossDesign`) — 못 세우면 그 자리 { error }(파이썬은 ValueError 로 되던짐). "cross.design": () => { const batch = inputs.cross_design; return (batch?.stations ?? []).map((station) => { try { const options = crossOptions(station.options, batch!.standard); return computeCrossDesign(station.samples, station.design_elevation_m, options); } catch (error) { return { error: error instanceof Error ? error.message : String(error) }; } }); }, // 편집 전 정본 — 계획선 샘플은 저장분 그대로(corridor 묶음과 같은 값) · 측점 둘 미만이면 null. "corridor.mesh": () => { const detail = inputs.detail; if (!detail) return null; const routePoints = inputs.route_points ?? []; const samples = detail.longitudinal?.design_profiles?.[0]?.samples; const build = buildCorridor(detail.cross_sections, routePoints, samples); return build ? serialize(build, corridorHash(detail, routePoints)) : null; }, }; function need(id: string): unknown { if (!memo.has(id)) memo.set(id, RUNNERS[id]()); return memo.get(id); } const unknown = run.filter((id) => !(id in RUNNERS)); const results: Record = {}; if (!unknown.length) for (const id of run) results[id] = need(id); return { results, unknown, available: Object.keys(RUNNERS) }; } const [inputPath, outputPath] = process.argv.slice(2); if (!inputPath || !outputPath) { console.error("사용법: node <묶음> <입력.json> <출력.json>"); process.exit(2); } const request = JSON.parse(readFileSync(inputPath, "utf8")) as { run?: string[]; inputs?: CalcInputs; }; const { results, unknown, available } = runDesignCalc(request.run ?? [], request.inputs ?? {}); if (unknown.length) { console.error(`모르는 id: ${unknown.join(", ")}`); process.exit(4); } writeFileSync(outputPath, JSON.stringify({ results, available }));