From 26a5aff517c9f8001605d808a640a2a77a58f4de Mon Sep 17 00:00:00 2001 From: ypshim Date: Mon, 26 Oct 2026 11:06:10 +0900 Subject: [PATCH] =?UTF-8?q?feat(ground):=20=ED=95=98=EC=83=81=20selectable?= =?UTF-8?q?;=20=EC=84=A4=EA=B3=84=ED=99=8D=EC=88=98=EC=9C=84=20and=20?= =?UTF-8?q?=EC=84=B8=EA=B5=B4=20=EA=B9=8A=EC=9D=B4=20for=20=ED=95=98?= =?UTF-8?q?=EC=83=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - shape-options: a per-option "confirmed" flag; 하상 is confirmed, 해상 is not. - The 위치 chips store the choice in the pier's draft; 선택 shows it. - 하상: the groundwater inputs give way to 설계홍수위 DHWL (floodLevelEl) and 세굴 깊이 (scourDepthM). The groundwater value is kept (남겨두기). - Checks: G07 DHWL missing, G08 DHWL below the 지반고, G09 scour negative or the scour line below the footing bottom. The groundwater check is skipped for 하상. - Section: the DHWL as a solid line and the 세굴선 dashed. Co-Authored-By: Claude Haiku 5.5 --- src/core/changes.ts | 4 +- src/core/copy.ts | 2 +- src/core/ground.ts | 22 +++++++++- src/core/shape-options.ts | 9 ++-- src/core/types.ts | 4 ++ src/main.ts | 87 ++++++++++++++++++++++++------------- src/view/ground-svg.ts | 25 ++++++++--- tests/ground-inputs.test.ts | 45 +++++++++++++++++++ tests/shape-options.test.ts | 10 ++++- 9 files changed, 163 insertions(+), 45 deletions(-) diff --git a/src/core/changes.ts b/src/core/changes.ts index 711c2d7..a02831e 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', 'strataRows'] as const; +export const TRACKED_KEYS = [...EDITABLE_FIELDS.map((f) => f.key), 'pileRows', 'groundPosition', 'groundWaterOn', 'groundWaterEl', 'floodLevelEl', 'scourDepthM', 'strataRows'] as const; export type EditableKey = (typeof TRACKED_KEYS)[number]; @@ -47,6 +47,8 @@ export const FIELD_LABELS: Record = { groundPosition: '지반 위치', groundWaterOn: '지하수위 고려', groundWaterEl: '지하수위 EL', + floodLevelEl: '설계홍수위 DHWL', + scourDepthM: '세굴 깊이', strataRows: '지층 행', }; diff --git a/src/core/copy.ts b/src/core/copy.ts index 1b1161e..adf7c47 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'], defaultOn: false, pileItem: false }, + 'ground-position': { label: '지반 위치·지하수위', keys: ['groundPosition', 'groundWaterOn', 'groundWaterEl', 'floodLevelEl'], 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 d45df95..a2c60f7 100644 --- a/src/core/ground.ts +++ b/src/core/ground.ts @@ -16,7 +16,8 @@ export const PILE_EMBED_D = 1.0; /** * 지반·지층 입력 검사 (승인 2026-10-26). * G01: 층 하단 EL은 위 경계보다 낮아야 함. G02: 지하수위는 지반고와 가장 아래 층 사이에 있어야 함. - * G03: 층 이름은 지층 목록에 있어야 함. Ground values are inputs only; they do not change the 3D model. + * G03: 층 이름은 지층 목록에 있어야 함. 하상: G07 DHWL 없음, G08 DHWL이 지반고보다 낮음, G09 세굴선 확인. + * Ground values are inputs only; they do not change the 3D model. */ export function validateGround(input: PierInput, issues: Issue[]): void { const rows = input.strataRows ?? []; @@ -31,7 +32,24 @@ export function validateGround(input: PierInput, issues: Issue[]): void { } upper = row.bottomEl; }); - if (input.groundWaterOn) { + const position = input.groundPosition ?? '육상'; + if (position === '하상') { + // 하상 (승인 2026-10-26): the 설계홍수위 DHWL replaces the groundwater level. + const f = input.floodLevelEl; + if (f === undefined || !Number.isFinite(f)) { + issues.push({ code: 'G07', level: 'W', pier: input.name, message: '하상입니다. 설계홍수위(DHWL) EL을 입력하세요.' }); + } 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)}보다 낮습니다 (기초 노출).` }); + } + } + } else if (input.groundWaterOn) { const w = input.groundWaterEl; const lowest = rows.length ? rows[rows.length - 1].bottomEl : undefined; if (w === undefined || !Number.isFinite(w)) { diff --git a/src/core/shape-options.ts b/src/core/shape-options.ts index 977dc28..d772ca6 100644 --- a/src/core/shape-options.ts +++ b/src/core/shape-options.ts @@ -8,6 +8,8 @@ export interface ShapeOption { label: string; /** 굵은 글씨 in the spec: the default that the 1단계 model builds. */ isDefault: boolean; + /** The rule for this option is confirmed (승인 2026-10-26: 하상), so it can be chosen even if it is not the default. */ + confirmed?: boolean; } export interface ShapeGroup { @@ -17,6 +19,7 @@ export interface ShapeGroup { const d = (label: string): ShapeOption => ({ label, isDefault: true }); const x = (label: string): ShapeOption => ({ label, isDefault: false }); +const ok = (label: string): ShapeOption => ({ label, isDefault: false, confirmed: true }); export const SHAPE_GROUPS: Record = { bearing: [{ title: '배치', options: [d('분리'), x('일체')] }], @@ -34,14 +37,14 @@ export const SHAPE_GROUPS: Record = { { title: '말뚝 종류', options: [d('강관'), x('PHC'), x('복합')] }, { title: '경사말뚝', options: [d('없음'), x('있음')] }, ], - ground: [{ title: '위치', options: [d('육상'), x('하상'), x('해상')] }], + ground: [{ title: '위치', options: [d('육상'), ok('하상'), x('해상')] }], }; export const UNDEFINED_REASON = '규칙 미정 (Q6 확정 전 선택 불가)'; -/** Options currently selectable. Defaults are the only choices today. */ +/** Options currently selectable: the defaults and the options whose rule is confirmed. */ export function isSelectable(option: ShapeOption): boolean { - return option.isDefault; + return option.isDefault || option.confirmed === true; } /** The default summary of a member, e.g. "교좌면 직각 · 하면 직선". */ diff --git a/src/core/types.ts b/src/core/types.ts index 98cd038..6d0496c 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -61,6 +61,10 @@ export interface PierInput { groundPosition?: GroundPosition; groundWaterOn?: boolean; groundWaterEl?: number; + /** 설계홍수위 DHWL (m), used for 하상 only. Kept when the position changes (승인 2026-10-26). */ + floodLevelEl?: number; + /** 세굴 깊이 (m) below the 지반고, used for 하상 only. */ + scourDepthM?: number; strataRows?: StrataRow[]; } diff --git a/src/main.ts b/src/main.ts index aa3d512..b8d5731 100644 --- a/src/main.ts +++ b/src/main.ts @@ -834,7 +834,7 @@ function setPileRow(name: string, row: number, delta: number): void { } /** 형상 선택칩 (plan.md C5): the default is pressed; every other option is shown and disabled with the reason. */ -function renderShapeOptions(member: MemberId): void { +function renderShapeOptions(member: MemberId, pier?: Pier): void { const box = $('member-options'); box.replaceChildren(); SHAPE_GROUPS[member].forEach((group, gi) => { @@ -843,12 +843,15 @@ function renderShapeOptions(member: MemberId): void { for (const option of group.options) { const chip = el('button', option.label, 'chip-opt'); chip.type = 'button'; - chip.setAttribute('aria-pressed', String(option.isDefault)); + // The ground 위치 is stored in the pier's draft, so its pressed chip is the chosen one (승인 2026-10-26). + const chosen = member === 'ground' && pier ? (pier.draft.groundPosition ?? '육상') : undefined; + chip.setAttribute('aria-pressed', String(chosen !== undefined ? option.label === chosen : option.isDefault)); chip.disabled = !isSelectable(option); if (chip.disabled) chip.title = UNDEFINED_REASON; chip.addEventListener('click', () => { try { choose(member, gi, option.label); + if (member === 'ground' && pier) editGround(pier.input.name, 'groundPosition', option.label); } catch (err) { setError((err as Error).message); } @@ -1512,11 +1515,12 @@ 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', value: unknown): void { +function editGround(name: string, key: 'groundPosition' | 'groundWaterOn' | 'groundWaterEl' | 'floodLevelEl' | 'scourDepthM', value: unknown): void { const p = state.piers.find((q) => q.input.name === name); if (!p) return; - if (key === 'groundWaterEl' && value !== undefined && !Number.isFinite(value)) { - setError('지하수위 EL은 숫자로 입력하세요. 값을 되돌렸습니다.'); + const numeric = key === 'groundWaterEl' || key === 'floodLevelEl' || key === 'scourDepthM'; + if (numeric && value !== undefined && !Number.isFinite(value)) { + setError(`${FIELD_LABELS[key]}은(는) 숫자로 입력하세요. 값을 되돌렸습니다.`); renderAll(); return; } @@ -1552,29 +1556,50 @@ function renderGroundModel(box: HTMLDivElement, pier: Pier): void { // 위치 is the 형상 옵션 of this member (the chips above the card); only the default is selectable until Q6. - wrap.appendChild(el('p', '지하수위', 'lbl')); - const water = el('div', undefined, 'ground-water'); - water.style.display = 'flex'; - water.style.flexWrap = 'wrap'; - water.style.alignItems = 'center'; - water.style.gap = '8px'; - const check = el('input'); - check.type = 'checkbox'; - check.checked = d.groundWaterOn === true; - check.id = 'ground-water-on'; - check.addEventListener('change', () => editGround(name, 'groundWaterOn', check.checked)); - const checkLabel = el('label', ' 지하수위 고려'); - checkLabel.prepend(check); - const level = el('input'); - level.type = 'number'; - level.step = 'any'; - level.id = 'ground-water-el'; - level.placeholder = '지하수위 EL (m)'; - level.value = d.groundWaterEl === undefined ? '' : String(d.groundWaterEl); - level.disabled = !check.checked; - level.addEventListener('change', () => editGround(name, 'groundWaterEl', level.value === '' ? undefined : Number(level.value))); - water.append(checkLabel, level, el('span', 'm', 'muted small')); - wrap.appendChild(water); + const position = d.groundPosition ?? '육상'; + if (position === '하상') { + // 하상 (승인 2026-10-26): the 설계홍수위 DHWL and the 세굴 깊이 replace the groundwater; the groundwater value is kept. + wrap.appendChild(el('p', '설계홍수위 DHWL · 세굴', 'lbl')); + const scourRow = el('div', undefined, 'ground-water'); + scourRow.style.display = 'flex'; + scourRow.style.flexWrap = 'wrap'; + scourRow.style.alignItems = 'center'; + scourRow.style.gap = '8px'; + for (const [key, label, value] of [['floodLevelEl', '설계홍수위 EL (m)', d.floodLevelEl], ['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))); + scourRow.append(el('span', label, 'muted small'), input); + } + wrap.appendChild(scourRow); + } else { + wrap.appendChild(el('p', '지하수위', 'lbl')); + const water = el('div', undefined, 'ground-water'); + water.style.display = 'flex'; + water.style.flexWrap = 'wrap'; + water.style.alignItems = 'center'; + water.style.gap = '8px'; + const check = el('input'); + check.type = 'checkbox'; + check.checked = d.groundWaterOn === true; + check.id = 'ground-water-on'; + check.addEventListener('change', () => editGround(name, 'groundWaterOn', check.checked)); + const checkLabel = el('label', ' 지하수위 고려'); + checkLabel.prepend(check); + const level = el('input'); + level.type = 'number'; + level.step = 'any'; + level.id = 'ground-water-el'; + level.placeholder = '지하수위 EL (m)'; + level.value = d.groundWaterEl === undefined ? '' : String(d.groundWaterEl); + level.disabled = !check.checked; + level.addEventListener('change', () => editGround(name, 'groundWaterEl', level.value === '' ? undefined : Number(level.value))); + water.append(checkLabel, level, el('span', 'm', 'muted small')); + wrap.appendChild(water); + } // 지층 행 (승인 2026-10-26): one row per layer, from the top down; the EL is the layer's bottom EL. const rows = d.strataRows ?? []; @@ -1868,8 +1893,10 @@ function renderPlan(): void { const member = MEMBERS.find((m) => m.id === state.member)!; $('member-title').textContent = member.label; // Default and current selection are shown separately (spec §5.1). Only defaults are selectable today. - $('member-summary').textContent = `기본: ${defaultSummary(member.id)} · 선택: ${defaultSummary(member.id)}`; - renderShapeOptions(member.id); + // 선택 shows the pier's own 위치 for the ground; the other members still choose only their defaults. + const selected = member.id === 'ground' && pier ? (pier.draft.groundPosition ?? '육상') : defaultSummary(member.id); + $('member-summary').textContent = `기본: ${defaultSummary(member.id)} · 선택: ${selected}`; + renderShapeOptions(member.id, pier); $('member-note').textContent = member.note; if (member.id === 'bearing' && pier?.model) { // Row skew of the bearing blocks (계산값): the block sides follow the bearing row (승인 2026-10-23). diff --git a/src/view/ground-svg.ts b/src/view/ground-svg.ts index 3bd464b..02d7a88 100644 --- a/src/view/ground-svg.ts +++ b/src/view/ground-svg.ts @@ -20,9 +20,15 @@ export function groundSectionSvg(input: PierInput, model: PierModel): string { const footBottom = Math.min(...model.footings.map((f) => f.bottom)); const pileTips = model.piles.map((p) => p.bottom); const lowestRow = rows.length ? rows[rows.length - 1].bottomEl : ground - 1; - const water = input.groundWaterOn && input.groundWaterEl !== undefined && Number.isFinite(input.groundWaterEl) ? input.groundWaterEl : undefined; + // 하상: the 설계홍수위 (solid) and the 세굴선 replace the groundwater (dashed, 승인 2026-10-26). + const river = (input.groundPosition ?? '육상') === '하상'; + const level = river + ? (Number.isFinite(input.floodLevelEl) ? input.floodLevelEl : undefined) + : (input.groundWaterOn && Number.isFinite(input.groundWaterEl) ? input.groundWaterEl : undefined); + 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, water ?? ground) - 1; + const yMin = Math.min(footBottom, lowestRow, ...pileTips, level ?? 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; @@ -51,10 +57,17 @@ export function groundSectionSvg(input: PierInput, model: PierModel): string { parts.push(``); parts.push(`지반고 ${ground.toFixed(3)}`); - // Groundwater (only when the input is on and has a value). - if (water !== undefined) { - parts.push(``); - parts.push(`지하수위 ${water.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' : '지하수위'; + parts.push(``); + parts.push(`${label} ${level.toFixed(3)}`); + } + // 세굴선 (하상): the bed after scour. + if (scourEl !== undefined) { + parts.push(``); + parts.push(`세굴선 ${scourEl.toFixed(3)}`); } // Footing, column stub, and piles. diff --git a/tests/ground-inputs.test.ts b/tests/ground-inputs.test.ts index 702f111..3024d20 100644 --- a/tests/ground-inputs.test.ts +++ b/tests/ground-inputs.test.ts @@ -171,3 +171,48 @@ describe('N값 입력 (승인 2026-10-26: 글자 입력, 타수/관입량 mm)', expect(() => parseProject(text)).toThrow('지층 행 형식'); }); }); + +describe('하상 (승인 2026-10-26: 설계홍수위 DHWL, 세굴 깊이)', () => { + const river = { groundPosition: '하상' as const }; + + it('G07: a 하상 without the DHWL', () => { + expect(ground(withGround({ ...river }))).toEqual(['G07']); + }); + + it('G08: a DHWL below the 지반고', () => { + expect(ground(withGround({ ...river, floodLevelEl: 28 }))).toEqual(['G08']); + }); + + it('a DHWL above the 지반고 passes', () => { + expect(ground(withGround({ ...river, floodLevelEl: 30 }))).toEqual([]); + }); + + it('G09: a negative scour depth, or a scour line below the footing bottom', () => { + expect(ground(withGround({ ...river, floodLevelEl: 30, scourDepthM: -1 }))).toEqual(['G09']); + // 28.931 − 3 = 25.931 is below the footing bottom 26.208; 28.931 − 1 = 27.931 is above it + expect(ground(withGround({ ...river, floodLevelEl: 30, scourDepthM: 3 }))).toEqual(['G09']); + expect(ground(withGround({ ...river, floodLevelEl: 30, scourDepthM: 1 }))).toEqual([]); + }); + + it('the groundwater check does not apply to a 하상, so a high groundwater value is ignored there', () => { + expect(ground(withGround({ ...river, floodLevelEl: 30, groundWaterOn: true, groundWaterEl: 99 }))).toEqual([]); + }); + + it('the 설계홍수위 is kept when the position is changed back to 육상 (남겨두기)', () => { + const input = withGround({ ...river, floodLevelEl: 30 }); + const back = { ...input, groundPosition: '육상' as const }; + expect(back.floodLevelEl).toBe(30); + }); + + it('the copy item carries the 설계홍수위 with the position', () => { + expect(COPY_ITEMS['ground-position'].keys).toContain('floodLevelEl'); + }); + + it('the section draws the 설계홍수위 and the 세굴선, and no groundwater label', () => { + const model = buildPier(base); + const svg = groundSectionSvg(withGround({ ...river, floodLevelEl: 30, scourDepthM: 1, groundWaterOn: true, groundWaterEl: 25 }), model); + expect(svg).toContain('설계홍수위 DHWL 30.000'); + expect(svg).toContain('세굴선 27.931'); + expect(svg).not.toContain('지하수위 25'); + }); +}); diff --git a/tests/shape-options.test.ts b/tests/shape-options.test.ts index 3a24d68..ac4a160 100644 --- a/tests/shape-options.test.ts +++ b/tests/shape-options.test.ts @@ -16,11 +16,11 @@ describe('형상 선택지 (spec §5.2, plan C5)', () => { expect(defaultSummary('ground')).toBe('육상'); }); - it('only defaults are selectable; every other option is refused with the reason', () => { + it('defaults and confirmed options are selectable (하상, 승인 2026-10-26); every other option is refused with the reason', () => { for (const m of MEMBERS) { SHAPE_GROUPS[m].forEach((g, gi) => { for (const o of g.options) { - if (o.isDefault) { + if (o.isDefault || o.confirmed) { expect(choose(m, gi, o.label)).toBe(o.label); } else { expect(isSelectable(o)).toBe(false); @@ -35,3 +35,9 @@ describe('형상 선택지 (spec §5.2, plan C5)', () => { expect(() => choose('coping', 0, '없는선택')).toThrow('알 수 없는'); }); }); + +it('하상 is the only confirmed ground option; 해상 stays refused until its rule is confirmed', () => { + const ground = SHAPE_GROUPS.ground[0].options; + expect(ground.find((o) => o.label === '하상')!.confirmed).toBe(true); + expect(() => choose('ground', 0, '해상')).toThrow('규칙 미정'); +});