Files
Aislo/B06_Section/B06_Section_Structure_Layouts.ts
T
eomsangdonandClaude Opus 5 5f5e91d655 feat(B06): 배수관 연장(m)을 정본에 실음 — 수량이 관 공종을 셀 수 있게
B08 창 보고(2026-09-08): 관 11개가 `pipe_points.json` 에 있고 품셈 공종도 있는데 **연장(m)이
없어** 배수관 물량이 막혀 있었음. 관 길이는 횡단 기하가 **m 단위 올림까지** 끝낸 값
(`CulvertLayout.pipe.lengthM`)인데 **정본(`design`)에 안 실렸음**.

**옮기거나 새로 짜지 않았음** — 그 계산은 **이미 서버가 Node 로 그대로 실행**하고 있었음
(`B06_Section_Server_Calc_Node` ← `Server_Calc_Prebuild.recompute_server_side`, [저장]·[확정]).
구조물 폐회로 면적이 실리는 **그 다리에 값 한 줄만** 더 실었음 — 계산을 두 벌로 짜지 않음
(CLAUDE.md 5장). B08 창이 낸 갈래 둘(서버로 이관 / 저장 때 남기기)은 둘 다 불필요했음.

- `STRUCTURE_AREA_KEYS` → **`STRUCTURE_ROW_KEYS`** 로 이름을 고치고 `pipe_length_m` 추가.
  이름이 「면적」이라 길이를 담으면 거짓말이 됨.
- **면적을 못 내는 측점에서도 길이는 냄** — 설계선이 모자라 폐회로가 안 닫히는 자리에도
  관은 서 있고 수량은 그 길이가 필요함(`return pipeRow`).
- ⚠ **서버에 화이트리스트가 하나 더 있었음** — `_AREA_KEYS`(「Node 가 다른 키를 내도 안 흘림」).
  거기 안 넣으면 Node 가 값을 내도 **조용히 버려짐**. 넣고, **두 목록을 대조하는 시험**을 둠.
- 키 이름은 `pipe_length_m` 으로 **B08 창과 미리 못 박음** — 오늘 `back_len_cm` 이 엔진에서
  `stone_back_length_cm` 로 읽혀 저장값이 영영 안 닿던 사고가 있었음.

⚠ 값이 정본에 실리려면 그 프로젝트에서 **[저장] 한 번**이 필요하고, **번들 재빌드**
(`npm run build:server-calc`)도 필요함 — 번들은 git 밖이라 창마다 각자 돌려야 함.

시험 6건 — 패치로 받는지 · 음수 거부 · 면적과 **같은 목록**으로 실리는지 · 면적 실패 시에도
길이는 나오는지 · 이름을 두 번 적지 않는지 · **서버 화이트리스트와 TS 목록이 짝인지**.
전체 507 passed · 17 skipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 09:25:55 +09:00

197 lines
8.6 KiB
TypeScript

/* =============================================================================
* B06_Section_Structure_Layouts.ts
* 정본(`section.design`)만 읽어 **구조물 기하 한 벌**을 내는 자리 — 화면·조작 없이 돈다.
*
* 왜 있나(2026-09-06, CLAUDE.md 5장 「계산 자리」) — 같은 기하를 세 곳이 쓴다:
* ① B06 횡단 카드(사용자 조작 중) ② B07 도면 작도 ③ 서버 초기 계산(Node 진입점).
* ①은 조작 제어기를 물고 돌아야 하고, ②·③은 저장된 값만 보면 된다. ②가 갖고 있던
* 「제어기 흉내내기」를 여기로 옮겨 ③이 그대로 쓴다 — 기하를 두 벌로 만들지 않는다.
*
* DOM·SVG 를 부르지 않는다. 브라우저에서도 Node 에서도 같은 결과가 나와야 한다.
* ========================================================================== */
import { computeStructureAreas } from "@util/common_util_cross_structure_areas";
import type { CrossSection } from "./B06_Section_Api_Fetch";
import { computeBoxLayout, DEFAULT_BOX_SIDE_ADJUST } from "./B06_Section_UI_Cross_Box_Geom";
import { DEFAULT_BASIN_ADJUST, ZERO_ADJUST } from "./B06_Section_UI_Cross_Culvert_Types";
import type { WallAdjust } from "./B06_Section_UI_Cross_Culvert_Types";
import { computeCardCulvert, culvertLinkFor } from "./B06_Section_UI_Cross_Culvert_Wire";
import type {
ExtraWallControl,
InletStructureControl,
RevetOffsetControl,
} from "./B06_Section_UI_Cross_Culvert_Wire";
import { computeFordLayout, DEFAULT_FORD_WALL_ADJUST } from "./B06_Section_UI_Cross_Ford_Geom";
import { computeRevetmentLayout } from "./B06_Section_UI_Cross_Revetment";
/** 같은 측점으로 볼 누가거리 오차(m) — 정본 반올림 자릿수보다 크게 잡는다. */
const CHAINAGE_TOLERANCE_M = 0.02;
function storedWallAdjust(section: CrossSection, role: string): WallAdjust {
const stored = section.design?.revet_adjust?.[role];
return stored ? { ...ZERO_ADJUST, ...(stored as Partial<WallAdjust>) } : { ...ZERO_ADJUST };
}
/* 정본만 읽는 조작값 — 편집하지 않으므로 되받기·토스트는 빈 동작이다. */
const revetOffset: RevetOffsetControl = {
adjustFor: (section, role) => storedWallAdjust(section, role),
storedAdjustFor: (section, role) =>
section.design?.revet_adjust?.[role] ? storedWallAdjust(section, role) : null,
selectedFor: () => null,
highlightFor: () => null,
select: () => undefined,
syncApplied: () => undefined,
update: () => undefined,
reset: () => undefined,
};
const extraWalls: ExtraWallControl = {
countFor: (section, side = "outlet") => section.design?.extra_wall_counts?.[side] ?? 0,
setCount: () => undefined,
equalize: () => undefined,
consumeEqualize: () => false,
syncCount: () => undefined,
};
const inletStructure: InletStructureControl = {
valueFor: (section) => section.design?.inlet_structure ?? "auto",
adjustFor: (section) => ({ ...DEFAULT_BASIN_ADJUST, ...(section.design?.basin_adjust ?? {}) }),
set: () => undefined,
updateAdjust: () => undefined,
resetAdjust: () => undefined,
};
/** 한 측점의 구조물 기하 한 벌 — 설계선 트림과 구조물 그리기가 같은 결과를 나눠 쓴다. */
export function computeStoredLayouts(section: CrossSection, sections: readonly CrossSection[]) {
const design = section.design;
if (!design) return null;
const designZAt = (chainageM: number): number | null => {
const found = sections.find(
(item) => Math.abs(item.chainage_m - chainageM) <= CHAINAGE_TOLERANCE_M,
);
return found?.design?.design_elevation_m ?? null;
};
const link = section.culvert ? undefined : culvertLinkFor(section, sections, designZAt);
const culvert = computeCardCulvert(
section,
section.samples,
null,
revetOffset,
inletStructure,
extraWalls,
link,
);
const box = computeBoxLayout(section, section.samples, {
left: { ...DEFAULT_BOX_SIDE_ADJUST, ...(design.box_adjust?.left ?? {}) },
right: { ...DEFAULT_BOX_SIDE_ADJUST, ...(design.box_adjust?.right ?? {}) },
});
const ford = computeFordLayout(section, section.samples, {
inlet: { ...DEFAULT_FORD_WALL_ADJUST, ...(design.ford_adjust?.inlet ?? {}) },
outlet: { ...DEFAULT_FORD_WALL_ADJUST, ...(design.ford_adjust?.outlet ?? {}) },
});
// 독립 기슭막이(옛 D군 경로) — 배관 세트가 붙었거나 옆에서 이어져 오면 그쪽이 그린다.
const own =
!section.culvert && !link ? computeRevetmentLayout(section, design.revet_adjust?.own) : null;
return { design, link, culvert, box, ford, own };
}
export type StoredLayouts = NonNullable<ReturnType<typeof computeStoredLayouts>>;
/** 실제로 그려지는 설계선의 트림 — B06 카드와 **같은 우선순위**로 고른다. */
export function trimOfLayouts(layouts: StoredLayouts) {
return (
layouts.culvert?.designTrim ??
layouts.ford?.designTrim ??
layouts.box?.designTrim ??
layouts.own?.designTrim
);
}
/**
* 정본에 얹는 키 — 구조물이 선 측점에서만 나오는 값들.
*
* 면적 넷에 **관 길이**를 더했다(2026-09-08). 관 길이는 브라우저 기하가 **m 단위 올림까지**
* 끝낸 값인데 정본에 없어 **수량이 배수관 연장을 못 냈다**(B08 창 보고). 계산을 서버로
* 옮기거나 새로 짜지 않고, **이미 서버가 Node 로 돌리는 이 다리에 한 줄 더 실었다**
* (CLAUDE.md 5장 — 계산은 한 벌).
*/
export const STRUCTURE_ROW_KEYS = [
"cut_area_m2",
"fill_area_m2",
"cut_soil_area_m2",
"cut_rock_area_m2",
"pipe_length_m",
] as const;
/** 측점 하나의 폐회로 면적 — 구조물이 없으면 null(표준 계산값이 이미 맞다). */
function areaRowOf(
section: CrossSection,
sections: readonly CrossSection[],
): Record<string, number> | null {
const layouts = computeStoredLayouts(section, sections);
if (!layouts) return null;
// 관 길이는 면적과 **따로** 낸다 — 폐회로 면적을 못 내는 측점(설계선이 모자란 자리)에도
// 관은 서 있고, 수량은 그 길이를 필요로 한다(2026-09-08).
const pipeLengthM = layouts.culvert?.pipe?.lengthM;
const pipeRow: Record<string, number> | null =
typeof pipeLengthM === "number" && pipeLengthM > 0
? { chainage_m: section.chainage_m, pipe_length_m: Number(pipeLengthM.toFixed(4)) }
: null;
const trim = trimOfLayouts(layouts);
const design = layouts.design;
if (!trim || !Array.isArray(design.design_line) || design.design_line.length < 2) return pipeRow;
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 pipeRow;
const round = (value: number): number => Number(value.toFixed(4));
const row: Record<string, number> = {
...(pipeRow ?? {}),
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") {
row.cut_soil_area_m2 = round(areas.cutSoilAreaM2);
row.cut_rock_area_m2 = round(areas.cutRockAreaM2);
}
return row;
}
/**
* 구조물이 선 측점의 절·성토 면적 — **카드를 그리지 않은 측점까지** 전부 낸다.
* 화면 그리기(`applyStructureAreas`)와 같은 계산이며, [저장]·[확정]과 초기값 산출
* (Node 진입점)이 이 한 벌을 함께 쓴다.
*/
export function structureAreaRows(
sections: readonly CrossSection[],
): Array<Record<string, number>> {
return sections
.map((section) => areaRowOf(section, sections))
.filter((row): row is Record<string, number> => !!row);
}
/** 낸 면적을 측점 자료에 도로 얹는다 — 유토곡선이 고쳐진 면적 위에서 쌓이도록. */
export function applyStructureAreaRows(
sections: readonly CrossSection[],
rows: ReadonlyArray<Record<string, number>>,
): void {
for (const row of rows) {
const design = sections.find((item) => item.chainage_m === row.chainage_m)?.design as
Record<string, unknown> | undefined;
if (!design) continue;
for (const key of STRUCTURE_ROW_KEYS) {
if (typeof row[key] === "number") design[key] = row[key];
}
}
}