diff --git a/src/core/cast-in-place-shape.ts b/src/core/cast-in-place-shape.ts index add046c..578be46 100644 --- a/src/core/cast-in-place-shape.ts +++ b/src/core/cast-in-place-shape.ts @@ -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) { +/** 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): number { + const s = input.castInPlaceShape ?? {}; + const d = input.columnDiameterMm ?? 2000; + return s.socketDiameterMm ?? ((s.casingOn ?? true) ? d - 150 : d); +} + +export function castShape(input: Pick) { 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, 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, part: CastRebarPart): NonNullable { + 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, 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) => { 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]} 단부 띠철근 커플러 규격 확인 필요`); } diff --git a/src/main.ts b/src/main.ts index 7e46029..c2ced92 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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')); diff --git a/tests/cast-detail.test.ts b/tests/cast-detail.test.ts index 42aafca..33350c0 100644 --- a/tests/cast-detail.test.ts +++ b/tests/cast-detail.test.ts @@ -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 } } }); diff --git a/tests/cast-layout.test.ts b/tests/cast-layout.test.ts index 50c3e6a..c71d5d0 100644 --- a/tests/cast-layout.test.ts +++ b/tests/cast-layout.test.ts @@ -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> = {}) => 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(' { + 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); + }); +});