feat(core): parse P1 workbook and build T-type pier shape

- parse Excel input by header strings (pier, IP, bearing tables)
- DMS azimuth parser (E03), input validation (E01, E02)
- pure geometry: axis, bearings, stepped coping, column, footing, piles, blinding
- warnings W01, W02, W05, W07, I01
- regression tests against spec.md section 12 (P1)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
ypshimandClaude Haiku 5.5 committed 2026-10-21 17:26:16 +09:00
commit d963d8ed3c
15 files changed
+2527

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
node_modules/
dist/
*.log
+1680
View File
File diff suppressed because it is too large. Load diff
+27
View File
@@ -0,0 +1,27 @@
{
"name": "pierzainer-3d-drawing",
"version": "0.1.0",
"description": "교각 3D 형상 생성 웹앱 (1단계: 형상 생성)",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"typecheck": "tsc --noEmit",
"test": "vitest run"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "module",
"dependencies": {
"three": "^0.186.1",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@types/node": "^26.6.5",
"@types/three": "^0.186.0",
"typescript": "^7.0.2",
"vite": "^8.3.5",
"vitest": "^5.0.5"
},
"private": true
}
Binary file not shown.
+12
View File
@@ -0,0 +1,12 @@
const DMS = /(\d+(?:\.\d+)?)°\s*(\d+)'\s*(\d+(?:\.\d+)?)"/;
/** Parses a degree-minute-second string such as ` 75°56'55.9"` into decimal degrees. Returns null when invalid. */
export function parseDms(text: string): number | null {
const m = DMS.exec(String(text));
if (!m) return null;
const deg = Number(m[1]);
const min = Number(m[2]);
const sec = Number(m[3]);
if (min >= 60 || sec >= 60) return null;
return deg + min / 60 + sec / 3600;
}
+335
View File
@@ -0,0 +1,335 @@
import type { PierInput, Row } from './types';
/** Dimensions in m. Sources: intent.PR sheet (P1 rules) and spec.md §3 defaults. */
export const DIM = {
columnDiameter: 2.0,
copingH1: 1.0,
copingH2: 1.5,
levelWidth: 2.1,
padThickness: 0.05,
padSize: 1.0,
blockSize: 1.2,
footingL: 10.4,
footingW: 8.15,
footingH: 2.0,
blindingMargin: 0.2,
blindingT: 0.1,
pileDiameter: 0.6,
pileEnd: 0.7,
pileSpacing: 2.25,
/** Front/back EL difference below this is treated as no lateral step (spec §4.3, Q2). */
lateralTolerance: 0.005,
} as const;
export interface Vec2 {
e: number;
n: number;
}
export interface Segment {
s0: number;
s1: number;
top: number;
}
export interface BearingLocal {
no: number;
row: Row;
s: number;
n: number;
z: number;
}
export interface BlockModel {
no: number;
row: Row;
s: number;
n: number;
bottom: number;
top: number;
padTop: number;
}
export interface ColumnModel {
s: number;
top: number;
bottom: number;
diameter: number;
}
export interface FootingModel {
s: number;
top: number;
bottom: number;
length: number;
width: number;
}
export interface BlindingModel {
s: number;
top: number;
length: number;
width: number;
thickness: number;
}
export interface PileModel {
s: number;
n: number;
top: number;
bottom: number;
diameter: number;
}
export interface PierModel {
name: string;
lengthIp: number;
azimuthDeg: number;
/** Unit vector along the pier axis (east, north). */
u: Vec2;
/** Unit vector across the axis: (u.n, -u.e). Front row is negative, back row positive. */
nrm: Vec2;
ip1: Vec2;
ip2Input: Vec2;
ip2Calc: Vec2;
dIp2: number;
bp1: Vec2;
bp2: Vec2;
/** Horizontal centre of the drawing frame: the centre of the first column. */
pivot: Vec2;
copingL: number;
copingB: number;
copingRefTop: number;
h1: number;
h2: number;
segments: Segment[];
/** Coping elevation outline as (s, EL) points, closed. */
profile: [number, number][];
levelBottom: number;
endBottom: number;
bearings: BearingLocal[];
blocks: BlockModel[];
columns: ColumnModel[];
footings: FootingModel[];
blindings: BlindingModel[];
piles: PileModel[];
groundEl: number;
derived: {
columnHeight: number;
soilCover: number;
pileCount: number;
pileTipEl: number | null;
blockHeightFront: number;
blockHeightBack: number;
frontBackDiffSpread: number;
};
}
function toLocal(p: Vec2, origin: Vec2, u: Vec2, nrm: Vec2): { s: number; n: number } {
const dx = p.e - origin.e;
const dy = p.n - origin.n;
return { s: dx * u.e + dy * u.n, n: dx * nrm.e + dy * nrm.n };
}
/**
* Builds the first-stage (shape only) model of a T-type pier from one input record.
* Pure function: same input always gives the same output. Throws for cases not implemented yet.
*/
export function buildPier(input: PierInput): PierModel {
const ip = (name: string) => {
const p = input.ips.find((q) => q.name === name);
if (!p) throw new Error(`IP 누락: ${name}`);
return p;
};
const ip1 = ip('IP1');
const ip2 = ip('IP2');
const ip3 = ip('IP3');
const ip4 = ip('IP4');
if (ip4.angleDeg !== 0) {
throw new Error('미실시: 꺾인 코핑(IP4 꺾임각 ≠ 0)은 아직 지원하지 않습니다.');
}
const dx = ip3.x - ip1.x;
const dy = ip3.y - ip1.y;
const lengthIp = Math.hypot(dx, dy);
const u: Vec2 = { e: dx / lengthIp, n: dy / lengthIp };
const nrm: Vec2 = { e: u.n, n: -u.e };
const azimuthDeg = (((Math.atan2(u.e, u.n) * 180) / Math.PI) + 360) % 360;
// IP2 is a reference point at a 1:9 division of IP1–IP3 (intent.PR, spec §2.2).
const ip2Calc: Vec2 = { e: ip1.x + 0.1 * dx, n: ip1.y + 0.1 * dy };
const ip2Input: Vec2 = { e: ip2.x, n: ip2.y };
const L = input.copingLengthMm / 1000;
const B = input.copingWidthMm / 1000;
const ip1v: Vec2 = { e: ip1.x, n: ip1.y };
const bp1: Vec2 = { e: ip1v.e + u.e * (lengthIp / 2 - L / 2), n: ip1v.n + u.n * (lengthIp / 2 - L / 2) };
const bp2: Vec2 = { e: bp1.e + u.e * L, n: bp1.n + u.n * L };
const front = input.bearings.filter((b) => b.row === '전열').sort((a, b) => a.no - b.no);
const back = input.bearings.filter((b) => b.row === '후열').sort((a, b) => a.no - b.no);
if (front.length !== back.length) throw new Error('E02: 전열·후열 받침 수가 다릅니다.');
if (front.length < 2) throw new Error('받침이 2개 미만입니다.');
// Bearing block heights (spec §4.3 h1OshoeBlock / h2, pad 50 included). Not the coping end heights (DIM.copingH1/H2).
const h1 = front[0].z - input.copingTopEl;
const h2 = h1 + (back[0].z - front[0].z);
const frontLocal = front.map((b) => ({ no: b.no, row: '전열' as Row, z: b.z, ...toLocal({ e: b.x, n: b.y }, bp1, u, nrm) }));
const backLocal = back.map((b) => ({ no: b.no, row: '후열' as Row, z: b.z, ...toLocal({ e: b.x, n: b.y }, bp1, u, nrm) }));
// Step boundaries sit midway between consecutive front bearings (spec §4.3).
const bounds = [0];
for (let k = 0; k < frontLocal.length - 1; k++) {
bounds.push((frontLocal[k].s + frontLocal[k + 1].s) / 2);
}
bounds.push(L);
const segments: Segment[] = frontLocal.map((b, k) => ({ s0: bounds[k], s1: bounds[k + 1], top: b.z - h1 }));
// Lateral step: front and back coping tops must match segment by segment (not supported yet).
frontLocal.forEach((f, k) => {
const backTop = backLocal[k].z - h2;
if (Math.abs(segments[k].top - backTop) > DIM.lateralTolerance) {
throw new Error('미실시: 횡방향 단차(전열·후열 상면 차 ≥ 5 mm)는 아직 지원하지 않습니다.');
}
});
// Columns (spec §4.5). Multi-column positions are placed; haunch and separate-footing checks are not.
const colS = columnPositions(input.type, L);
// Coping underside: ends at (top − end height h1 = 1000), Level zone at (top − h1 − h2 taper height = 1500).
const levelBottom = input.copingTopEl - DIM.copingH1 - DIM.copingH2;
const endBottom = input.copingTopEl - DIM.copingH1;
const levelStart = Math.min(...colS) - DIM.levelWidth / 2;
const levelEnd = Math.max(...colS) + DIM.levelWidth / 2;
if (levelStart < 0 || levelEnd > L) throw new Error('코핑 길이가 Level 구간보다 짧습니다.');
const profile: [number, number][] = [[0, endBottom]];
for (const seg of segments) {
profile.push([seg.s0, seg.top], [seg.s1, seg.top]);
}
profile.push([L, endBottom], [levelEnd, levelBottom], [levelStart, levelBottom], [0, endBottom]);
profile.pop(); // the last point repeats the first one
const footingTop = input.foundationType === '현장타설말뚝' ? input.footingBottomEl : input.footingBottomEl + DIM.footingH;
const columns: ColumnModel[] = colS.map((s) => ({
s,
top: levelBottom,
bottom: footingTop,
diameter: DIM.columnDiameter,
}));
const footings: FootingModel[] = [];
const blindings: BlindingModel[] = [];
const piles: PileModel[] = [];
const pileLength = input.pileLengthMm / 1000;
const pileTip = input.footingBottomEl - pileLength;
for (const s of colS) {
if (input.foundationType === '현장타설말뚝') {
piles.push({ s, n: 0, top: input.footingBottomEl, bottom: pileTip, diameter: DIM.columnDiameter });
continue;
}
footings.push({ s, top: footingTop, bottom: input.footingBottomEl, length: DIM.footingL, width: DIM.footingW });
blindings.push({
s,
top: input.footingBottomEl,
length: DIM.footingL + DIM.blindingMargin,
width: DIM.footingW + DIM.blindingMargin,
thickness: DIM.blindingT,
});
if (input.foundationType === '말뚝기초') {
piles.push(...pilesFor(s, input.footingBottomEl, pileLength));
}
}
const blocks: BlockModel[] = [];
frontLocal.forEach((b) => {
const bottom = segmentTop(segments, b.s);
blocks.push({ no: b.no, row: '전열', s: b.s, n: b.n, bottom, top: b.z - DIM.padThickness, padTop: b.z });
});
backLocal.forEach((b) => {
const bottom = b.z - h2;
blocks.push({ no: b.no, row: '후열', s: b.s, n: b.n, bottom, top: b.z - DIM.padThickness, padTop: b.z });
});
const blockHeightFront = blocks.find((b) => b.row === '전열')!.top - blocks.find((b) => b.row === '전열')!.bottom;
const blockHeightBack = blocks.find((b) => b.row === '후열')!.top - blocks.find((b) => b.row === '후열')!.bottom;
const diffs = frontLocal.map((f, k) => f.z - backLocal[k].z);
const frontBackDiffSpread = Math.max(...diffs) - Math.min(...diffs);
return {
name: input.name,
lengthIp,
azimuthDeg,
u,
nrm,
ip1: ip1v,
ip2Input,
ip2Calc,
dIp2: (ip2Calc.e - bp1.e) * u.e + (ip2Calc.n - bp1.n) * u.n,
bp1,
bp2,
pivot: { e: bp1.e + u.e * (L / 2), n: bp1.n + u.n * (L / 2) },
copingL: L,
copingB: B,
copingRefTop: input.copingTopEl,
h1,
h2,
segments,
profile,
levelBottom,
endBottom,
bearings: [...frontLocal, ...backLocal].map((b) => ({ no: b.no, row: b.row, s: b.s, n: b.n, z: b.z })),
blocks,
columns,
footings,
blindings,
piles,
groundEl: input.groundEl,
derived: {
columnHeight: levelBottom - columns[0].bottom,
soilCover: input.groundEl - (input.foundationType === '현장타설말뚝' ? input.footingBottomEl : footingTop),
pileCount: piles.length,
pileTipEl: piles.length ? pileTip : null,
blockHeightFront,
blockHeightBack,
frontBackDiffSpread,
},
};
}
export function columnPositions(type: PierInput['type'], L: number): number[] {
if (type === 'T형') return [L / 2];
if (type === '2주형') return [L / 5, L - L / 5];
return [L / 5, L / 2, L - L / 5];
}
function segmentTop(segments: Segment[], s: number): number {
const hit = segments.find((seg) => s >= seg.s0 && s < seg.s1) ?? segments[segments.length - 1];
return hit.top;
}
/**
* Piles under one footing (spec §4.6): rows across the footing width, piles along the length,
* both with an end distance of 0.7 m and an even spacing that never exceeds 2.25 m.
*/
export function pilesFor(s: number, topEl: number, length: number): PileModel[] {
const span = (size: number) => Math.floor((size - 2 * DIM.pileEnd) / DIM.pileSpacing) + 1;
const countS = span(DIM.footingL);
const countN = span(DIM.footingW);
const spacingS = (DIM.footingL - 2 * DIM.pileEnd) / (countS - 1);
const spacingN = (DIM.footingW - 2 * DIM.pileEnd) / (countN - 1);
const out: PileModel[] = [];
for (let i = 0; i < countS; i++) {
for (let j = 0; j < countN; j++) {
out.push({
s: s + (i - (countS - 1) / 2) * spacingS,
n: (j - (countN - 1) / 2) * spacingN,
top: topEl,
bottom: topEl - length,
diameter: DIM.pileDiameter,
});
}
}
return out;
}
+121
View File
@@ -0,0 +1,121 @@
import * as XLSX from 'xlsx';
import { parseDms } from './dms';
import type { Bearing, FoundationType, Issue, IpPoint, PierInput, PierType, Row } from './types';
type Cell = string | number | null | undefined;
type Grid = Cell[][];
export interface ParseResult {
piers: PierInput[];
issues: Issue[];
}
const PIER_TYPES: PierType[] = ['T형', '2주형', '3주형'];
const FOUNDATION_TYPES: FoundationType[] = ['말뚝기초', '직접기초', '현장타설말뚝'];
/** Reads every sheet and collects the pier tables found by their header strings (position-independent). */
export function parseWorkbook(wb: XLSX.WorkBook): ParseResult {
const piers: PierInput[] = [];
const issues: Issue[] = [];
for (const sheetName of wb.SheetNames) {
const ws = wb.Sheets[sheetName];
const grid = XLSX.utils.sheet_to_json<Cell[]>(ws, { header: 1, raw: true, defval: null }) as Grid;
piers.push(...parseSheet(grid, sheetName, issues));
}
return { piers, issues };
}
function parseSheet(grid: Grid, sheet: string, issues: Issue[]): PierInput[] {
const pierHeaders = findHeaders(grid, (r) => r[0] === '교각명' && r[1] === '교각형식');
const ipHeaders = findHeaders(grid, (r) => r[0] === '교각명' && r[1] === 'IP점');
const bearingHeaders = findHeaders(grid, (r) => r[0] === '교각명' && r[1] === '받침번호');
const ips = ipHeaders.flatMap((h) => readBlock(grid, h).map((r) => toIp(r)));
const bearings: { pier: string; bearing: Bearing; row: number; azimuthOk: boolean }[] = [];
for (const h of bearingHeaders) {
for (const { cells, index } of readBlock(grid, h)) {
const azimuth = parseDms(String(cells[5] ?? ''));
bearings.push({
pier: String(cells[0]),
row: index + 1,
azimuthOk: azimuth !== null,
bearing: {
no: Number(cells[1]),
x: Number(cells[2]),
y: Number(cells[3]),
z: Number(cells[4]),
azimuthDeg: azimuth ?? NaN,
row: String(cells[6]) as Row,
},
});
}
}
const piers: PierInput[] = [];
for (const h of pierHeaders) {
for (const { cells, index } of readBlock(grid, h)) {
const name = String(cells[0]);
const bad = bearings.find((b) => b.pier === name && !b.azimuthOk);
if (bad) {
issues.push({
code: 'E03',
level: 'E',
pier: name,
message: `${sheet} ${bad.row}행: 방위각 문자열을 읽을 수 없습니다. 예: 75°56'55.9"`,
});
continue;
}
const type = String(cells[1]) as PierType;
const foundation = String(cells[5]) as FoundationType;
if (!PIER_TYPES.includes(type) || !FOUNDATION_TYPES.includes(foundation)) {
issues.push({ code: 'E01', level: 'E', pier: name, message: `${sheet} ${index + 1}행: 교각형식 또는 기초 형식을 알 수 없습니다.` });
continue;
}
piers.push({
name,
type,
copingTopEl: Number(cells[2]),
footingBottomEl: Number(cells[3]),
groundEl: Number(cells[4]),
foundationType: foundation,
pileLengthMm: Number(cells[6]),
copingWidthMm: Number(cells[7]),
copingLengthMm: Number(cells[8]),
columnCount: Number(cells[9]),
ips: ips.filter((p) => p.pier === name).map((p) => p.ip),
bearings: bearings.filter((b) => b.pier === name).map((b) => b.bearing),
});
}
}
return piers;
}
function findHeaders(grid: Grid, match: (row: Cell[]) => boolean): number[] {
const out: number[] = [];
grid.forEach((row, i) => {
if (match(row)) out.push(i);
});
return out;
}
/** Reads the data rows under a header until the first blank row. Blank pier-name cells inherit the row above. */
function readBlock(grid: Grid, header: number): { cells: Cell[]; index: number }[] {
const out: { cells: Cell[]; index: number }[] = [];
let lastName: Cell = null;
for (let i = header + 1; i < grid.length; i++) {
const row = grid[i] ?? [];
if (row[1] === null || row[1] === undefined || row[1] === '') break;
const name: Cell = row[0] ?? lastName;
lastName = name;
out.push({ cells: [name, ...row.slice(1)], index: i });
}
return out;
}
function toIp(entry: { cells: Cell[]; index: number }): { pier: string; ip: IpPoint } {
const c = entry.cells;
return {
pier: String(c[0]),
ip: { name: String(c[1]), x: Number(c[2]), y: Number(c[3]), angleDeg: Number(c[4] ?? 0) },
};
}
+44
View File
@@ -0,0 +1,44 @@
export type PierType = 'T형' | '2주형' | '3주형';
export type FoundationType = '말뚝기초' | '직접기초' | '현장타설말뚝';
export type Row = '전열' | '후열';
/** Inputs are stored as the sheet gives them: coordinates in m, EL in m, lengths in mm. */
export interface IpPoint {
name: string;
x: number;
y: number;
angleDeg: number;
}
export interface Bearing {
no: number;
x: number;
y: number;
z: number;
azimuthDeg: number;
row: Row;
}
export interface PierInput {
name: string;
type: PierType;
copingTopEl: number;
footingBottomEl: number;
groundEl: number;
foundationType: FoundationType;
pileLengthMm: number;
copingWidthMm: number;
copingLengthMm: number;
columnCount: number;
ips: IpPoint[];
bearings: Bearing[];
}
export type IssueLevel = 'E' | 'W' | 'I';
export interface Issue {
code: string;
level: IssueLevel;
pier?: string;
message: string;
}
+69
View File
@@ -0,0 +1,69 @@
import { DIM, type PierModel } from './geometry';
import type { Issue, PierInput, PierType } from './types';
const EXPECTED_COLUMNS: Record<PierType, number> = { 'T형': 1, '2주형': 2, '3주형': 3 };
/** Rules from spec §10 that are not implemented in this stage. Shown to the user, never silently skipped. */
export const NOT_IMPLEMENTED_RULES = [
'E04 fck 미입력 (배근 단계, 3단계)',
'W03 받침 중심 ~ 계단 경계 < 블럭 폭/2',
'W04 블럭이 코핑 평면 밖',
'W06 속성 복사 건너뜀 (2단계)',
'W08 솔플레이트 T1−T2 검사 (2단계)',
'다주형 코핑 헌치·Level−300 (Q3), 일체기초 판정 (2·3주형)',
] as const;
/** Input-level checks (spec §10 E01, E02). Returns false when the pier must not be built. */
export function validateInput(input: PierInput, issues: Issue[]): boolean {
let ok = true;
if (input.columnCount !== EXPECTED_COLUMNS[input.type]) {
issues.push({
code: 'E01',
level: 'E',
pier: input.name,
message: `교각형식 ${input.type}과 기둥갯수 ${input.columnCount}개가 맞지 않습니다. 기둥 ${EXPECTED_COLUMNS[input.type]}개로 맞추세요.`,
});
ok = false;
}
const front = input.bearings.filter((b) => b.row === '전열').length;
const back = input.bearings.filter((b) => b.row === '후열').length;
if (front !== back) {
issues.push({ code: 'E02', level: 'E', pier: input.name, message: `전열 받침 ${front}개, 후열 받침 ${back}개로 수가 다릅니다.` });
ok = false;
}
return ok;
}
/** Geometry-level checks (spec §10 W01, W02, W05, W07, I01) on a built model. */
export function validatePier(input: PierInput, model: PierModel, issues: Issue[]): void {
const ip2Gap = Math.hypot(model.ip2Input.e - model.ip2Calc.e, model.ip2Input.n - model.ip2Calc.n);
if (ip2Gap > 0.001) {
issues.push({
code: 'W01',
level: 'W',
pier: input.name,
message: `IP2 입력과 재계산값이 ${(ip2Gap * 1000).toFixed(1)} mm 다릅니다. 재계산값을 사용합니다.`,
});
}
const minBlock = Math.min(model.derived.blockHeightFront, model.derived.blockHeightBack);
if (minBlock <= 0) {
issues.push({ code: 'W02', level: 'W', pier: input.name, message: '받침블럭 콘크리트 높이가 0 이하입니다. 블럭을 생략합니다.' });
}
if (input.foundationType !== '현장타설말뚝' && model.footings.some((f) => f.top > input.groundEl)) {
issues.push({ code: 'W05', level: 'W', pier: input.name, message: '확대기초 상면이 지반고보다 높습니다.' });
}
if (model.derived.frontBackDiffSpread > DIM.lateralTolerance) {
issues.push({ code: 'W07', level: 'I', pier: input.name, message: '전열·후열 EL 차이가 받침마다 다릅니다 (정보).' });
}
// I01: block concrete below cover + bar diameter (cover 100 mm, S1·S2 H19 from spec §6.3).
const cover = 0.1;
const barDia = 0.019;
if (minBlock < cover + barDia) {
issues.push({
code: 'I01',
level: 'I',
pier: input.name,
message: `받침블럭 높이 ${(minBlock * 1000).toFixed(0)} mm가 피복 + 철근 지름(${((cover + barDia) * 1000).toFixed(0)} mm)보다 작습니다. 블럭 철근은 코핑 안에 묻힙니다.`,
});
}
}
+13
View File
@@ -0,0 +1,13 @@
import { describe, expect, it } from 'vitest';
import { parseDms } from '../src/core/dms';
describe('parseDms', () => {
it('converts the P1 sheet string to decimal degrees (spec §12: 75.9489°)', () => {
expect(parseDms(` 75°56'55.9"`)).toBeCloseTo(75.9489, 4);
});
it('returns null for malformed strings (E03)', () => {
expect(parseDms('75.9489')).toBeNull();
expect(parseDms(`75°61'00"`)).toBeNull();
});
});
+19
View File
@@ -0,0 +1,19 @@
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import * as XLSX from 'xlsx';
import { parseWorkbook } from '../src/core/parseWorkbook';
import type { PierInput } from '../src/core/types';
const P1_PATH = fileURLToPath(new URL('../public/samples/P1.xlsx', import.meta.url));
export function loadWorkbook(path: string = P1_PATH): XLSX.WorkBook {
return XLSX.read(readFileSync(path), { type: 'buffer' });
}
export function loadP1(): PierInput {
const { piers, issues } = parseWorkbook(loadWorkbook());
if (issues.length) throw new Error(`P1 parse issues: ${JSON.stringify(issues)}`);
const p1 = piers.find((p) => p.name === 'P1');
if (!p1) throw new Error('P1 not found');
return p1;
}
+102
View File
@@ -0,0 +1,102 @@
import { describe, expect, it } from 'vitest';
import { buildPier } from '../src/core/geometry';
import { loadP1 } from './helpers';
/**
* Regression against spec.md §12 (P1, 운곡교, T형, 말뚝기초).
* Coordinates and EL: ±1 mm (0.001 m). Angles: ±0.01°.
*/
const TOL = 0.001;
const model = buildPier(loadP1());
describe('P1 regression — spec.md §12', () => {
it('axis length and azimuth', () => {
expect(model.lengthIp).toBeCloseTo(12.166, 3);
expect(model.azimuthDeg).toBeCloseTo(350.37, 2);
});
it('BP1, BP2 coordinates (국토좌표 E, N)', () => {
expect(model.bp1.e).toBeCloseTo(259866.681, 2);
expect(model.bp1.n).toBeCloseTo(179132.173, 2);
expect(Math.abs(model.bp2.e - 259864.674)).toBeLessThan(TOL);
expect(Math.abs(model.bp2.n - 179144.004)).toBeLessThan(TOL);
});
it('IP2 reference recalculation and d_IP2', () => {
expect(Math.abs(model.dIp2 - 1.134)).toBeLessThan(TOL);
});
it('column centre coordinates', () => {
expect(Math.abs(model.pivot.e - 259865.677)).toBeLessThan(TOL);
expect(Math.abs(model.pivot.n - 179138.089)).toBeLessThan(TOL);
});
it('bearing positions: front row across-axis −0.698, back row +0.600; front s from 0.943 to 10.965', () => {
const front = model.bearings.filter((b) => b.row === '전열');
const back = model.bearings.filter((b) => b.row === '후열');
expect(front[0].n).toBeCloseTo(-0.698, 3);
expect(back[0].n).toBeCloseTo(0.6, 3);
expect(front[0].s).toBeCloseTo(0.943, 3);
expect(front[4].s).toBeCloseTo(10.965, 3);
expect(back[0].s).toBeCloseTo(1.029, 3);
expect(back[4].s).toBeCloseTo(11.05, 3);
});
it('step boundaries at the midpoints of front bearings: 2.196 / 4.701 / 7.207 / 9.712', () => {
const bounds = model.segments.slice(1).map((s) => s.s0);
[2.196, 4.701, 7.207, 9.712].forEach((b, i) => expect(Math.abs(bounds[i] - b)).toBeLessThan(TOL));
});
it('front segment tops: 43.411 / 43.483 / 43.555 / 43.626 / 43.698', () => {
const tops = model.segments.map((s) => s.top);
[43.411, 43.483, 43.555, 43.626, 43.698].forEach((t, i) => expect(Math.abs(tops[i] - t)).toBeLessThan(TOL));
});
it('bearing block heights: h1 = 150, h2 = 200 mm; concrete 100 / 150 mm', () => {
expect(model.h1).toBeCloseTo(0.15, 6);
expect(model.h2).toBeCloseTo(0.2, 6);
expect(model.derived.blockHeightFront).toBeCloseTo(0.1, 6);
expect(model.derived.blockHeightBack).toBeCloseTo(0.15, 6);
});
it('no lateral step (front and back tops agree)', () => {
expect(model.derived.frontBackDiffSpread).toBeLessThan(0.0051);
});
it('coping bottom: 42.411 at the ends, 40.911 at the Level zone', () => {
expect(Math.abs(model.endBottom - 42.411)).toBeLessThan(TOL);
expect(Math.abs(model.levelBottom - 40.911)).toBeLessThan(TOL);
});
it('column height 12.703 m and footing top 28.208 m, soil cover 0.723 m', () => {
expect(Math.abs(model.derived.columnHeight - 12.703)).toBeLessThan(TOL);
expect(Math.abs(model.footings[0].top - 28.208)).toBeLessThan(TOL);
expect(Math.abs(model.derived.soilCover - 0.723)).toBeLessThan(TOL);
});
it('piles: 4 rows × 5 = 20, spacing 2250 mm, tip at 2.208 m', () => {
expect(model.piles).toHaveLength(20);
expect(model.derived.pileCount).toBe(20);
expect(Math.abs((model.derived.pileTipEl ?? NaN) - 2.208)).toBeLessThan(TOL);
const sValues = [...new Set(model.piles.map((p) => p.s.toFixed(4)))];
const nValues = [...new Set(model.piles.map((p) => p.n.toFixed(4)))];
expect(sValues).toHaveLength(5);
expect(nValues).toHaveLength(4);
expect(Number(nValues[1]) - Number(nValues[0])).toBeCloseTo(2.25, 4);
expect(Number(sValues[1]) - Number(sValues[0])).toBeCloseTo(2.25, 4);
});
it('blinding concrete 10600 × 8350 × 100 under the footing', () => {
const b = model.blindings[0];
expect(b.length).toBeCloseTo(10.6, 4);
expect(b.width).toBeCloseTo(8.35, 4);
expect(b.thickness).toBeCloseTo(0.1, 4);
});
it('footing 10400 × 8150 × 2000 mm', () => {
const f = model.footings[0];
expect(f.length).toBeCloseTo(10.4, 4);
expect(f.width).toBeCloseTo(8.15, 4);
expect(f.top - f.bottom).toBeCloseTo(2.0, 4);
});
});
+34
View File
@@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest';
import { parseWorkbook } from '../src/core/parseWorkbook';
import { loadP1, loadWorkbook } from './helpers';
describe('parseWorkbook (P1 sheet intent.PR)', () => {
it('finds exactly one pier P1 with the input values of the sheet', () => {
const { piers, issues } = parseWorkbook(loadWorkbook());
expect(issues).toEqual([]);
expect(piers.map((p) => p.name)).toEqual(['P1']);
const p1 = loadP1();
expect(p1.type).toBe('T형');
expect(p1.foundationType).toBe('말뚝기초');
expect(p1.copingTopEl).toBeCloseTo(43.411, 6);
expect(p1.footingBottomEl).toBeCloseTo(26.208, 6);
expect(p1.groundEl).toBeCloseTo(28.931, 6);
expect(p1.pileLengthMm).toBe(24000);
expect(p1.copingWidthMm).toBe(3100);
expect(p1.copingLengthMm).toBe(12000);
expect(p1.columnCount).toBe(1);
});
it('reads 4 IP points and 10 bearings (5 front, 5 back)', () => {
const p1 = loadP1();
expect(p1.ips.map((p) => p.name)).toEqual(['IP1', 'IP2', 'IP3', 'IP4']);
expect(p1.bearings).toHaveLength(10);
expect(p1.bearings.filter((b) => b.row === '전열')).toHaveLength(5);
expect(p1.bearings.filter((b) => b.row === '후열')).toHaveLength(5);
});
it('converts the bearing azimuth strings to decimal degrees', () => {
const p1 = loadP1();
expect(p1.bearings[0].azimuthDeg).toBeCloseTo(75.9489, 4);
});
});
+54
View File
@@ -0,0 +1,54 @@
import { describe, expect, it } from 'vitest';
import { buildPier } from '../src/core/geometry';
import { validateInput, validatePier } from '../src/core/validate';
import type { Issue, PierInput } from '../src/core/types';
import { loadP1 } from './helpers';
function run(input: PierInput): Issue[] {
const issues: Issue[] = [];
if (validateInput(input, issues)) validatePier(input, buildPier(input), issues);
return issues;
}
describe('validation (spec §10)', () => {
it('P1 raises I01 (block 100 mm < cover + bar 119 mm) and no E/W', () => {
const issues = run(loadP1());
expect(issues.map((i) => i.code)).toContain('I01');
expect(issues.filter((i) => i.level !== 'I')).toEqual([]);
});
it('E01 when the pier type and column count disagree', () => {
const issues = run({ ...loadP1(), type: '2주형' });
expect(issues.map((i) => i.code)).toContain('E01');
});
it('E02 when front and back bearing counts differ', () => {
const p1 = loadP1();
const issues: Issue[] = [];
const bad = { ...p1, bearings: p1.bearings.filter((b) => !(b.row === '후열' && b.no === 10)) };
expect(validateInput(bad, issues)).toBe(false);
expect(issues.map((i) => i.code)).toContain('E02');
});
it('W01 when IP2 disagrees with the 1:9 recalculation by more than 1 mm', () => {
const p1 = loadP1();
const ips = p1.ips.map((p) => (p.name === 'IP2' ? { ...p, x: p.x + 0.002 } : p));
expect(run({ ...p1, ips }).map((i) => i.code)).toContain('W01');
});
});
describe('foundation types', () => {
it('직접기초: footing, no piles', () => {
const m = buildPier({ ...loadP1(), foundationType: '직접기초' });
expect(m.footings).toHaveLength(1);
expect(m.piles).toHaveLength(0);
});
it('현장타설말뚝: no footing, one pile of column diameter per column, tip = bottom EL − length', () => {
const m = buildPier({ ...loadP1(), foundationType: '현장타설말뚝' });
expect(m.footings).toHaveLength(0);
expect(m.piles).toHaveLength(1);
expect(m.piles[0].diameter).toBeCloseTo(2.0, 6);
expect(m.piles[0].bottom).toBeCloseTo(26.208 - 24, 6);
});
});
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"isolatedModules": true,
"types": ["node"]
},
"include": ["src", "tests"]
}