From 7cf58356891fa3da883e2687a3bc2078c2a0cf5d Mon Sep 17 00:00:00 2001 From: ypshim Date: Fri, 23 Oct 2026 08:53:39 +0900 Subject: [PATCH] =?UTF-8?q?feat(rebar):=20per-member=20fck=20on=20?= =?UTF-8?q?=E2=91=A1=20for=20column,=20coping=20and=20footing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each member can take its own fck on its ② card. A member without one uses the ③ 공통 fck, and fy stays common. The rebar table and the 3D model both read the resolved value. The I02 note stays until every fck member has its own value. The project file stores the values as optional fckByMember and refuses a bad value on open. Changing the fck is not a shape change, so it is not counted in the pending changes. Co-Authored-By: Claude Haiku 5.5 --- src/core/project.ts | 11 ++++- src/core/rebar/common.ts | 13 ++++++ src/core/rebar/summary.ts | 27 ++++++++---- src/main.ts | 54 ++++++++++++++++++++++-- src/view/rebar3d.ts | 12 +++--- tests/rebar-member-fck.test.ts | 76 ++++++++++++++++++++++++++++++++++ 6 files changed, 175 insertions(+), 18 deletions(-) create mode 100644 tests/rebar-member-fck.test.ts 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'); + }); +});