Files
Aislo/B06_Section/B06_Section_Server_Calc_Node.ts
T
eomsangdon 1f3b30e698 @
feat(B06): 구조물 면적·유토곡선 서버 계산 자리 마련 + 상단측 저장 누락 수정

계산 자리 일원화(CLAUDE.md 5장) — 브라우저에서만 돌던 두 계산을 서버가 같은 TS 로
한 번 더 돌려 정본에 얹음. 파이썬 포팅 금지(기하가 두 벌이 되면 그림과 수량이 갈림).

- B06_Section_Server_Calc_Node.ts 신설 — 구조물 폐회로 면적 계산 후 그 위에서
  유토곡선을 쌓음(화면과 같은 순서). balloon 위치는 서버가 만들지 않음.
- B06_Section_Structure_Layouts.ts 신설 — 정본만 읽는 제어기 흉내를 B07 도면에서
  떼어 공용화. B07·서버가 같은 한 벌을 씀.
- common_util_node_bundle.py 신설 — 번들 빌드·실행 배관 공용화(코리도도 이걸 씀).
- 전처리 체인(초기값 스냅샷 앞)·[저장]·[확정]에서 서버 재계산 호출.
- 상단측(측구 방향) 변경이 B05 [임시저장]에만 실리던 것을 B06 [저장]·[확정]에도
  실음 — flushUphillOverrides.
- 죽은 세션 등록 항목 pipes 제거(읽는 곳도 쓰는 곳도 없었음).

검증: tsc --noEmit 통과, pytest 386 passed, tmp/tests/test_b06_server_calc_node.mjs 통과.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@
2026-09-06 14:20:54 +09:00

101 lines
5.0 KiB
TypeScript

/* =============================================================================
* B06_Section_Server_Calc_Node.ts
* 브라우저에서만 돌던 횡단 계산을 **서버가 한 번 돌리는** 진입점 — 구조물 폐회로
* 절·성토 면적 + 그것을 쌓아 만든 유토곡선.
*
* 왜 있나(2026-09-06, CLAUDE.md 5장 「계산 자리」) — 이 두 값은 지금까지 브라우저에서만
* 나왔다. 사용자가 B06 을 한 번도 안 열면 값이 없고, 저장·확정 뒤 서버가 다시 계산하면
* 구조물을 모르는 표준값으로 되돌아갔다. 코리도(`B05_Profile_Corridor_Node.ts`)와 같은
* 방식으로 **브라우저가 쓰는 코드를 서버가 그대로 실행**한다 — 계산을 두 벌로 짜지 않는다.
*
* 순서가 중요하다: 면적 보정을 **먼저** 얹고 그 위에서 유토곡선을 쌓는다. 화면도 같은
* 순서다(카드를 그리며 면적을 고친 뒤 유토곡선을 낸다).
*
* 실행: node <번들> <입력.json> <출력.json>
* 입력 { detail: 종횡단 상세(API와 같은 꼴), context: { earthwork_conversion,
* natural_spoil_min_ground_slope, haul_equipment_limits } }
* 출력 { areas: [{ chainage_m, cut_area_m2, … }], mass_haul: {…} | null }
* — areas 는 **구조물 트림이 있는 측점만**. 나머지는 표준 계산값이 이미 맞다.
* 끝 코드: 0 성공 / 2 인자 오류
* ========================================================================== */
import { readFileSync, writeFileSync } from "node:fs";
import { computeStructureAreas } from "@util/common_util_cross_structure_areas";
import { computeMassHaul, massHaulPayload } from "@util/common_util_mass_haul";
import { computeHaulPlan } from "@util/common_util_mass_haul_balance";
import type { CrossSection, SectionDetailResponse } from "./B06_Section_Api_Fetch";
import { computeStoredLayouts, trimOfLayouts } from "./B06_Section_Structure_Layouts";
interface ServerCalcInput {
detail: SectionDetailResponse;
context?: {
earthwork_conversion?: Parameters<typeof computeMassHaul>[1];
natural_spoil_min_ground_slope?: number | null;
haul_equipment_limits?: Parameters<typeof computeHaulPlan>[1];
};
}
const [inputPath, outputPath] = process.argv.slice(2);
if (!inputPath || !outputPath) {
console.error("사용법: node <번들> <입력.json> <출력.json>");
process.exit(2);
}
const input = JSON.parse(readFileSync(inputPath, "utf8")) as ServerCalcInput;
const sections: CrossSection[] = input.detail?.cross_sections ?? [];
/** 화면(`B06_Section_UI_Cross_View.applyStructureAreas`)과 같은 입력을 만든다. */
function areasOf(section: CrossSection): Record<string, number> | null {
const layouts = computeStoredLayouts(section, sections);
if (!layouts) return null;
const trim = trimOfLayouts(layouts);
const design = layouts.design;
if (!trim || !Array.isArray(design.design_line) || design.design_line.length < 2) return null;
const ground = section.samples
.filter((sample) => sample.valid !== false && typeof sample.elevation_m === "number")
.map((sample) => ({ offset: sample.offset_m ?? 0, elevation: sample.elevation_m as number }))
.sort((a, b) => a.offset - b.offset);
const areas = computeStructureAreas({
designLine: design.design_line as Array<{ offset_m: number; elevation_m: number }>,
ground,
trim,
rockBoundaryOffsetM:
typeof design.rock_boundary_offset_m === "number" ? design.rock_boundary_offset_m : null,
});
if (!areas) return null;
const round = (value: number): number => Number(value.toFixed(4));
const out: Record<string, number> = {
chainage_m: section.chainage_m,
cut_area_m2: round(areas.cutAreaM2),
fill_area_m2: round(areas.fillAreaM2),
};
// 토사·암 분리는 원래 값이 있을 때만 덮는다 — 화면 규칙과 같다.
if (typeof design.cut_soil_area_m2 === "number") {
out.cut_soil_area_m2 = round(areas.cutSoilAreaM2);
out.cut_rock_area_m2 = round(areas.cutRockAreaM2);
}
return out;
}
const areas = sections.map(areasOf).filter((entry): entry is Record<string, number> => !!entry);
// 보정값을 **자리에서** 얹는다 — 유토곡선이 고쳐진 면적 위에서 쌓이게 한다.
areas.forEach((row) => {
const section = sections.find((item) => item.chainage_m === row.chainage_m);
const design = section?.design;
if (!design) return;
for (const key of ["cut_area_m2", "fill_area_m2", "cut_soil_area_m2", "cut_rock_area_m2"]) {
if (typeof row[key] === "number") (design as Record<string, unknown>)[key] = row[key];
}
});
// 유토곡선 — balloon 위치는 사용자 화면값이라 서버가 만들지 않는다(파이썬이 보존).
const conversion = input.context?.earthwork_conversion;
const result = conversion
? computeMassHaul(sections, conversion, input.context?.natural_spoil_min_ground_slope ?? undefined)
: null;
const massHaul = result
? massHaulPayload(result, computeHaulPlan(result, input.context?.haul_equipment_limits))
: null;
writeFileSync(outputPath, JSON.stringify({ areas, mass_haul: massHaul }));