diff --git a/src/core/changes.ts b/src/core/changes.ts index a38c22d..b4ca707 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', 'highWaterEl', 'lowWaterEl', 'strataRows', 'bearingType', 'bearingLayout', 'blockRebar'] as const; +export const TRACKED_KEYS = [...EDITABLE_FIELDS.map((f) => f.key), 'pileRows', 'groundPosition', 'groundWaterOn', 'groundWaterEl', 'floodLevelEl', 'scourDepthM', 'highWaterEl', 'lowWaterEl', 'strataRows', 'bearingType', 'bearingLayout', 'blockRebar', 'copingRebar'] as const; export type EditableKey = (typeof TRACKED_KEYS)[number]; @@ -54,6 +54,7 @@ export const FIELD_LABELS: Record = { bearingType: '받침 종류·용량', bearingLayout: '받침 배치', blockRebar: '받침블럭 철근 설정', + copingRebar: '코핑 배근 입력', strataRows: '지층 행', }; diff --git a/src/core/geometry.ts b/src/core/geometry.ts index e5515fc..7866d17 100644 --- a/src/core/geometry.ts +++ b/src/core/geometry.ts @@ -1,6 +1,7 @@ import { buildAxis, copingSpan, fromAxis, kinkArc, nearKink, toAxis } from './axis'; import { buildIntegratedBlocks, type IntegratedBlock } from './integrated-block'; import { type BlockRebarSettings, DEFAULT_BLOCK_REBAR } from './rebar/bearing-block'; +import { type CopingRebarSettings, DEFAULT_COPING_REBAR } from './rebar/coping-settings'; import { layoutText, resolveRowCounts, rowLayout, type PileGeometry } from './piles'; import type { PierInput, Row } from './types'; import { type CopingGeom, P1_COPING_GEOM } from './coping-geom'; @@ -176,6 +177,8 @@ export interface PierModel { integratedBlocks: IntegratedBlock[]; /** 받침블럭 철근 설정 used by the rebar (승인 2026-10-26). */ blockRebar: BlockRebarSettings; + /** 코핑 배근 입력 used by the coping rebar (승인 2026-10-26). */ + copingRebar: CopingRebarSettings; columns: ColumnModel[]; footings: FootingModel[]; blindings: BlindingModel[]; @@ -464,6 +467,7 @@ export function buildPier(input: PierInput): PierModel { blocks, integratedBlocks: buildIntegratedBlocks(blocks, input.bearingLayout === '일체'), blockRebar: { ...DEFAULT_BLOCK_REBAR, ...input.blockRebar }, + copingRebar: { ...DEFAULT_COPING_REBAR, ...input.copingRebar }, columns, footings, blindings, diff --git a/src/core/rebar/coping-settings.ts b/src/core/rebar/coping-settings.ts new file mode 100644 index 0000000..a4b9623 --- /dev/null +++ b/src/core/rebar/coping-settings.ts @@ -0,0 +1,86 @@ +/** + * 코핑 배근 입력 (승인 2026-10-26, 제안 = RAG/코핑_입력범위_제안.md). The 12 numbers of the ③ 코핑 배근 입력 panel. + * Defaults are the 코핑철근.md values; the ranges are the proposal's, pending the design-standard check. + */ +export interface CopingRebarSettings { + /** 피복 (철근 중심, mm). */ + cover: number; + /** 주철근 기본간격 (mm). */ + mainSpacing: number; + /** 주철근 끝단 최소 여유 (mm). */ + mainEndGap: number; + /** 상면 주철근 단 간격 (mm). */ + mainLayerGap: number; + /** 첫 전단철근 최소 여유 (피복 다음, mm). */ + firstShearGap: number; + /** 전단철근 다리 수 (짝수). */ + shearLegs: number; + /** 전단철근 간격 · 경사 (mm). */ + spacingS: number; + /** 전단철근 간격 · Level (mm). */ + spacingL: number; + /** 측면 수평 간격 (mm). */ + sideSpacing: number; + /** 측면 위 끝 아래 최소 여유 (mm). */ + sideEndGap: number; + /** C14 간격 위 2줄 (mm). */ + c14UpperSpacing: number; + /** C14 간격 아래 줄 (mm). */ + c14LowerSpacing: number; +} + +export const DEFAULT_COPING_REBAR: CopingRebarSettings = { + cover: 100, + mainSpacing: 125, + mainEndGap: 80, + mainLayerGap: 57.2, + firstShearGap: 200, + shearLegs: 6, + spacingS: 250, + spacingL: 300, + sideSpacing: 250, + sideEndGap: 400, + c14UpperSpacing: 1000, + c14LowerSpacing: 2000, +}; + +/** Allowed range per input (inclusive, mm). The shear legs are checked for evenness separately (CP02). */ +export const COPING_REBAR_RANGES: Record = { + cover: [80, 150], + mainSpacing: [100, 150], + mainEndGap: [60, 100], + mainLayerGap: [50, 70], + firstShearGap: [100, 300], + shearLegs: [4, 8], + spacingS: [200, 350], + spacingL: [200, 350], + sideSpacing: [200, 300], + sideEndGap: [300, 500], + c14UpperSpacing: [800, 1200], + c14LowerSpacing: [1500, 2500], +}; + +export interface CopingSettingsIssue { + code: 'CP01' | 'CP02'; + key: keyof CopingRebarSettings; + message: string; +} + +/** + * Checks the settings against the ranges. CP01: a value is not a finite number or is out of range (W). + * CP02: the shear leg count is odd (the 폐합 bars come in pairs of legs). + */ +export function copingSettingsIssues(s: CopingRebarSettings): CopingSettingsIssue[] { + const out: CopingSettingsIssue[] = []; + for (const key of Object.keys(COPING_REBAR_RANGES) as (keyof CopingRebarSettings)[]) { + const v = s[key]; + const [lo, hi] = COPING_REBAR_RANGES[key]; + if (!Number.isFinite(v) || v < lo || v > hi) { + out.push({ code: 'CP01', key, message: `CP01 ${key} ${v} 범위 밖 (${lo}–${hi})` }); + } + } + if (Number.isFinite(s.shearLegs) && s.shearLegs % 2 !== 0) { + out.push({ code: 'CP02', key: 'shearLegs', message: `CP02 전단철근 다리 수 ${s.shearLegs}는 홀수 (짝수만 허용)` }); + } + return out; +} diff --git a/src/core/types.ts b/src/core/types.ts index fe74b7f..410684d 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -1,3 +1,5 @@ +import type { CopingRebarSettings } from './rebar/coping-settings'; + export type PierType = 'T형' | '2주형' | '3주형'; export type FoundationType = '말뚝기초' | '직접기초' | '현장타설말뚝'; export type Row = '전열' | '후열'; @@ -74,6 +76,8 @@ export interface PierInput { blockRebar?: Partial<{ coverTop: number; coverSide: number; firstGap: number; spacingS1: number; spacingS2: number; legLength: number }>; /** 받침 배치 (승인 2026-10-26): 분리 (default) or 일체. 일체 is allowed only when every front–back pair is within 100 mm. */ bearingLayout?: '분리' | '일체'; + /** 코핑 배근 입력 (승인 2026-10-26): the 12 numbers of ③ 코핑 배근. Absent = the 코핑철근.md defaults. */ + copingRebar?: Partial; strataRows?: StrataRow[]; } diff --git a/src/main.ts b/src/main.ts index 217151b..d581878 100644 --- a/src/main.ts +++ b/src/main.ts @@ -27,6 +27,7 @@ import { blockRebarPlanSvg } from './view/block-rebar-plan-svg'; import { copingEndStation, copingFrontSvg, copingSectionSvg } from './view/coping-rebar-svg'; import { BLOCK_REBAR_RANGES } from './core/rebar/block-check'; import { DEFAULT_BLOCK_REBAR, type BlockRebarSettings } from './core/rebar/bearing-block'; +import { COPING_REBAR_RANGES, copingSettingsIssues, type CopingRebarSettings, DEFAULT_COPING_REBAR } from './core/rebar/coping-settings'; import { groundSectionSvg } from './view/ground-svg'; import { parseBearingElevationTable, parseSolplateTable, readDxfTexts } from './core/dxf-table'; import { columnDiameterIssues } from './core/column-check'; @@ -1073,6 +1074,85 @@ function renderBlockRebarSettings(box: HTMLDivElement, pier: Pier): void { /** 코핑 배근 (승인 2026-10-26, 추천): 정면, and the 단면·폐합 at a station the user chooses (default mid-length). */ let copingView: 'front' | 'section' = 'front'; +/** 코핑 배근 입력 (승인 2026-10-26, 제안 = RAG/코핑_입력범위_제안.md). Order follows the ③ 코핑 배근 panel. */ +const COPING_REBAR_FIELDS: { key: keyof CopingRebarSettings; label: string }[] = [ + { key: 'cover', label: '피복 (철근 중심, mm)' }, + { key: 'mainSpacing', label: '주철근 기본간격 (mm)' }, + { key: 'mainEndGap', label: '주철근 끝단 최소 여유 (mm)' }, + { key: 'mainLayerGap', label: '상면 주철근 단 간격 (mm)' }, + { key: 'firstShearGap', label: '첫 전단철근 최소 여유 (피복 다음, mm)' }, + { key: 'shearLegs', label: '전단철근 다리 수' }, + { key: 'spacingS', label: '전단철근 간격 · 경사 (mm)' }, + { key: 'spacingL', label: '전단철근 간격 · Level (mm)' }, + { key: 'sideSpacing', label: '측면 수평 간격 (mm)' }, + { key: 'sideEndGap', label: '측면 위 끝 아래 최소 여유 (mm)' }, + { key: 'c14UpperSpacing', label: 'C14 간격 위 2줄 (mm)' }, + { key: 'c14LowerSpacing', label: 'C14 간격 아래 줄 (mm)' }, +]; + +/** 적용: the values go to the draft and the model is rebuilt only when every check passes (CP01·CP02). */ +function applyCopingRebar(name: string, next: CopingRebarSettings): void { + const p = state.piers.find((q) => q.input.name === name); + if (!p) return; + p.draft = { ...p.draft, copingRebar: next }; + regenerateOne(name); +} + +function renderCopingSettings(box: HTMLDivElement, pier: Pier): void { + if (!pier.model) return; + const wrap = el('div', undefined, 'bearing-ref'); + const current: CopingRebarSettings = { ...DEFAULT_COPING_REBAR, ...pier.draft.copingRebar }; + wrap.appendChild(el('p', '코핑 배근 입력 (기본값 = 코핑철근.md · 적용을 눌러야 반영)', 'lbl')); + const grid = el('div', undefined, 'edit-fields'); + const controls = new Map(); + for (const f of COPING_REBAR_FIELDS) { + const row = el('div', undefined, 'edit-row'); + const label = el('label', f.label); + let control: HTMLInputElement | HTMLSelectElement; + if (f.key === 'shearLegs') { + // 다리 수는 짝수만 고르도록 목록으로 둔다 (CP02). + const sel = el('select'); + for (const n of [4, 6, 8]) { + const opt = el('option', String(n)); + opt.value = String(n); + sel.appendChild(opt); + } + sel.value = String(current.shearLegs); + control = sel; + } else { + const input = el('input'); + input.type = 'number'; + input.step = 'any'; + input.value = String(current[f.key]); + control = input; + } + control.id = `coping-${f.key}`; + label.htmlFor = control.id; + controls.set(f.key, control); + const [lo, hi] = COPING_REBAR_RANGES[f.key]; + const changed = current[f.key] !== DEFAULT_COPING_REBAR[f.key] ? ' · 기본과 다름' : ''; + row.append(label, control, el('span', `범위 ${lo}–${hi} · 기본 ${DEFAULT_COPING_REBAR[f.key]}${changed}`, 'muted small')); + grid.appendChild(row); + } + wrap.appendChild(grid); + const msg = el('p', undefined, 'muted small'); + const apply = el('button', '적용', 'btn small'); + apply.type = 'button'; + apply.addEventListener('click', () => { + const next = { ...current }; + for (const f of COPING_REBAR_FIELDS) next[f.key] = Number(controls.get(f.key)?.value); + const issues = copingSettingsIssues(next); + if (issues.length > 0) { + msg.textContent = issues.map((i) => i.message).join(' · '); + setError('코핑 배근 입력을 확인하세요. 값은 반영되지 않았습니다.'); + return; + } + applyCopingRebar(pier.input.name, next); + }); + wrap.append(msg, apply); + box.appendChild(wrap); +} + function renderCopingDrawings(box: HTMLDivElement, pier: Pier): void { if (!pier.model || !rebar3d) return; const L = pier.model.copingL; @@ -1147,6 +1227,7 @@ function renderRebar(): void { $('rebar-note').textContent = `${pier.input.name} · 형상 기준 계산 · 값은 기본값 기준 문서 규칙`; if (state.rebarMember === 'bearing') renderBlockRebarSettings(box, pier); if (state.rebarMember === 'coping' && rebar3d) renderCopingDrawings(box, pier); + if (state.rebarMember === 'coping') renderCopingSettings(box, pier); const shown = rows.filter((r) => r.member === state.rebarMember); box.appendChild(rebarSelectBar(shown.map((r) => r.name))); const table = el('table', undefined, 'grid-table rebar-table'); diff --git a/tests/coping-settings.test.ts b/tests/coping-settings.test.ts new file mode 100644 index 0000000..52e0f19 --- /dev/null +++ b/tests/coping-settings.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; +import { COPING_REBAR_RANGES, copingSettingsIssues, DEFAULT_COPING_REBAR } from '../src/core/rebar/coping-settings'; + +describe('코핑 배근 입력 범위 (승인 2026-10-26)', () => { + it('the defaults are inside every range and raise no issue', () => { + expect(copingSettingsIssues(DEFAULT_COPING_REBAR)).toEqual([]); + for (const [key, [lo, hi]] of Object.entries(COPING_REBAR_RANGES)) { + const v = DEFAULT_COPING_REBAR[key as keyof typeof DEFAULT_COPING_REBAR]; + expect(v >= lo && v <= hi, key).toBe(true); + } + }); + + it('a value at either end of its range is accepted', () => { + const lo = { ...DEFAULT_COPING_REBAR, cover: 80, shearLegs: 4 }; + const hi = { ...DEFAULT_COPING_REBAR, cover: 150, shearLegs: 8 }; + expect(copingSettingsIssues(lo)).toEqual([]); + expect(copingSettingsIssues(hi)).toEqual([]); + }); + + it('a value outside its range gives CP01 on that key', () => { + const issues = copingSettingsIssues({ ...DEFAULT_COPING_REBAR, cover: 151 }); + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ code: 'CP01', key: 'cover' }); + }); + + it('a non-finite value gives CP01', () => { + const issues = copingSettingsIssues({ ...DEFAULT_COPING_REBAR, spacingS: Number.NaN }); + expect(issues).toEqual([expect.objectContaining({ code: 'CP01', key: 'spacingS' })]); + }); + + it('an odd shear leg count gives CP02 (and no CP01 because 5 is in range)', () => { + const issues = copingSettingsIssues({ ...DEFAULT_COPING_REBAR, shearLegs: 5 }); + expect(issues).toEqual([expect.objectContaining({ code: 'CP02', key: 'shearLegs' })]); + }); +});