diff --git a/index.html b/index.html index 5f52b57..3f8b71a 100644 --- a/index.html +++ b/index.html @@ -42,6 +42,8 @@
+

엑셀 시트 인식 결과

+

계산 결과

알림

diff --git a/src/core/geometry.ts b/src/core/geometry.ts index 671f952..012c203 100644 --- a/src/core/geometry.ts +++ b/src/core/geometry.ts @@ -118,6 +118,7 @@ export interface PierModel { columnHeight: number; soilCover: number; pileCount: number; + pileLayout: string | null; pileTipEl: number | null; blockHeightFront: number; blockHeightBack: number; @@ -290,6 +291,7 @@ export function buildPier(input: PierInput): PierModel { columnHeight: levelBottom - columns[0].bottom, soilCover: input.groundEl - (input.foundationType === '현장타설말뚝' ? input.footingBottomEl : footingTop), pileCount: piles.length, + pileLayout: input.foundationType === '말뚝기초' ? pileLayoutText() : null, pileTipEl: piles.length ? pileTip : null, blockHeightFront, blockHeightBack, @@ -313,12 +315,26 @@ function segmentTop(segments: Segment[], s: number): number { * 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[] { +export function pileGrid(): { countS: number; countN: number; spacingS: number; spacingN: number } { 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); + return { + countS, + countN, + spacingS: (DIM.footingL - 2 * DIM.pileEnd) / (countS - 1), + spacingN: (DIM.footingW - 2 * DIM.pileEnd) / (countN - 1), + }; +} + +/** Display string such as "4@2250 × 3@2250 = 20본" (gaps along the length × gaps across the width). */ +export function pileLayoutText(): string { + const g = pileGrid(); + return `${g.countS - 1}@${Math.round(g.spacingS * 1000)} × ${g.countN - 1}@${Math.round(g.spacingN * 1000)} = ${g.countS * g.countN}본`; +} + +export function pilesFor(s: number, topEl: number, length: number): PileModel[] { + const { countS, countN, spacingS, spacingN } = pileGrid(); const out: PileModel[] = []; for (let i = 0; i < countS; i++) { for (let j = 0; j < countN; j++) { diff --git a/src/core/parseWorkbook.ts b/src/core/parseWorkbook.ts index 51c08aa..38d3b1f 100644 --- a/src/core/parseWorkbook.ts +++ b/src/core/parseWorkbook.ts @@ -5,24 +5,50 @@ import type { Bearing, FoundationType, Issue, IpPoint, PierInput, PierType, Row type Cell = string | number | null | undefined; type Grid = Cell[][]; +export interface SheetReport { + name: string; + /** Pier input tables found (교각 정보 표 개수). */ + tables: { pier: number; ip: number; bearing: number }; + piers: number; + issues: Issue[]; + /** A sheet with any E-level issue cannot be selected (spec §2.3). */ + ok: boolean; +} + export interface ParseResult { piers: PierInput[]; issues: Issue[]; + sheets: SheetReport[]; } 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). */ +/** Reads every sheet, finds the pier tables by their header strings, and reports each sheet separately. */ export function parseWorkbook(wb: XLSX.WorkBook): ParseResult { const piers: PierInput[] = []; const issues: Issue[] = []; + const sheets: SheetReport[] = []; for (const sheetName of wb.SheetNames) { const ws = wb.Sheets[sheetName]; const grid = XLSX.utils.sheet_to_json(ws, { header: 1, raw: true, defval: null }) as Grid; - piers.push(...parseSheet(grid, sheetName, issues)); + const sheetIssues: Issue[] = []; + const found = parseSheet(grid, sheetName, sheetIssues); + piers.push(...found); + issues.push(...sheetIssues); + sheets.push({ + name: sheetName, + tables: { + pier: findHeaders(grid, (r) => r[0] === '교각명' && r[1] === '교각형식').length, + ip: findHeaders(grid, (r) => r[0] === '교각명' && r[1] === 'IP점').length, + bearing: findHeaders(grid, (r) => r[0] === '교각명' && r[1] === '받침번호').length, + }, + piers: found.length, + issues: sheetIssues, + ok: found.length > 0 && !sheetIssues.some((i) => i.level === 'E'), + }); } - return { piers, issues }; + return { piers, issues, sheets }; } function parseSheet(grid: Grid, sheet: string, issues: Issue[]): PierInput[] { @@ -61,18 +87,19 @@ function parseSheet(grid: Grid, sheet: string, issues: Issue[]): PierInput[] { code: 'E03', level: 'E', pier: name, - message: `${sheet} ${bad.row}행: 방위각 문자열을 읽을 수 없습니다. 예: 75°56'55.9"`, + message: `${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}행: 교각형식 또는 기초 형식을 알 수 없습니다.` }); + issues.push({ code: 'E01', level: 'E', pier: name, message: `${index + 1}행: 교각형식 또는 기초 형식을 알 수 없습니다.` }); continue; } piers.push({ name, + sheet, type, copingTopEl: Number(cells[2]), footingBottomEl: Number(cells[3]), diff --git a/src/core/types.ts b/src/core/types.ts index 1b371fd..d935bf2 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -21,6 +21,8 @@ export interface Bearing { export interface PierInput { name: string; + /** Source sheet name in the workbook (1 sheet = 1 pier in the spec, several allowed). */ + sheet: string; type: PierType; copingTopEl: number; footingBottomEl: number; diff --git a/src/main.ts b/src/main.ts index c1b96bf..d0470ce 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2,7 +2,7 @@ import './style.css'; import * as THREE from 'three'; import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; import * as XLSX from 'xlsx'; -import { parseWorkbook } from './core/parseWorkbook'; +import { parseWorkbook, type SheetReport } from './core/parseWorkbook'; import { buildPier, type PierModel } from './core/geometry'; import { NOT_IMPLEMENTED_RULES, validateInput, validatePier } from './core/validate'; import type { Issue, PierInput } from './core/types'; @@ -16,17 +16,19 @@ interface Loaded { const $ = (id: string): T => document.getElementById(id) as T; -const VIEW_LABELS: Record = { iso: '등각', front: '정면', side: '측면', top: '평면' }; +const VIEW_LABELS: Record = { iso: '아이소', front: '정면', side: '측면', top: '평면' }; const state: { piers: Loaded[]; parseIssues: Issue[]; + sheets: SheetReport[]; current: { loaded: Loaded; scene: PierScene } | null; view: ViewKind; visible: Record; } = { piers: [], parseIssues: [], + sheets: [], current: null, view: 'iso', visible: Object.fromEntries(Object.keys(PART_LABELS).map((k) => [k, true])) as Record, @@ -113,7 +115,7 @@ function setView(kind: ViewKind): void { // --- Loading ---------------------------------------------------------------------- function loadWorkbookBytes(bytes: Uint8Array): void { - const { piers, issues: parseIssues } = parseWorkbook(XLSX.read(bytes, { type: 'array' })); + const { piers, issues: parseIssues, sheets } = parseWorkbook(XLSX.read(bytes, { type: 'array' })); const loaded: Loaded[] = piers.map((input) => { const issues: Issue[] = []; let model: PierModel | null = null; @@ -129,17 +131,20 @@ function loadWorkbookBytes(bytes: Uint8Array): void { }); state.piers = loaded; state.parseIssues = parseIssues; + state.sheets = sheets; + renderSheets(); + // Only piers from sheets without E-level issues can be selected (spec §2.3). + const selectable = (l: Loaded) => !!l.model && sheets.find((s) => s.name === l.input.sheet)?.ok === true; const select = $('pier'); select.replaceChildren(); - for (const item of loaded) { - if (!item.model) continue; + for (const item of loaded.filter(selectable)) { select.appendChild(new Option(item.input.name, item.input.name)); } select.disabled = select.options.length === 0; - const built = loaded.filter((l) => l.model).length; - $('status').textContent = `교각 ${piers.length}기 인식, 형상 생성 ${built}기`; - const first = loaded.find((l) => l.model); + const built = loaded.filter(selectable).length; + $('status').textContent = `시트 ${sheets.length}장 · 교각 ${piers.length}기 인식 · 선택 가능 ${built}기`; + const first = loaded.find(selectable); if (first) showPier(first.input.name); else showPier(null); } @@ -192,12 +197,15 @@ function renderResults(loaded: Loaded | null): void { row(body, '기둥 중심 (국토좌표 m)', pt(m.pivot)); row(body, '코핑 상면 EL 기준 (m)', fmt(loaded.input.copingTopEl)); row(body, '전열 구간 상면 EL (m)', m.segments.map((s) => fmt(s.top)).join(' / ')); + row(body, '계단 경계 (BP1 기준, m)', m.segments.slice(1).map((s) => fmt(s.s0)).join(' / ')); + row(body, '횡방향 단차', '없음'); row(body, '코핑 끝단 하면 EL (m)', fmt(m.endBottom)); row(body, '코핑 Level 하면 EL (m)', fmt(m.levelBottom)); row(body, '기둥 높이 (m)', fmt(m.derived.columnHeight)); row(body, '확대기초 상면 EL (m)', m.footings.length ? fmt(m.footings[0].top) : '없음'); row(body, '흙덮개 (m)', fmt(m.derived.soilCover)); row(body, '말뚝 수 (본)', String(m.derived.pileCount)); + row(body, '말뚝 배치', m.derived.pileLayout ?? '없음'); row(body, '말뚝 선단 EL (m)', m.derived.pileTipEl === null ? '없음' : fmt(m.derived.pileTipEl)); row(body, '받침블럭 콘크리트 전열 / 후열 (mm)', `${Math.round(m.derived.blockHeightFront * 1000)} / ${Math.round(m.derived.blockHeightBack * 1000)}`); } else { @@ -226,6 +234,28 @@ function renderResults(loaded: Loaded | null): void { for (const text of items) defaults.appendChild(el('li', text)); } +function renderSheets(): void { + const box = $('sheets'); + box.replaceChildren(); + if (state.sheets.length === 0) { + box.appendChild(el('li', '엑셀을 불러오면 시트별 인식 결과가 표시됩니다.')); + return; + } + for (const s of state.sheets) { + const li = el('li'); + const status = s.ok ? '선택 가능' : '선택 불가 (오류)'; + const head = el('span', `${s.name} · ${status}`, s.ok ? 'sheet-ok' : 'code'); + const detail = document.createTextNode(` · 교각 ${s.tables.pier}표 · IP ${s.tables.ip}표 · 받침 ${s.tables.bearing}표`); + li.append(head, detail); + box.appendChild(li); + for (const issue of s.issues) { + const sub = el('li', `${issue.code} ${issue.pier ? `[${issue.pier}] ` : ''}${issue.message}`, issue.level === 'E' ? 'code' : undefined); + sub.style.marginLeft = '16px'; + box.appendChild(sub); + } + } +} + // --- Events ----------------------------------------------------------------------- $('sample').addEventListener('click', async () => { $('status').textContent = '샘플 불러오는 중…'; @@ -252,6 +282,7 @@ $('pier').addEventListener('change', (ev) => showPier((ev.tar buildPartToggles(); buildViewButtons(); +renderSheets(); renderResults(null); // Deep link for the bundled sample: open with ?sample=1 to load P1 without clicking. diff --git a/src/style.css b/src/style.css index bb20c0f..c194302 100644 --- a/src/style.css +++ b/src/style.css @@ -216,6 +216,11 @@ td:first-child { color: var(--warn); } +.issues .sheet-ok { + font-weight: 600; + color: var(--primary); +} + @media (max-width: 900px) { .layout { grid-template-columns: 1fr; diff --git a/tests/p1-regression.test.ts b/tests/p1-regression.test.ts index aeb1986..3932fd1 100644 --- a/tests/p1-regression.test.ts +++ b/tests/p1-regression.test.ts @@ -100,3 +100,9 @@ describe('P1 regression — spec.md §12', () => { expect(f.top - f.bottom).toBeCloseTo(2.0, 4); }); }); + +describe('pile layout text (spec §12, §6.8 display)', () => { + it('reads 4@2250 × 3@2250 = 20본', () => { + expect(model.derived.pileLayout).toBe('4@2250 × 3@2250 = 20본'); + }); +}); diff --git a/tests/parse.test.ts b/tests/parse.test.ts index 691d607..dd08d1c 100644 --- a/tests/parse.test.ts +++ b/tests/parse.test.ts @@ -32,3 +32,20 @@ describe('parseWorkbook (P1 sheet intent.PR)', () => { expect(p1.bearings[0].azimuthDeg).toBeCloseTo(75.9489, 4); }); }); + +describe('sheet report (spec §2.3)', () => { + it('marks the P1 sheet as selectable and counts its tables', () => { + const { sheets } = parseWorkbook(loadWorkbook()); + expect(sheets).toHaveLength(1); + expect(sheets[0]).toMatchObject({ name: 'intent.PR', ok: true, piers: 1, tables: { pier: 1, ip: 1, bearing: 1 } }); + }); + + it('marks a sheet with a bad azimuth (E03) as not selectable and drops its pier', () => { + const wb = loadWorkbook(); + wb.Sheets['intent.PR']['F32'].v = '75.9'; + const { piers, sheets, issues } = parseWorkbook(wb); + expect(issues.map((i) => i.code)).toContain('E03'); + expect(sheets[0].ok).toBe(false); + expect(piers).toHaveLength(0); + }); +});