- POST /design/save 에 route(노선 초안) · carry(옛 노선 세션 칸) 칸 · 서버 = 노선 저장 → Node facility.rebase(rebasePipeDraft · rebaseStructureDraft) → 6-A 차례(새 노선) → Node stations.carry(routeCarryPatches) → 횡단 저장 - 되돌림 범위 = 노선 행 · 자식 표 · 단계 · 배수 폴더 · 노선 파일 · 지표면 확정(설계 지형 바꿈) - 손 상단측은 안 넘김(요구 274) · 노선 바뀐 [확정] = [저장](새 노선 본 뒤 다시) - 브라우저 = 노선 초안 + 사용자 칸만 한 본문 · 옛 차례 길(saveRouteDraft 뒤 flush · carryAfterRouteSave · keepDraftsOnNewRoute) · 부름 0 된 flush 창구 여섯 · saveCachedCrossPatches 걷음 - 등록부 R9 stations.carry · F10 facility.rebase · 옛 차례 단언 시험 한 본문 꼴로 · test_472_6_route_save(옛 길 = 새 길 · 실패 되돌림) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
247 lines
11 KiB
TypeScript
247 lines
11 KiB
TypeScript
/* =============================================================================
|
|
* A00_Common/design/logic/design_calc_node.ts
|
|
* 계산 등록부(`design_calc_registry.ts`)의 서버 Node 입구 하나(PLAN 472-3) — id → 함수 표.
|
|
* 등록부에서 실행기가 `node:<id>` 인 줄만 여기 있음(나머지는 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] } ·
|
|
* rebase? · carry?(노선 바뀐 [저장] · `design_calc_node_carry.ts`) } }
|
|
* 출력 { 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 { carryRunners, type CarryInputs } from "./design_calc_node_carry";
|
|
import { followRunners, type FollowInputs } from "./design_calc_node_follow";
|
|
|
|
type HaulOptions = NonNullable<Parameters<typeof computeHaulPlan>[2]>;
|
|
|
|
interface CalcInputs extends FollowInputs, CarryInputs {
|
|
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<typeof computeMassHaul>[1];
|
|
natural_spoil_min_ground_slope?: number | null;
|
|
haul_equipment_limits?: Parameters<typeof computeHaulPlan>[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<typeof bermSpansFromStructures>[0];
|
|
chainages: number[];
|
|
};
|
|
cross_design?: {
|
|
standard: CrossDesignOptions["standard"];
|
|
stations?: Array<{
|
|
samples: CrossGroundSample[];
|
|
design_elevation_m: number | null;
|
|
options: 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,
|
|
};
|
|
}
|
|
|
|
function crossOptions(
|
|
raw: Record<string, unknown>,
|
|
standard: CrossDesignOptions["standard"],
|
|
): CrossDesignOptions {
|
|
const options: Record<string, unknown> = { 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<string, unknown>();
|
|
const sections = inputs.detail?.cross_sections ?? [];
|
|
const context = inputs.context ?? {};
|
|
let massHaul: MassHaulResult | null = null;
|
|
|
|
const RUNNERS: Record<string, () => unknown> = {
|
|
...followRunners(inputs), // 측점 열쇠 · 따라감 · 꼭짓점 세움(472-4 ①)
|
|
...carryRunners(inputs), // 노선 바뀐 [저장] 다시 얹기 — 관 · 구조물 초안 · 횡단 고침(472-6 6-B)
|
|
// 구조물 측점 면적 · 관 길이를 상세에 얹음(자리에서) — 뒤 누가토량이 고친 면적 위에서 쌓임.
|
|
"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<string, unknown> = {};
|
|
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 }));
|