feat(view): 도면 불러오기 — read the bearing drawing values from an xlsx or csv sheet on ②
The sheet needs the header names No, EL.X, h1 and h2 (case, spaces, dots and units in parentheses are ignored). Rows are checked: No must be a unique positive integer, EL.X must be in metres (30–100), h1 and h2 must be positive mm up to 1000. Any error stops the import with the first messages; a valid sheet fills this pier's drawing values and regenerates the pier. The parser is pure and tested. Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e4e141c801
commit
6f32bd96c4
3 files changed
+124
No files matched your search
@@ -0,0 +1,61 @@
|
|||||||
|
/**
|
||||||
|
* 도면 불러오기 (승인 2026-10-23): reads the bearing drawing values from a sheet (xlsx or csv rows). Pure: the caller
|
||||||
|
* passes the rows (arrays of cells). The header row names: No, EL.X (m), h1 (mm), h2 (mm). 열 and other columns are ignored.
|
||||||
|
*/
|
||||||
|
import type { DrawingBearing } from './bearing-ref';
|
||||||
|
|
||||||
|
export interface DrawingImportResult {
|
||||||
|
rows: DrawingBearing[];
|
||||||
|
errors: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Header names ignore case, spaces, dots and a unit in parentheses: 'EL. X (m)' → 'elx'.
|
||||||
|
const norm = (v: unknown) => String(v ?? '').replace(/\(.*?\)/g, '').toLowerCase().replace(/[\s.]/g, '');
|
||||||
|
|
||||||
|
/** Column of each header name, or -1 when missing. */
|
||||||
|
function headerIndex(header: unknown[]): { no: number; elx: number; h1: number; h2: number } {
|
||||||
|
const find = (...names: string[]) => header.findIndex((c) => names.includes(norm(c)));
|
||||||
|
return { no: find('no'), elx: find('elx', 'el'), h1: find('h1', '블럭높이h1'), h2: find('h2', '몰탈h2', '모르타르h2') };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseDrawingRows(rows: unknown[][]): DrawingImportResult {
|
||||||
|
const errors: string[] = [];
|
||||||
|
const headerAt = rows.findIndex((r) => {
|
||||||
|
const idx = headerIndex(r);
|
||||||
|
return idx.no >= 0 && idx.elx >= 0 && idx.h1 >= 0 && idx.h2 >= 0;
|
||||||
|
});
|
||||||
|
if (headerAt < 0) {
|
||||||
|
return { rows: [], errors: ['머리글을 찾지 못했습니다. No, EL.X, h1, h2 열이 있어야 합니다.'] };
|
||||||
|
}
|
||||||
|
const idx = headerIndex(rows[headerAt]);
|
||||||
|
const out: DrawingBearing[] = [];
|
||||||
|
const seen = new Set<number>();
|
||||||
|
rows.slice(headerAt + 1).forEach((r, i) => {
|
||||||
|
const rowNo = headerAt + 2 + i;
|
||||||
|
if (r.every((c) => String(c ?? '').trim() === '')) return;
|
||||||
|
const no = Number(r[idx.no]);
|
||||||
|
const elx = Number(r[idx.elx]);
|
||||||
|
const h1 = Number(r[idx.h1]);
|
||||||
|
const h2 = Number(r[idx.h2]);
|
||||||
|
if (!Number.isInteger(no) || no < 1) {
|
||||||
|
errors.push(`${rowNo}행: No가 올바르지 않습니다.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (seen.has(no)) {
|
||||||
|
errors.push(`${rowNo}행: No ${no}가 중복됩니다.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(elx) || elx < 30 || elx > 100) {
|
||||||
|
errors.push(`${rowNo}행: EL.X는 m 단위로 적어 주세요 (예: 43.625). 값: ${String(r[idx.elx])}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(h1) || h1 <= 0 || h1 > 1000 || !Number.isFinite(h2) || h2 <= 0 || h2 > 1000) {
|
||||||
|
errors.push(`${rowNo}행: h1·h2는 mm 단위 양수로 적어 주세요.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
seen.add(no);
|
||||||
|
out.push({ no, elx, h1, h2 });
|
||||||
|
});
|
||||||
|
if (out.length === 0) errors.push('불러온 행이 없습니다.');
|
||||||
|
return { rows: out.sort((a, b) => a.no - b.no), errors };
|
||||||
|
}
|
||||||
+31
@@ -15,6 +15,7 @@ import { shearSegments } from './core/rebar/coping';
|
|||||||
import { buildRebar3D, type RebarColorMode, type RebarScene } from './view/rebar3d';
|
import { buildRebar3D, type RebarColorMode, type RebarScene } from './view/rebar3d';
|
||||||
import { DEFAULT_MATERIAL, FCK_MEMBERS, fyOf, GRADE_CHOICES, isAssumedMaterial, materialFrom, type BendFactors, type FckByMember, type FckMember, type GradeKey, type Material } from './core/rebar/common';
|
import { DEFAULT_MATERIAL, FCK_MEMBERS, fyOf, GRADE_CHOICES, isAssumedMaterial, materialFrom, type BendFactors, type FckByMember, type FckMember, type GradeKey, type Material } from './core/rebar/common';
|
||||||
import { buildQuantity, quantityRows, type Quantity, type QuantityLine } from './core/rebar/quantity';
|
import { buildQuantity, quantityRows, type Quantity, type QuantityLine } from './core/rebar/quantity';
|
||||||
|
import { parseDrawingRows } from './core/drawing-import';
|
||||||
import { P1_COPING_GEOM } from './core/coping-geom';
|
import { P1_COPING_GEOM } from './core/coping-geom';
|
||||||
import { applyBearingRules, BEARING_ELEVATION_REF, BEARING_RULES_DEFAULT, type BearingBasis, type BearingRules, type DrawingBearing, DRAWING_TOL_MM, drawingCopingCheck, SOLPLATE_REF, SOLPLATE_T_RULE, solplateTs, withDrawingBasis } from './core/bearing-ref';
|
import { applyBearingRules, BEARING_ELEVATION_REF, BEARING_RULES_DEFAULT, type BearingBasis, type BearingRules, type DrawingBearing, DRAWING_TOL_MM, drawingCopingCheck, SOLPLATE_REF, SOLPLATE_T_RULE, solplateTs, withDrawingBasis } from './core/bearing-ref';
|
||||||
|
|
||||||
@@ -1337,6 +1338,36 @@ function renderBearingBasis(wrap: HTMLDivElement, pier: Pier): void {
|
|||||||
table.appendChild(body);
|
table.appendChild(body);
|
||||||
wrap.appendChild(el('div', undefined, 'table-scroll')).appendChild(table);
|
wrap.appendChild(el('div', undefined, 'table-scroll')).appendChild(table);
|
||||||
|
|
||||||
|
// 도면 불러오기 (승인 2026-10-23): a sheet with No, EL.X, h1, h2 (xlsx or csv) fills this pier's drawing values.
|
||||||
|
const importInput = el('input');
|
||||||
|
importInput.type = 'file';
|
||||||
|
importInput.accept = '.xlsx,.xls,.csv';
|
||||||
|
importInput.id = 'drawing-import';
|
||||||
|
importInput.addEventListener('change', async () => {
|
||||||
|
const file = importInput.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
try {
|
||||||
|
const wb = XLSX.read(await file.arrayBuffer(), { type: 'array' });
|
||||||
|
const sheet = wb.Sheets[wb.SheetNames[0]];
|
||||||
|
const rows = XLSX.utils.sheet_to_json<unknown[]>(sheet, { header: 1, blankrows: false, defval: '' });
|
||||||
|
const result = parseDrawingRows(rows);
|
||||||
|
if (result.errors.length) {
|
||||||
|
setError(`도면 불러오기 실패: ${result.errors.slice(0, 3).join(' ')}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
bearingDrawing = { ...bearingDrawing, [name]: result.rows };
|
||||||
|
regenerateAllPiers();
|
||||||
|
renderAll();
|
||||||
|
setStatus(`${file.name}에서 받침 ${result.rows.length}개의 도면 값을 불러왔습니다.`);
|
||||||
|
} catch (err) {
|
||||||
|
setError(`도면 파일을 읽지 못했습니다: ${(err as Error).message}`);
|
||||||
|
} finally {
|
||||||
|
importInput.value = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const importLabel = el('label', '도면 파일 불러오기 (xlsx·csv)');
|
||||||
|
importLabel.appendChild(importInput);
|
||||||
|
wrap.appendChild(importLabel);
|
||||||
const fill = el('button', '도면값 채우기 (참조 값)', 'btn small');
|
const fill = el('button', '도면값 채우기 (참조 값)', 'btn small');
|
||||||
fill.type = 'button';
|
fill.type = 'button';
|
||||||
fill.addEventListener('click', () => {
|
fill.addEventListener('click', () => {
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { parseDrawingRows } from '../src/core/drawing-import';
|
||||||
|
|
||||||
|
const header = ['No', '열', 'EL.X (m)', 'h1 (mm)', 'h2 (mm)'];
|
||||||
|
|
||||||
|
describe('도면 불러오기 (승인 2026-10-23)', () => {
|
||||||
|
it('reads No, EL.X, h1, h2 from a sheet with a header and ignores other columns', () => {
|
||||||
|
const rows = [['도면 받침', '', '', '', ''], header, [1, '전열', 43.625, 164, 50], [2, '전열', 43.697, 164, 50]];
|
||||||
|
const r = parseDrawingRows(rows);
|
||||||
|
expect(r.errors).toEqual([]);
|
||||||
|
expect(r.rows).toEqual([
|
||||||
|
{ no: 1, elx: 43.625, h1: 164, h2: 50 },
|
||||||
|
{ no: 2, elx: 43.697, h1: 164, h2: 50 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts the header spelled as "EL. X" and in a different order', () => {
|
||||||
|
const rows = [['h2', 'No', 'h1', 'EL. X'], [50, 1, 164, 43.625]];
|
||||||
|
const r = parseDrawingRows(rows);
|
||||||
|
expect(r.errors).toEqual([]);
|
||||||
|
expect(r.rows[0]).toEqual({ no: 1, elx: 43.625, h1: 164, h2: 50 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports a missing header, a duplicate No, and an EL.X given in mm', () => {
|
||||||
|
expect(parseDrawingRows([['x', 'y']]).errors[0]).toContain('머리글');
|
||||||
|
const dup = parseDrawingRows([header, [1, '', 43.6, 164, 50], [1, '', 43.7, 164, 50]]);
|
||||||
|
expect(dup.errors.some((e) => e.includes('중복'))).toBe(true);
|
||||||
|
const mm = parseDrawingRows([header, [1, '', 43625, 164, 50]]);
|
||||||
|
expect(mm.errors.some((e) => e.includes('m 단위'))).toBe(true);
|
||||||
|
expect(mm.rows).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user