diff --git a/src/core/changes.ts b/src/core/changes.ts index b4ca707..ce00698 100644 --- a/src/core/changes.ts +++ b/src/core/changes.ts @@ -23,7 +23,7 @@ export const EDITABLE_FIELDS = [ ] as const; /** Inputs tracked for overrides and pending changes: the numeric cards plus 열별 본수 (an array, compared by value). */ -export const TRACKED_KEYS = [...EDITABLE_FIELDS.map((f) => f.key), 'pileRows', 'groundPosition', 'groundWaterOn', 'groundWaterEl', 'floodLevelEl', 'scourDepthM', 'highWaterEl', 'lowWaterEl', 'strataRows', 'bearingType', 'bearingLayout', 'blockRebar', 'copingRebar'] as const; +export const TRACKED_KEYS = [...EDITABLE_FIELDS.map((f) => f.key), 'pileRows', 'groundPosition', 'groundWaterOn', 'groundWaterEl', 'floodLevelEl', 'scourDepthM', 'highWaterEl', 'lowWaterEl', 'strataRows', 'bearingType', 'bearingLayout', 'blockRebar', 'copingRebar', 'topSplice'] as const; export type EditableKey = (typeof TRACKED_KEYS)[number]; @@ -55,6 +55,7 @@ export const FIELD_LABELS: Record = { bearingLayout: '받침 배치', blockRebar: '받침블럭 철근 설정', copingRebar: '코핑 배근 입력', + topSplice: '상면 주철근 이음', strataRows: '지층 행', }; diff --git a/src/core/geometry.ts b/src/core/geometry.ts index 7866d17..1198c2e 100644 --- a/src/core/geometry.ts +++ b/src/core/geometry.ts @@ -1,7 +1,7 @@ import { buildAxis, copingSpan, fromAxis, kinkArc, nearKink, toAxis } from './axis'; import { buildIntegratedBlocks, type IntegratedBlock } from './integrated-block'; import { type BlockRebarSettings, DEFAULT_BLOCK_REBAR } from './rebar/bearing-block'; -import { type CopingRebarSettings, DEFAULT_COPING_REBAR } from './rebar/coping-settings'; +import { type CopingRebarSettings, DEFAULT_COPING_REBAR, DEFAULT_TOP_SPLICE, type TopSpliceSettings } from './rebar/coping-settings'; import { layoutText, resolveRowCounts, rowLayout, type PileGeometry } from './piles'; import type { PierInput, Row } from './types'; import { type CopingGeom, P1_COPING_GEOM } from './coping-geom'; @@ -179,6 +179,8 @@ export interface PierModel { blockRebar: BlockRebarSettings; /** 코핑 배근 입력 used by the coping rebar (승인 2026-10-26). */ copingRebar: CopingRebarSettings; + /** 상면 주철근 이음 used by the coping rebar (승인 2026-10-26). */ + topSplice: TopSpliceSettings; columns: ColumnModel[]; footings: FootingModel[]; blindings: BlindingModel[]; @@ -468,6 +470,7 @@ export function buildPier(input: PierInput): PierModel { integratedBlocks: buildIntegratedBlocks(blocks, input.bearingLayout === '일체'), blockRebar: { ...DEFAULT_BLOCK_REBAR, ...input.blockRebar }, copingRebar: { ...DEFAULT_COPING_REBAR, ...input.copingRebar }, + topSplice: { ...DEFAULT_TOP_SPLICE, ...input.topSplice }, columns, footings, blindings, diff --git a/src/core/rebar/common.ts b/src/core/rebar/common.ts index b9a0f31..20bfa42 100644 --- a/src/core/rebar/common.ts +++ b/src/core/rebar/common.ts @@ -117,6 +117,9 @@ export function developmentLength(db: number, fy: number, fck: number, lambda = return (0.6 * db * fy) / (lambda * Math.sqrt(fck)); } +/** 정척 L_bar (mm) from 확대기초철근.md §1.2. Bars longer than this need a splice. */ +export const STANDARD_BAR = 12000; + /** Lap splice length l_s = 1.3·l_d (spec §4 공통 설계 규약). */ export function spliceLength(db: number, fy: number, fck: number, lambda = MATERIAL_DEFAULTS.lambda): number { return 1.3 * developmentLength(db, fy, fck, lambda); diff --git a/src/core/rebar/coping-main.ts b/src/core/rebar/coping-main.ts index 37baa5f..9227be3 100644 --- a/src/core/rebar/coping-main.ts +++ b/src/core/rebar/coping-main.ts @@ -1,5 +1,5 @@ -import { DEFAULT_MATERIAL, fyOf, hookLength, Material, NOMINAL_DIAMETER, spliceLength } from './common'; -import { type CopingRebarSettings, DEFAULT_COPING_REBAR } from './coping-settings'; +import { DEFAULT_MATERIAL, fyOf, hookLength, Material, NOMINAL_DIAMETER, spliceLength, STANDARD_BAR } from './common'; +import { type CopingRebarSettings, DEFAULT_COPING_REBAR, type TopSpliceAt } from './coping-settings'; /** * C1–C8 상·하면 주철근 (plan C3 continued). Source: RAG/코핑철근.md §5, 2026-10-22 최종 정리. @@ -71,6 +71,19 @@ export function topBarLength(L: number, k: number): number { return path + 2 * MAIN.hook; } +/** + * Top bar C1–C6 when its 이음 is applied (승인 2026-10-26). Only a bar longer than the standard length is split (2 pieces, l_s lap). + * splitFromLeft is measured from the bar's left end (mm). 기둥 중심: the centre. 단부: the split closest to the end that keeps + * both pieces within the standard length, so the short piece is total + l_s − standard and the long piece is the standard length. + */ +export function topSplicePieces(L: number, k: number, at: TopSpliceAt, m: Material = DEFAULT_MATERIAL): { pieces: [number, number]; splitFromLeft: number; lap: number } | null { + const total = topBarLength(L, k); + if (total <= STANDARD_BAR) return null; + const lap = spliceLength(NOMINAL_DIAMETER.H29, fyOf(m, 'H29'), m.fck); + const splitFromLeft = at === 'center' ? total / 2 : total + lap / 2 - STANDARD_BAR; + return { pieces: [splitFromLeft + lap / 2, total - splitFromLeft + lap / 2], splitFromLeft, lap }; +} + /** * 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, with the material inputs). diff --git a/src/core/rebar/coping-settings.ts b/src/core/rebar/coping-settings.ts index 7f2f529..b416927 100644 --- a/src/core/rebar/coping-settings.ts +++ b/src/core/rebar/coping-settings.ts @@ -60,6 +60,18 @@ export const COPING_REBAR_RANGES: Record; + /** 상면 주철근(C1–C6) 이음 (승인 2026-10-26). Absent = 이음 무시, 기둥 중심. */ + topSplice?: Partial; strataRows?: StrataRow[]; } diff --git a/src/main.ts b/src/main.ts index 5210972..8847b53 100644 --- a/src/main.ts +++ b/src/main.ts @@ -27,7 +27,7 @@ import { blockRebarPlanSvg } from './view/block-rebar-plan-svg'; import { copingEndStation, copingFrontSvg, copingSectionSvg } from './view/coping-rebar-svg'; import { BLOCK_REBAR_RANGES } from './core/rebar/block-check'; import { DEFAULT_BLOCK_REBAR, type BlockRebarSettings } from './core/rebar/bearing-block'; -import { COPING_REBAR_RANGES, copingSettingsIssues, type CopingRebarSettings, DEFAULT_COPING_REBAR } from './core/rebar/coping-settings'; +import { COPING_REBAR_RANGES, copingSettingsIssues, type CopingRebarSettings, DEFAULT_COPING_REBAR, DEFAULT_TOP_SPLICE, type TopSpliceSettings } from './core/rebar/coping-settings'; import { groundSectionSvg } from './view/ground-svg'; import { parseBearingElevationTable, parseSolplateTable, readDxfTexts } from './core/dxf-table'; import { columnDiameterIssues } from './core/column-check'; @@ -1091,13 +1091,26 @@ const COPING_REBAR_FIELDS: { key: keyof CopingRebarSettings; label: string }[] = ]; /** 적용: the values go to the draft and the model is rebuilt only when every check passes (CP01·CP02). */ -function applyCopingRebar(name: string, next: CopingRebarSettings): void { +function applyCopingRebar(name: string, next: CopingRebarSettings, splice: TopSpliceSettings): void { const p = state.piers.find((q) => q.input.name === name); if (!p) return; - p.draft = { ...p.draft, copingRebar: next }; + p.draft = { ...p.draft, copingRebar: next, topSplice: splice }; regenerateOne(name); } +/** A row of toggle buttons (aria-pressed), as on the 배치 chips of ②. */ +function toggleRow(options: [T, string][], current: T, onPick: (v: T) => void): HTMLDivElement { + const row = el('div', undefined, 'opts'); + for (const [v, label] of options) { + const b = el('button', label, 'btn small'); + b.type = 'button'; + b.setAttribute('aria-pressed', String(v === current)); + b.addEventListener('click', () => onPick(v)); + row.appendChild(b); + } + return row; +} + function renderCopingSettings(box: HTMLDivElement, pier: Pier): void { if (!pier.model) return; const wrap = el('div', undefined, 'bearing-ref'); @@ -1135,6 +1148,23 @@ function renderCopingSettings(box: HTMLDivElement, pier: Pier): void { grid.appendChild(row); } wrap.appendChild(grid); + // 상면 주철근(C1–C6) 이음 (승인 2026-10-26): 무시 (사양 기본) / 반영, and the 위치 when 반영. + let splice: TopSpliceSettings = { ...DEFAULT_TOP_SPLICE, ...pier.draft.topSplice }; + const spliceBox = el('div', undefined, 'bearing-ref'); + const drawSplice = (): void => { + spliceBox.replaceChildren(el('p', '상면 주철근(C1–C6) 이음', 'muted small'), toggleRow([['ignore', '이음 무시'], ['apply', '반영']] as const, splice.mode, (v) => { + splice = { ...splice, mode: v }; + drawSplice(); + })); + if (splice.mode === 'apply') { + spliceBox.appendChild(toggleRow([['center', '기둥 중심'], ['end', '단부']] as const, splice.at, (v) => { + splice = { ...splice, at: v }; + drawSplice(); + })); + } + }; + drawSplice(); + wrap.appendChild(spliceBox); const msg = el('p', undefined, 'muted small'); const apply = el('button', '적용', 'btn small'); apply.type = 'button'; @@ -1147,7 +1177,7 @@ function renderCopingSettings(box: HTMLDivElement, pier: Pier): void { setError('코핑 배근 입력을 확인하세요. 값은 반영되지 않았습니다.'); return; } - applyCopingRebar(pier.input.name, next); + applyCopingRebar(pier.input.name, next, splice); }); wrap.append(msg, apply); box.appendChild(wrap); diff --git a/src/view/rebar3d.ts b/src/view/rebar3d.ts index 46ca8e8..a627585 100644 --- a/src/view/rebar3d.ts +++ b/src/view/rebar3d.ts @@ -1,7 +1,7 @@ import * as THREE from 'three'; import type { PierModel } from '../core/geometry'; import { assemblyEndOffset, assemblyJs, b10Levels, b9Bars, FOOTING_DEFAULTS, gridPositions, grid, layers, legLength, legOffsets, sideDepth } from '../core/rebar/footing'; -import { bottomPiece, MAIN, widthLayout } from '../core/rebar/coping-main'; +import { bottomPiece, MAIN, topSplicePieces, widthLayout } from '../core/rebar/coping-main'; import { C14_INSET, c14Bars, CAP, COPING_REBAR, SHEAR_LOOP_STEP, shearBars, shearBarsMulti, shearUHeight, copingSideBars, SIDE_HOOK_LEG, stairBars, undersideAt } from '../core/rebar/coping'; import { sideRowLevels } from '../core/coping-geom'; import { columnRebar, columnInputFor, COLUMN_P1 } from '../core/rebar/column'; @@ -152,8 +152,17 @@ export function buildRebar3D(m: PierModel, common: Material = DEFAULT_MATERIAL, for (let k = 1; k <= 3; k++) { const z = topEl - 0.127 - (k - 1) * (m.copingRebar.mainLayerGap / 1000); const xk = cover + ((k - 1) * MAIN.layHorizontal) / 1000; + // 상면 이음 (승인 2026-10-26): when applied and longer than the standard, the bar is two pieces overlapping by l_s at the split. + const sp = m.topSplice.mode === 'apply' ? topSplicePieces(L * 1000, k, m.topSplice.at, mCoping) : null; + const splitX = sp ? xk + (sp.splitFromLeft - MAIN.hook) / 1000 : 0; + const half = sp ? sp.lap / 2000 : 0; xs.forEach((n, j) => { const name = `C${k + (j % 2 === 1 ? 0 : 3)}`; + if (sp) { + addPath(name, 0, [[xk, z - hookTop, n], [xk, z, n], [splitX + half, z, n]], NOMINAL_DIAMETER.H29, COLOR.coping); + addPath(name, 0, [[splitX - half, z, n], [L - xk, z, n], [L - xk, z - hookTop, n]], NOMINAL_DIAMETER.H29, COLOR.coping); + return; + } const pts: [number, number, number][] = [ [xk, z - hookTop, n], [xk, z, n], diff --git a/tests/rebar-coping-main.test.ts b/tests/rebar-coping-main.test.ts index f0d743a..850e6a1 100644 --- a/tests/rebar-coping-main.test.ts +++ b/tests/rebar-coping-main.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import { developmentLength, hookLength, NOMINAL_DIAMETER, spliceLength } from '../src/core/rebar/common'; -import { bottomPiece, topBarLength, topRowEl, widthLayout } from '../src/core/rebar/coping-main'; +import { bottomPiece, topBarLength, topRowEl, topSplicePieces, widthLayout } from '../src/core/rebar/coping-main'; import { buildPier } from '../src/core/geometry'; import { rebarSummary } from '../src/core/rebar/summary'; import { DEFAULT_COPING_REBAR } from '../src/core/rebar/coping-settings'; @@ -54,6 +54,42 @@ describe('C1–C8 상·하면 주철근 (코핑철근.md §5, plan C3)', () => { expect([s1.spacing, s1.actual, s1.basis]).toEqual(['130', '130', 'input']); }); + it('상면 이음 (승인 2026-10-26): 기둥 중심 → total/2 + l_s/2 each; 단부 → total + l_s − 12000 and 12000', () => { + const center = topSplicePieces(12000, 1, 'center')!; + expect(center.lap).toBeGreaterThan(1700); + expect(center.pieces[0]).toBeCloseTo(12744 / 2 + center.lap / 2, 3); + expect(center.pieces[1]).toBeCloseTo(center.pieces[0], 3); + expect(center.pieces[0] + center.pieces[1]).toBeCloseTo(12744 + center.lap, 3); + + const end = topSplicePieces(12000, 1, 'end')!; + expect(end.pieces[0]).toBeCloseTo(12744 + end.lap - 12000, 3); + expect(end.pieces[1]).toBeCloseTo(12000, 3); + }); + + it('상면 이음: a bar within the standard length is not split', () => { + expect(topSplicePieces(10000, 1, 'center')).toBeNull(); + }); + + it('철근표: 이음 무시 → 단일 길이 12744 · 반영(기둥 중심) → 2조각, 각 조각 개수 = 행 개수', () => { + const ignore = rebarSummary(buildPier(loadP1())).rows.find((r) => r.name === 'C1')!; + expect(ignore.length).toBeCloseTo(12744, 0); + expect(ignore.pieces).toBeUndefined(); + + const applied = rebarSummary(buildPier({ ...loadP1(), topSplice: { mode: 'apply', at: 'center' } })).rows; + const c1 = applied.find((r) => r.name === 'C1')!; + const lap = topSplicePieces(12000, 1, 'center')!.lap; + expect(c1.length).toBeNull(); + expect(c1.pieces).toHaveLength(2); + expect(c1.pieces![0].count).toBe(c1.count); + expect(c1.pieces![0].length).toBeCloseTo(12744 / 2 + lap / 2, 3); + expect(c1.pieces![1].length).toBeCloseTo(12744 / 2 + lap / 2, 3); + expect(c1.note).toContain('기둥 중심'); + + const end = rebarSummary(buildPier({ ...loadP1(), topSplice: { mode: 'apply', at: 'end' } })).rows.find((r) => r.name === 'C1')!; + expect(end.pieces![1].length).toBeCloseTo(12000, 3); + expect(end.note).toContain('단부'); + }); + it('CP04: the default P1 has an even count (no warning); 기본간격 120 gives an odd count and a CP04 warning', () => { const base = rebarSummary(buildPier(loadP1())).warnings.filter((w) => w.code === 'CP04'); expect(base).toEqual([]);