diff --git a/src/core/changes.ts b/src/core/changes.ts index a02831e..c569eb4 100644 --- a/src/core/changes.ts +++ b/src/core/changes.ts @@ -23,7 +23,7 @@ export const EDITABLE_FIELDS = [ ] as const; /** Inputs tracked for overrides and pending changes: the numeric cards plus 열별 본수 (an array, compared by value). */ -export const TRACKED_KEYS = [...EDITABLE_FIELDS.map((f) => f.key), 'pileRows', 'groundPosition', 'groundWaterOn', 'groundWaterEl', 'floodLevelEl', 'scourDepthM', 'strataRows'] as const; +export const TRACKED_KEYS = [...EDITABLE_FIELDS.map((f) => f.key), 'pileRows', 'groundPosition', 'groundWaterOn', 'groundWaterEl', 'floodLevelEl', 'scourDepthM', 'highWaterEl', 'lowWaterEl', 'strataRows'] as const; export type EditableKey = (typeof TRACKED_KEYS)[number]; @@ -49,6 +49,8 @@ export const FIELD_LABELS: Record = { groundWaterEl: '지하수위 EL', floodLevelEl: '설계홍수위 DHWL', scourDepthM: '세굴 깊이', + highWaterEl: '고조위 HWL', + lowWaterEl: '저조위 LWL', strataRows: '지층 행', }; diff --git a/src/core/copy.ts b/src/core/copy.ts index adf7c47..d432c26 100644 --- a/src/core/copy.ts +++ b/src/core/copy.ts @@ -21,7 +21,7 @@ export const COPY_ITEMS: Record = { 'coping-length': { label: '코핑 길이 L', keys: ['copingLengthMm'], defaultOn: false, pileItem: false }, 'pile-length': { label: '말뚝 길이', keys: ['pileLengthMm'], defaultOn: false, pileItem: true }, 'pile-rows': { label: '열별 본수', keys: ['pileRows'], defaultOn: false, pileItem: true }, - 'ground-position': { label: '지반 위치·지하수위', keys: ['groundPosition', 'groundWaterOn', 'groundWaterEl', 'floodLevelEl'], defaultOn: false, pileItem: false }, + 'ground-position': { label: '지반 위치·지하수위', keys: ['groundPosition', 'groundWaterOn', 'groundWaterEl', 'floodLevelEl', 'highWaterEl', 'lowWaterEl'], defaultOn: false, pileItem: false }, }; export const DEFAULT_COPY_ITEMS: CopyItem[] = (Object.keys(COPY_ITEMS) as CopyItem[]).filter((k) => COPY_ITEMS[k].defaultOn); diff --git a/src/core/ground.ts b/src/core/ground.ts index a2c60f7..28586af 100644 --- a/src/core/ground.ts +++ b/src/core/ground.ts @@ -13,6 +13,15 @@ export const SUPPORT_STRATUM = '연암'; /** 근입 = 이 값 × 말뚝 지름 (지반 모델 G-7, embed_d 1.0). */ export const PILE_EMBED_D = 1.0; +/** 해상 datum (승인 2026-10-26): mean sea level. */ +export const SEA_DATUM = 'MSL'; + +/** + * 부식 여유 (승인 2026-10-26: 기준값으로 고정). PLACEHOLDER: the value is not in the RAG documents yet, + * so 1 mm is a reference to confirm against the design standard before use. + */ +export const CORROSION_ALLOWANCE_MM = 1; + /** * 지반·지층 입력 검사 (승인 2026-10-26). * G01: 층 하단 EL은 위 경계보다 낮아야 함. G02: 지하수위는 지반고와 가장 아래 층 사이에 있어야 함. @@ -41,14 +50,10 @@ export function validateGround(input: PierInput, issues: Issue[]): void { } else if (f < input.groundEl) { issues.push({ code: 'G08', level: 'W', pier: input.name, message: `설계홍수위 ${f.toFixed(3)}이 지반고 ${input.groundEl.toFixed(3)}보다 낮습니다.` }); } - const scour = input.scourDepthM; - if (scour !== undefined) { - if (!(scour >= 0)) { - issues.push({ code: 'G09', level: 'W', pier: input.name, message: '세굴 깊이는 0 이상이어야 합니다.' }); - } else if (input.groundEl - scour < input.footingBottomEl) { - issues.push({ code: 'G09', level: 'W', pier: input.name, message: `세굴선 EL ${(input.groundEl - scour).toFixed(3)}이 확대기초 하면 ${input.footingBottomEl.toFixed(3)}보다 낮습니다 (기초 노출).` }); - } - } + validateScour(input, issues); + } else if (position === '해상') { + validateSea(input, issues); + validateScour(input, issues); } else if (input.groundWaterOn) { const w = input.groundWaterEl; const lowest = rows.length ? rows[rows.length - 1].bottomEl : undefined; @@ -130,3 +135,38 @@ export function validateStrataNValues(input: PierInput, issues: Issue[]): void { } }); } + +/** G09: the 세굴 깊이 is not negative, and the scour line stays above the footing bottom (하상·해상). */ +function validateScour(input: PierInput, issues: Issue[]): void { + const scour = input.scourDepthM; + if (scour === undefined) return; + if (!(scour >= 0)) { + issues.push({ code: 'G09', level: 'W', pier: input.name, message: '세굴 깊이는 0 이상이어야 합니다.' }); + } else if (input.groundEl - scour < input.footingBottomEl) { + issues.push({ code: 'G09', level: 'W', pier: input.name, message: `세굴선 EL ${(input.groundEl - scour).toFixed(3)}이 확대기초 하면 ${input.footingBottomEl.toFixed(3)}보다 낮습니다 (기초 노출).` }); + } +} + +/** + * 해상 (승인 2026-10-26, MSL 기준): 고조위와 저조위 are required, the 해저면 (groundEl) lies below the 고조위, and + * 저조위 is below 고조위. G13 missing; G10 해저면이 고조위보다 낮지 않음; G11 저조위가 고조위보다 낮지 않음; + * G14 강관 두께가 부식 여유 기준 이하. 파랑과 선박 하중은 이번 범위에서 제외. + */ +function validateSea(input: PierInput, issues: Issue[]): void { + const hw = input.highWaterEl; + const lw = input.lowWaterEl; + if (hw === undefined || lw === undefined || !Number.isFinite(hw) || !Number.isFinite(lw)) { + issues.push({ code: 'G13', level: 'W', pier: input.name, message: `해상입니다. 고조위(HWL)와 저조위(LWL)를 ${SEA_DATUM} 기준으로 입력하세요.` }); + } else { + if (hw <= input.groundEl) { + issues.push({ code: 'G10', level: 'W', pier: input.name, message: `고조위 ${hw.toFixed(3)}이 해저면 ${input.groundEl.toFixed(3)}보다 높아야 합니다 (수심 확인).` }); + } + if (lw >= hw) { + issues.push({ code: 'G11', level: 'W', pier: input.name, message: `저조위 ${lw.toFixed(3)}이 고조위 ${hw.toFixed(3)}보다 낮아야 합니다.` }); + } + } + const thickness = input.pileThicknessMm ?? 12; + if (thickness <= CORROSION_ALLOWANCE_MM) { + issues.push({ code: 'G14', level: 'W', pier: input.name, message: `말뚝 두께 ${thickness} mm가 부식 여유 기준 ${CORROSION_ALLOWANCE_MM} mm 이하입니다 (기준값 확인 필요).` }); + } +} diff --git a/src/core/shape-options.ts b/src/core/shape-options.ts index d772ca6..e6e8bfc 100644 --- a/src/core/shape-options.ts +++ b/src/core/shape-options.ts @@ -37,7 +37,7 @@ export const SHAPE_GROUPS: Record = { { title: '말뚝 종류', options: [d('강관'), x('PHC'), x('복합')] }, { title: '경사말뚝', options: [d('없음'), x('있음')] }, ], - ground: [{ title: '위치', options: [d('육상'), ok('하상'), x('해상')] }], + ground: [{ title: '위치', options: [d('육상'), ok('하상'), ok('해상')] }], }; export const UNDEFINED_REASON = '규칙 미정 (Q6 확정 전 선택 불가)'; diff --git a/src/core/types.ts b/src/core/types.ts index 6d0496c..f974c11 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -65,6 +65,9 @@ export interface PierInput { floodLevelEl?: number; /** 세굴 깊이 (m) below the 지반고, used for 하상 only. */ scourDepthM?: number; + /** 해상 (승인 2026-10-26): 고조위 HWL and 저조위 LWL (m, MSL datum). The groundEl is the 해저면 EL for 해상. */ + highWaterEl?: number; + lowWaterEl?: number; strataRows?: StrataRow[]; } diff --git a/src/main.ts b/src/main.ts index b8d5731..09f8a10 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 { parseNValue, STRATA_NAMES } from './core/ground'; +import { CORROSION_ALLOWANCE_MM, parseNValue, SEA_DATUM, 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'; @@ -1515,10 +1515,10 @@ function valueText(key: EditableKey, v: unknown): string { } /** Sets one 지반·지층 input in the draft (applied to the model by 다시 생성). */ -function editGround(name: string, key: 'groundPosition' | 'groundWaterOn' | 'groundWaterEl' | 'floodLevelEl' | 'scourDepthM', value: unknown): void { +function editGround(name: string, key: 'groundPosition' | 'groundWaterOn' | 'groundWaterEl' | 'floodLevelEl' | 'scourDepthM' | 'highWaterEl' | 'lowWaterEl', value: unknown): void { const p = state.piers.find((q) => q.input.name === name); if (!p) return; - const numeric = key === 'groundWaterEl' || key === 'floodLevelEl' || key === 'scourDepthM'; + const numeric = key !== 'groundPosition' && key !== 'groundWaterOn'; if (numeric && value !== undefined && !Number.isFinite(value)) { setError(`${FIELD_LABELS[key]}은(는) 숫자로 입력하세요. 값을 되돌렸습니다.`); renderAll(); @@ -1575,6 +1575,25 @@ function renderGroundModel(box: HTMLDivElement, pier: Pier): void { scourRow.append(el('span', label, 'muted small'), input); } wrap.appendChild(scourRow); + } else if (position === '해상') { + // 해상 (승인 2026-10-26, 기준면 MSL): 고조위·저조위와 세굴. 파랑과 선박 하중은 이번 범위에서 제외. + wrap.appendChild(el('p', `해상 · 기준면 ${SEA_DATUM} · 지반고 칸은 해저면 EL`, 'lbl')); + const seaRow = el('div', undefined, 'ground-water'); + seaRow.style.display = 'flex'; + seaRow.style.flexWrap = 'wrap'; + seaRow.style.alignItems = 'center'; + seaRow.style.gap = '8px'; + for (const [key, label, value] of [['highWaterEl', '고조위 HWL (m)', d.highWaterEl], ['lowWaterEl', '저조위 LWL (m)', d.lowWaterEl], ['scourDepthM', '세굴 깊이 (m)', d.scourDepthM]] as const) { + const input = el('input'); + input.type = 'number'; + input.step = 'any'; + input.placeholder = label; + input.value = value === undefined ? '' : String(value); + input.addEventListener('change', () => editGround(name, key, input.value === '' ? undefined : Number(input.value))); + seaRow.append(el('span', label, 'muted small'), input); + } + wrap.appendChild(seaRow); + wrap.appendChild(el('p', `부식 여유 기준 ${CORROSION_ALLOWANCE_MM} mm (확인 필요) · 파랑과 선박 하중은 이번 범위에서 제외`, 'muted small')); } else { wrap.appendChild(el('p', '지하수위', 'lbl')); const water = el('div', undefined, 'ground-water'); diff --git a/src/view/ground-svg.ts b/src/view/ground-svg.ts index 02d7a88..c1eaea2 100644 --- a/src/view/ground-svg.ts +++ b/src/view/ground-svg.ts @@ -21,14 +21,21 @@ export function groundSectionSvg(input: PierInput, model: PierModel): string { const pileTips = model.piles.map((p) => p.bottom); const lowestRow = rows.length ? rows[rows.length - 1].bottomEl : ground - 1; // 하상: the 설계홍수위 (solid) and the 세굴선 replace the groundwater (dashed, 승인 2026-10-26). - const river = (input.groundPosition ?? '육상') === '하상'; + const position = input.groundPosition ?? '육상'; + const river = position === '하상'; + const sea = position === '해상'; const level = river ? (Number.isFinite(input.floodLevelEl) ? input.floodLevelEl : undefined) - : (input.groundWaterOn && Number.isFinite(input.groundWaterEl) ? input.groundWaterEl : undefined); + : sea + ? (Number.isFinite(input.highWaterEl) ? input.highWaterEl : undefined) + : (input.groundWaterOn && Number.isFinite(input.groundWaterEl) ? input.groundWaterEl : undefined); + // 해상: the 저조위 is drawn too (dashed), on the MSL datum. + const lowLevel = sea && Number.isFinite(input.lowWaterEl) ? input.lowWaterEl : undefined; + const groundLabel = sea ? '해저면 EL' : '지반고'; const scour = river && Number.isFinite(input.scourDepthM) && (input.scourDepthM as number) >= 0 ? (input.scourDepthM as number) : undefined; const scourEl = scour !== undefined ? ground - scour : undefined; - const yMin = Math.min(footBottom, lowestRow, ...pileTips, level ?? ground, scourEl ?? ground) - 1; + const yMin = Math.min(footBottom, lowestRow, ...pileTips, level ?? ground, lowLevel ?? ground, scourEl ?? ground) - 1; const yMax = Math.max(ground, footTop, input.copingTopEl) + 0.5; const scale = PLOT_H / (yMax - yMin); const y = (el: number) => TOP_PAD + (yMax - el) * scale; @@ -55,16 +62,21 @@ export function groundSectionSvg(input: PierInput, model: PierModel): string { // Ground line and label. parts.push(``); - parts.push(`지반고 ${ground.toFixed(3)}`); + parts.push(`${groundLabel} ${ground.toFixed(3)}`); // Groundwater (육상, when on) as a dashed line; the 설계홍수위 (하상) as a solid line. if (level !== undefined) { const dash = river ? '' : ' stroke-dasharray="6 4"'; - const label = river ? '설계홍수위 DHWL' : '지하수위'; + const label = river ? '설계홍수위 DHWL' : sea ? '고조위 HWL' : '지하수위'; parts.push(``); parts.push(`${label} ${level.toFixed(3)}`); } - // 세굴선 (하상): the bed after scour. + // 해상: 저조위 (dashed, MSL datum). + if (lowLevel !== undefined) { + parts.push(``); + parts.push(`저조위 LWL ${lowLevel.toFixed(3)}`); + } + // 세굴선 (하상·해상): the bed after scour. if (scourEl !== undefined) { parts.push(``); parts.push(`세굴선 ${scourEl.toFixed(3)}`); diff --git a/tests/ground-inputs.test.ts b/tests/ground-inputs.test.ts index 3024d20..b39c6ce 100644 --- a/tests/ground-inputs.test.ts +++ b/tests/ground-inputs.test.ts @@ -216,3 +216,36 @@ describe('하상 (승인 2026-10-26: 설계홍수위 DHWL, 세굴 깊이)', () = expect(svg).not.toContain('지하수위 25'); }); }); + +describe('해상 (승인 2026-10-26: MSL 기준, 고조위·저조위 두 개, 부식 여유 기준값, 파랑·선박 하중 제외)', () => { + const sea = { groundPosition: '해상' as const }; + + it('G13: a 해상 without both the 고조위 and the 저조위', () => { + expect(ground(withGround({ ...sea, highWaterEl: 30 }))).toEqual(['G13']); + }); + + it('G10: the 해저면 not below the 고조위', () => { + expect(ground(withGround({ ...sea, highWaterEl: 28, lowWaterEl: 25 }))).toEqual(['G10']); + }); + + it('G11: the 저조위 not below the 고조위', () => { + expect(ground(withGround({ ...sea, highWaterEl: 30, lowWaterEl: 31 }))).toEqual(['G11']); + }); + + it('a valid 해상 passes, and the 육상 groundwater and 하상 flood inputs are ignored', () => { + expect(ground(withGround({ ...sea, highWaterEl: 30, lowWaterEl: 25, groundWaterOn: true, groundWaterEl: 99, floodLevelEl: 99 }))).toEqual([]); + }); + + it('G14: a pile wall at or below the corrosion allowance reference', () => { + const issues: Issue[] = []; + validateGround(withGround({ ...sea, highWaterEl: 30, lowWaterEl: 25, pileThicknessMm: 1 }), issues); + expect(issues.map((i) => i.code)).toEqual(['G14']); + }); + + it('the section shows the 고조위 (solid), the 저조위 (dashed), and the 해저면 label', () => { + const svg = groundSectionSvg(withGround({ ...sea, highWaterEl: 30, lowWaterEl: 25 }), buildPier(base)); + expect(svg).toContain('고조위 HWL 30.000'); + expect(svg).toContain('저조위 LWL 25.000'); + expect(svg).toContain('해저면 EL 28.931'); + }); +}); diff --git a/tests/shape-options.test.ts b/tests/shape-options.test.ts index ac4a160..01e061d 100644 --- a/tests/shape-options.test.ts +++ b/tests/shape-options.test.ts @@ -36,8 +36,9 @@ describe('형상 선택지 (spec §5.2, plan C5)', () => { }); }); -it('하상 is the only confirmed ground option; 해상 stays refused until its rule is confirmed', () => { +it('하상 and 해상 are confirmed ground options (승인 2026-10-26); 육상 is the default', () => { const ground = SHAPE_GROUPS.ground[0].options; expect(ground.find((o) => o.label === '하상')!.confirmed).toBe(true); - expect(() => choose('ground', 0, '해상')).toThrow('규칙 미정'); + expect(ground.find((o) => o.label === '해상')!.confirmed).toBe(true); + expect(choose('ground', 0, '해상')).toBe('해상'); });