/** * 교대 입력 읽기 (2026-11-03, spec_abut §2): 입력 엑셀의 교대 행을 읽는다. 교대 표는 교각 표와 같은 시트에 있으며, * 첫 두 헤더 칸(교대명 + 형식 / IP점 / 받침번호)으로 구분한다. 워크북 객체에 대해 순수하다. 좌표: 시트 x_m = 북, y_m = 동 → 내부 (E, N) = (y_m, x_m). * * 받침높이 (2026-11-03 결정 1): 교각과 같은 정의 — 패드 상면(z)에서 솔플레이트 상면까지. * 받침 행의 값을 그대로 읽는다(A1 시트 값 275). 여기서 계산하지 않는다. */ import * as XLSX from 'xlsx'; import { parseDms } from '../dms'; import { swapXYAzimuth } from '../coordinate-frame'; import type { Issue } from '../types'; import type { AbutInput, AbutPoint, AbutType, WingForm } from './geometry'; import type { AbutShape } from './shape'; import type { AbutBearingRule } from './bearing-table'; import type { WingDims } from './wings'; import type { AbutSlab } from './slab'; import type { AbutGround } from './ground'; import type { AbutFootingRebarSettings } from './rebar/footing'; import type { AbutWallRebarSettings } from './rebar/wall'; import type { AbutParapetRebarSettings } from './rebar/parapet'; import type { AbutWingRebarSettings } from './rebar/wing'; import type { AbutSlabRebarSettings } from './rebar/slab'; type Cell = string | number | null | undefined; type Grid = Cell[][]; export interface AbutRecordBearing { no: number; /** Position (E, N), m: E = y_m, N = x_m. */ pos: AbutPoint; z: number; /** 교좌면_EL of this bearing (m). Absent = the block rule (z − 블럭 높이). */ seatEl?: number; /** 이 받침에 적힌 받침높이 (mm). */ bearingHeightMm: number; /** 받침 방위각 (deg, model-xy, 교각과 같은 swapXYAzimuth 변환). 비었거나 읽을 수 없으면 없음. */ azimuthDeg?: number; row: string; } export interface AbutRecord { name: string; sheet: string; type: AbutType; /** 첫 받침(구간 ①)의 교좌면 EL (m). */ seatEl: number; /** 기초 하단 EL (m), 지반고 EL (m), 기초 형식, 말뚝길이 (mm). */ foundationBottomEl: number; groundEl: number; foundation: string; pileLengthMm: number; /** skew (deg) from the DMS text, and 종단경사 i (decimal). */ skewDeg: number; slopeI: number; ip1: AbutPoint; ip2: AbutPoint; ip3: AbutPoint; bearings: AbutRecordBearing[]; /** 받침 행에서 읽은 받침높이 (mm). 모든 받침이 같아야 하며, 다르면 AP07 경고. */ bearingHeightMm: number; /** 측별 날개벽 형식 (편집 카드에서 정함). Absent = 양측 일반형. */ wings?: { left: WingForm; right: WingForm }; /** 형상 입력 (벽체·흉벽, 확대기초·말뚝, mm). Only the changed values; the rest are the defaults. Absent = all defaults. */ shape?: Partial; /** 받침 규칙 (② 교량받침 카드: h3, 솔플레이트 T·여유, 받침 종류). Only the changed values. Absent = 교각 P1 가정값 (QA-15). */ bearingRule?: Partial; /** 날개벽 치수 (② 날개벽 카드, 측별, mm). Only the changed values. */ wingDims?: { left?: Partial; right?: Partial }; /** 접속슬래브 입력 (② 접속슬래브 카드: 형식, 경사, 치수 mm). Only the changed values. Absent = 매립형 · 하향 4 % 기본값. */ slab?: Partial; /** 지반·지층 입력 (② 지반·지층 카드, 교각과 같은 필드). Absent = 육상, 지하수위 없음, 지층 행 없음. */ ground?: AbutGround; /** ③ 배근 입력 (부재별, 바꾼 값만). Absent = 엑셀 Abut 시트 기본값. */ rebar?: { footing?: Partial; wall?: Partial; parapet?: Partial; wing?: Partial; slab?: Partial }; /** 예전 "좌우 같게" 값 (2026-11-04 이전 파일). 지금은 읽기만 하고 쓰지 않는다. */ wingsLinked?: boolean; } export interface AbutParseResult { abutments: AbutRecord[]; issues: Issue[]; } const num = (c: Cell) => (c === null || c === undefined || c === '' ? NaN : Number(c)); const isBlank = (c: Cell) => c === null || c === undefined || c === ''; /** 교대 표의 헤더 행 (첫 두 칸). */ const isAbutInfoHeader = (r: Cell[]) => r[0] === '교대명' && r[1] === '형식'; const isAbutIpHeader = (r: Cell[]) => r[0] === '교대명' && r[1] === 'IP점'; const isAbutBearingHeader = (r: Cell[]) => r[0] === '교대명' && r[1] === '받침번호'; /** 헤더 아래 데이터 행, 둘째 칸이 빌 때까지. 교대명 칸이 비면 위 행을 이어받는다. */ function readBlock(grid: Grid, header: number): { cells: Cell[]; row: number }[] { const out: { cells: Cell[]; row: number }[] = []; let last: Cell = null; for (let i = header + 1; i < grid.length; i++) { const r = grid[i] ?? []; if (isBlank(r[1])) break; const name: Cell = isBlank(r[0]) ? last : r[0]; last = name; out.push({ cells: [name, ...r.slice(1)], row: i + 1 }); } return out; } export function parseAbutments(wb: XLSX.WorkBook): AbutParseResult { const issues: Issue[] = []; const info: { cells: Cell[]; row: number; sheet: string }[] = []; const ips: { cells: Cell[]; row: number; sheet: string }[] = []; const bearings: { cells: Cell[]; row: number; sheet: string }[] = []; for (const sheet of wb.SheetNames) { const grid = XLSX.utils.sheet_to_json(wb.Sheets[sheet], { header: 1, raw: true, defval: null }) as Grid; grid.forEach((r, i) => { const pick = (match: (r: Cell[]) => boolean, into: typeof info) => { if (!match(r)) return; for (const rec of readBlock(grid, i)) into.push({ ...rec, sheet }); }; pick(isAbutInfoHeader, info); pick(isAbutIpHeader, ips); pick(isAbutBearingHeader, bearings); }); } const abutments: AbutRecord[] = []; for (const rec of info) { const c = rec.cells; const name = String(c[0] ?? '').trim(); const where = `${rec.sheet} ${rec.row}행`; if (!/^A\d+$/.test(name)) { issues.push({ code: 'AP01', level: 'E', sheet: rec.sheet, message: `${where}: 교대명 "${String(c[0] ?? '')}"이 올바르지 않습니다 (A1, A2 …).` }); continue; } const type = String(c[1] ?? '').trim(); if (type !== 'A1' && type !== 'A2') { issues.push({ code: 'AP02', level: 'E', pier: name, sheet: rec.sheet, message: `${where}: 교대 ${name}의 형식 "${type}"은 A1 또는 A2여야 합니다.` }); continue; } const skewDeg = parseDms(String(c[7] ?? '')); if (skewDeg === null) { issues.push({ code: 'AP03', level: 'E', pier: name, sheet: rec.sheet, message: `${where}: 교대 ${name}의 skew "${String(c[7] ?? '')}"를 읽지 못했습니다.` }); continue; } abutments.push({ name, sheet: rec.sheet, type, seatEl: num(c[2]), foundationBottomEl: num(c[3]), groundEl: num(c[4]), foundation: String(c[5] ?? ''), pileLengthMm: num(c[6]), skewDeg, slopeI: num(c[8]), ip1: { e: NaN, n: NaN }, ip2: { e: NaN, n: NaN }, ip3: { e: NaN, n: NaN }, bearings: [], bearingHeightMm: NaN, }); } for (const rec of ips) { const name = String(rec.cells[0] ?? '').trim(); const ab = abutments.find((a) => a.name === name); if (!ab) { issues.push({ code: 'AP04', level: 'E', sheet: rec.sheet, message: `${rec.sheet} ${rec.row}행: 교대명 ${name}의 교대 정보 행이 없습니다.` }); continue; } const point = { e: num(rec.cells[3]), n: num(rec.cells[2]) }; const ipName = String(rec.cells[1] ?? ''); if (ipName === 'IP1') ab.ip1 = point; else if (ipName === 'IP2') ab.ip2 = point; else if (ipName === 'IP3') ab.ip3 = point; } for (const rec of bearings) { const c = rec.cells; const name = String(c[0] ?? '').trim(); const ab = abutments.find((a) => a.name === name); if (!ab) { issues.push({ code: 'AP04', level: 'E', sheet: rec.sheet, message: `${rec.sheet} ${rec.row}행: 교대명 ${name}의 교대 정보 행이 없습니다.` }); continue; } const h = num(c[8]); if (!isBlank(c[5]) && parseDms(String(c[5])) === null) { issues.push({ code: 'AP08', level: 'W', pier: ab.name, sheet: rec.sheet, message: `${rec.sheet} ${rec.row}행: 받침 방위각을 읽을 수 없습니다. 예: 75°56'55.9" 형식. 블럭은 교대축에 맞춥니다.` }); } ab.bearings.push({ no: num(c[1]), pos: { e: num(c[3]), n: num(c[2]) }, z: num(c[4]), seatEl: isBlank(c[7]) ? undefined : num(c[7]), bearingHeightMm: h, azimuthDeg: (() => { const deg = isBlank(c[5]) ? null : parseDms(String(c[5])); return deg === null ? undefined : swapXYAzimuth(deg); })(), row: `${rec.sheet} ${rec.row}행`, }); } for (const ab of abutments) { const missing = [ab.ip1, ab.ip2, ab.ip3].some((p) => !Number.isFinite(p.e) || !Number.isFinite(p.n)); if (missing) issues.push({ code: 'AP05', level: 'E', pier: ab.name, sheet: ab.sheet, message: `교대 ${ab.name}: IP1·IP2·IP3 중 빠진 점이 있습니다.` }); if (ab.bearings.length < 2) issues.push({ code: 'AP06', level: 'E', pier: ab.name, sheet: ab.sheet, message: `교대 ${ab.name}: 받침이 2개 이상 필요합니다.` }); const heights = [...new Set(ab.bearings.map((b) => b.bearingHeightMm))]; if (heights.length > 1) issues.push({ code: 'AP07', level: 'W', pier: ab.name, sheet: ab.sheet, message: `교대 ${ab.name}: 받침높이가 받침마다 다릅니다 (${heights.join(', ')} mm). 첫 값을 씁니다.` }); ab.bearingHeightMm = heights[0] ?? NaN; } return { abutments: abutments.filter((a) => Number.isFinite(a.ip1.e) && a.bearings.length >= 2), issues }; } /** The layout input of AA1–AA2 from a parsed 교대 record. */ export function toAbutInput(rec: AbutRecord): AbutInput { return { name: rec.name, type: rec.type, seatEl: rec.seatEl, ip1: rec.ip1, ip2: rec.ip2, ip3: rec.ip3, bearings: rec.bearings.map((b) => ({ no: b.no, pos: b.pos, z: b.z, seatEl: b.seatEl, azimuthDeg: b.azimuthDeg })), skewDeg: rec.skewDeg, slopeI: rec.slopeI, wings: rec.wings, wingThicknessMm: { left: rec.wingDims?.left?.thicknessMm, right: rec.wingDims?.right?.thicknessMm }, slabWingGapMm: rec.slab?.wingGapMm, foundationBottomEl: rec.foundationBottomEl, pileLengthMm: rec.pileLengthMm, foundationType: rec.foundation, }; }