- AbutRecord.rebar.slab (바꾼 값만), applySlabRebar, 프로젝트 파일 검증 Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
235 lines
10 KiB
TypeScript
235 lines
10 KiB
TypeScript
/**
|
||
* 교대 입력 읽기 (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<AbutShape>;
|
||
/** 받침 규칙 (② 교량받침 카드: h3, 솔플레이트 T·여유, 받침 종류). Only the changed values. Absent = 교각 P1 가정값 (QA-15). */
|
||
bearingRule?: Partial<AbutBearingRule>;
|
||
/** 날개벽 치수 (② 날개벽 카드, 측별, mm). Only the changed values. */
|
||
wingDims?: { left?: Partial<WingDims>; right?: Partial<WingDims> };
|
||
/** 접속슬래브 입력 (② 접속슬래브 카드: 형식, 경사, 치수 mm). Only the changed values. Absent = 매립형 · 하향 4 % 기본값. */
|
||
slab?: Partial<AbutSlab>;
|
||
/** 지반·지층 입력 (② 지반·지층 카드, 교각과 같은 필드). Absent = 육상, 지하수위 없음, 지층 행 없음. */
|
||
ground?: AbutGround;
|
||
/** ③ 배근 입력 (부재별, 바꾼 값만). Absent = 엑셀 Abut 시트 기본값. */
|
||
rebar?: { footing?: Partial<AbutFootingRebarSettings>; wall?: Partial<AbutWallRebarSettings>; parapet?: Partial<AbutParapetRebarSettings>; wing?: Partial<AbutWingRebarSettings>; slab?: Partial<AbutSlabRebarSettings> };
|
||
/** 예전 "좌우 같게" 값 (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<Cell[]>(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,
|
||
};
|
||
}
|