From 7b5517e3127e47b05fe3b5cc59c4c64608ba858d Mon Sep 17 00:00:00 2001 From: ypshim Date: Mon, 26 Oct 2026 10:48:37 +0900 Subject: [PATCH] =?UTF-8?q?feat(ground):=20N=20value=20per=20layer=20row?= =?UTF-8?q?=20(text,=20blow=20count=20or=20=ED=83=80=EC=88=98/=EA=B4=80?= =?UTF-8?q?=EC=9E=85=EB=9F=89=20mm)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - StrataRow.nValue: text. "23" is a blow count (0–100), "50/5" a refusal (50 blows for 5 mm, 1–300 mm). Empty means not known yet. - G06 (warning): a malformed N value. Strict, because the values will feed the bearing-capacity and liquefaction checks. - The layer table has an N값 column; the project file checks that the rows are well formed. Co-Authored-By: Claude Haiku 5.5 --- src/core/ground.ts | 41 +++++++++++++++++++++++++++++++++++++ src/core/project.ts | 5 +++++ src/core/types.ts | 2 ++ src/core/validate.ts | 3 ++- src/main.ts | 16 ++++++++++++--- tests/ground-inputs.test.ts | 38 +++++++++++++++++++++++++++++++++- 6 files changed, 100 insertions(+), 5 deletions(-) diff --git a/src/core/ground.ts b/src/core/ground.ts index 3257459..d45df95 100644 --- a/src/core/ground.ts +++ b/src/core/ground.ts @@ -71,3 +71,44 @@ export function validatePileEmbedment(input: PierInput, model: PierModel, issues }); } } + +/** N값 as read from the text: a blow count, or a refusal "타수/관입량" with the penetration in mm. */ +export type NValue = { kind: 'empty' } | { kind: 'n'; n: number } | { kind: 'refusal'; blows: number; mm: number } | { kind: 'invalid' }; + +/** Largest blow count and penetration accepted (표준관입시험 의 일반 범위; 연타 한계 50 blows / 300 mm). */ +export const N_MAX = 100; +export const PENETRATION_MAX_MM = 300; + +/** Reads an N value. "" is empty; "23" is 23 blows; "50/5" is 50 blows for 5 mm (관입량 mm). */ +export function parseNValue(text: string | undefined): NValue { + const v = (text ?? '').trim(); + if (v === '') return { kind: 'empty' }; + if (/^\d+$/.test(v)) { + const n = Number(v); + return n <= N_MAX ? { kind: 'n', n } : { kind: 'invalid' }; + } + const m = /^(\d+)\/(\d+)$/.exec(v); + if (m) { + const blows = Number(m[1]); + const mm = Number(m[2]); + if (blows >= 1 && blows <= N_MAX && mm >= 1 && mm <= PENETRATION_MAX_MM) return { kind: 'refusal', blows, mm }; + } + return { kind: 'invalid' }; +} + +/** + * G06 (경고): an N value that is not a blow count or a refusal in the accepted range. Empty is allowed. + * The values are not used in the calculation yet; they will feed the 지지력 and 액상화 checks, so the format is strict. + */ +export function validateStrataNValues(input: PierInput, issues: Issue[]): void { + (input.strataRows ?? []).forEach((row, i) => { + if (parseNValue(row.nValue).kind === 'invalid') { + issues.push({ + code: 'G06', + level: 'W', + pier: input.name, + message: `지층 ${i + 1}행 (${row.name}) N값 "${row.nValue}"의 형식이 맞지 않습니다. 타수(0–${N_MAX}) 또는 "타수/관입량 mm"(예: 50/5)으로 적으세요.`, + }); + } + }); +} diff --git a/src/core/project.ts b/src/core/project.ts index a0c0703..f9de536 100644 --- a/src/core/project.ts +++ b/src/core/project.ts @@ -75,6 +75,11 @@ export function parseProject(text: string): ProjectFile { obj.piers.forEach((p, i) => { if (!p || typeof p.id !== 'string' || !p.input) throw new Error(`교각 ${i + 1}: id 또는 input이 없습니다.`); const input = p.input; + const rows = input.strataRows; + if (rows !== undefined) { + const ok = Array.isArray(rows) && rows.every((r) => typeof r?.name === 'string' && typeof r?.bottomEl === 'number' && Number.isFinite(r.bottomEl) && (r.nValue === undefined || typeof r.nValue === 'string')); + if (!ok) throw new Error(`교각 ${input.name}: 지층 행 형식이 맞지 않습니다.`); + } if (typeof input.name !== 'string' || !Array.isArray(input.ips) || !Array.isArray(input.bearings)) { throw new Error(`교각 ${i + 1}(${String(input.name)}): 입력 형식이 맞지 않습니다.`); } diff --git a/src/core/types.ts b/src/core/types.ts index 7ad0bc0..98cd038 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -70,6 +70,8 @@ export interface StrataRow { name: string; /** 이 층의 하단 EL (m). */ bottomEl: number; + /** N값 as written: "23" (blows) or "50/5" (50 blows for 5 mm, 관입량 mm). Absent = not known yet. */ + nValue?: string; } export type IssueLevel = 'E' | 'W' | 'I'; diff --git a/src/core/validate.ts b/src/core/validate.ts index e449f88..d0e11c2 100644 --- a/src/core/validate.ts +++ b/src/core/validate.ts @@ -1,6 +1,6 @@ import { DIM, type PierModel } from './geometry'; import type { Issue, PierInput, PierType } from './types'; -import { validateGround, validatePileEmbedment } from './ground'; +import { validateGround, validatePileEmbedment, validateStrataNValues } from './ground'; const EXPECTED_COLUMNS: Record = { 'T형': 1, '2주형': 2, '3주형': 3 }; @@ -92,4 +92,5 @@ export function validatePier(input: PierInput, model: PierModel, issues: Issue[] } validateGround(input, issues); validatePileEmbedment(input, model, issues); + validateStrataNValues(input, issues); } diff --git a/src/main.ts b/src/main.ts index 6b9baf6..aa3d512 100644 --- a/src/main.ts +++ b/src/main.ts @@ -17,7 +17,7 @@ import { DEFAULT_MATERIAL, FCK_MEMBERS, fyOf, GRADE_CHOICES, isAssumedMaterial, import { buildQuantity, quantityRows, type Quantity, type QuantityLine } from './core/rebar/quantity'; import { parseDrawingRows } from './core/drawing-import'; import { groundElFor, parseGroundModel, strataRowsFor, type GroundModelFile } from './core/ground-model'; -import { STRATA_NAMES } from './core/ground'; +import { parseNValue, STRATA_NAMES } from './core/ground'; import { groundSectionSvg } from './view/ground-svg'; import { parseBearingElevationTable, parseSolplateTable, readDxfTexts } from './core/dxf-table'; import { columnDiameterIssues } from './core/column-check'; @@ -1582,7 +1582,7 @@ function renderGroundModel(box: HTMLDivElement, pier: Pier): void { const table = el('table', undefined, 'grid-table rebar-table'); const head = el('thead'); const hr = el('tr'); - for (const h of ['지층', '하단 EL (m)', '']) hr.appendChild(el('th', h)); + for (const h of ['지층', '하단 EL (m)', 'N값 (타수 · 타수/관입량 mm)', '']) hr.appendChild(el('th', h)); head.appendChild(hr); table.appendChild(head); const body = el('tbody'); @@ -1606,10 +1606,20 @@ function renderGroundModel(box: HTMLDivElement, pier: Pier): void { bottomInput.step = '0.001'; bottomInput.value = String(row.bottomEl); bottomInput.addEventListener('change', () => setStrataRows(name, rows.map((r, j) => (j === i ? { ...r, bottomEl: Number(bottomInput.value) } : r)))); + // N값 as text: a blow count or "타수/관입량 mm" (승인 2026-10-26). Empty means not known yet. + const nInput = el('input'); + nInput.type = 'text'; + nInput.placeholder = '예: 23 · 50/5'; + nInput.value = row.nValue ?? ''; + nInput.addEventListener('change', () => { + const v = nInput.value.trim(); + setStrataRows(name, rows.map((r, j) => (j === i ? { ...r, nValue: v === '' ? undefined : v } : r))); + }); + if (parseNValue(row.nValue).kind === 'invalid') nInput.classList.add('warn-text'); const del = el('button', '삭제', 'btn small'); del.type = 'button'; del.addEventListener('click', () => setStrataRows(name, rows.filter((_, j) => j !== i))); - tr.append(...[select, bottomInput, del].map((x) => { + tr.append(...[select, bottomInput, nInput, del].map((x) => { const td = el('td'); td.appendChild(x); return td; diff --git a/tests/ground-inputs.test.ts b/tests/ground-inputs.test.ts index 2ec5c4d..702f111 100644 --- a/tests/ground-inputs.test.ts +++ b/tests/ground-inputs.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { buildPier } from '../src/core/geometry'; -import { validateGround, validatePileEmbedment } from '../src/core/ground'; +import { parseNValue, validateGround, validatePileEmbedment, validateStrataNValues } from '../src/core/ground'; +import { parseProject, serializeProject, SCHEMA_VERSION } from '../src/core/project'; import { changedKeys } from '../src/core/changes'; import { COPY_ITEMS, applyCopy, planCopy } from '../src/core/copy'; import type { Issue, PierInput } from '../src/core/types'; @@ -135,3 +136,38 @@ describe('말뚝 선단 근입 (G04·G05)', () => { expect(embed(withGround({ strataRows: [{ name: '풍화토', bottomEl: 20 }] }))).toEqual(['G05']); }); }); + +describe('N값 입력 (승인 2026-10-26: 글자 입력, 타수/관입량 mm)', () => { + it('reads a blow count, a refusal in mm, and empty; surrounding spaces are ignored', () => { + expect(parseNValue('')).toEqual({ kind: 'empty' }); + expect(parseNValue(undefined)).toEqual({ kind: 'empty' }); + expect(parseNValue(' 23 ')).toEqual({ kind: 'n', n: 23 }); + expect(parseNValue('0')).toEqual({ kind: 'n', n: 0 }); + expect(parseNValue('50/5')).toEqual({ kind: 'refusal', blows: 50, mm: 5 }); + }); + + it('rejects text, out-of-range blow counts, and penetrations', () => { + for (const bad of ['abc', '-3', '101', '50/0', '50/301', '50/', '/5', '23.5']) { + expect(parseNValue(bad).kind).toBe('invalid'); + } + }); + + it('G06: a malformed N value is a warning; empty and valid values are not', () => { + const issues: Issue[] = []; + validateStrataNValues(withGround({ strataRows: [{ name: '매립토', bottomEl: 27, nValue: '가' }, { name: '풍화토', bottomEl: 22, nValue: '50/5' }, { name: '연암', bottomEl: 10 }] }), issues); + expect(issues.map((i) => i.code)).toEqual(['G06']); + expect(issues[0].level).toBe('W'); + }); + + it('a project file refuses a layer row whose N value is not text', () => { + const bad = { ...loadP1(), strataRows: [{ name: '연암', bottomEl: 10, nValue: 5 }] } as unknown as PierInput; + const text = serializeProject({ + schemaVersion: SCHEMA_VERSION, + project: { name: 'x', bridge: '운곡교', client: '', author: '', crs: 'KATEC', memo: '' }, + source: { fileName: 'x.xlsx', sheets: [] }, + piers: [{ id: 'P1', input: bad }], + history: [], + }); + expect(() => parseProject(text)).toThrow('지층 행 형식'); + }); +});