From b3533b69dfb742e72f49070eec71fa8d73e7016b Mon Sep 17 00:00:00 2001 From: ypshim Date: Fri, 23 Oct 2026 09:51:36 +0900 Subject: [PATCH] =?UTF-8?q?feat(rebar):=20rebar=20strength=20per=20?= =?UTF-8?q?=EA=B7=9C=EA=B2=A9=20prefix=20as=20inputs=20on=20=E2=91=A2=20(D?= =?UTF-8?q?=20SD300,=20H=20SD400,=20S=20SD500)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fyH and fyS become a grade per prefix (D · H · S), chosen from SD300 · SD400 · SD500. The defaults are the P1 values, so the P1 results do not change. Each bar takes its grade from its 규격 (fyOf). The 철근표 has a 강도 column. Older files that stored fyH / fyS are read into the grade map. Only fck is still an assumed value (I02). Approved 2026-10-23. Co-Authored-By: Claude Haiku 5.5 --- index.html | 5 ++-- src/core/rebar/column.ts | 6 ++--- src/core/rebar/common.ts | 42 ++++++++++++++++++++++++++-------- src/core/rebar/coping-main.ts | 4 ++-- src/core/rebar/coping.ts | 4 ++-- src/core/rebar/summary.ts | 6 ++--- src/main.ts | 28 +++++++++++++---------- tests/project.test.ts | 4 ++-- tests/rebar-grade.test.ts | 36 +++++++++++++++++++++++++++++ tests/rebar-material.test.ts | 2 +- tests/rebar-member-fck.test.ts | 2 +- 11 files changed, 102 insertions(+), 37 deletions(-) create mode 100644 tests/rebar-grade.test.ts diff --git a/index.html b/index.html index e220e05..9beaf1c 100644 --- a/index.html +++ b/index.html @@ -178,8 +178,9 @@

공통 설정

- - + + +

diff --git a/src/core/rebar/column.ts b/src/core/rebar/column.ts index 5069ea3..eee2363 100644 --- a/src/core/rebar/column.ts +++ b/src/core/rebar/column.ts @@ -1,4 +1,4 @@ -import { DEFAULT_MATERIAL, developmentLength, hookLength, Material, NOMINAL_DIAMETER, spliceLength } from './common'; +import { DEFAULT_MATERIAL, developmentLength, fyOf, hookLength, Material, NOMINAL_DIAMETER, spliceLength } from './common'; import { layers } from './footing'; /** @@ -95,7 +95,7 @@ export function columnRebar(x: ColumnInput = COLUMN_P1, m: Material = DEFAULT_MA const r1 = x.D / 2 - x.c; const r2 = r1 - dS32; const zTop1 = x.Hc + x.hcop - x.eP1; - const lD2 = developmentLength(dS32, m.fyS, m.fck); + const lD2 = developmentLength(dS32, fyOf(m, 'S32'), m.fck); const zTop2 = x.Hc + lD2; // Bottom of P1 and P2: 확대기초 하면 2단 (B7) 위 + (d_B7 + d_P1)/2 (기둥철근.md §3.2, the same for both). const zBot = -x.Hf + layers(x.Hf).B7 + (NOMINAL_DIAMETER.S19 + dS32) / 2; @@ -137,7 +137,7 @@ export function columnRebar(x: ColumnInput = COLUMN_P1, m: Material = DEFAULT_MA const p6Length = p6Straight + 2 * Math.round(p6Hook); // 135 mm per hook, as in the document // 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 lapP3 = spliceLength(dH19, fyOf(m, 'H19'), 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 940f867..9ddb59b 100644 --- a/src/core/rebar/common.ts +++ b/src/core/rebar/common.ts @@ -1,18 +1,42 @@ /** * ③ 철근배근 공통 규칙 (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). + * fck and the rebar grade per 규격 prefix are inputs. fck's default is an assumed value (I02); the grades' defaults are confirmed. */ -/** Material inputs of ③ 공통 설정 (spec §6.2). fck and fy are inputs; the defaults are assumed values (I02). */ +/** Rebar strength choices on ③ (SD300 · SD400 · SD500, MPa). */ +export const GRADE_CHOICES = [300, 400, 500] as const; + +/** Bar family by 규격 prefix: D · H · S. */ +export type GradeKey = 'D' | 'H' | 'S'; + +/** Material inputs of ③ 공통 설정 (spec §6.2). fck and the grade per prefix are inputs. */ export interface Material { /** 콘크리트 강도 fck (MPa). */ fck: number; - /** H = SD400 항복강도 (MPa). */ - fyH: number; - /** S = SD500 항복강도 (MPa). */ - fyS: number; + /** 규격 접두어별 철근 강도 (MPa): D = SD300, H = SD400, S = SD500 (입력). */ + grade: Record; } -export const DEFAULT_MATERIAL: Material = { fck: 27, fyH: 400, fyS: 500 }; +export const DEFAULT_MATERIAL: Material = { fck: 27, grade: { D: 300, H: 400, S: 500 } }; + +/** Yield strength (MPa) of a bar from its 규격, e.g. H19 → the H grade. */ +export function fyOf(m: Material, size: string): number { + return m.grade[size.charAt(0) as GradeKey]; +} + +/** + * Reads a material from the current shape, or from an older file that stored fyH / fyS. + * A value outside GRADE_CHOICES falls back to the default for that prefix. + */ +export function materialFrom(raw: unknown): Material { + const r = (raw ?? {}) as { fck?: unknown; grade?: Partial>; fyH?: unknown; fyS?: unknown }; + const pick = (v: unknown, fallback: number) => ((GRADE_CHOICES as readonly number[]).includes(v as number) ? (v as number) : fallback); + const grade = { + D: pick(r.grade?.D, DEFAULT_MATERIAL.grade.D), + H: pick(r.grade?.H ?? r.fyH, DEFAULT_MATERIAL.grade.H), + S: pick(r.grade?.S ?? r.fyS, DEFAULT_MATERIAL.grade.S), + }; + return { fck: typeof r.fck === 'number' ? r.fck : DEFAULT_MATERIAL.fck, grade }; +} /** Members that can take their own fck on ② (부재 카드). Other members always use the 공통 fck. */ export type FckMember = 'column' | 'coping' | 'footing'; @@ -27,9 +51,9 @@ export function materialFor(common: Material, byMember: FckByMember, member: Fck return { ...common, fck: byMember[member] ?? common.fck }; } -/** True while the values are the assumed defaults (정보 I02 on the rebar screen). */ +/** True while fck is the assumed default (정보 I02 on the rebar screen). The grades are confirmed inputs (decision 2026-10-23). */ export function isAssumedMaterial(m: Material): boolean { - return m.fck === DEFAULT_MATERIAL.fck && m.fyH === DEFAULT_MATERIAL.fyH && m.fyS === DEFAULT_MATERIAL.fyS; + return m.fck === DEFAULT_MATERIAL.fck; } export const MATERIAL_DEFAULTS = { diff --git a/src/core/rebar/coping-main.ts b/src/core/rebar/coping-main.ts index 3aa87dc..5efb564 100644 --- a/src/core/rebar/coping-main.ts +++ b/src/core/rebar/coping-main.ts @@ -1,4 +1,4 @@ -import { DEFAULT_MATERIAL, hookLength, Material, NOMINAL_DIAMETER, spliceLength } from './common'; +import { DEFAULT_MATERIAL, fyOf, hookLength, Material, NOMINAL_DIAMETER, spliceLength } from './common'; /** * C1–C8 상·하면 주철근 (plan C3 continued). Source: RAG/코핑철근.md §5, 2026-10-22 최종 정리. @@ -73,7 +73,7 @@ export function topBarLength(L: number, k: number): 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, m.fyH, m.fck); + const ls = spliceLength(NOMINAL_DIAMETER.H29, fyOf(m, 'H29'), m.fck); const lh = hookLength('main90', NOMINAL_DIAMETER.H29); return { path, ls, piece: path / 2 + ls / 2 + lh }; } diff --git a/src/core/rebar/coping.ts b/src/core/rebar/coping.ts index 4dad3aa..e6ce2c1 100644 --- a/src/core/rebar/coping.ts +++ b/src/core/rebar/coping.ts @@ -2,7 +2,7 @@ * 코핑 철근 (plan C3). Source: RAG/코핑철근.md, 2026-10-22 최종 정리 (spec §6.4 is older and is not used here). * Positions are along the coping length from its start, in mm. */ -import { DEFAULT_MATERIAL, developmentLength, hookLength, Material, NOMINAL_DIAMETER } from './common'; +import { DEFAULT_MATERIAL, developmentLength, fyOf, hookLength, Material, NOMINAL_DIAMETER } from './common'; export const COPING_REBAR = { /** 피복 (최외측 철근 중심까지). */ cover: 100, @@ -144,7 +144,7 @@ export const C14_INSET = COPING_REBAR.cover + (NOMINAL_DIAMETER.H25 + NOMINAL_DI /** Development length l_d of H19 (882 for fy 400, fck 27), rounded to mm as the document does (§7.2). */ export function stepDevLength(m: Material = DEFAULT_MATERIAL): number { - return Math.round(developmentLength(NOMINAL_DIAMETER.H19, m.fyH, m.fck)); + return Math.round(developmentLength(NOMINAL_DIAMETER.H19, fyOf(m, 'H19'), m.fck)); } export interface C14Bar { diff --git a/src/core/rebar/summary.ts b/src/core/rebar/summary.ts index 001943c..2839386 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, shearBarsMulti, copingSideBars, SIDE_HOOK_LEG, c14Bars, c14Length, stairBars, stepDevLength, undersideAt, shearULength, capLength, PIECES_PER_POSITION } from './coping'; import { columnRebar, COLUMN_P1 } from './column'; import { bottomBars, crossBars, sideBars, assemblyBars, shearB9 } from './footing'; -import { DEFAULT_MATERIAL, FckByMember, isAssumedMaterial, Material, materialFor, splitPieces } from './common'; +import { DEFAULT_MATERIAL, FckByMember, fyOf, isAssumedMaterial, Material, materialFor, splitPieces } from './common'; /** 근거 badge (spec §6.1): where the value comes from. */ export type Basis = 'input' | 'default' | 'calc' | 'assumed'; @@ -126,7 +126,7 @@ export function rebarSummary( // The I02 note stays while the common values are the assumed ones, unless every fck-bearing member has its own fck. const ownFck = (['column', 'coping', 'footing'] as const).every((k) => byMember[k] !== undefined); if (isAssumedMaterial(common) && !ownFck) { - warnings.push({ code: 'I02', message: `fck ${common.fck} MPa · fy ${common.fyH} / ${common.fyS} MPa는 가정값입니다. 정착·이음 길이에 반영됩니다. 공통 설정에서 확인하세요.`, target: 'common' }); + warnings.push({ code: 'I02', message: `fck ${common.fck} MPa는 가정값입니다. 정착·이음 길이에 반영됩니다. 공통 설정에서 확인하세요.`, target: 'common' }); } const L = model.copingL * 1000; const B = model.copingB * 1000; @@ -228,7 +228,7 @@ export function rebarSummary( } } for (const b of agg.values()) { - const split = splitPieces(b.length, 25.4, mFooting.fyS, mFooting.fck, STANDARD_BAR); + const split = splitPieces(b.length, 25.4, fyOf(mFooting, b.size), mFooting.fck, STANDARD_BAR); if (split && b.length > STANDARD_BAR && b.name !== 'B9') { rows.push({ member: 'footing', name: b.name, size: b.size, count: b.count, length: split.pieceLength, note: `${b.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).`, target: 'footing', bar: b.name }); diff --git a/src/main.ts b/src/main.ts index 1fbfa52..8873bff 100644 --- a/src/main.ts +++ b/src/main.ts @@ -13,7 +13,7 @@ import { pileGeometry } from './core/geometry'; import { rebarSummary, type RebarRow } from './core/rebar/summary'; import { shearSegments } from './core/rebar/coping'; import { buildRebar3D, type RebarColorMode, type RebarScene } from './view/rebar3d'; -import { DEFAULT_MATERIAL, FCK_MEMBERS, isAssumedMaterial, type FckByMember, type FckMember, type Material } from './core/rebar/common'; +import { DEFAULT_MATERIAL, FCK_MEMBERS, fyOf, GRADE_CHOICES, isAssumedMaterial, materialFrom, type FckByMember, type FckMember, type GradeKey, 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'; @@ -838,7 +838,7 @@ function renderRebar(): void { const table = el('table', undefined, 'grid-table rebar-table'); const head = el('thead'); const hr = el('tr'); - for (const h of ['구분', '번호', '규격', '간격', '실간격', '위치', '방향', '형상', '길이 (mm)', '개수', '근거', '비고']) hr.appendChild(el('th', h)); + for (const h of ['구분', '번호', '규격', '강도', '간격', '실간격', '위치', '방향', '형상', '길이 (mm)', '개수', '근거', '비고']) hr.appendChild(el('th', h)); head.appendChild(hr); table.appendChild(head); const body = el('tbody'); @@ -855,6 +855,7 @@ function renderRebar(): void { el('td', ROLE[r.name] ?? '—'), nameCell, el('td', r.size), + el('td', /^[DHS]\d/.test(r.size) ? `SD${fyOf(state.material, r.size)}` : '—'), el('td', r.spacing), el('td', r.actual), el('td', r.position), @@ -952,9 +953,10 @@ 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): 입력을 바꾸면 정착·이음 길이가 달라집니다.' : '입력값을 사용합니다.'; + for (const k of ['d', 'h', 's'] as const) { + $(`mat-grade-${k}`).value = String(m.grade[k.toUpperCase() as GradeKey]); + } + $('mat-note').textContent = assumed ? 'fck 가정값 (I02): 입력을 바꾸면 정착·이음 길이가 달라집니다.' : '입력값을 사용합니다.'; } /** Sets or clears one member's fck. An empty value clears it, so the member uses the 공통 fck again. */ @@ -1001,14 +1003,15 @@ function renderMemberFck(box: HTMLDivElement, member: FckMember): void { box.appendChild(row); } -function setMaterial(key: keyof Material, raw: string): void { +function setMaterial(key: 'fck' | GradeKey, raw: string): void { const value = Number(raw); - if (!Number.isFinite(value) || value <= 0) { - setStatus('공통 설정 값은 0보다 큰 숫자여야 합니다.'); + const valid = key === 'fck' ? Number.isFinite(value) && value > 0 : (GRADE_CHOICES as readonly number[]).includes(value); + if (!valid) { + setStatus(key === 'fck' ? '공통 설정 fck는 0보다 큰 숫자여야 합니다.' : '철근 강도는 SD300 · SD400 · SD500 중 하나여야 합니다.'); renderMaterial(); return; } - state.material = { ...state.material, [key]: value }; + state.material = key === 'fck' ? { ...state.material, fck: value } : { ...state.material, grade: { ...state.material.grade, [key]: value } }; // The 3D bars depend on the material too (their lengths), so the scene is rebuilt with it. setupScene(); renderAll(); @@ -1180,7 +1183,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.material = materialFrom(parsed.common); state.fckByMember = { ...parsed.fckByMember }; state.piers = parsed.piers.map((p) => ({ ...buildEntry(p.input, false), @@ -1414,8 +1417,9 @@ $('nav-shape').addEventListener('click', () => setStage('shap $('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)); +for (const k of ['D', 'H', 'S'] as const) { + $(`mat-grade-${k.toLowerCase()}`).addEventListener('change', (ev) => setMaterial(k, (ev.target as HTMLSelectElement).value)); +} $('member-regen').addEventListener('click', () => { const pier = activePier(); if (pier) regenerateOne(pier.input.name); diff --git a/tests/project.test.ts b/tests/project.test.ts index c0c17ad..b629ab8 100644 --- a/tests/project.test.ts +++ b/tests/project.test.ts @@ -68,8 +68,8 @@ describe('project file round trip (spec §8, plan B3)', () => { 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 }); + const withCommon = { ...p, common: { fck: 35, grade: { D: 300, H: 400, S: 500 } } }; + expect(parseProject(serializeProject(withCommon)).common).toEqual({ fck: 35, grade: { D: 300, H: 400, S: 500 } }); expect(parseProject(serializeProject(p)).common).toBeUndefined(); }); }); diff --git a/tests/rebar-grade.test.ts b/tests/rebar-grade.test.ts new file mode 100644 index 0000000..eae6e53 --- /dev/null +++ b/tests/rebar-grade.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; +import { buildPier } from '../src/core/geometry'; +import { DEFAULT_MATERIAL, fyOf, isAssumedMaterial, materialFrom } from '../src/core/rebar/common'; +import { rebarSummary } from '../src/core/rebar/summary'; +import { loadP1 } from './helpers'; + +describe('③ 철근 강도: 규격 접두어별 입력 (승인 2026-10-23)', () => { + it('defaults: D = SD300, H = SD400, S = SD500, the same as the P1 values', () => { + expect(DEFAULT_MATERIAL.grade).toEqual({ D: 300, H: 400, S: 500 }); + expect(fyOf(DEFAULT_MATERIAL, 'H19')).toBe(400); + expect(fyOf(DEFAULT_MATERIAL, 'S32')).toBe(500); + expect(fyOf(DEFAULT_MATERIAL, 'D22')).toBe(300); + }); + + it('only fck is assumed now; the grades are confirmed inputs', () => { + expect(isAssumedMaterial(DEFAULT_MATERIAL)).toBe(true); + expect(isAssumedMaterial({ ...DEFAULT_MATERIAL, fck: 35 })).toBe(false); + expect(isAssumedMaterial({ ...DEFAULT_MATERIAL, grade: { D: 300, H: 400, S: 400 } })).toBe(true); + }); + + it('an older file that stored fyH / fyS is read into the grade map', () => { + expect(materialFrom({ fck: 35, fyH: 400, fyS: 400 })).toEqual({ fck: 35, grade: { D: 300, H: 400, S: 400 } }); + }); + + it('a value outside SD300 · SD400 · SD500 falls back to the default for its prefix', () => { + expect(materialFrom({ fck: 27, grade: { S: 600 } }).grade.S).toBe(500); + expect(materialFrom(undefined)).toEqual(DEFAULT_MATERIAL); + }); + + it('the P1 values do not change with the defaults (S = SD500 gives the same C7 length)', () => { + const model = buildPier(loadP1()); + const base = rebarSummary(model).rows.find((r) => r.name === 'C7')!.length; + const same = rebarSummary(model, materialFrom({ fck: 27, grade: { D: 300, H: 400, S: 500 } })).rows.find((r) => r.name === 'C7')!.length; + expect(same).toBe(base); + }); +}); diff --git a/tests/rebar-material.test.ts b/tests/rebar-material.test.ts index 1bae448..7d7b715 100644 --- a/tests/rebar-material.test.ts +++ b/tests/rebar-material.test.ts @@ -27,7 +27,7 @@ describe('③ 공통 설정: fck · fy 입력 (spec §6.2)', () => { }); it('a non-default material drops the I02 warning', () => { - const codes = rebarSummary(model, { ...DEFAULT_MATERIAL, fyS: 600 }).warnings.map((w) => w.code); + const codes = rebarSummary(model, { ...DEFAULT_MATERIAL, fck: 35 }).warnings.map((w) => w.code); expect(codes).not.toContain('I02'); }); }); diff --git a/tests/rebar-member-fck.test.ts b/tests/rebar-member-fck.test.ts index ac8ddfe..fbe47be 100644 --- a/tests/rebar-member-fck.test.ts +++ b/tests/rebar-member-fck.test.ts @@ -23,7 +23,7 @@ describe('② 부재별 fck (spec §6.2 · ②)', () => { const common = { ...DEFAULT_MATERIAL, fck: 30 }; expect(materialFor(common, { coping: 35 }, 'coping').fck).toBe(35); expect(materialFor(common, { coping: 35 }, 'column').fck).toBe(30); - expect(materialFor(common, { coping: 35 }, 'coping').fyS).toBe(common.fyS); + expect(materialFor(common, { coping: 35 }, 'coping').grade).toEqual(common.grade); }); it('a coping fck changes only the coping rows (C15 stair legs use the H19 development length)', () => {