diff --git a/index.html b/index.html index e12d04f..af54a8d 100644 --- a/index.html +++ b/index.html @@ -138,6 +138,13 @@
diff --git a/src/core/project.ts b/src/core/project.ts index b119ff9..ef93cb9 100644 --- a/src/core/project.ts +++ b/src/core/project.ts @@ -1,3 +1,4 @@ +import type { Material } from './rebar/common'; import type { PierInput } from './types'; /** Project file schema (spec §8). Bump only with a migration. */ @@ -28,6 +29,8 @@ export interface ProjectFile { source: { fileName: string; sheets: string[] }; piers: ProjectPier[]; history: { version: string; note: string; at: string }[]; + /** ③ 공통 설정 (fck · fy). Absent in older files: the defaults are used. */ + common?: Material; } /** Serialises a project to the pretty-printed JSON stored in <교량명>_교각설계.pzr.json. */ diff --git a/src/core/rebar/column.ts b/src/core/rebar/column.ts index b7b3d25..912502a 100644 --- a/src/core/rebar/column.ts +++ b/src/core/rebar/column.ts @@ -1,4 +1,4 @@ -import { developmentLength, hookLength, NOMINAL_DIAMETER, spliceLength } from './common'; +import { DEFAULT_MATERIAL, developmentLength, hookLength, Material, NOMINAL_DIAMETER, spliceLength } from './common'; import { layers } from './footing'; /** @@ -90,12 +90,12 @@ const dH19 = NOMINAL_DIAMETER.H19; const dH16 = NOMINAL_DIAMETER.H16; const round = (v: number, d = 1) => Math.round(v * 10 ** d) / 10 ** d; -export function columnRebar(x: ColumnInput = COLUMN_P1) { +export function columnRebar(x: ColumnInput = COLUMN_P1, m: Material = DEFAULT_MATERIAL) { // P1 and P2 main bars (기둥철근.md §3) const r1 = x.D / 2 - x.c; const r2 = r1 - dS32; const zTop1 = x.Hc + x.hcop - x.eP1; - const lD2 = developmentLength(dS32, 500, 27); + const lD2 = developmentLength(dS32, m.fyS, m.fck); const zTop2 = x.Hc + lD2; const couplerLevels = Array.from({ length: Math.max(0, Math.floor(zTop1 / x.ctcCoupler) - 1) }, (_, i) => (i + 1) * x.ctcCoupler); const couplersP1 = couplerLevels.length; @@ -128,8 +128,8 @@ export function columnRebar(x: ColumnInput = COLUMN_P1) { const p6Hook = hookLength('tie90', dH16); const p6Length = p6Straight + 2 * p6Hook; - // P3 lap: Q-C6 is open; recommended = 1.3·l_d of H19 (fck 27, fy 400). - const lapP3 = spliceLength(dH19, 400, 27); + // P3 lap: Q-C6 is open; recommended = 1.3·l_d of H19 with the material inputs. + const lapP3 = spliceLength(dH19, m.fyH, m.fck); const p3Length = 2 * Math.PI * tieRadius + lapP3; const p5Length = 2 * Math.PI * tieRadius; diff --git a/src/core/rebar/common.ts b/src/core/rebar/common.ts index f27ba16..8ce5451 100644 --- a/src/core/rebar/common.ts +++ b/src/core/rebar/common.ts @@ -2,6 +2,23 @@ * ③ 철근배근 공통 규칙 (spec §6.2 · plan C1). Lengths in mm, stresses in MPa. * fck and fy are inputs. The defaults below are assumed values and must be shown as such (I02). */ +/** Material inputs of ③ 공통 설정 (spec §6.2). fck and fy are inputs; the defaults are assumed values (I02). */ +export interface Material { + /** 콘크리트 강도 fck (MPa). */ + fck: number; + /** H = SD400 항복강도 (MPa). */ + fyH: number; + /** S = SD500 항복강도 (MPa). */ + fyS: number; +} + +export const DEFAULT_MATERIAL: Material = { fck: 27, fyH: 400, fyS: 500 }; + +/** True while the values are the assumed defaults (정보 I02 on the rebar screen). */ +export function isAssumedMaterial(m: Material): boolean { + return m.fck === DEFAULT_MATERIAL.fck && m.fyH === DEFAULT_MATERIAL.fyH && m.fyS === DEFAULT_MATERIAL.fyS; +} + export const MATERIAL_DEFAULTS = { fck: 27, /** H = SD400, S = SD500. */ diff --git a/src/core/rebar/coping-main.ts b/src/core/rebar/coping-main.ts index 977e479..3aa87dc 100644 --- a/src/core/rebar/coping-main.ts +++ b/src/core/rebar/coping-main.ts @@ -1,4 +1,4 @@ -import { hookLength, NOMINAL_DIAMETER, spliceLength } from './common'; +import { DEFAULT_MATERIAL, hookLength, Material, NOMINAL_DIAMETER, spliceLength } from './common'; /** * C1–C8 상·하면 주철근 (plan C3 continued). Source: RAG/코핑철근.md §5, 2026-10-22 최종 정리. @@ -69,11 +69,11 @@ export function topBarLength(L: number, k: number): number { /** * Bottom bar C7·C8 (spliced at the column centre, 하면 겹침이음). Each piece: - * path/2 + l_s/2 + l_h, with path = 2·slope + Level and l_s = 1.3·l_d for H29 (nominal 28.6, fck 27, fy 400). + * path/2 + l_s/2 + l_h, with path = 2·slope + Level and l_s = 1.3·l_d for H29 (nominal 28.6, with the material inputs). */ -export function bottomPiece(fck = 27): { path: number; ls: number; piece: number } { +export function bottomPiece(m: Material = DEFAULT_MATERIAL): { path: number; ls: number; piece: number } { const path = 2 * MAIN.slopeEach + MAIN.level; - const ls = spliceLength(NOMINAL_DIAMETER.H29, 400, fck); + const ls = spliceLength(NOMINAL_DIAMETER.H29, m.fyH, m.fck); const lh = hookLength('main90', NOMINAL_DIAMETER.H29); return { path, ls, piece: path / 2 + ls / 2 + lh }; } diff --git a/src/core/rebar/summary.ts b/src/core/rebar/summary.ts index bf17db8..d4bbf78 100644 --- a/src/core/rebar/summary.ts +++ b/src/core/rebar/summary.ts @@ -4,7 +4,7 @@ import { bottomPiece, topBarLength, widthLayout } from './coping-main'; import { shearBars, sideRows, C14_COUNTS, c14Total, STEP_BAR_COUNTS, shearULength, SIDE_LENGTHS, STEP_LENGTHS, capLength, PIECES_PER_POSITION } from './coping'; import { columnRebar, COLUMN_P1 } from './column'; import { bottomBars, crossBars, sideBars, assemblyBars, shearB9 } from './footing'; -import { splitPieces } from './common'; +import { DEFAULT_MATERIAL, isAssumedMaterial, Material, splitPieces } from './common'; /** One line of a 철근표. length is null where the document gives no length yet. */ export interface RebarRow { @@ -28,9 +28,12 @@ export const STANDARD_BAR = 12000; * 철근표 for one pier (③ screen v1): bearing blocks, coping, column, footing. Built from the generated model. * Values come from the document rules in src/core/rebar/*. B9 (전단철근) is not computed yet. */ -export function rebarSummary(model: PierModel): { rows: RebarRow[]; warnings: RebarWarning[] } { +export function rebarSummary(model: PierModel, m: Material = DEFAULT_MATERIAL): { rows: RebarRow[]; warnings: RebarWarning[] } { const rows: RebarRow[] = []; const warnings: RebarWarning[] = []; + if (isAssumedMaterial(m)) { + warnings.push({ code: 'I02', message: `fck ${m.fck} MPa · fy ${m.fyH} / ${m.fyS} MPa는 가정값입니다. 정착·이음 길이에 반영됩니다. 공통 설정에서 확인하세요.` }); + } const L = model.copingL * 1000; const B = model.copingB * 1000; @@ -46,7 +49,7 @@ export function rebarSummary(model: PierModel): { rows: RebarRow[]; warnings: Re for (const [name, k] of [['C1', 1], ['C2', 2], ['C3', 3], ['C4', 1], ['C5', 2], ['C6', 3]] as const) { rows.push({ member: 'coping', name, size: 'H29', count: perRow, length: topBarLength(L, k), note: '상면, 이음 무시' }); } - const bottom = bottomPiece(); + const bottom = bottomPiece(m); rows.push({ member: 'coping', name: 'C7', size: 'H29', count: perRow, length: bottom.piece, note: '하면, 2조각 겹침이음' }); rows.push({ member: 'coping', name: 'C8', size: 'H29', count: perRow, length: bottom.piece, note: '하면, 2조각 겹침이음' }); const shear = shearBars(L); @@ -71,7 +74,7 @@ export function rebarSummary(model: PierModel): { rows: RebarRow[]; warnings: Re rows.push({ member: 'coping', name: 'C16', size: 'H19', count: STEP_BAR_COUNTS.C16, length: STEP_LENGTHS.C16[0], note: `계단부, 3·4구간 (${STEP_LENGTHS.C16[0]} / ${STEP_LENGTHS.C16[1]})` }); // Column (기둥철근.md · plan C4), P1 defaults - const col = columnRebar(COLUMN_P1); + const col = columnRebar(COLUMN_P1, m); rows.push({ member: 'column', name: 'P1', size: 'S32', count: col.mainP1, length: null, note: '외측 r 875, 마디 4697.5 + 갈고리 525 / 3000 ×3 / 3147 (코핑 밖 마디는 기둥철근.md §3.2)' }); rows.push({ member: 'column', name: 'P2', size: 'S32', count: col.mainP2, length: null, note: '내측 r 843.2, 마디 4847.5 / 3000 ×3 / 2389' }); rows.push({ member: 'column', name: '커플러', size: 'L150 Ø60', count: col.couplers, length: 150, note: '@3000, P2 +150' }); @@ -88,7 +91,7 @@ export function rebarSummary(model: PierModel): { rows: RebarRow[]; warnings: Re const Hf = (f.top - f.bottom) * 1000; // Bars longer than the standard length are split in two (추천안, Q-B6). The count is still the bar count. const addBar = (b: { name: string; size: string; count: number; length: number }, note: string) => { - const split = splitPieces(b.length, 25.4, 500, 27, STANDARD_BAR); + const split = splitPieces(b.length, 25.4, m.fyS, m.fck, STANDARD_BAR); if (split) { rows.push({ member: 'footing', name: b.name, size: b.size, count: b.count, length: split.pieceLength, note: `${note}, 2조각 이음 (추천안, 겹침 ${Math.round(split.lap)})` }); warnings.push({ code: 'RB-F08', message: `${b.name} 길이 ${Math.round(b.length)} mm > 정척 ${STANDARD_BAR} mm: 2조각 이음을 추천안으로 적용 (Q-B6).` }); diff --git a/src/main.ts b/src/main.ts index 0bf803b..38c6542 100644 --- a/src/main.ts +++ b/src/main.ts @@ -11,6 +11,7 @@ import { adjustRow, layoutText, resolveRowCounts, rowLayout, totalPiles } from ' import { applyCopy, DEFAULT_COPY_ITEMS, planCopy, type CopyItem } from './core/copy'; import { pileGeometry } from './core/geometry'; import { rebarSummary, type RebarRow } from './core/rebar/summary'; +import { DEFAULT_MATERIAL, isAssumedMaterial, type Material } from './core/rebar/common'; import { allVisibleChecked, setVisibleChecked, type TypeFilter } from './core/selection'; import { choose, defaultSummary, isSelectable, SHAPE_GROUPS, UNDEFINED_REASON, type MemberId } from './core/shape-options'; import { parseProject, SCHEMA_VERSION, serializeProject, type ProjectFile, type ProjectInfo } from './core/project'; @@ -71,6 +72,8 @@ const state = { project: { name: '', bridge: '', client: '', author: '', crs: CRS, memo: '' } as ProjectInfo, /** Which stage screen is shown: ① 형상 생성 or ② 형상계획 (plan.md C1). */ stage: 'shape' as 'shape' | 'plan' | 'rebar', + /** ③ 공통 설정 (fck · fy). */ + material: { ...DEFAULT_MATERIAL } as Material, /** Member tab on ③ (철근표). */ rebarMember: 'coping' as RebarRow['member'], /** Member card selected on ② (시안 ② 부재 목록). */ @@ -718,7 +721,7 @@ function renderRebar(): void { $('rebar-note').textContent = '교각을 생성하면 철근표가 나옵니다.'; return; } - const { rows, warnings } = rebarSummary(pier.model); + const { rows, warnings } = rebarSummary(pier.model, state.material); $('rebar-note').textContent = `${pier.input.name} · 형상 기준 계산 · 값은 기본값 기준 문서 규칙`; const shown = rows.filter((r) => r.member === state.rebarMember); const table = el('table', undefined, 'grid-table rebar-table'); @@ -744,6 +747,28 @@ function renderRebar(): void { for (const w of warnings) issues.appendChild(el('li', `${w.code} ${w.message}`)); if (issues.childElementCount === 0) issues.appendChild(el('li', '알림이 없습니다.')); $('step3-status').textContent = `계산됨 · 경고 ${warnings.length}`; + renderMaterial(); +} + +/** ③ 공통 설정 inputs. Changes apply on 'change' and regenerate the rebar screen. */ +function renderMaterial(): void { + const m = state.material; + const assumed = isAssumedMaterial(m); + $('mat-fck').value = String(m.fck); + $('mat-fyh').value = String(m.fyH); + $('mat-fys').value = String(m.fyS); + $('mat-note').textContent = assumed ? '가정값 (I02): 입력을 바꾸면 정착·이음 길이가 달라집니다.' : '입력값을 사용합니다.'; +} + +function setMaterial(key: keyof Material, raw: string): void { + const value = Number(raw); + if (!Number.isFinite(value) || value <= 0) { + setStatus('공통 설정 값은 0보다 큰 숫자여야 합니다.'); + renderMaterial(); + return; + } + state.material = { ...state.material, [key]: value }; + renderAll(); } function renderPlan(): void { @@ -810,6 +835,7 @@ function snapshot(): string { project: state.project, source: state.source, piers: state.piers.map((p) => ({ input: p.input, defaults: p.defaults, draft: p.draft })), + material: state.material, }); } @@ -824,6 +850,7 @@ function currentProjectText(): string { source: state.source, piers: state.piers.map((p) => ({ id: p.input.name, input: p.input, defaults: p.defaults, draft: p.draft })), history: state.history, + common: state.material, }); } @@ -907,6 +934,7 @@ function openProjectText(text: string, saved: boolean): void { state.project = { ...parsed.project }; state.source = parsed.source; state.history = parsed.history; + state.material = parsed.common ? { ...parsed.common } : { ...DEFAULT_MATERIAL }; state.piers = parsed.piers.map((p) => ({ ...buildEntry(p.input, false), defaults: p.defaults ?? p.input, @@ -1137,6 +1165,9 @@ $('bulk-copy').addEventListener('click', openCopyDialog); $('nav-shape').addEventListener('click', () => setStage('shape')); $('nav-plan').addEventListener('click', () => setStage('plan')); $('nav-rebar').addEventListener('click', () => setStage('rebar')); +$('mat-fck').addEventListener('change', (ev) => setMaterial('fck', (ev.target as HTMLInputElement).value)); +$('mat-fyh').addEventListener('change', (ev) => setMaterial('fyH', (ev.target as HTMLInputElement).value)); +$('mat-fys').addEventListener('change', (ev) => setMaterial('fyS', (ev.target as HTMLInputElement).value)); $('member-regen').addEventListener('click', () => { const pier = activePier(); if (pier) regenerateOne(pier.input.name); diff --git a/src/style.css b/src/style.css index 76c202e..f827b96 100644 --- a/src/style.css +++ b/src/style.css @@ -686,4 +686,8 @@ body { background: #eef1f4; font-family: 'Noto Sans KR', system-ui, sans-serif; .rebar-table td:nth-child(5), .rebar-table th:nth-child(5) { text-align: left; } .rebar-table td:nth-child(5) { white-space: normal; min-width: 260px; } +/* ③ 공통 설정 */ +.common { margin: 10px 0; display: grid; gap: 6px; } +.mat { display: flex; justify-content: space-between; align-items: center; gap: 8px; font-size: 13px; } +.mat input { width: 96px; padding: 4px 6px; border: 1px solid #b9c4d0; border-radius: 4px; } diff --git a/tests/project.test.ts b/tests/project.test.ts index ea9ea83..c0c17ad 100644 --- a/tests/project.test.ts +++ b/tests/project.test.ts @@ -64,3 +64,12 @@ describe('project file round trip (spec §8, plan B3)', () => { expect(reopened.piers[0].draft).toBeUndefined(); }); }); + +describe('③ 공통 설정 in the project file', () => { + it('fck and fy are kept through save and open; older files get the defaults through the app', () => { + const p = sampleProject(); + const withCommon = { ...p, common: { fck: 35, fyH: 400, fyS: 500 } }; + expect(parseProject(serializeProject(withCommon)).common).toEqual({ fck: 35, fyH: 400, fyS: 500 }); + expect(parseProject(serializeProject(p)).common).toBeUndefined(); + }); +}); diff --git a/tests/rebar-material.test.ts b/tests/rebar-material.test.ts new file mode 100644 index 0000000..1bae448 --- /dev/null +++ b/tests/rebar-material.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest'; +import { buildPier } from '../src/core/geometry'; +import { DEFAULT_MATERIAL, isAssumedMaterial } from '../src/core/rebar/common'; +import { columnRebar } from '../src/core/rebar/column'; +import { rebarSummary } from '../src/core/rebar/summary'; +import { loadP1 } from './helpers'; + +describe('③ 공통 설정: fck · fy 입력 (spec §6.2)', () => { + const model = buildPier(loadP1()); + + it('defaults are assumed (I02); changed values are not', () => { + expect(isAssumedMaterial(DEFAULT_MATERIAL)).toBe(true); + expect(isAssumedMaterial({ ...DEFAULT_MATERIAL, fck: 35 })).toBe(false); + }); + + it('a higher fck shortens the laps: P3 lap scales with 1/√fck (27 → 35)', () => { + const base = columnRebar(); + const high = columnRebar(undefined, { ...DEFAULT_MATERIAL, fck: 35 }); + expect(high.lapP3 / base.lapP3).toBeCloseTo(Math.sqrt(27 / 35), 3); + expect(high.zTop2).toBeLessThan(base.zTop2); + }); + + it('the rebar summary follows the material: the C7 splice piece changes with fck', () => { + const base = rebarSummary(model).rows.find((r) => r.name === 'C7')!.length!; + const high = rebarSummary(model, { ...DEFAULT_MATERIAL, fck: 35 }).rows.find((r) => r.name === 'C7')!.length!; + expect(high).toBeLessThan(base); + }); + + it('a non-default material drops the I02 warning', () => { + const codes = rebarSummary(model, { ...DEFAULT_MATERIAL, fyS: 600 }).warnings.map((w) => w.code); + expect(codes).not.toContain('I02'); + }); +}); diff --git a/tests/rebar-summary.test.ts b/tests/rebar-summary.test.ts index 20d316c..52115fd 100644 --- a/tests/rebar-summary.test.ts +++ b/tests/rebar-summary.test.ts @@ -45,7 +45,7 @@ describe('③ 철근표 요약 (P1)', () => { it('B9: 320 bars; warnings: B2 needs a splice (13646 > 12000)', () => { expect(count('B9')).toBe(320); - expect(warnings.map((w) => w.code)).toEqual(['RB-F08']); + expect(warnings.map((w) => w.code)).toEqual(['I02', 'RB-F08']); // defaults are assumed values (I02) // B2 is split into two pieces (추천안): each piece is about half the 13646 bar plus half the lap. expect(rows.find((r) => r.name === 'B2')!.length!).toBeCloseTo(7776, 0); });