diff --git a/src/core/project.ts b/src/core/project.ts index ef93cb9..1441a7d 100644 --- a/src/core/project.ts +++ b/src/core/project.ts @@ -1,4 +1,4 @@ -import type { Material } from './rebar/common'; +import type { FckByMember, Material } from './rebar/common'; import type { PierInput } from './types'; /** Project file schema (spec §8). Bump only with a migration. */ @@ -31,6 +31,8 @@ export interface ProjectFile { history: { version: string; note: string; at: string }[]; /** ③ 공통 설정 (fck · fy). Absent in older files: the defaults are used. */ common?: Material; + /** ② 부재별 fck (MPa). Absent in older files: every member uses the 공통 fck. */ + fckByMember?: FckByMember; } /** Serialises a project to the pretty-printed JSON stored in <교량명>_교각설계.pzr.json. */ @@ -55,6 +57,13 @@ export function parseProject(text: string): ProjectFile { throw new Error(`지원하지 않는 파일 버전입니다 (schemaVersion ${String(obj.schemaVersion)}, 지원: ${SCHEMA_VERSION}).`); } if (!Array.isArray(obj.piers)) throw new Error('piers 목록이 없습니다.'); + if (obj.fckByMember !== undefined) { + for (const [member, value] of Object.entries(obj.fckByMember)) { + if (!['column', 'coping', 'footing'].includes(member) || typeof value !== 'number' || !(value > 0)) { + throw new Error(`부재별 fck 값이 맞지 않습니다 (${member}: ${String(value)}).`); + } + } + } obj.piers.forEach((p, i) => { if (!p || typeof p.id !== 'string' || !p.input) throw new Error(`교각 ${i + 1}: id 또는 input이 없습니다.`); const input = p.input; diff --git a/src/core/rebar/common.ts b/src/core/rebar/common.ts index 8ce5451..940f867 100644 --- a/src/core/rebar/common.ts +++ b/src/core/rebar/common.ts @@ -14,6 +14,19 @@ export interface Material { export const DEFAULT_MATERIAL: Material = { fck: 27, fyH: 400, fyS: 500 }; +/** Members that can take their own fck on ② (부재 카드). Other members always use the 공통 fck. */ +export type FckMember = 'column' | 'coping' | 'footing'; +/** The members of {@link FckMember}, for the ② cards. */ +export const FCK_MEMBERS: readonly FckMember[] = ['column', 'coping', 'footing']; + +/** Per-member fck (MPa). A member without an entry uses the ③ 공통 설정 fck. */ +export type FckByMember = Partial>; + +/** The material a member is designed with: its own fck when set, otherwise the 공통 value. fy stays common. */ +export function materialFor(common: Material, byMember: FckByMember, member: FckMember): Material { + return { ...common, fck: byMember[member] ?? common.fck }; +} + /** 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; diff --git a/src/core/rebar/summary.ts b/src/core/rebar/summary.ts index 487f994..001943c 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, isAssumedMaterial, Material, splitPieces } from './common'; +import { DEFAULT_MATERIAL, FckByMember, isAssumedMaterial, Material, materialFor, splitPieces } from './common'; /** 근거 badge (spec §6.1): where the value comes from. */ export type Basis = 'input' | 'default' | 'calc' | 'assumed'; @@ -113,11 +113,20 @@ function footingBarList(Lf: number, Wf: number, Hf: number, columnX: number[]): type RawRow = Omit; -export function rebarSummary(model: PierModel, m: Material = DEFAULT_MATERIAL): { rows: RebarRow[]; warnings: RebarWarning[] } { +export function rebarSummary( + model: PierModel, + common: Material = DEFAULT_MATERIAL, + byMember: FckByMember = {}, +): { rows: RebarRow[]; warnings: RebarWarning[] } { const rows: RawRow[] = []; const warnings: RebarWarning[] = []; - if (isAssumedMaterial(m)) { - warnings.push({ code: 'I02', message: `fck ${m.fck} MPa · fy ${m.fyH} / ${m.fyS} MPa는 가정값입니다. 정착·이음 길이에 반영됩니다. 공통 설정에서 확인하세요.`, target: 'common' }); + const mCoping = materialFor(common, byMember, 'coping'); + const mColumn = materialFor(common, byMember, 'column'); + const mFooting = materialFor(common, byMember, 'footing'); + // 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' }); } const L = model.copingL * 1000; const B = model.copingB * 1000; @@ -130,7 +139,7 @@ export function rebarSummary(model: PierModel, m: Material = DEFAULT_MATERIAL): const emb1 = embedments('S1'); const emb2 = embedments('S2'); const range = (xs: number[]) => `${Math.round(Math.min(...xs))}–${Math.round(Math.max(...xs))}`; - const anchorage = stepDevLength(m); // RB-S04: the embedment must reach the H19 development length + const anchorage = stepDevLength(mCoping); // RB-S04: the embedment must reach the H19 development length const shortest = Math.min(...emb1, ...emb2); if (shortest < anchorage) { warnings.push({ code: 'RB-S04', message: `받침블럭 철근 묻힘 ${Math.round(shortest)} mm < 정착길이 ${anchorage} mm (코핑 안 묻힘 부족)`, target: 'bearing' }); @@ -145,7 +154,7 @@ export function rebarSummary(model: PierModel, m: Material = DEFAULT_MATERIAL): 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(m); + const bottom = bottomPiece(mCoping); 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 colS = model.columns.map((c) => c.s * 1000); @@ -182,12 +191,12 @@ export function rebarSummary(model: PierModel, m: Material = DEFAULT_MATERIAL): const lineCount = (line: number) => c14.filter((b) => b.line === line).length; const omitted = c14.filter((b) => !b.legs).length; rows.push({ member: 'coping', name: 'C14', size: 'H19', count: c14.length, length: c14Length(model.copingB * 1000), note: `위 2 줄 ${lineCount(1)} · ${lineCount(2)}본씩, 아래 ${[3, 4, 5, 6].map(lineCount).join(' · ')} (합 ${c14.length}) · 연직 발 생략 ${omitted}개 (하면 밖)` }); - const stairs = stairBars(model.copingL * 1000, model.copingB * 1000, model.copingRefTop, m); + const stairs = stairBars(model.copingL * 1000, model.copingB * 1000, model.copingRefTop, mCoping); rows.push({ member: 'coping', name: 'C15', size: 'H19', count: stairs.c15.length, length: stairs.c15[0].length, note: '계단부, 가장 높은 구간 (거꾸로 U)' }); rows.push({ member: 'coping', name: 'C16', size: 'H19', count: stairs.c16.length, length: stairs.c16[0].length, note: `계단부, 3·4구간 (${Math.round(stairs.c16[0].length)} / ${Math.round(stairs.c16[stairs.c16.length - 1].length)})` }); // Column (기둥철근.md · plan C4), P1 defaults - const col = columnRebar(COLUMN_P1, m); + const col = columnRebar(COLUMN_P1, mColumn); rows.push({ member: 'column', name: 'P1', size: 'S32', count: col.mainP1, length: null, note: `외측 r ${col.r1}, 마디 ${col.p1Pieces.join(' / ')} (첫 마디에 갈고리 ${col.p1Hook}, 기둥철근.md §3.2)` }); rows.push({ member: 'column', name: 'P2', size: 'S32', count: col.mainP2, length: null, note: `내측 r ${col.r2}, 마디 ${col.p2Pieces.join(' / ')}` }); rows.push({ member: 'column', name: '주철근 커플러', size: 'D32용 (L150 Ø60)', count: col.couplers, length: 150, note: '@3000, P2는 +150 mm 상향 엇갈림' }); @@ -219,7 +228,7 @@ export function rebarSummary(model: PierModel, m: Material = DEFAULT_MATERIAL): } } for (const b of agg.values()) { - const split = splitPieces(b.length, 25.4, m.fyS, m.fck, STANDARD_BAR); + const split = splitPieces(b.length, 25.4, mFooting.fyS, 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 6b74fbc..b430de7 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 RebarScene } from './view/rebar3d'; -import { DEFAULT_MATERIAL, isAssumedMaterial, type Material } from './core/rebar/common'; +import { DEFAULT_MATERIAL, FCK_MEMBERS, isAssumedMaterial, type FckByMember, type FckMember, 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'; @@ -76,6 +76,8 @@ const state = { stage: 'shape' as 'shape' | 'plan' | 'rebar', /** ③ 공통 설정 (fck · fy). */ material: { ...DEFAULT_MATERIAL } as Material, + /** ② 부재별 fck. A member without an entry uses the 공통 fck (③). */ + fckByMember: {} as FckByMember, /** Bar highlighted on ③ after [보기] (시안 ③ 알림). */ rebarFocus: null as string | null, /** Member tab on ③ (철근표). */ @@ -225,7 +227,7 @@ function setupScene(): void { rebar3d = null; const pier = activePier(); if (pier?.model) { - rebar3d = buildRebar3D(pier.model, state.material); + rebar3d = buildRebar3D(pier.model, state.material, state.fckByMember); rebar3d.group.visible = state.stage === 'rebar'; world.add(rebar3d.group); const scene = buildPierScene(pier.model); @@ -793,7 +795,7 @@ function renderRebar(): void { $('rebar-note').textContent = '교각을 생성하면 철근표가 나옵니다.'; return; } - const { rows, warnings } = rebarSummary(pier.model, state.material); + const { rows, warnings } = rebarSummary(pier.model, state.material, state.fckByMember); $('rebar-note').textContent = `${pier.input.name} · 형상 기준 계산 · 값은 기본값 기준 문서 규칙`; const shown = rows.filter((r) => r.member === state.rebarMember); const table = el('table', undefined, 'grid-table rebar-table'); @@ -918,6 +920,48 @@ function renderMaterial(): void { $('mat-note').textContent = assumed ? '가정값 (I02): 입력을 바꾸면 정착·이음 길이가 달라집니다.' : '입력값을 사용합니다.'; } +/** Sets or clears one member's fck. An empty value clears it, so the member uses the 공통 fck again. */ +function setMemberFck(member: FckMember, raw: string): void { + const next = { ...state.fckByMember }; + if (raw.trim() === '') { + delete next[member]; + } else { + const value = Number(raw); + if (!Number.isFinite(value) || value <= 0) { + setStatus('부재별 fck는 0보다 큰 숫자여야 합니다.'); + renderPlan(); + return; + } + next[member] = value; + } + state.fckByMember = next; + setupScene(); + renderAll(); +} + +/** fck row on a ② card for a member that has its own fck: value, the 공통 value beside it, and a revert when set. */ +function renderMemberFck(box: HTMLDivElement, member: FckMember): void { + const own = state.fckByMember[member]; + const row = el('div', undefined, own === undefined ? 'edit-row' : 'edit-row changed'); + const id = `member-fck-${member}`; + const label = el('label', 'fck (MPa)'); + label.htmlFor = id; + const input = el('input'); + input.id = id; + input.type = 'number'; + input.step = 'any'; + input.value = String(own ?? state.material.fck); + input.addEventListener('change', () => setMemberFck(member, input.value)); + row.append(label, input, el('span', `공통 ${state.material.fck}`, 'muted small')); + if (own !== undefined) { + const revert = el('button', '되돌리기', 'btn small'); + revert.type = 'button'; + revert.addEventListener('click', () => setMemberFck(member, '')); + row.appendChild(revert); + } + box.appendChild(row); +} + function setMaterial(key: keyof Material, raw: string): void { const value = Number(raw); if (!Number.isFinite(value) || value <= 0) { @@ -965,6 +1009,7 @@ function renderPlan(): void { } else { fieldRows(fields, pier, member.keys); if (member.id === 'footing') renderPileRows(fields, pier); + if (FCK_MEMBERS.includes(member.id as FckMember)) renderMemberFck(fields, member.id as FckMember); regen.textContent = `다시 생성 · 변경 ${pendingKeys(pier).length}`; regen.disabled = pendingKeys(pier).length === 0; } @@ -996,6 +1041,7 @@ function snapshot(): string { source: state.source, piers: state.piers.map((p) => ({ input: p.input, defaults: p.defaults, draft: p.draft })), material: state.material, + fckByMember: state.fckByMember, }); } @@ -1011,6 +1057,7 @@ function currentProjectText(): string { piers: state.piers.map((p) => ({ id: p.input.name, input: p.input, defaults: p.defaults, draft: p.draft })), history: state.history, common: state.material, + fckByMember: state.fckByMember, }); } @@ -1095,6 +1142,7 @@ function openProjectText(text: string, saved: boolean): void { state.source = parsed.source; state.history = parsed.history; state.material = parsed.common ? { ...parsed.common } : { ...DEFAULT_MATERIAL }; + state.fckByMember = { ...parsed.fckByMember }; state.piers = parsed.piers.map((p) => ({ ...buildEntry(p.input, false), defaults: p.defaults ?? p.input, diff --git a/src/view/rebar3d.ts b/src/view/rebar3d.ts index ee98600..003359f 100644 --- a/src/view/rebar3d.ts +++ b/src/view/rebar3d.ts @@ -5,7 +5,7 @@ import { bottomPiece, MAIN, widthLayout } from '../core/rebar/coping-main'; import { C14_INSET, c14Bars, CAP, COPING_REBAR, shearBars, shearBarsMulti, shearUHeight, copingSideBars, SIDE_HOOK_LEG, stairBars, undersideAt } from '../core/rebar/coping'; import { columnRebar, COLUMN_P1 } from '../core/rebar/column'; import { barsAcross, BLOCK_REBAR } from '../core/rebar/bearing-block'; -import { DEFAULT_MATERIAL, hookLength, Material, NOMINAL_DIAMETER } from '../core/rebar/common'; +import { DEFAULT_MATERIAL, FckByMember, hookLength, Material, materialFor, NOMINAL_DIAMETER } from '../core/rebar/common'; import { stretchMatrix } from './scene'; /** @@ -34,7 +34,9 @@ const COLOR = { const mm = (v: number) => v / 1000; -export function buildRebar3D(m: PierModel, mat: Material = DEFAULT_MATERIAL): RebarScene { +export function buildRebar3D(m: PierModel, common: Material = DEFAULT_MATERIAL, byMember: FckByMember = {}): RebarScene { + const mCoping = materialFor(common, byMember, 'coping'); + const mColumn = materialFor(common, byMember, 'column'); const group = new THREE.Group(); const byName = new Map(); const Ms = m.stretches.map((_, k) => stretchMatrix(m, k)); @@ -87,7 +89,7 @@ export function buildRebar3D(m: PierModel, mat: Material = DEFAULT_MATERIAL): Re // at each end to the C1 level. Each bar is two pieces spliced at the column centre (overlap l_s). The slope runs x_L0 from each end (T형). const xL0 = L / 2 - COPING_REBAR.levelWidth / 2000; const under = (x: number) => undersideAt(L * 1000, x * 1000, m.endBottom, m.levelBottom); - const lap = bottomPiece(mat).ls / 2000; + const lap = bottomPiece(mCoping).ls / 2000; xs.forEach((n, j) => { const z = (x: number) => under(x) + 0.127; const left: [number, number, number][] = [ @@ -166,7 +168,7 @@ export function buildRebar3D(m: PierModel, mat: Material = DEFAULT_MATERIAL): Re } } // C15 (거꾸로 된 U) and C16 (L형) on the stair (코핑철근.md §7): 11 bars each across the width. - const stairs = stairBars(L * 1000, B * 1000, topEl, mat); + const stairs = stairBars(L * 1000, B * 1000, topEl, mCoping); for (const s of stairs.c15) { const n = mm(s.n); const ld = mm(s.leg); @@ -187,7 +189,7 @@ export function buildRebar3D(m: PierModel, mat: Material = DEFAULT_MATERIAL): Re } // Columns: P1 / P2 main bars on their radii, ties P3 / P5 / P4 as rings, P6 crossties across the column. - const col = columnRebar(COLUMN_P1, mat); + const col = columnRebar(COLUMN_P1, mColumn); const zBot = col.zBot; // mm const ring = (name: string, k: number, sc: number, y: number, r: number, d: number) => { const N = 36; diff --git a/tests/rebar-member-fck.test.ts b/tests/rebar-member-fck.test.ts new file mode 100644 index 0000000..ac8ddfe --- /dev/null +++ b/tests/rebar-member-fck.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest'; +import { buildPier } from '../src/core/geometry'; +import { DEFAULT_MATERIAL, materialFor } from '../src/core/rebar/common'; +import { rebarSummary, type RebarRow } from '../src/core/rebar/summary'; +import { parseProject, SCHEMA_VERSION } from '../src/core/project'; +import { loadP1 } from './helpers'; + +const model = buildPier(loadP1()); +const byName = (rows: RebarRow[]) => new Map(rows.map((r) => [r.name, r])); +const base = rebarSummary(model).rows; +const members = (rows: RebarRow[], member: RebarRow['member']) => rows.filter((r) => r.member === member); +/** Rows of one member whose length or count changed against the base. */ +const changedIn = (rows: RebarRow[], member: RebarRow['member']) => + members(rows, member) + .filter((r) => { + const b = byName(base).get(r.name)!; + return b.length !== r.length || b.count !== r.count; + }) + .map((r) => r.name); + +describe('② 부재별 fck (spec §6.2 · ②)', () => { + it('a member with its own fck uses it; the others keep the 공통 value', () => { + 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); + }); + + it('a coping fck changes only the coping rows (C15 stair legs use the H19 development length)', () => { + const rows = rebarSummary(model, DEFAULT_MATERIAL, { coping: 20 }).rows; + expect(changedIn(rows, 'column')).toEqual([]); + expect(changedIn(rows, 'footing')).toEqual([]); + expect(changedIn(rows, 'coping')).toContain('C15'); + expect(byName(rows).get('C15')!.length!).toBeGreaterThan(byName(base).get('C15')!.length!); + }); + + it('a column fck changes only the column rows', () => { + const rows = rebarSummary(model, DEFAULT_MATERIAL, { column: 35 }).rows; + expect(changedIn(rows, 'coping')).toEqual([]); + expect(changedIn(rows, 'footing')).toEqual([]); + expect(changedIn(rows, 'column').length).toBeGreaterThan(0); + }); + + it('a footing fck changes only the footing rows (B2 splice)', () => { + const rows = rebarSummary(model, DEFAULT_MATERIAL, { footing: 35 }).rows; + expect(changedIn(rows, 'coping')).toEqual([]); + expect(changedIn(rows, 'column')).toEqual([]); + expect(changedIn(rows, 'footing')).toContain('B2'); + }); + + it('a per-member fck equal to the 공통 value changes nothing', () => { + expect(rebarSummary(model, DEFAULT_MATERIAL, { coping: 27, column: 27, footing: 27 }).rows).toEqual(base); + }); + + it('I02 stays while a member uses the assumed fck; it goes when all three members have their own fck', () => { + const codes = (byMember: Parameters[2]) => rebarSummary(model, DEFAULT_MATERIAL, byMember).warnings.map((w) => w.code); + expect(codes({})).toContain('I02'); + expect(codes({ coping: 35, column: 35 })).toContain('I02'); + expect(codes({ coping: 35, column: 35, footing: 35 })).not.toContain('I02'); + }); +}); + +describe('② 부재별 fck: 프로젝트 파일', () => { + const file = (fckByMember: unknown) => JSON.stringify({ schemaVersion: SCHEMA_VERSION, project: {}, source: {}, piers: [], history: [], fckByMember }); + + it('accepts known members with positive numbers, and an absent field', () => { + expect(parseProject(file({ coping: 35, column: 30 })).fckByMember).toEqual({ coping: 35, column: 30 }); + expect(parseProject(file(undefined)).fckByMember).toBeUndefined(); + }); + + it('refuses an unknown member or a value that is not a positive number', () => { + expect(() => parseProject(file({ bearing: 35 }))).toThrow('부재별 fck'); + expect(() => parseProject(file({ coping: 0 }))).toThrow('부재별 fck'); + expect(() => parseProject(file({ coping: '35' }))).toThrow('부재별 fck'); + }); +});