feat(cast): 기둥–말뚝 겹침이음 · 말뚝 철근망 근입부 기준 (Q-PL3·Q-PL6)

현장타설말뚝철근.md §5–§6 (사용자 결정 2026-11-06: 11-06 겹침 결정이 11-05 커플러 회신을 대체).
- 희생강관 기본 적용, 상단 지반고 + 2500 (그림 값, Q-PL4 가정). 암반 근입부 지름 입력 추가(기본 D − 150, 강관 없으면 D)
- 말뚝 철근망 반지름을 근입부 기준으로 (D 3000: r 1300 / 1268.2)
- 두 철근 원이 다르면 자동 겹침이음: 기둥 주철근이 지반고 아래 l_s(1.3 l_d, S32 2387)까지 내려감,
  지반고 커플러 없음. 원이 같으면(근입부 없음) 지반고 커플러. 저장된 방식은 그대로 둠
- 겹침 구간의 말뚝 띠철근은 바깥 기둥 주철근을 감싸는 반지름
- 겹침 모드에서도 주기 커플러(기둥 지반고 + @3000, 말뚝 지반고 − @3000, 내측 + 150)
- RB-PL07: 두 원 간격 > min(l_s/5, 150)이면 경고
- 이전 '겹침부 미계산' 표시 제거. 기존 커플러 테스트는 희생강관 미적용 입력으로 유지

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ypshimandClaude Opus 5.5 committed 2026-11-06 14:37:12 +09:00
1 parent 3d0d484524
commit 5fec2743cb
6 files changed
+126 -30

No files matched your search

+16 -5
View File
@@ -1,6 +1,6 @@
import type { PierInput } from './types';
/** EL in metres, lengths in mm. User image and clarifications, 2026-11-05. */
/** EL in metres, lengths in mm. User image and clarifications 2026-11-05; casing default and rock socket per 현장타설말뚝철근.md (2026-11-06). */
export interface CastInPlaceShapeSettings {
betaLengthMm?: number;
weatheredRockEl?: number;
@@ -9,9 +9,18 @@ export interface CastInPlaceShapeSettings {
casingTopEl?: number;
casingBottomEl?: number;
clayBottomEl?: number;
/** 암반 근입부 지름 (mm, 희생강관 아래). Absent = D − 150 with a casing, D without (현장타설말뚝철근.md §1.1, Q-PL3). */
socketDiameterMm?: number;
}
export function castShape(input: Pick<PierInput, 'groundEl' | 'pileLengthMm' | 'castInPlaceShape'>) {
/** Rock-socket diameter (mm): the input, else D − 150 when the casing is on (default on, spec §3 2026-11-06), else D. */
export function castSocketDiameterMm(input: Pick<PierInput, 'castInPlaceShape' | 'columnDiameterMm'>): number {
const s = input.castInPlaceShape ?? {};
const d = input.columnDiameterMm ?? 2000;
return s.socketDiameterMm ?? ((s.casingOn ?? true) ? d - 150 : d);
}
export function castShape(input: Pick<PierInput, 'groundEl' | 'pileLengthMm' | 'castInPlaceShape' | 'columnDiameterMm'>) {
const s = input.castInPlaceShape ?? {};
const pileBottomEl = input.groundEl - input.pileLengthMm / 1000;
const bedrockEl = s.bedrockEl ?? pileBottomEl + 1;
@@ -21,10 +30,12 @@ export function castShape(input: Pick<PierInput, 'groundEl' | 'pileLengthMm' | '
betaLengthMm, betaEl: input.groundEl - betaLengthMm / 1000,
weatheredRockEl: s.weatheredRockEl ?? pileBottomEl + 5,
bedrockEl,
casingOn: s.casingOn ?? false,
casingTopEl: s.casingTopEl ?? input.groundEl + 3,
// 희생강관: 적용, 상단 지반고 + 2500 (그림 값, 엑셀 문구 + 3000 — Q-PL4 가정), 하단 = 기반암 EL.
casingOn: s.casingOn ?? true,
casingTopEl: s.casingTopEl ?? input.groundEl + 2.5,
casingBottomEl: s.casingBottomEl ?? bedrockEl,
clayBottomEl: s.clayBottomEl,
socketDiameterMm: castSocketDiameterMm(input),
};
}
@@ -35,7 +46,7 @@ export function castShapeIssues(value: unknown): string[] {
for (const [key, v] of Object.entries(value)) {
if (v === undefined) continue;
if (key === 'casingOn') { if (typeof v !== 'boolean') out.push('희생강관 적용 여부가 맞지 않습니다.'); }
else if (!['betaLengthMm', 'weatheredRockEl', 'bedrockEl', 'casingTopEl', 'casingBottomEl', 'clayBottomEl'].includes(key) || typeof v !== 'number' || !Number.isFinite(v) || key === 'betaLengthMm' && v <= 0) out.push(`현장타설 형상 ${key} 값이 맞지 않습니다.`);
else if (!['betaLengthMm', 'weatheredRockEl', 'bedrockEl', 'casingTopEl', 'casingBottomEl', 'clayBottomEl', 'socketDiameterMm'].includes(key) || typeof v !== 'number' || !Number.isFinite(v) || (key === 'betaLengthMm' || key === 'socketDiameterMm') && v <= 0) out.push(`현장타설 형상 ${key} 값이 맞지 않습니다.`);
}
return out;
}
+21 -3
View File
@@ -1,6 +1,7 @@
import type { PierInput } from '../types';
import { DEFAULT_COLUMN_REBAR } from './column-settings';
import { NOMINAL_DIAMETER, DEFAULT_MATERIAL, type Material } from './common';
import { NOMINAL_DIAMETER, DEFAULT_MATERIAL, type Material, spliceLength } from './common';
import { castSocketDiameterMm } from '../cast-in-place-shape';
/** Dedicated settings: round-column defaults were authorised by the user on 2026-11-05. */
export interface CastRebarPart {
@@ -51,7 +52,19 @@ export const CAST_PART_FIELDS: { key: Exclude<keyof CastRebarPart, 'tieKind' | '
{ key: 'innerMainCount', label: '내측 주철근 본수 (본)' },
];
export function castRebarDefaults(input: Pick<PierInput, 'columnRebar' | 'castInPlaceRebar'>, material: Material = DEFAULT_MATERIAL): CastInPlaceRebar {
/**
* Column–pile connection when none is saved (현장타설말뚝철근.md §5, Q-PL3·Q-PL6 결정 2026-11-06): the pile cage follows the
* rock socket, so with a socket (or a pile diameter other than D) the two cages differ and they are joined by a lap of
* l_s = 1.3·l_d of the main bar; otherwise by a coupler at ground. A saved mode is kept.
*/
export function castAutoConnection(input: Pick<PierInput, 'castInPlaceShape' | 'columnDiameterMm'>, part: CastRebarPart): NonNullable<CastInPlaceRebar['connection']> {
const d = input.columnDiameterMm ?? 2000;
return castSocketDiameterMm(input) !== d
? { mode: 'lap', lapLengthMm: Math.round(spliceLength(part.mainDiameterMm!, part.mainGradeMpa!, part.concreteStrengthMpa!)) }
: { mode: 'coupler' };
}
export function castRebarDefaults(input: Pick<PierInput, 'columnRebar' | 'castInPlaceRebar' | 'castInPlaceShape' | 'columnDiameterMm'>, material: Material = DEFAULT_MATERIAL): CastInPlaceRebar {
const c = { ...DEFAULT_COLUMN_REBAR, ...input.columnRebar };
const saved = input.castInPlaceRebar ?? {};
const part = (key: 'column' | 'pile'): CastRebarPart => ({
@@ -64,7 +77,12 @@ export function castRebarDefaults(input: Pick<PierInput, 'columnRebar' | 'castIn
...(key === 'pile' ? { endCoverMm: 250 } : {}),
...saved[key],
});
return { column: part('column'), pile: part('pile'), connection: { mode: 'coupler', couplerLengthMm: 150, ...saved.connection } };
const column = part('column');
const auto = castAutoConnection(input, column);
const connection = { couplerLengthMm: 150, ...auto, ...saved.connection };
// A lap saved without a length gets the l_s of the main bar.
if (connection.mode === 'lap' && connection.lapLengthMm === undefined) connection.lapLengthMm = Math.round(spliceLength(column.mainDiameterMm!, column.mainGradeMpa!, column.concreteStrengthMpa!));
return { column, pile: part('pile'), connection };
}
export const CAST_PENDING = ['현장타설 기둥 배근 상세 미확정', '현장타설말뚝 배근 상세 미확정', '기둥·말뚝 접합부 배근 상세 미확정'];
+27 -15
View File
@@ -1,5 +1,5 @@
import type { PierModel } from '../geometry';
import { castLowerZone } from '../cast-in-place-shape';
import { castLowerZone, castSocketDiameterMm } from '../cast-in-place-shape';
import { castRebarDefaults, castSettingsIssues, type CastRebarPart } from './cast-in-place';
import { DEFAULT_MATERIAL, developmentLength, hookLength, spliceLength, type Material } from './common';
@@ -41,21 +41,28 @@ export function castRebarLayout(model: PierModel, material: Material = DEFAULT_M
const add = (bar: Omit<CastBar, 'columnIndex'>) => {
if (bar.lengthMm > 0 && Number.isFinite(bar.lengthMm)) bars.push({ ...bar, columnIndex: owner });
};
const radii = (p: CastRebarPart) => {
const outer = D / 2 - p.coverMm! - (p.coverReference === 'clear' ? p.tieDiameterMm! + p.mainDiameterMm! / 2 : 0);
// The pile cage follows the rock socket D_s (현장타설말뚝철근.md §5, Q-PL3 결정 2026-11-06); the column cage follows D.
const section = { column: D, pile: castSocketDiameterMm({ ...input, columnDiameterMm: D }) };
const radii = (p: CastRebarPart, diameter: number) => {
const outer = diameter / 2 - p.coverMm! - (p.coverReference === 'clear' ? p.tieDiameterMm! + p.mainDiameterMm! / 2 : 0);
return { outer, inner: outer - (p.mainDiameterMm! + p.innerMainDiameterMm!) / 2, tie: outer + (p.mainDiameterMm! + p.tieDiameterMm!) / 2 };
};
const r = { column: radii(saved.column!), pile: radii(saved.pile!) };
const r = { column: radii(saved.column!, section.column), pile: radii(saved.pile!, section.pile) };
const aligned = (['outer', 'inner'] as const).every((layer, i) => {
const count = i ? 'innerMainCount' : 'mainCount';
const diameter = i ? 'innerMainDiameterMm' : 'mainDiameterMm';
return saved.column![count] === saved.pile![count] && saved.column![diameter] === saved.pile![diameter]
&& saved.column!.mainGradeMpa === saved.pile!.mainGradeMpa && Math.abs(r.column[layer] - r.pile[layer]) < 0.01;
});
if (!aligned) missing.add('접합부 주철근 본수·규격·강도·축 위치 불일치 · 연결 미계산');
const connected = aligned && (mode === 'continuous' || mode === 'coupler' && !!couplingLength || mode === 'lap' && !!lap);
// A lap joins cages of different radius (비접촉 겹침, RB-PL03); a coupler or a continuous bar needs the same bars on one circle.
if (!aligned && mode !== 'lap') missing.add('접합부 주철근 본수·규격·강도·축 위치 불일치 · 연결 미계산');
const connected = mode === 'lap' ? !!lap : aligned && (mode === 'continuous' || mode === 'coupler' && !!couplingLength);
if (!mode || mode === 'coupler' && !couplingLength || mode === 'lap' && !lap) missing.add('접합부 연결 방식 또는 연결 길이 미입력');
if (mode === 'lap') missing.add('겹침이음의 횡방향 배치 상세 미확정 · 겹침부 미계산');
if (connected && mode === 'lap') {
// RB-PL07: the two circles of a non-contact lap at most min(l_s/5, 150) apart.
const gap = Math.abs(r.column.outer - r.pile.outer);
if (gap > Math.min(lap! / 5, 150) + 1e-6) missing.add(`RB-PL07 기둥–말뚝 겹침 두 철근 원 간격 ${Math.round(gap)} mm > min(l_s/5, 150) · 비접촉 겹침 한계 초과`);
}
missing.add('말뚝 저부 P6 검토안 미적용');
model.columns.forEach((column, columnIndex) => {
@@ -78,7 +85,7 @@ export function castRebarLayout(model: PierModel, material: Material = DEFAULT_M
column.top + (ext ?? developmentLength(saved.column!.innerMainDiameterMm!, saved.column!.mainGradeMpa!, saved.column!.concreteStrengthMpa!)) / 1000];
const validRadii = (part: 'column' | 'pile') => {
const p = saved[part]!;
return r[part].inner > p.innerMainDiameterMm! / 2 && r[part].tie + p.tieDiameterMm! / 2 <= D / 2 + 1e-8;
return r[part].inner > p.innerMainDiameterMm! / 2 && r[part].tie + p.tieDiameterMm! / 2 <= section[part] / 2 + 1e-8;
};
for (const part of parts) {
if (!validRadii(part)) { missing.add(`${names[part]} 철근 단면·피복 범위 오류`); continue; }
@@ -94,7 +101,8 @@ export function castRebarLayout(model: PierModel, material: Material = DEFAULT_M
}
const offset = layerIndex ? stagger / 1000 : 0;
const boundary = connected && mode === 'coupler' ? joint + offset : joint;
const start = part === 'column' ? boundary : cageBottom;
// Lap: the column bars run down past ground by l_s into the pile (현장타설말뚝철근.md §5).
const start = part === 'column' ? (connected && mode === 'lap' ? joint - lap! / 1000 : boundary) : cageBottom;
const end = part === 'column' ? top : boundary;
const combined = connected && mode === 'continuous' && pileValid && validRadii('pile');
if (combined) missing.add('연속 주철근 제작·시공 이음 상세 미확정');
@@ -103,7 +111,7 @@ export function castRebarLayout(model: PierModel, material: Material = DEFAULT_M
if (end <= lo) { missing.add(`${names[part]} 주철근 유효 길이 없음`); continue; }
const cuts: number[] = [];
// Periodic column joints reuse the existing approved pitch and stagger; no footing reference.
if (mode === 'coupler' && couplingLength && pitch > 0) {
if ((mode === 'coupler' || mode === 'lap') && couplingLength && pitch > 0) {
for (let h = joint + offset + pitch / 1000; h < end - 2.5; h += pitch / 1000) if (h > lo) cuts.push(h);
for (let h = joint + offset - pitch / 1000; h > lo + couplingLength / 1000; h -= pitch / 1000) if (h < end) cuts.push(h);
}
@@ -144,6 +152,9 @@ export function castRebarLayout(model: PierModel, material: Material = DEFAULT_M
}
const radius = r[part].tie;
const tieNote = '일반/단부 경계에서 구간별 반간격 시작 · 겹침 원형 이음';
// Pile hoops inside the lap wrap the outer column bars (현장타설말뚝철근.md §6).
const lapBottom = part === 'pile' && connected && mode === 'lap' ? joint - lap! / 1000 : Infinity;
const tieRadius = (h: number) => (h >= lapBottom - 1e-9 ? r.column.tie : radius);
const intervals: { from: number; to: number; step: number; mark: string }[] = [];
const ordinary = (from: number, to: number) => { if (to > from) intervals.push({ from, to, step: p.tieSpacingMm!, mark: 'P3' }); };
const zones = zone ? [zone] : [];
@@ -182,15 +193,16 @@ export function castRebarLayout(model: PierModel, material: Material = DEFAULT_M
makeTie(pts, Math.hypot(2 * Math.PI * radius * turns, (b - a) * 1000), '나선 중심선 · 단부 정착·구간 전이 상세 미확정');
missing.add(`${names[part]} 나선철근 단부 정착·구간 전이 미확정`);
} else {
const totalAngle = 2 * Math.PI + lapLength / radius;
for (let h = a, index = 0; h <= b + 1e-9; h += step / 1000, index++) {
const pts = Array.from({ length: 65 }, (_, j) => point(radius, index % 2 * Math.PI + totalAngle * j / 64, h));
makeTie(pts, 2 * Math.PI * radius + lapLength, mark === 'P5' ? '단부 원형 띠 · 기존 H19용 커플러 폐합' : tieNote);
const rad = tieRadius(h);
const angleSpan = 2 * Math.PI + lapLength / rad;
const pts = Array.from({ length: 65 }, (_, j) => point(rad, index % 2 * Math.PI + angleSpan * j / 64, h));
makeTie(pts, 2 * Math.PI * rad + lapLength, mark === 'P5' ? '단부 원형 띠 · 기존 H19용 커플러 폐합' : rad !== radius ? `${tieNote} · 겹침이음 구간, 바깥 기둥 주철근을 감쌈` : tieNote);
if (mark === 'P5') {
const angle = index % 2 * Math.PI + Math.PI / p.mainCount!;
const half = 50 / radius;
const half = 50 / rad;
add({ name: `${names[part]}-P5 띠철근 커플러`, member: part, kind: 'coupler', k: column.k,
pts: [point(radius, angle - half, h), point(radius, angle + half, h)], diameterMm: 40,
pts: [point(rad, angle - half, h), point(rad, angle + half, h)], diameterMm: 40,
gradeMpa: p.tieGradeMpa!, lengthMm: 100, note: '기존 H19용 L100·Ø40 폐합 상세' });
if (Math.abs(p.tieDiameterMm! - 19.1) > 0.05) missing.add(`${names[part]} 단부 띠철근 커플러 규격 확인 필요`);
}
+3 -1
View File
@@ -5228,6 +5228,7 @@ function renderCastShape(fields: HTMLDivElement, pier: Pier): void {
{ key: 'clayBottomEl', label: '점토층 하단 EL (m, 별도 입력)' },
{ key: 'casingTopEl', label: '희생강관 상단 EL (m)' },
{ key: 'casingBottomEl', label: '희생강관 하단 EL (m)' },
{ key: 'socketDiameterMm', label: '암반 근입부 지름 (mm)' },
];
const change = (key: keyof CastInPlaceShapeSettings, value: number | boolean | undefined) => {
const next = { ...pier.draft.castInPlaceShape, [key]: value };
@@ -5248,12 +5249,13 @@ function renderCastShape(fields: HTMLDivElement, pier: Pier): void {
input.value = shape[f.key] === undefined ? '' : String(shape[f.key]);
input.placeholder = f.key === 'clayBottomEl' ? '직접 입력' : '자동 기본값';
input.disabled = f.key.startsWith('casing') && !shape.casingOn;
if (f.key === 'socketDiameterMm') input.step = '50';
input.addEventListener('change', () => change(f.key, input.value.trim() ? Number(input.value) : undefined));
const automatic = pier.draft.castInPlaceShape?.[f.key] === undefined;
row.append(lab, input, el('span', f.key === 'clayBottomEl' && automatic ? '풍화암과 별도 · 입력 필요' : automatic ? '자동 기본값 · 비우면 자동 연동' : '직접 입력 · 비우면 자동값으로 복원', 'muted small'));
fields.appendChild(row);
}
fields.appendChild(el('p', `말뚝 두부 = 지반고 EL ${shape.pileTopEl.toFixed(3)} · 말뚝 하단 EL ${shape.pileBottomEl.toFixed(3)} (지반고 − 말뚝길이). 풍화암 기본 = 선단+5 m, 기반암 기본 = 선단+1 m. 강관 기본 = 지반고+3 m ~ 기반암.`, 'muted small'));
fields.appendChild(el('p', `말뚝 두부 = 지반고 EL ${shape.pileTopEl.toFixed(3)} · 말뚝 하단 EL ${shape.pileBottomEl.toFixed(3)} (지반고 − 말뚝길이). 풍화암 기본 = 선단+5 m, 기반암 기본 = 선단+1 m. 강관 기본 = 적용, 지반고+2.5 m(가정, Q-PL4) ~ 기반암. 근입부 지름 기본 = 강관 적용 시 D − 150 (말뚝 철근망은 이 지름 기준).`, 'muted small'));
const zone = castLowerZone(pier.draft);
fields.appendChild(el('p', zone ? `DXF 하단 단부구역 기준점: EL ${zone.bottomEl.toFixed(3)} ~ ${zone.topEl.toFixed(3)} (1/β−3D, 점토층 하단+D).${zone.valid ? '' : ' 구간 순서가 역전됩니다. 입력 EL을 확인하세요.'}` : '말뚝 하단 단부구역: 점토층 하단 EL 입력 필요. 풍화암 값으로 대체하지 않습니다.', 'muted small'));
if (shape.casingOn) fields.appendChild(el('p', '희생강관은 현재 위치·범위 입력입니다. 강관 두께·재료 수량은 포함하지 않습니다.', 'muted small'));
+7 -3
View File
@@ -17,7 +17,9 @@ describe('2026-11-05 cast-in-place drawing and user decisions', () => {
expect(model.derived.soilCover).toBe(0);
});
it('uses the clarified positive rock offsets, 9000 mm beta and casing defaults', () => {
expect(castShape(input)).toMatchObject({ pileBottomEl: 6, betaLengthMm: 9000, betaEl: 21, weatheredRockEl: 11, bedrockEl: 7, casingOn: false, casingTopEl: 33, casingBottomEl: 7, clayBottomEl: undefined });
expect(castShape(input)).toMatchObject({ pileBottomEl: 6, betaLengthMm: 9000, betaEl: 21, weatheredRockEl: 11, bedrockEl: 7, casingOn: true, casingTopEl: 32.5, casingBottomEl: 7, clayBottomEl: undefined, socketDiameterMm: 1850 });
// Without a casing there is no rock socket: D.
expect(castShape({ ...input, castInPlaceShape: { casingOn: false } }).socketDiameterMm).toBe(2000);
});
it('rejects a pile head above the coping instead of generating an inverted column', () => {
expect(() => buildPier({ ...input, groundEl: 100 })).toThrow('지반고');
@@ -29,7 +31,7 @@ describe('2026-11-05 cast-in-place drawing and user decisions', () => {
const auto = castShape({ ...input, groundEl: 40 });
expect(auto.bedrockEl).toBe(17);
expect(auto.casingBottomEl).toBe(17);
expect(auto.casingTopEl).toBe(43);
expect(auto.casingTopEl).toBe(42.5);
const manual = castShape({ ...input, groundEl: 40, castInPlaceShape: { bedrockEl: -2, casingTopEl: 35, weatheredRockEl: 3 } });
expect(manual.casingBottomEl).toBe(-2);
expect(manual.casingTopEl).toBe(35);
@@ -44,7 +46,9 @@ describe('2026-11-05 cast-in-place drawing and user decisions', () => {
const s = castRebarDefaults(input);
expect(s.column).toMatchObject({ mainDiameterMm: 31.8, innerMainDiameterMm: 31.8, mainCount: 46, innerMainCount: 46, coverMm: 125, coverReference: 'main-center', tieSpacingMm: 300, endZoneSpacingMm: 120, tieKind: 'hoop' });
expect(s.pile?.endCoverMm).toBe(250);
expect(s.connection).toEqual({ mode: 'coupler', couplerLengthMm: 150 });
// Rock socket D − 150 → the cages differ → lap of l_s = 1.3·l_d (S32, fy 500, fck 27) = 2387 (Q-PL3·6).
expect(s.connection).toEqual({ mode: 'lap', couplerLengthMm: 150, lapLengthMm: 2387 });
expect(castRebarDefaults({ ...input, castInPlaceShape: { casingOn: false } }).connection).toEqual({ mode: 'coupler', couplerLengthMm: 150 });
});
it('preserves old explicit clear cover and independent edits', () => {
const s = castRebarDefaults({ ...input, columnRebar: { nDiv: 48, c: 140 }, castInPlaceRebar: { column: { coverMm: 100, mainCount: 44 }, pile: { endCoverMm: 300 } } });
+52 -3
View File
@@ -7,8 +7,10 @@ import { DEFAULT_MATERIAL } from '../src/core/rebar/common';
import { castRebarSvg } from '../src/view/cast-rebar-svg';
import { parseProject, serializeProject, SCHEMA_VERSION, type ProjectFile } from '../src/core/project';
import { loadP1 } from './helpers';
import type { CastInPlaceShapeSettings } from '../src/core/cast-in-place-shape';
const base = () => ({ ...loadP1(), foundationType: '현장타설말뚝' as const, groundEl: 30, pileLengthMm: 24000 });
// No casing → no rock socket → both cages on one circle → coupler at ground (the pre-2026-11-06 default these tests cover).
const base = () => ({ ...loadP1(), foundationType: '현장타설말뚝' as const, groundEl: 30, pileLengthMm: 24000, castInPlaceShape: { casingOn: false } as CastInPlaceShapeSettings });
const layout = (changes: Partial<ReturnType<typeof base>> = {}) => castRebarLayout(buildPier({ ...base(), ...changes }));
describe('dedicated cast-in-place physical reinforcement', () => {
@@ -79,10 +81,11 @@ describe('dedicated cast-in-place physical reinforcement', () => {
expect(result.bars.some(b=>b.kind==='coupler')).toBe(false);
expect(result.bars.find(b=>b.name==='기둥·말뚝-P1')!.pts[0][1]).toBeLessThan(30);
});
it('does not claim a missing lap or mismatched joint is connected', () => {
it('a lap saved without a length gets l_s and no ground coupler', () => {
const result=layout({castInPlaceRebar:{connection:{mode:'lap'}}});
expect(result.bars.some(b=>b.name.startsWith('접합-'))).toBe(false);
expect(result.missing.some(m=>m.includes('겹침'))).toBe(true);
expect(result.missing.some(m=>m.includes('연결 방식 또는 연결 길이'))).toBe(false);
expect(Math.min(...result.bars.filter(b=>b.name==='기둥-P1').flatMap(b=>b.pts.map(p=>p[1])))).toBeCloseTo(30-2.387,6);
});
it('excludes an invalid pile cage rather than silently clipping it', () => {
const result=layout({castInPlaceRebar:{pile:{cageLengthMm:24000}}});
@@ -132,3 +135,49 @@ describe('dedicated cast-in-place physical reinforcement', () => {
expect(castRebarSvg(buildPier(restored),castRebarLayout(buildPier(restored)))).toContain('<polyline');
});
});
// 현장타설말뚝철근.md §5–§6 · §10 (Q-PL3·Q-PL6 결정 2026-11-06): D 3000, 지반고 30.000, 말뚝길이 20450, 점토층 하단 23.000.
describe('기둥–말뚝 겹침이음 (철근망 근입부 기준)', () => {
const example = { ...loadP1(), foundationType: '현장타설말뚝' as const, groundEl: 30, pileLengthMm: 20450, columnDiameterMm: 3000,
copingWidthMm: 3500, castInPlaceShape: { clayBottomEl: 23 } };
const model = buildPier(example);
const result = castRebarLayout(model);
const x = model.columns[0].s;
const radius = (b: { pts: number[][] }) => Math.hypot(b.pts[0][0] - x, b.pts[0][2]) * 1000;
it('pile cage on the rock socket 2850: r 1300 / 1268.2; column cage r 1375 / 1343.2', () => {
expect(radius(result.bars.find(b => b.name === '말뚝-P1')!)).toBeCloseTo(1300, 6);
expect(radius(result.bars.find(b => b.name === '말뚝-P2')!)).toBeCloseTo(1268.2, 6);
expect(radius(result.bars.find(b => b.name === '기둥-P1')!)).toBeCloseTo(1375, 6);
});
it('column mains run down to ground − 2387 (lap), the pile mains up to ground; no ground coupler', () => {
const colLow = Math.min(...result.bars.filter(b => b.name === '기둥-P1').flatMap(b => b.pts.map(p => p[1])));
const pileHigh = Math.max(...result.bars.filter(b => b.name === '말뚝-P1').flatMap(b => b.pts.map(p => p[1])));
expect(colLow).toBeCloseTo(27.613, 6);
expect(pileHigh).toBeCloseTo(30, 6);
expect(result.bars.some(b => b.name.startsWith('접합-'))).toBe(false);
expect(result.missing.some(m => m.includes('겹침') || m.includes('RB-PL07'))).toBe(false);
});
it('periodic couplers: column ground + 3 / + 6 m, pile ground − 3 m … , outside the lap (K2·D2 + 150)', () => {
const mid = (b: { pts: number[][] }) => Math.round((b.pts[0][1] + b.pts[1][1]) / 2 * 1000) / 1000;
// The P1 coping sits higher than the DWG example (coping underside 38.950), so the column may carry one more coupler.
const column = [...new Set(result.bars.filter(b => b.name === '기둥-P1 커플러').map(mid))].sort((a, b) => a - b);
expect(column.slice(0, 2)).toEqual([33, 36]);
expect(column.every(h => Math.abs(((h - 30) / 3) - Math.round((h - 30) / 3)) < 1e-9)).toBe(true);
const pile = [...new Set(result.bars.filter(b => b.name === '말뚝-P1 커플러').map(mid))].sort((a, b) => b - a);
expect(pile[0]).toBe(27);
expect(pile.every(h => h < 27.613)).toBe(true);
expect([...new Set(result.bars.filter(b => b.name === '말뚝-P2 커플러').map(mid))]).toContain(27.15);
});
it('8 pile hoops inside the lap wrap the column bars (r 1400.45), the others sit on the pile cage (r 1325.45)', () => {
const hoops = result.bars.filter(b => b.name === '말뚝-P3');
const wrap = hoops.filter(b => Math.abs(radius(b) - 1400.45) < 1e-6);
expect(wrap).toHaveLength(8);
expect(wrap.every(b => b.pts[0][1] >= 27.613 - 1e-9 && b.pts[0][1] <= 30)).toBe(true);
expect(hoops.filter(b => b.pts[0][1] < 27.613 - 1e-9).every(b => Math.abs(radius(b) - 1325.45) < 1e-6)).toBe(true);
});
it('RB-PL07: a socket 400 smaller puts the circles 200 apart (> 150) → warning', () => {
const narrow = castRebarLayout(buildPier({ ...example, castInPlaceShape: { clayBottomEl: 23, socketDiameterMm: 2600 } }));
expect(narrow.missing.some(m => m.includes('RB-PL07'))).toBe(true);
});
});