feat(abut): 벽체 높이 H_w = 받침 ① 교좌면 ~ 흉벽 상단 연직 (기본 3326)

- 사용자 도면(2026-11-04): 받침 ① 위치에서 받침 ① 교좌면부터 흉벽 상단까지 연직 거리. 모든 교대 기본값 3326 (QA-4 가정 2500 대체)
- 입력 이름 "벽체 높이 · 받침 ① 기준 연직", ② 정면도에 받침 ① 기준선과 H_w 치수
- ③ 흉벽 철근표 대표 단면 = 받침 ① 위치 (P1–P3는 그 단면에서 정확히 계산)
- 테스트: 흉벽 상단 IP1 48.006 / IP3 47.661 등 + 0.826 반영, 교대흉벽철근.md §11 재현은 2500을 넣어 유지, 3326 기본 확인 (P1 3945)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
ypshimandClaude Haiku 5.5 committed 2026-11-04 17:20:05 +09:00
1 parent 51a36669a8
commit 5e64a4f2ed
17 files changed
+125 -71

No files matched your search

+1 -1
View File
@@ -214,7 +214,7 @@ export function applySlabEdit(rec: AbutRecord, key: (typeof SLAB_EDIT_FIELDS)[nu
export const SHAPE_EDIT_FIELDS: { key: keyof AbutShape; label: string; group: 'wall' | 'footing'; note?: string }[] = [
// 벽체 · 흉벽 (시안 입력 격자 순서). 벽체 두께는 자동 = 브라켓 폭 + 흉벽 두께 + 교좌면 폭.
{ key: 'wallFrontMm', label: '교좌면 폭 (mm)', group: 'wall' },
{ key: 'parapetHeightMm', label: '흉벽 높이 (mm)', group: 'wall' },
{ key: 'parapetHeightMm', label: '벽체 높이 · 받침 ① 기준 연직 (mm)', group: 'wall', note: '받침 ① 교좌면 ~ 흉벽 상단' },
{ key: 'parapetThicknessMm', label: '흉벽 두께 (mm)', group: 'wall' },
{ key: 'bracketHeightMm', label: '브라켓 높이 (mm)', group: 'wall', note: '= 접속슬래브 카드 흉벽 높이' },
{ key: 'bracketWidthMm', label: '브라켓 폭 (mm)', group: 'wall' },
+1 -1
View File
@@ -2,7 +2,7 @@
* 교대 부재 · AA3 (2026-11-03, spec_abut §4.3–4.6): 벽체, 흉벽 상단, 저판, 말뚝의 치수와 위치. Pure.
*
* - 교대축 기준 t: 전면 쪽이 +. 벽체 전면 +1.6 m (교좌면 끝), 배면 −0.8 m (브라켓 배면). 두께 2.4 m.
* - 흉벽: t −0.5 ~ 0 m. 상단 EL(s) = 교좌면 EL(구간 ①) + 흉벽 높이 2.5 m + i_b × (s − s_1), i_b = 받침 z의 축 방향 경사.
* - 흉벽: t −0.5 ~ 0 m. 상단 EL(s) = 받침 ① 교좌면 EL + 벽체 높이 H_w (받침 ① 기준 연직, 기본 3.326 m) + i_b × (s − s_1), i_b = 받침 z의 축 방향 경사.
* - 저판: 하면 = 기초 하단 EL, 두께 1.1 m. 폭은 t −1.8 ~ +2.8 m (앞굽 1.2, 뒷굽 1.0, 벽체 2.4). 길이 = 교좌면 길이 L.
* - 말뚝: 열 위치 t = −1.15 / +0.85 / +2.15 m. 열 안 본수 = int((L − 1.3)/2.0) + 1, 양끝 0.65 m 띄움, 균등 간격.
* 말뚝 선단 EL = 기초 하단 EL − 말뚝길이. (skew 평행사변형에서 열 끝이 밀리는 보정은 아직 없다 — QA-5 이후.)
+23 -25
View File
@@ -47,8 +47,10 @@ export interface AbutParapetGeom {
/** 흉벽 상단 z at u = 0 and u = L (저판 하면에서), straight between. 브라켓 상면 = 흉벽 상단 − bracketH. */
top: [number, number];
bracketH: number;
/** 흉벽 높이 입력 (교좌면 → 흉벽 상단). 철근표 대표 단면 = 흉벽 높이가 이 값인 곳 (기획서 §2 "구간 1 끝 2500"과 같은 단면). */
/** 벽체 높이 H_w (받침 ① 교좌면 → 흉벽 상단, 받침 ① 위치에서 연직). */
Hp: number;
/** 받침 ① 위치 u (mm). 철근표 대표 단면 (사용자 도면 2026-11-04). */
ref: number;
/** 접속슬래브: 폭 B (교대축 방향), 다웰 선(브라켓 가운데)에서 슬래브 양 측면의 u, 슬래브 끝 t, 측별 모서리 모따기 (0 = 없음). */
slab: { B: number; a: number; b: number; nearT: number; chamferA: number; chamferB: number } | null;
}
@@ -62,7 +64,7 @@ export interface AbutParapetBar {
spacing: string;
actual: string;
shape: string;
/** 대표 길이 (흉벽 높이 = H_p 단면) and the range. */
/** 대표 길이 (받침 ① 위치 단면) and the range. */
length: number;
lengthMin: number;
lengthMax: number;
@@ -84,7 +86,7 @@ export interface AbutParapetRebar {
x: { P1: number; P2back: number; P3: number; P4: number; P5up: number; P5low: number; P10: number };
/** 연직 격자 오프셋 (저판 격자 기준). */
offsets: { P1: number; P2: number; P3: number };
/** 대표 단면 (흉벽 높이 = H_p)의 단 수, and that section's u. */
/** 대표 단면 (받침 ① 위치)의 단 수, and that section's u. */
levels: { P4: number; P5up: number; P5low: number; P6: number; P7: number };
uRef: number;
dowel: { n: number; e: number; moved: number; positions: number[] };
@@ -110,10 +112,15 @@ export function parapetRebarIssues(s: AbutParapetRebarSettings): string[] {
}
/** 흉벽 형상 from the 벽체 형상 (브라켓 상면 + 브라켓 높이 = 흉벽 상단) and the 접속슬래브 values (mm). */
export function abutParapetGeom(wall: AbutWallGeom, shape: { parapetBackM: number; bracketHeightM: number; parapetHeightM: number }, slab: AbutParapetGeom['slab']): AbutParapetGeom {
export function abutParapetGeom(
wall: AbutWallGeom,
shape: { parapetBackM: number; bracketHeightM: number; parapetHeightM: number },
bearing1SM: number,
slab: AbutParapetGeom['slab'],
): AbutParapetGeom {
const mm = (m: number) => Math.round(m * 1e6) / 1e3;
const bracketH = mm(shape.bracketHeightM);
return { wall, parapetBack: mm(shape.parapetBackM), top: [wall.bracketTop[0] + bracketH, wall.bracketTop[1] + bracketH], bracketH, Hp: mm(shape.parapetHeightM), slab };
return { wall, parapetBack: mm(shape.parapetBackM), top: [wall.bracketTop[0] + bracketH, wall.bracketTop[1] + bracketH], bracketH, Hp: mm(shape.parapetHeightM), ref: mm(bearing1SM), slab };
}
/**
@@ -160,21 +167,8 @@ export function abutParapetRebar(g: AbutParapetGeom, wall: AbutWallRebar, change
const seat = (u: number) => W.HF + segs[segIndex(u)].H;
const top = (u: number) => g.top[0] + ((g.top[1] - g.top[0]) * u) / W.L;
const br = (u: number) => top(u) - g.bracketH;
// 대표 단면: 흉벽 높이(상단 − 교좌면)가 입력값 H_p인 곳 (기획서 §2의 "구간 1 끝 2500" 단면). Linear in each step, so the
// closest point is a clamped root per step.
let uRef = W.L;
let best = Infinity;
for (const sg of segs) {
const hAt = (u: number) => top(u) - (W.HF + sg.H);
const h0 = hAt(sg.from);
const h1 = hAt(sg.to);
const f = h1 === h0 ? 0 : Math.min(1, Math.max(0, (g.Hp - h0) / (h1 - h0)));
const u = sg.from + f * (sg.to - sg.from);
if (Math.abs(hAt(u) - g.Hp) < best - 1e-9) {
best = Math.abs(hAt(u) - g.Hp);
uRef = Math.min(u, sg.to - 1e-6);
}
}
// 대표 단면 = 받침 ① 위치 (벽체 높이 H_w의 기준선, 사용자 도면 2026-11-04). 그 단면의 흉벽 높이가 H_w다.
const uRef = Math.min(Math.max(g.ref, 0), W.L - 1e-6);
// 단면 위치 (§3).
const xP4 = -c;
@@ -198,21 +192,25 @@ export function abutParapetRebar(g: AbutParapetGeom, wall: AbutWallRebar, change
lengths.set(mark, [...(lengths.get(mark) ?? []), { u, l: length }]);
};
// P1 · P2 · P3 (§4.2).
// P1 · P2 · P3 (§4.2). Lengths as functions of u, so the 철근표 대표 can be taken exactly at the 받침 ① 단면.
const lenP1 = (u: number) => top(u) - c - offP2 - (seat(u) - ld.H16);
const lenP2 = (u: number) => ld.H16 + (xP1 - xP2back) + (top(u) - c - (br(u) - ld.H16));
const lenP3 = (u: number) => br(u) - c - (seat(u) - ld.H16) + (xP4 - xP3);
let shortBack = false;
base.forEach((u, i) => {
const z0 = seat(u);
const zt = top(u);
const zb = br(u);
if (i % 2 === 0) put('P1', [[u, xP1, z0 - ld.H16], [u, xP1, zt - c - offP2]], zt - c - offP2 - (z0 - ld.H16), u);
if (i % 2 === 0) put('P1', [[u, xP1, z0 - ld.H16], [u, xP1, zt - c - offP2]], lenP1(u), u);
const u2 = u + offP2;
const zh = zt - c;
const backEnd = zb - ld.H16;
if (backEnd < seat(u2)) shortBack = true;
put('P2', [[u2, xP1, zh - ld.H16], [u2, xP1, zh], [u2, xP2back, zh], [u2, xP2back, backEnd]], ld.H16 + (xP1 - xP2back) + (zh - backEnd), u2);
put('P2', [[u2, xP1, zh - ld.H16], [u2, xP1, zh], [u2, xP2back, zh], [u2, xP2back, backEnd]], lenP2(u2), u2);
const u3 = u + offP3;
put('P3', [[u3, xP3, seat(u3) - ld.H16], [u3, xP3, br(u3) - c], [u3, xP4, br(u3) - c]], br(u3) - c - (seat(u3) - ld.H16) + (xP4 - xP3), u3);
put('P3', [[u3, xP3, seat(u3) - ld.H16], [u3, xP3, br(u3) - c], [u3, xP4, br(u3) - c]], lenP3(u3), u3);
});
const atRef: Partial<Record<ParapetMark, number>> = { P1: lenP1(uRef), P2: lenP2(uRef), P3: lenP3(uRef) };
if (shortBack) add('RB-AP01', 'W', `흉벽 높이 − 브라켓 높이가 l_d ${ld.H16}보다 작아 P2 뒤 다리가 교좌면 아래로 내려갑니다.`);
// 수평 단 (§5): 흉벽 상단에서 c + k·s_v 아래, 상단 경사와 나란함.
@@ -340,7 +338,7 @@ export function abutParapetRebar(g: AbutParapetGeom, wall: AbutWallRebar, change
if (kinds > 10) add('RB-AP04', 'I', `${b.mark} 길이가 흉벽 상단 경사 · 계단으로 ${kinds}종입니다 (철근표에는 범위).`);
const splice = splicePlan(max, d(b.size), fy(b.size), material.fck) ?? undefined;
if (splice) add('RB-AP07', 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(ref.l), lengthMin: Math.round(Math.min(...ls)), lengthMax: Math.round(max), count: all.length, splice });
bars.push({ ...b, note: splice ? `${b.note} · ${splice.pieces}조각 이음 l_s ${splice.lap}` : b.note, length: Math.round(atRef[b.mark] ?? ref.l), lengthMin: Math.round(Math.min(...ls)), lengthMax: Math.round(max), count: all.length, splice });
};
const vMod = s.sVert !== undefined;
const hMod = s.sHoriz !== undefined;
+3 -2
View File
@@ -45,7 +45,8 @@ export interface AbutShape {
export const ABUT_SHAPE_DEFAULT: AbutShape = {
wallFrontMm: 1600,
parapetHeightMm: 2500,
/** 벽체 높이 H_w: 받침 ① 위치에서 받침 ① 교좌면 ~ 흉벽 상단 연직 거리 (사용자 도면, 2026-11-04). 모든 교대 기본값. */
parapetHeightMm: 3326,
parapetThicknessMm: 500,
bracketHeightMm: 850,
bracketWidthMm: 300,
@@ -166,7 +167,7 @@ export function shapeIssues(changes: Partial<AbutShape>): string[] {
const out: string[] = [];
if (!(v.wallFrontMm > 0)) out.push('교좌면 폭은 0보다 커야 합니다.');
if (!(v.bracketWidthMm >= 0)) out.push('브라켓 폭은 0 이상이어야 합니다.');
if (!(v.parapetHeightMm > 0)) out.push('흉벽 높이는 0보다 커야 합니다.');
if (!(v.parapetHeightMm > 0)) out.push('벽체 높이(받침 ① 기준)는 0보다 커야 합니다.');
if (!(v.parapetThicknessMm > 0)) out.push('흉벽 두께는 0보다 커야 합니다.');
if (!(v.bracketHeightMm >= 0)) out.push('브라켓 높이는 0 이상이어야 합니다.');
if (!(v.footingThicknessMm > 0)) out.push('저판 두께는 0보다 커야 합니다.');
+1 -1
View File
@@ -30,7 +30,7 @@ export function abutSummaryRows(rec: AbutRecord): [string, string][] {
['받침블럭 높이 (패드 포함)', mm(first.blockHeightMm)],
['벽체 높이 (구간 ①)', m3(members.wallSegments.find((w) => Math.abs(w.seatEl - first.seatEl) < 1e-9)?.heightM ?? NaN)],
['벽체 두께 · 전면', `${Math.round(shape.wallFrontM * 1000 - shape.wallBackM * 1000)} mm · ${Math.round(shape.wallFrontM * 1000)} mm`],
['흉벽 높이 · 두께', `${Math.round(shape.parapetHeightM * 1000)} mm · ${Math.round(-shape.parapetBackM * 1000)} mm`],
['벽체 높이 (받침 ① 기준) · 흉벽 두께', `${Math.round(shape.parapetHeightM * 1000)} mm · ${Math.round(-shape.parapetBackM * 1000)} mm`],
['흉벽 상단 EL (IP1 끝 / IP3 끝)', `${members.parapetTopStartEl.toFixed(3)} / ${members.parapetTopEndEl.toFixed(3)} m`],
['브라켓 상면 EL (구간 ①)', m3(first.seatEl + shape.parapetHeightM - shape.bracketHeightM)],
['흉벽 경사', `${(members.parapetSlope * 100).toFixed(3)} %`],
+4 -4
View File
@@ -1885,7 +1885,7 @@ function fillAbutSlot(slot: (typeof ABUT_SLOTS)[keyof typeof ABUT_SLOTS], rec: A
shape.append(
el(
'p',
'시안 메모 · 벽체는 교좌면 계단 구간마다 높이가 다릅니다. 흉벽 높이 2500은 ① 구간 교좌면 기준(가정, QA-4)이고, Level 좌·우는 계산에 쓰지 않습니다. 인접 무시 기준보다 작은 교좌면 단차는 한 구간으로 묶습니다.',
'시안 메모 · 벽체는 교좌면 계단 구간마다 높이가 다릅니다. 벽체 높이 H_w(기본 3326)는 받침 ① 위치에서 받침 ① 교좌면 ~ 흉벽 상단 연직 거리이고(사용자 도면 2026-11-04, QA-4 확정), 흉벽 상단은 받침 z 경사를 따릅니다. Level 좌·우는 계산에 쓰지 않습니다. 인접 무시 기준보다 작은 교좌면 단차는 한 구간으로 묶습니다.',
'muted small abut-memo',
),
);
@@ -3144,7 +3144,7 @@ function abutParapetBuilt(rec: AbutRecord, wall: { geom: AbutWallGeom; result: A
const chamfer = (sEnd: number) => (wings.find((w) => Math.abs(w.sEnd - sEnd) < 1e-9)?.haunchM ?? 0) * 1000;
const B = model.wing?.slabWidthMm;
const slabGeom = span && B !== undefined ? { B, a: span.a, b: span.b, nearT: members.shape.parapetBackM * 1000 - slab.parapetGapMm, chamferA: chamfer(0), chamferB: chamfer(model.length) } : null;
const geom = abutParapetGeom(wall.geom, members.shape, slabGeom);
const geom = abutParapetGeom(wall.geom, members.shape, model.bearings[0].s, slabGeom);
return { geom, result: abutParapetRebar(geom, wall.result, rec.rebar?.parapet, materialFor(state.material, state.fckByMember, 'abutBody'), rec.name) };
} catch {
return null;
@@ -3527,11 +3527,11 @@ function fillAbutParapetRebarCard(box: HTMLDivElement, rec: AbutRecord, built: {
svg: (pick) => (view === 'sec' ? abutParapetSectionSvg(geom, r, pick) : view === 'elev' ? abutParapetElevationSvg(geom, r, pick) : abutParapetDowelSvg(geom, r, pick)),
caption:
view === 'sec'
? `교대축 직각 단면 · 대표 단면 (흉벽 높이 ${lenText(geom.Hp)}) · 왼쪽 브라켓 배면 → 오른쪽 흉벽 전면`
? `교대축 직각 단면 · 받침 ① 위치 (벽체 높이 H_w ${lenText(geom.Hp)}) · 왼쪽 브라켓 배면 → 오른쪽 흉벽 전면`
: view === 'elev'
? '교대축 단면 · 전면 · 왼쪽 IP3 → 오른쪽 IP1 · 수평 P4는 흉벽 상단 경사와 나란함, 끝면 P9 (위) · P8 (아래) · P11'
: `P10 다웰 배치 · 평면 (접속슬래브 폭 ${geom.slab ? Math.round(geom.slab.B) : '—'}) · 다웰이 P3 수평 다리와 겹치면 가장 가까운 자리로 옮김 (Q-P5)`,
memo: `흉벽 상단선은 받침 ① · 마지막 받침으로 정해져 흉벽 높이가 위치마다 다릅니다 (IP1 ${Math.round(geom.top[0] - (geom.wall.HF + geom.wall.segments[0].H))} · IP3 ${Math.round(geom.top[1] - (geom.wall.HF + geom.wall.segments[geom.wall.segments.length - 1].H))}). 철근표 대표 길이 · 단 수는 흉벽 높이 ${lenText(geom.Hp)} 단면, 괄호는 범위입니다.`,
memo: `흉벽 상단은 받침 ① 교좌면 + 벽체 높이 H_w ${lenText(geom.Hp)}에서 받침 z 경사를 따라 기울어 교좌면 위 높이가 위치마다 다릅니다 (IP1 ${Math.round(geom.top[0] - (geom.wall.HF + geom.wall.segments[0].H))} · IP3 ${Math.round(geom.top[1] - (geom.wall.HF + geom.wall.segments[geom.wall.segments.length - 1].H))}). 철근표 대표 길이 · 단 수는 받침 ① 위치 단면, 괄호는 범위입니다.`,
legendHtml: `<span style="color:${C.vertical}">━ 연직 (P1–P3 · P11)</span> · <span style="color:${C.horizontal}">━ 수평 (P4 · P5)</span> · <span style="color:${C.tie}">┅ 간격재 (P6 · P7)</span> · <span style="color:${C.side}">┅ 측면 (P8 · P9) · 다웰 P10</span>`,
baseTitle: '기본간격 · 흉벽 (부재별 입력) · 교대축 직각은 두지 않음',
base: [
+1 -1
View File
@@ -24,7 +24,7 @@ const seatAt = (g: AbutParapetGeom, u: number) => {
const nearest = (paths: ParapetBarPath[], mark: ParapetMark, u: number, at = 1) =>
paths.filter((p) => p.mark === mark).reduce<ParapetBarPath | undefined>((a, b) => (!a || Math.abs(b.pts[at][0] - u) < Math.abs(a.pts[at][0] - u) ? b : a), undefined);
/** 교대축 직각 단면 at the 대표 단면 (흉벽 높이 = H_p): t 가로 (왼쪽 브라켓 배면 → 오른쪽 흉벽 전면), z 세로. */
/** 교대축 직각 단면 at the 대표 단면 (받침 ① 위치): t 가로 (왼쪽 브라켓 배면 → 오른쪽 흉벽 전면), z 세로. */
export function abutParapetSectionSvg(g: AbutParapetGeom, r: AbutParapetRebar, pick: AbutRebarPick = NO_PICK): string {
const u = r.uRef;
const seat = seatAt(g, u);
+12 -1
View File
@@ -90,7 +90,7 @@ export function abutWallSectionSvg(d: AbutWallDrawing): string {
}
/**
* 정면·배면도: 교좌면 계단(구간별 교좌면 EL), 흉벽 상단 경사, 받침 블럭, 수축줄눈, 저판. 왼쪽 IP3(① 쪽), 오른쪽 IP1.
* 정면·배면도: 교좌면 계단(구간별 교좌면 EL), 흉벽 상단 경사, 받침 블럭, 수축줄눈, 저판, 받침 ① 기준선과 벽체 높이 H_w. 왼쪽 IP3(① 쪽), 오른쪽 IP1.
*/
export function abutWallElevationSvg(d: AbutWallDrawing): string {
const k = 46;
@@ -118,6 +118,16 @@ export function abutWallElevationSvg(d: AbutWallDrawing): string {
parts.push(`<line x1="${X(s).toFixed(1)}" y1="${Y(parapetTopAt(d, s)).toFixed(1)}" x2="${X(s).toFixed(1)}" y2="${Y(d.footingTopEl).toFixed(1)}" stroke="${INK}" stroke-dasharray="5 3"/>`);
parts.push(label(X(s) + 4, Y(d.footingTopEl) - 8, `수축줄눈 ${j}`, 'start'));
}
// 벽체 높이 H_w (사용자 도면 2026-11-04): 받침 ① 연직 기준선, 받침 ① 교좌면 ~ 흉벽 상단.
const b1 = d.bearings.find((b) => b.no === 1) ?? d.bearings[0];
if (b1) {
const topB1 = parapetTopAt(d, b1.s);
const xb = X(b1.s);
parts.push(`<line x1="${xb.toFixed(1)}" y1="${(Y(topB1) - 14).toFixed(1)}" x2="${xb.toFixed(1)}" y2="${(Y(d.footingTopEl) + 6).toFixed(1)}" stroke="#c0392b" stroke-dasharray="10 3 2 3"/>`);
const xd = xb - 22;
parts.push(`<line x1="${xd.toFixed(1)}" y1="${Y(topB1).toFixed(1)}" x2="${xd.toFixed(1)}" y2="${Y(b1.seatEl).toFixed(1)}" stroke="#c0392b" marker-start="url(#hw-a)" marker-end="url(#hw-a)"/>`);
parts.push(`<text x="${(xd - 4).toFixed(1)}" y="${((Y(topB1) + Y(b1.seatEl)) / 2).toFixed(1)}" font-size="11" fill="#c0392b" text-anchor="end">H_w ${Math.round((topB1 - b1.seatEl) * 1000)}</text>`);
}
const first = d.segments.find((g) => d.lengthM >= g.sFrom && d.lengthM <= g.sTo) ?? d.segments[d.segments.length - 1];
const last = d.segments.find((g) => g.sFrom <= 0 && g.sTo >= 0) ?? d.segments[0];
parts.push(label(X(d.lengthM), Y(d.parapetTopEndEl) - 6, el(d.parapetTopEndEl), 'start'));
@@ -130,6 +140,7 @@ export function abutWallElevationSvg(d: AbutWallDrawing): string {
parts.push(label((X(0) + X(d.lengthM)) / 2, H - 8, `${d.lengthM.toFixed(3)} m`));
return [
`<svg viewBox="0 0 ${W.toFixed(0)} ${H.toFixed(0)}" width="100%" role="img" aria-label="정면·배면도: 흉벽 상단 ${t3(d.parapetTopEndEl)} (IP3) → ${t3(d.parapetTopStartEl)} (IP1), 교좌면 계단 ${d.segments.length}구간, 수축줄눈 ${d.shrinkJointCount}">`,
'<defs><marker id="hw-a" viewBox="0 0 10 10" refX="5" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" fill="#c0392b"/></marker></defs>',
...parts,
'</svg>',
].join('');
+3 -3
View File
@@ -32,9 +32,9 @@ describe('교대 A1 벽체 · 흉벽 · 저판 · 말뚝 (AA3, spec_abut §10.1)
for (let i = 1; i < w.length; i++) expect(w[i].sFrom).toBeCloseTo(w[i - 1].sTo, 9);
});
it('흉벽 상단 EL 47.180 at IP1 end and 46.835 at IP3 end; slope −2.829 %', () => {
expect(m.parapetTopStartEl).toBeCloseTo(47.18, 2);
expect(m.parapetTopEndEl).toBeCloseTo(46.835, 2);
it('흉벽 상단 EL 48.006 at IP1 end and 47.661 at IP3 end; slope −2.829 %', () => {
expect(m.parapetTopStartEl).toBeCloseTo(48.006, 2);
expect(m.parapetTopEndEl).toBeCloseTo(47.661, 2);
expect(m.parapetSlope * 100).toBeCloseTo(-2.829, 2);
});
+18 -5
View File
@@ -14,11 +14,12 @@ import { resolveSlab } from '../src/core/abut/slab';
import { abutPlanOutline, abutSkewK } from '../src/view/abut-scene';
import { DEFAULT_MATERIAL } from '../src/core/rebar/common';
// 예제 A1 (교대흉벽철근.md §11). 기획서 기대값은 fck 27 · fy 400, 구간 1 끝 (IP3 끝) 단면.
// 예제 A1 (교대흉벽철근.md §11). 기획서 기대값은 fck 27 · fy 400, 벽체 높이 2500 (받침 ① 기준) 단면.
// 기본 벽체 높이는 3326 (사용자 도면 2026-11-04)이라, 기획서 재현은 2500을 따로 넣는다.
const RAG = fileURLToPath(new URL('../../../RAG/운곡교P1입력.xlsx', import.meta.url));
const rec = parseAbutments(XLSX.read(readFileSync(RAG), { type: 'buffer' })).abutments[0];
const model = buildAbut(toAbutInput(rec));
const members = buildMembers(model, { bottomEl: rec.foundationBottomEl, pileLengthMm: rec.pileLengthMm });
const members = buildMembers(model, { bottomEl: rec.foundationBottomEl, pileLengthMm: rec.pileLengthMm }, { parapetHeightMm: 2500 });
const wings = buildWings(model, members);
const angle = skewAngle(rec.skewDeg);
const footing = abutFootingRebar(abutFootingGeom(model.length, members.shape, angle));
@@ -30,7 +31,7 @@ const xP10 = (wGeom.xb - 500) / 2;
const slab = resolveSlab();
const span = slabSpanAt(abutPlanOutline(model, members, wings, slab).slab, xP10 / 1000, k)!;
const chamfer = (sEnd: number) => (wings.find((w) => w.sEnd === sEnd)?.haunchM ?? 0) * 1000;
const geom: AbutParapetGeom = abutParapetGeom(wGeom, members.shape, {
const geom: AbutParapetGeom = abutParapetGeom(wGeom, members.shape, model.bearings[0].s, {
B: model.wing!.slabWidthMm,
a: span.a,
b: span.b,
@@ -42,11 +43,23 @@ const A1 = abutParapetRebar(geom, wall, undefined, fck27, 'A1');
const bar = (r: typeof A1, mark: string) => r.bars.find((b) => b.mark === mark)!;
describe('교대 흉벽 · 브라켓 배근 A1 (AD5, 교대흉벽철근.md §11)', () => {
it('geometry: 흉벽 높이 입력 2500, 브라켓 850, 흉벽 배면 −500, 슬래브 폭 11253 (반올림 11254). 상단선은 받침 ① · 마지막 받침이 정해 IP1 2529 · IP3 2468', () => {
it('geometry: 벽체 높이 2500 (기획서), 브라켓 850, 흉벽 배면 −500, 슬래브 폭 11253 (반올림 11254). 대표 단면 = 받침 ① 위치 (IP1에서 11.057 m)', () => {
expect([geom.Hp, geom.bracketH, geom.parapetBack]).toEqual([2500, 850, -500]);
expect(Math.abs(geom.slab!.B - 11253)).toBeLessThanOrEqual(1);
expect(A1.uRef).toBeCloseTo(model.bearings[0].s * 1000, 3);
const hAt = (u: number) => geom.top[0] + ((geom.top[1] - geom.top[0]) * u) / wGeom.L - (wGeom.HF + wGeom.segments.find((s) => u < s.to)!.H);
expect(hAt(A1.uRef)).toBeCloseTo(2500, 6); // 대표 단면 = 흉벽 높이 2500인 곳
expect(hAt(A1.uRef)).toBeCloseTo(2500, 3); // 받침 ① 위치에서 교좌면 ~ 흉벽 상단 = 벽체 높이
});
it('기본 벽체 높이 3326 (받침 ① 교좌면 기준 연직): 흉벽 상단 받침 ① 47.693, P1 735 + 3326 − 100 − 15.9 = 3945', () => {
const m = buildMembers(model, { bottomEl: rec.foundationBottomEl, pileLengthMm: rec.pileLengthMm });
expect(m.shape.parapetHeightM).toBe(3.326);
const wg = abutWallGeom(model.length, m, angle);
const g = abutParapetGeom(wg, m.shape, model.bearings[0].s, geom.slab);
const r = abutParapetRebar(g, abutWallRebar(wg, footing, undefined, fck27), undefined, fck27);
const topAtB1 = m.parapetTopStartEl + ((m.parapetTopEndEl - m.parapetTopStartEl) * model.bearings[0].s) / model.length;
expect(topAtB1).toBeCloseTo(44.367 + 3.326, 6);
expect(bar(r, 'P1').length).toBe(3945);
});
it('위치: P4 −100, P1 −115.9, P2 뒤 다리 −400, P5 −384.1 / −684.1, P3 −700, P10 −650', () => {
+1 -1
View File
@@ -104,7 +104,7 @@ describe('교대 흉벽 · 브라켓 3D 철근 (③, 2026-11-04)', () => {
const footing = abutFootingRebar(geom);
const wGeom = abutWallGeom(model.length, members, skewAngle(rec.skewDeg));
const wall = abutWallRebar(wGeom, footing);
const pGeom = abutParapetGeom(wGeom, members.shape, { B: 11254, a: 300, b: 11554, nearT: -505, chamferA: 300, chamferB: 300 });
const pGeom = abutParapetGeom(wGeom, members.shape, model.bearings[0].s, { B: 11254, a: 300, b: 11554, nearT: -505, chamferA: 300, chamferB: 300 });
const para = abutParapetRebar(pGeom, wall);
const scene = buildAbutRebar3D(geom, footing, k, back, bottom, wall, para);
+5 -4
View File
@@ -31,11 +31,12 @@ describe('교대 A1 3D 장면 (AA3·AA3b·AA3c)', () => {
expect(count('haunches')).toBe(6); // per wing: upper and lower 연직 헌치, and the 저판 상면 헌치
});
it('spans from the pile tip (19.295 m) up to the highest parapet end (47.18 m)', () => {
it('spans from the pile tip (19.295 m) up to the highest parapet end (48.006 m)', () => {
const box = new THREE.Box3().setFromObject(group);
expect(box.min.y).toBeCloseTo(19.295, 2);
expect(box.max.y).toBeGreaterThan(47.0);
expect(box.max.y).toBeLessThan(48.0);
// 벽체 높이 H_w 3326 (받침 ① 기준, 2026-11-04): 이전 2500 기준 상한 47.0 ~ 48.0에서 + 0.826.
expect(box.max.y).toBeGreaterThan(47.826);
expect(box.max.y).toBeLessThan(48.826);
});
it('spans the axis from IP1 (s = 0) to IP3 (s = L) along x', () => {
@@ -66,7 +67,7 @@ describe('교대 배치 (교각 장면과 같은 좌표계)', () => {
expect(v.z).toBeCloseTo(-(model.ip1.n - pivot.n), 4);
});
it('a placed group keeps the local parts: the parapet end at IP3 stays at 46.835 m', () => {
it('a placed group keeps the local parts: the parapet end at IP3 stays at 47.661 m', () => {
const placed = buildAbutGroup(model, members, wings, pivot);
const box = new THREE.Box3().setFromObject(placed);
expect(box.max.y).toBeGreaterThan(47.0);
+3 -3
View File
@@ -10,11 +10,11 @@ const rec = parseAbutments(XLSX.read(readFileSync(RAG), { type: 'buffer' })).abu
const rows = new Map(abutSummaryRows(rec));
describe('교대 상세 (삽도 항목, spec_abut §10.1)', () => {
it('the 삽도 values of A1: 저판 상면 41.995, 벽체 2.372 m, 브라켓 상면 46.017, 흉벽 상단 47.180 (IP1 끝) / 46.835 (IP3 끝)', () => {
it('the 삽도 values of A1: 저판 상면 41.995, 벽체 2.372 m, 브라켓 상면 46.843, 흉벽 상단 48.006 (IP1 끝) / 47.661 (IP3 끝)', () => {
expect(rows.get('저판 상면 EL')).toBe('41.995 m');
expect(rows.get('벽체 높이 (구간 ①)')).toBe('2.372 m');
expect(rows.get('브라켓 상면 EL (구간 ①)')).toBe('46.017 m');
expect(rows.get('흉벽 상단 EL (IP1 끝 / IP3 끝)')).toBe('47.180 / 46.835 m');
expect(rows.get('브라켓 상면 EL (구간 ①)')).toBe('46.843 m');
expect(rows.get('흉벽 상단 EL (IP1 끝 / IP3 끝)')).toBe('48.006 / 47.661 m');
});
it('the piles, joints, 접속슬래브 width and 받침블럭 height match the spec: 18 piles, tip 19.295, 1 joint, 11253 mm, 125 mm', () => {
+4 -3
View File
@@ -38,13 +38,14 @@ describe('교대 벽체·흉벽 삽도 (시안 ② 벽체·흉벽 카드)', () =
for (const s of ['300 + 500 + 1600 = 2400', '교좌면 EL.44.367', '벽체 2.372', '상면 EL.41.995', '기초 하단 EL.40.895', '교대축', '← 배면', '전면 →']) {
expect(svg).toContain(s);
}
expect(svg).toMatch(/흉벽 상단 EL\.4[67]\.\d{3}/);
expect(svg).toMatch(/흉벽 상단 EL\.4[78]\.\d{3}/);
expect(svg).toMatch(/브라켓 상면 EL\.4[56]\.\d{3}/);
});
it('정면·배면도: five steps, five bearings, the sloped 흉벽 top (IP3 46.835 → IP1 47.180) and the 수축줄눈', () => {
it('정면·배면도: five steps, five bearings, the sloped 흉벽 top (IP3 47.661 → IP1 48.006) and the 수축줄눈', () => {
const svg = abutWallElevationSvg(d);
for (const s of ['IP3', 'IP1', `${model.length.toFixed(3)} m`, '수축줄눈 1', 'EL.46.835', 'EL.47.180', '①', '⑤']) expect(svg).toContain(s);
for (const s of ['IP3', 'IP1', `${model.length.toFixed(3)} m`, '수축줄눈 1', 'EL.47.661', 'EL.48.006', '①', '⑤']) expect(svg).toContain(s);
expect(svg).toContain('H_w 3326'); // 받침 ① 기준선의 벽체 높이 (사용자 도면 2026-11-04)
expect(svg).toContain('교좌면 계단 5구간');
});
+15 -15
View File
@@ -39,20 +39,20 @@ describe('교대 A2 합성 · 날개벽 (AA3b, spec_abut §10.2)', () => {
const members = buildMembers(model, { bottomEl: 40.895, pileLengthMm: 21600 });
const [left, right] = buildWings(model, members);
it('좌 날개벽 (① 쪽, IP1 끝): top 46.838 at the start, 46.714 at the end, bottom end 45.214', () => {
it('좌 날개벽 (① 쪽, IP1 끝): top 47.664 at the start, 47.540 at the end, bottom end 46.040', () => {
expect(left.side).toBe('left');
expect(left.sEnd).toBeCloseTo(0, 9);
expect(left.topStartEl).toBeCloseTo(46.838, 2);
expect(left.topEndEl).toBeCloseTo(46.714, 2);
expect(left.bottomEndEl).toBeCloseTo(45.214, 2);
expect(left.topStartEl).toBeCloseTo(47.664, 2);
expect(left.topEndEl).toBeCloseTo(47.540, 2);
expect(left.bottomEndEl).toBeCloseTo(46.040, 2);
});
it('우 날개벽 (⑤ 쪽, IP3 끝): top 47.183 → 47.059, bottom end 45.559', () => {
it('우 날개벽 (⑤ 쪽, IP3 끝): top 48.009 → 47.885, bottom end 46.385', () => {
expect(right.side).toBe('right');
expect(right.sEnd).toBeCloseTo(model.length, 9);
expect(right.topStartEl).toBeCloseTo(47.183, 2);
expect(right.topEndEl).toBeCloseTo(47.059, 2);
expect(right.bottomEndEl).toBeCloseTo(45.559, 2);
expect(right.topStartEl).toBeCloseTo(48.009, 2);
expect(right.topEndEl).toBeCloseTo(47.885, 2);
expect(right.bottomEndEl).toBeCloseTo(46.385, 2);
});
it('the wing is 6.5 m long along the wing line and 0.45 m thick inward', () => {
@@ -90,7 +90,7 @@ describe('교대 A1 날개벽 (운곡교P1입력.xlsx)', () => {
expect(wings.map((w) => w.side)).toEqual(['left', 'right']);
const left = wings[0];
expect(left.sEnd).toBeCloseTo(model.length, 9);
expect(left.topStartEl).toBeCloseTo(46.835, 2);
expect(left.topStartEl).toBeCloseTo(47.661, 2);
// A1 날개벽 경사는 +1.91 % (벽체에서 멀어질수록 높아짐): 끝이 0.124 m 높다.
expect(left.topEndEl - left.topStartEl).toBeCloseTo(0.124, 2);
});
@@ -109,12 +109,12 @@ describe('교대 날개벽 하단 선 · 헌치 (AA3c, spec_abut §4.7)', () =>
expect(a2Left.heelDistanceM).toBeCloseTo(1.807, 2);
});
it('하단선: 뒷굽 끝까지 저판 상면 41.995, 그 밖은 단부 하단까지 직선 (A2 좌: 45.214)', () => {
it('하단선: 뒷굽 끝까지 저판 상면 41.995, 그 밖은 단부 하단까지 직선 (A2 좌: 46.040)', () => {
const line = a2Left.bottomLine;
expect(line[0].el).toBeCloseTo(41.995, 3);
expect(line[1].dM).toBeCloseTo(1.807, 2);
expect(line[1].el).toBeCloseTo(41.995, 3);
expect(line[2].el).toBeCloseTo(45.214, 2);
expect(line[2].el).toBeCloseTo(46.040, 2);
});
it('two vertical 300 × 300 haunches at the 내측면 corners: upper at the 흉벽 배면, lower at the 벽체 배면', () => {
@@ -136,11 +136,11 @@ describe('교대 날개벽 하단 선 · 헌치 (AA3c, spec_abut §4.7)', () =>
it('upper haunch runs from the bracket top (흉벽 상단 − 0.85) to the wing top', () => {
const [up, low] = a2Left.haunches;
// The upper haunch rests on the highest point of the 브라켓 top over its footprint (the top falls along s), never below it.
expect(up.zBottomEl).toBeGreaterThanOrEqual(46.838 - 0.85 - 1e-6);
expect(up.zBottomEl).toBeLessThan(46.838 - 0.85 + 0.05);
expect(up.zBottomEl).toBeGreaterThanOrEqual(47.664 - 0.85 - 1e-6);
expect(up.zBottomEl).toBeLessThan(47.664 - 0.85 + 0.05);
expect(up.zTopEl).toBeGreaterThan(up.zBottomEl);
expect(low.zBottomEl).toBeCloseTo(41.995, 3);
expect(low.zTopEl).toBeCloseTo(46.838 - 0.85, 2);
expect(low.zTopEl).toBeCloseTo(47.664 - 0.85, 2);
});
});
@@ -152,7 +152,7 @@ describe('날개벽 수평 단 (QA-12 초안 적용, spec_abut §4.7)', () => {
it('등단면 down to 1.5 m below the wing end, then 변화단면1 to the seat EL and 변화단면2 to the footing top', () => {
const [equal, v1, v2] = a2Left.bands;
expect(equal.name).toBe('등단면');
expect(equal.zBottomEl).toBeCloseTo(45.214, 2);
expect(equal.zBottomEl).toBeCloseTo(46.040, 2);
expect(v1.name).toBe('변화단면1');
expect(v1.zBottomEl).toBeCloseTo(44.367, 3);
expect(v2.name).toBe('변화단면2');
+29
View File
@@ -0,0 +1,29 @@
// 교대 A1 받침 ① 위치의 높이 후보들 (흉벽 상단 · 교좌면 · 받침 z · 저판 상면 · 기초 하단)을 JSON으로 저장한다.
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import * as XLSX from 'xlsx';
import { it } from 'vitest';
import { buildAbut } from '../../src/core/abut/geometry';
import { parseAbutments, toAbutInput } from '../../src/core/abut/parse';
import { buildMembers } from '../../src/core/abut/members';
it('dump 받침 ① 높이 후보', () => {
const out = process.env.DUMP_OUT ?? fileURLToPath(new URL('./out', import.meta.url));
mkdirSync(out, { recursive: true });
const xlsx = fileURLToPath(new URL('../../../../RAG/운곡교P1입력.xlsx', import.meta.url));
const rec = parseAbutments(XLSX.read(readFileSync(xlsx), { type: 'buffer' })).abutments[0];
const model = buildAbut(toAbutInput(rec));
const m = buildMembers(model, { bottomEl: rec.foundationBottomEl, pileLengthMm: rec.pileLengthMm });
const b1 = model.bearings[0];
const L = model.length;
const topAt = (s: number) => m.parapetTopStartEl + ((m.parapetTopEndEl - m.parapetTopStartEl) * s) / L;
const r = {
L, bearings: model.bearings.map((b) => ({ no: b.no, s: b.s, z: b.z, seatEl: b.seatEl, block: b.blockHeightMm })),
b1s: b1.s, top_b1: topAt(b1.s), seat_b1: b1.seatEl, z_b1: b1.z,
footingTop: m.footingTopEl, footingBottom: m.footingBottomEl,
parapetHeight: m.shape.parapetHeightM, bracketH: m.shape.bracketHeightM,
top_minus_seat: topAt(b1.s) - b1.seatEl, top_minus_z: topAt(b1.s) - b1.z, top_minus_footingTop: topAt(b1.s) - m.footingTopEl,
seat_minus_footingTop: b1.seatEl - m.footingTopEl, top_minus_footingBottom: topAt(b1.s) - (m.footingBottomEl ?? NaN),
};
writeFileSync(out + '/bearing1.json', JSON.stringify(r, null, 1));
});
+1 -1
View File
@@ -31,7 +31,7 @@ it('dump 교대 A1 흉벽 삽도', () => {
const tM = ((wg.xb + members.shape.parapetBackM * 1000) / 2) / 1000;
const span = slabSpanAt(abutPlanOutline(model, members, wings, slab).slab, tM, abutSkewK(model))!;
const ch = (sEnd: number) => (wings.find((w) => w.sEnd === sEnd)?.haunchM ?? 0) * 1000;
const g = abutParapetGeom(wg, members.shape, { B: model.wing!.slabWidthMm, a: span.a, b: span.b, nearT: members.shape.parapetBackM * 1000 - slab.parapetGapMm, chamferA: ch(0), chamferB: ch(model.length) });
const g = abutParapetGeom(wg, members.shape, model.bearings[0].s, { B: model.wing!.slabWidthMm, a: span.a, b: span.b, nearT: members.shape.parapetBackM * 1000 - slab.parapetGapMm, chamferA: ch(0), chamferB: ch(model.length) });
const r = abutParapetRebar(g, wall);
const ns = (svg: string) => svg.replace('<svg ', '<svg xmlns="http://www.w3.org/2000/svg" ');
writeFileSync(`${out}/parapet-section.svg`, ns(abutParapetSectionSvg(g, r)));