Files
Pier_v1/src/core/abut/rebar/slab.ts
T
ypshimandClaude Haiku 5.5 fa7d0032c3 feat(abut-rebar): 교대 접속슬래브 배근 규칙 R1–R8 (AD8)
- R1 · R3 (교대축 배치, 하면은 상면 구간 절반, 결정 A), R2 · R4 (길이 배치, 측면 피복 c / sin), 모따기 자름 (결정 E), R7 모서리 상 · 하, R8 엇갈림 (모따기 안 제외)
- ② 슬래브 평면 측면선 · 단면을 그대로 따라감 (상면 철근은 상면, 하면 철근은 하면). 노출형도 같은 규칙 (사용자 결정 2026-11-05)
- P10 다웰과의 간섭은 보지 않음 (사용자 결정 2026-11-05). k_8 편집 가능
- 테스트: 교대접속슬래브철근.md §11 (fck 27), 삽도 폭 11670 재현, 날개벽 형식별 R7 · 폭, 기본간격 연동, 노출형

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-05 09:23:42 +09:00

311 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 교대 접속슬래브 배근 · AD8 (2026-11-05, 교대접속슬래브철근.md, 결정 2026-11-02 · 2026-11-03 · 2026-11-05). 순수 함수, 길이는 mm.
*
* - R1 · R3 상 · 하면 길이 방향 주철근 (교량 진행방향, 측면과 나란히): 교대축을 따라 A = B − 2c, 상면 2·s_a로 구간을 정하고 하면은
* 같은 구간을 s_a로 (결정 A). R2 · R4 상 · 하면 교대축 방향 (끝면과 나란히): 길이를 따라 같은 방식, 2·s_l / s_l.
* - 모따기 (결정 E): 끝점에서 모따기선까지 < c이면 모따기선 − c에서 자름. R7: 모따기 모서리마다 상 · 하, 끝면 다리 l_d + 대각 + 측면 다리 l_d.
* - R8: 하면 교점 @k_8, 이웃 줄 반 칸 엇갈림, R1 · R3을 감쌈 (135° 갈고리). 모따기 안은 뺌. P10 다웰과의 간섭은 보지 않는다 (사용자 결정 2026-11-05).
* - 노출형도 같은 규칙 (사용자 결정 2026-11-05): 상면 철근은 상면을, 하면 철근은 하면을 따라간다.
* - 좌표: 교대 (s, t, z) mm — s 교대축, t 전면 +, z 저판 하면에서. u = 슬래브 0 측면(s 작은 쪽)에서 교대축을 따라 잰 거리,
* t' = 흉벽 쪽 끝에서 길이 방향(t −)으로 잰 거리.
*/
import { DEFAULT_MATERIAL, developmentLength, fyOf, NOMINAL_DIAMETER, STANDARD_BAR, type BarSize, type Material } from '../../rebar/common';
import type { Issue } from '../../types';
import { lenText } from './skew-layout';
import { splicePlan, type SplicePlan } from './splice';
export interface AbutSlabRebarSettings {
/** 피복 상 · 하면 / 측면 · 끝면. */
cv: number;
c: number;
/** 접속슬래브 기본간격: 교대축 / 교대축 직각. */
sA: number;
sL: number;
eMin: number;
/** R8 배수 (두 방향), 엇갈림 k_8·s/2. */
k8: number;
/** 묶음별 간격 수정 (하면 값, 상면은 2배): R1 · R3 (교대축), R2 · R4 (길이). */
sMain?: number;
sDist?: number;
}
export const ABUT_SLAB_REBAR_DEFAULT: AbutSlabRebarSettings = { cv: 80, c: 100, sA: 150, sL: 150, eMin: 150, k8: 8 };
export const ABUT_SLAB_SIZES = { R1: 'H22', R3: 'H22', R2: 'H13', R4: 'H13', R7: 'H13', R8: 'H13' } as const satisfies Record<string, BarSize>;
export type SlabMark = keyof typeof ABUT_SLAB_SIZES;
type V2 = [number, number];
/** 슬래브 형상 (mm). */
export interface AbutSlabGeom {
form: 'buried' | 'exposed';
/** 폭 (교대축 방향, 흉벽 쪽 끝에서) · 길이 (t 방향). */
B: number;
Ls: number;
/** 흉벽 쪽 끝 t, 먼 끝 t. */
near: number;
far: number;
/** 0 측면 · L 측면 선: 흉벽 쪽 끝 위의 점 (s, t)과 ds/dt (측면 기울기). */
side0: { s: number; k: number };
sideL: { s: number; k: number };
/** 측별 모서리 모따기 (0 = 없음). */
chamfer0: number;
chamferL: number;
/** 측면과 교대축 사잇각의 sin (측면 피복 c를 교대축 방향으로 잴 때 c / sin). */
sin: number;
/** 단면 (t 내림차순): 흉벽 상단 기준 상면 · 하면 (mm), 흉벽 상단 z (s = 0 · s = L, 저판 하면에서), 교대축 길이 L. */
stations: { t: number; top: number; bottom: number }[];
parapetTop: [number, number];
L: number;
/** 경사율 (표시용, %). */
gradePct: number;
}
export type SlabRole = '주철근' | '배력철근' | '모서리' | '전단';
export interface AbutSlabBar {
mark: SlabMark;
size: BarSize;
role: SlabRole;
spacing: string;
actual: string;
shape: string;
length: number;
lengthMin: number;
lengthMax: number;
count: number;
position: string;
note: string;
basis: '규칙' | '가정';
modified: boolean;
splice?: SplicePlan;
}
export interface SlabBarPath {
mark: SlabMark;
pts: [number, number, number][];
}
export interface AbutSlabRebar {
settings: AbutSlabRebarSettings;
z: { R1: number; R2: number; R3: number; R4: number };
/** 교대축 방향 배치 (u, 상면 R1 / 하면 R3) · 길이 방향 배치 (t', R2 / R4), 치수 문자열. */
across: { r1: number[]; r3: number[]; e: number; text: string };
along: { r2: number[]; r4: number[]; e: number; text: string };
trimmed: number;
ld: number;
paths: SlabBarPath[];
bars: AbutSlabBar[];
issues: Issue[];
}
const d = (size: BarSize) => NOMINAL_DIAMETER[size];
export function resolveSlabRebar(changes?: Partial<AbutSlabRebarSettings>): AbutSlabRebarSettings {
return { ...ABUT_SLAB_REBAR_DEFAULT, ...(changes ?? {}) };
}
export function slabRebarIssues(s: AbutSlabRebarSettings): string[] {
const out: string[] = [];
for (const key of ['cv', 'c', 'sA', 'sL', 'eMin'] as const) if (!(s[key] > 0)) out.push(`${key}은(는) 0보다 커야 합니다.`);
for (const key of ['sMain', 'sDist'] as const) if (s[key] !== undefined && !((s[key] as number) > 0)) out.push('철근 간격은 0보다 커야 합니다.');
if (!(Number.isInteger(s.k8) && s.k8 >= 1)) out.push('R8 배수는 1 이상의 정수여야 합니다.');
return out;
}
/** 상면 2s 격자로 구간을 정하고 하면은 같은 구간을 s로 (결정 A): A = total − 2·edge. */
function halfGrid(total: number, edge: number, eMin: number, s: number) {
const A = total - 2 * edge;
const n = Math.max(0, Math.floor((A - 2 * eMin) / (2 * s) + 1e-9));
const G = n * 2 * s;
const e = (A - G) / 2;
const top = Array.from({ length: n + 1 }, (_, i) => edge + e + i * 2 * s);
const bottom = Array.from({ length: 2 * n + 1 }, (_, i) => edge + e + i * s);
const text = `${lenText(edge)} · ${lenText(e)} · ${n}@${lenText(2 * s)}=${lenText(G)} · ${lenText(e)} · ${lenText(edge)}`;
return { top, bottom, e, text, n };
}
export function abutSlabRebar(g: AbutSlabGeom, changes?: Partial<AbutSlabRebarSettings>, material: Material = DEFAULT_MATERIAL, who = ''): AbutSlabRebar {
const s = resolveSlabRebar(changes);
const issues: Issue[] = [];
const add = (code: string, level: Issue['level'], message: string) => issues.push({ code, level, pier: who || undefined, message });
const SZ = ABUT_SLAB_SIZES;
const c = s.c;
const sMain = s.sMain ?? s.sA;
const sDist = s.sDist ?? s.sL;
if (s.sMain !== undefined || s.sDist !== undefined || s.k8 !== 8) add('RB-AS04', 'I', '철근 간격을 기본간격과 따로 고쳤거나 R8 배수가 기본값(8)이 아닙니다.');
const fy = (size: BarSize) => fyOf(material, size);
const ld = Math.ceil(developmentLength(d(SZ.R7), fy(SZ.R7), material.fck) - 1e-9);
// 높이 (§3): 상면 · 하면에서 잰다 (경사면과 나란히).
const z = {
R1: s.cv,
R2: s.cv + (d(SZ.R1) + d(SZ.R2)) / 2,
R3: s.cv,
R4: s.cv + (d(SZ.R3) + d(SZ.R4)) / 2,
};
const parapetAt = (sv: number) => g.parapetTop[0] + ((g.parapetTop[1] - g.parapetTop[0]) * sv) / g.L;
const level = (t: number) => {
const st = g.stations;
for (let i = 0; i + 1 < st.length; i++) {
if (t <= st[i].t + 1e-6 && t >= st[i + 1].t - 1e-6) {
const k = st[i].t === st[i + 1].t ? 0 : (st[i].t - t) / (st[i].t - st[i + 1].t);
return { top: st[i].top + k * (st[i + 1].top - st[i].top), bottom: st[i].bottom + k * (st[i + 1].bottom - st[i].bottom) };
}
}
const end = t > st[0].t ? st[0] : st[st.length - 1];
return { top: end.top, bottom: end.bottom };
};
/** z of a layer at (s, t): top layers measured down from the top, bottom layers up from the bottom. */
const zAt = (sv: number, t: number, layer: 'R1' | 'R2' | 'R3' | 'R4') => {
const lv = level(t);
const p = parapetAt(sv);
return layer === 'R1' || layer === 'R2' ? p + lv.top - z[layer] : p + lv.bottom + z[layer];
};
const side0 = (t: number) => g.side0.s + g.side0.k * (t - g.near);
const sideL = (t: number) => g.sideL.s + g.sideL.k * (t - g.near);
const sAt = (u: number, t: number) => side0(t) + u; // u along +s from the 0 측면
const tOf = (tp: number) => g.near - tp; // t' (from the near end) → t
const chamferCut = c * Math.SQRT2;
const paths: SlabBarPath[] = [];
const lengths = new Map<SlabMark, number[]>();
const put = (mark: SlabMark, pts: [number, number, number][]) => {
let len = 0;
for (let i = 1; i < pts.length; i++) len += Math.hypot(pts[i][0] - pts[i - 1][0], pts[i][1] - pts[i - 1][1], pts[i][2] - pts[i - 1][2]);
paths.push({ mark, pts });
lengths.set(mark, [...(lengths.get(mark) ?? []), len]);
};
let trimmed = 0;
// R1 · R3 (§4 · §6): u 배치, t' = c … L_s − c, 흉벽 쪽 끝은 모따기에 걸리면 자름. 단면 꺾임마다 점을 둬 면을 따라감.
const across = halfGrid(g.B, c, s.eMin, sMain);
const stationTs = g.stations.map((x) => x.t);
const runAlong = (mark: 'R1' | 'R3', u: number) => {
const fromL = g.B - u;
let tpNear = c;
if (g.chamfer0 > 0 && u < g.chamfer0 - c + chamferCut) tpNear = g.chamfer0 + chamferCut - u;
if (g.chamferL > 0 && fromL < g.chamferL - c + chamferCut) tpNear = Math.max(tpNear, g.chamferL + chamferCut - fromL);
if (tpNear > c + 1e-6) trimmed++;
const t0 = tOf(tpNear);
const t1 = g.far + c;
const ts = [t0, ...stationTs.filter((t) => t < t0 - 1e-6 && t > t1 + 1e-6), t1];
put(mark, ts.map((t) => [sAt(u, t), t, zAt(sAt(u, t), t, mark)] as [number, number, number]));
};
for (const u of across.top) runAlong('R1', u);
for (const u of across.bottom) runAlong('R3', u);
// R2 · R4 (§5 · §6): t' 배치, 측면 피복 c / sin, 모따기에 걸리는 쪽은 모따기선 − c.
const along = halfGrid(g.Ls, c, s.eMin, sDist);
const runAcross = (mark: 'R2' | 'R4', tp: number) => {
const t = tOf(tp);
let u0 = c / g.sin;
let u1 = sideL(t) - side0(t) - c / g.sin;
if (g.chamfer0 > 0 && tp < g.chamfer0 - c + chamferCut) {
u0 = Math.max(u0, g.chamfer0 + chamferCut - tp);
trimmed++;
}
if (g.chamferL > 0 && tp < g.chamferL - c + chamferCut) {
u1 = Math.min(u1, sideL(t) - side0(t) - (g.chamferL + chamferCut - tp));
trimmed++;
}
const a = sAt(u0, t);
const b = sAt(u1, t);
put(mark, [[a, t, zAt(a, t, mark)], [b, t, zAt(b, t, mark)]]);
};
for (const tp of along.top) runAcross('R2', tp);
for (const tp of along.bottom) runAcross('R4', tp);
if (trimmed) add('RB-AS02', 'I', `모따기에 걸려 짧아진 철근 ${trimmed}개 (모따기선 − c에서 자름).`);
// R7 (§6.1): 모따기 모서리마다 상(R2 층) · 하(R4 층). 끝면 피복선 다리 l_d → 모따기선 c 안쪽 대각 → 측면 피복선 다리 l_d.
for (const [ch, side] of [[g.chamfer0, 0], [g.chamferL, 1]] as const) {
if (!(ch > 0)) continue;
const corner = ch + chamferCut - c; // 341.4 at 300
for (const layer of ['R2', 'R4'] as const) {
const pt = (u: number, tp: number): [number, number, number] => {
const t = tOf(tp);
const uu = side === 0 ? u : sideL(t) - side0(t) - u;
const sv = sAt(uu, t);
return [sv, t, zAt(sv, t, layer)];
};
put('R7', [pt(corner + ld, c), pt(corner, c), pt(c, corner), pt(c, corner + ld)]);
}
}
// R8 (§7): 하면 교점 — R3 위치를 k_8칸마다 (홀수 줄은 k_8/2 밀어), 줄은 R4 위치를 k_8칸마다. 모따기 삼각형 안은 뺌.
const hook135 = 151;
for (let j = 0; j < along.bottom.length; j += s.k8) {
const row = (j / s.k8) % 2;
const tp = along.bottom[j];
const t = tOf(tp);
for (let i = row ? Math.floor(s.k8 / 2) : 0; i < across.bottom.length; i += s.k8) {
const u = across.bottom[i];
if ((g.chamfer0 > 0 && u + tp < g.chamfer0) || (g.chamferL > 0 && g.B - u + tp < g.chamferL)) continue;
const sv = sAt(u, t);
const zb = zAt(sv, t, 'R3') - (d(SZ.R3) + d(SZ.R8)) / 2;
const zt = zAt(sv, t, 'R1') + (d(SZ.R1) + d(SZ.R8)) / 2;
const hk = hook135 / Math.SQRT2;
put('R8', [[sv + hk, t, zb + hk], [sv, t, zb], [sv, t, zt], [sv + hk, t, zt - hk]]);
}
}
// 표.
const bars: AbutSlabBar[] = [];
const push = (b: Omit<AbutSlabBar, 'length' | 'lengthMin' | 'lengthMax' | 'count' | 'splice'>) => {
const all = lengths.get(b.mark) ?? [];
if (all.length === 0) return;
const max = Math.max(...all);
const splice = splicePlan(max, d(b.size), fy(b.size), material.fck) ?? undefined;
if (splice) add('RB-AS01', splice.over ? 'W' : 'I', `${b.mark} ${Math.round(max)} > 정척 ${STANDARD_BAR}: ${splice.pieces}조각 이음 l_s ${splice.lap}.`);
bars.push({ ...b, note: splice ? `${b.note} · ${splice.pieces}조각 이음 l_s ${splice.lap}` : b.note, length: Math.round(max), lengthMin: Math.round(Math.min(...all)), lengthMax: Math.round(max), count: all.length, splice });
};
const mMod = s.sMain !== undefined;
const dMod = s.sDist !== undefined;
push({ mark: 'R1', size: SZ.R1, role: '주철근', spacing: lenText(2 * sMain), actual: lenText(2 * sMain), shape: '직선 · 면과 나란히', position: `상면 최외측 (피복 ${lenText(s.cv)}) · 교량 진행방향`, note: `${across.text} · 삽도 ② = R1`, basis: '규칙', modified: mMod });
push({ mark: 'R2', size: SZ.R2, role: '배력철근', spacing: lenText(2 * sDist), actual: lenText(2 * sDist), shape: '직선', position: `상면 R1 아래 · 교대축 방향 (끝면과 나란히)`, note: along.text, basis: '규칙', modified: dMod });
push({ mark: 'R3', size: SZ.R3, role: '주철근', spacing: lenText(sMain), actual: lenText(sMain), shape: '직선 · 면과 나란히', position: `하면 최외측 (피복 ${lenText(s.cv)}) · 교량 진행방향`, note: 'R1 구간을 절반으로 (결정 A) · P10 다웰과의 간섭은 보지 않음', basis: '규칙', modified: mMod });
push({ mark: 'R4', size: SZ.R4, role: '배력철근', spacing: lenText(sDist), actual: lenText(sDist), shape: '직선', position: '하면 R3 위 · 교대축 방향', note: 'R2 구간을 절반으로', basis: '규칙', modified: dMod });
push({ mark: 'R7', size: SZ.R7, role: '모서리', spacing: '—', actual: '—', shape: `끝면 다리 l_d ${ld} · 모따기 대각 · 측면 다리 l_d ${ld}`, position: '흉벽 쪽 모따기 모서리 · 끝면 · 측면 피복선 위 · 상 · 하', note: '모서리마다 상 · 하 1개 · 상면 R2 층 / 하면 R4 층', basis: '가정', modified: false });
push({ mark: 'R8', size: SZ.R8, role: '전단', spacing: `${lenText(s.k8 * sMain)} × ${lenText(s.k8 * sDist)}`, actual: '—', shape: `R1 · R3 감쌈 · 양끝 135° 갈고리 ${hook135}`, position: '전단 · 하면 교점 · 반 칸 엇갈림', note: '모따기 안은 뺌 · R5 · R6 결번', basis: '규칙', modified: s.k8 !== 8 });
return { settings: s, z, across: { r1: across.top, r3: across.bottom, e: across.e, text: across.text }, along: { r2: along.top, r4: along.bottom, e: along.e, text: along.text }, trimmed, ld, paths, bars, issues };
}
/** 슬래브 형상 from the ② slab plan outline (abutPlanOutline().slab, m) and section (slabSection, m). */
export function abutSlabGeom(
form: 'buried' | 'exposed',
outline: [number, number][],
section: { t: number; top: number; bottom: number }[],
parapet: { startEl: number; endEl: number; L: number; bottomEl: number },
chamfer: [number, number],
lengthMm: number,
gradePct: number,
): AbutSlabGeom {
const n = outline.length;
const near = section[0].t * 1000;
const far = section[section.length - 1].t * 1000;
const sideAt = (p: V2, q: V2) => {
const k = (q[0] - p[0]) / (q[1] - p[1]);
return { s: (p[0] + k * (near / 1000 - p[1])) * 1000, k };
};
const side0 = sideAt(outline[0], outline[n - 1]);
const sideL = sideAt(outline[n - 3], outline[n - 2]);
const k0 = side0.k;
return {
form,
B: sideL.s - side0.s,
Ls: lengthMm,
near,
far,
side0,
sideL,
chamfer0: chamfer[0],
chamferL: chamfer[1],
sin: 1 / Math.sqrt(1 + k0 * k0),
stations: section.map((x) => ({ t: x.t * 1000, top: x.top * 1000, bottom: x.bottom * 1000 })),
parapetTop: [(parapet.startEl - parapet.bottomEl) * 1000, (parapet.endEl - parapet.bottomEl) * 1000],
L: parapet.L * 1000,
gradePct,
};
}