/* ============================================================================= * 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) } : { ...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, }; /** * 그 관의 **주인 측점** 누가거리 — 관 자리에서 가장 가까운 측점 하나. 관 자리를 모르면 `null`. * * ⚠ 관 자리와 측점 자리는 스냅 때문에 어긋날 수 있다(위 설명 참조). 거리 한계를 두지 않고 * **가장 가까운 하나**만 고르는 것이 요점 — 두 측점이 같이 「주인」이 되면 같은 관을 두 번 센다. */ function pipeOwnerChainage( section: CrossSection, sections: readonly CrossSection[], ): number | null { const target = section.culvert?.chainage_m; if (typeof target !== "number") return null; let best: number | null = null; for (const item of sections) { if (best === null || Math.abs(item.chainage_m - target) < Math.abs(best - target)) { best = item.chainage_m; } } return best; } /** 한 측점의 구조물 기하 한 벌 — 설계선 트림과 구조물 그리기가 같은 결과를 나눠 쓴다. */ 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>; /** 실제로 그려지는 설계선의 트림 — 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 | null { const layouts = computeStoredLayouts(section, sections); if (!layouts) return null; // 관 길이는 면적과 **따로** 낸다 — 폐회로 면적을 못 내는 측점(설계선이 모자란 자리)에도 // 관은 서 있고, 수량은 그 길이를 필요로 한다(2026-09-08). // // ⚠ **관을 가진 측점(소유)에만 싣는다.** 옆 측점도 그 관 구간에 걸리면 레이아웃을 만들지만 // (`culvertLinkFor` — 3D·카드가 이어 그리려고), 그 자리에 길이를 실으면 **같은 관을 두 번** // 세게 된다. 실측에서 관 9개에 값이 10곳 실렸던 자리다. // // ⚠⚠ **관 자리와 측점 자리는 최대 0.5m 어긋난다 — 그것이 설계다**(2026-09-09 실측). // 측점을 만들 때 **정수 미터가 같은 격자 측점이 있으면 그리로 스냅**한다 // (`B05_Profile_Engine_Sections_Core` — 횡단 파일명이 정수 미터라 두 측점이 한 파일을 // 덮어쓰는 것을 막는 가드). 그래서 관 440.241 은 **측점 440.0** 위에 선다. // ⇒ 0.02m 로 주인을 가리면 **그런 관은 주인이 없어** 길이가 아무 데도 안 실리고, // B08 이 「연장 없음」으로 막아 **금액이 통째로 빠진다**(실측: 배수관 넷). // ⇒ **가장 가까운 측점 하나**를 주인으로 본다. 거리로 자르지 않으므로 스냅 폭이 // 바뀌어도 따라가고, 하나만 고르므로 두 번 세지도 않는다. const ownerChainage = pipeOwnerChainage(section, sections); const pipeOwner = !!section.culvert && (ownerChainage === null || Math.abs(ownerChainage - section.chainage_m) <= CHAINAGE_TOLERANCE_M); const pipeLengthM = pipeOwner ? layouts.culvert?.pipe?.lengthM : undefined; const pipeRow: Record | 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 = { ...(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> { return sections .map((section) => areaRowOf(section, sections)) .filter((row): row is Record => !!row); } /** 낸 면적을 측점 자료에 도로 얹는다 — 유토곡선이 고쳐진 면적 위에서 쌓이도록. */ export function applyStructureAreaRows( sections: readonly CrossSection[], rows: ReadonlyArray>, ): void { for (const row of rows) { const design = sections.find((item) => item.chainage_m === row.chainage_m)?.design as Record | undefined; if (!design) continue; for (const key of STRUCTURE_ROW_KEYS) { if (typeof row[key] === "number") design[key] = row[key]; } } }