feat(abut): 교대 형상·입력 패널과 교각 받침·솔플레이트 개편

- 교대 형상(src/core/abut, 3D 장면, 목록·카메라·맞춤)과 교대 입력 패널 추가
- 교각 받침 기준 카드 제거, 솔플레이트 규칙을 교각별 입력으로 변경
- 받침 ELEVATION·솔플레이트 TABLE을 모델값으로 연결
- 교대 입력값 카드: 형식 행에 시점·종점 표시, 제목을 "A1 입력값 [입력]"으로 변경
- 테스트 614건 통과, 타입 검사 통과

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
ypshimandClaude Haiku 5.5 committed 2026-11-03 14:51:34 +09:00
1 parent b1b2ad1ddb
commit 1d31d2f3a9
44 files changed
+2571 -120

No files matched your search

+201
View File
@@ -0,0 +1,201 @@
/**
* 교대 입력 읽기 (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 type { Issue } from '../types';
import type { AbutInput, AbutPoint, AbutType, WingForm } from './geometry';
import type { AbutShape } from './shape';
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;
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>;
}
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]);
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,
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 })),
skewDeg: rec.skewDeg,
slopeI: rec.slopeI,
wings: rec.wings,
foundationBottomEl: rec.foundationBottomEl,
pileLengthMm: rec.pileLengthMm,
foundationType: rec.foundation,
};
}