feat(rebar): per-member fck on ② for column, coping and footing

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 <noreply@anthropic.com>
This commit is contained in:
ypshimandClaude Haiku 5.5 committed 2026-10-23 08:53:39 +09:00
1 parent d8fd6adcf0
commit 7cf5835689
6 files changed
+175 -18

No files matched your search

+10 -1
View File
@@ -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;
+13
View File
@@ -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<Record<FckMember, number>>;
/** 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;
+18 -9
View File
@@ -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<RebarRow, 'spacing' | 'actual' | 'position' | 'direction' | 'shape' | 'basis'>;
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 });
+51 -3
View File
@@ -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,
+7 -5
View File
@@ -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<string, THREE.Mesh[]>();
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;