diff --git a/src/core/abut/edit.ts b/src/core/abut/edit.ts index a4e77c8..f651ed8 100644 --- a/src/core/abut/edit.ts +++ b/src/core/abut/edit.ts @@ -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' }, diff --git a/src/core/abut/members.ts b/src/core/abut/members.ts index 3e1542e..11d1bb5 100644 --- a/src/core/abut/members.ts +++ b/src/core/abut/members.ts @@ -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 이후.) diff --git a/src/core/abut/rebar/parapet.ts b/src/core/abut/rebar/parapet.ts index 65acced..920afac 100644 --- a/src/core/abut/rebar/parapet.ts +++ b/src/core/abut/rebar/parapet.ts @@ -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> = { 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; diff --git a/src/core/abut/shape.ts b/src/core/abut/shape.ts index 4d9db45..7a21476 100644 --- a/src/core/abut/shape.ts +++ b/src/core/abut/shape.ts @@ -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): 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보다 커야 합니다.'); diff --git a/src/core/abut/summary.ts b/src/core/abut/summary.ts index e211bf1..e034586 100644 --- a/src/core/abut/summary.ts +++ b/src/core/abut/summary.ts @@ -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)} %`], diff --git a/src/main.ts b/src/main.ts index 9d8cea8..b1ca450 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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: `━ 연직 (P1–P3 · P11) · ━ 수평 (P4 · P5) · ┅ 간격재 (P6 · P7) · ┅ 측면 (P8 · P9) · 다웰 P10`, baseTitle: '기본간격 · 흉벽 (부재별 입력) · 교대축 직각은 두지 않음', base: [ diff --git a/src/view/abut-parapet-rebar-svg.ts b/src/view/abut-parapet-rebar-svg.ts index af2690c..eeeb3e0 100644 --- a/src/view/abut-parapet-rebar-svg.ts +++ b/src/view/abut-parapet-rebar-svg.ts @@ -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((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); diff --git a/src/view/abut-wall-svg.ts b/src/view/abut-wall-svg.ts index d56117a..0184860 100644 --- a/src/view/abut-wall-svg.ts +++ b/src/view/abut-wall-svg.ts @@ -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(``); 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(``); + const xd = xb - 22; + parts.push(``); + parts.push(`H_w ${Math.round((topB1 - b1.seatEl) * 1000)}`); + } 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 [ ``, + '', ...parts, '', ].join(''); diff --git a/tests/abut-members.test.ts b/tests/abut-members.test.ts index e5d71fb..37db644 100644 --- a/tests/abut-members.test.ts +++ b/tests/abut-members.test.ts @@ -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); }); diff --git a/tests/abut-rebar-parapet.test.ts b/tests/abut-rebar-parapet.test.ts index 4bd1910..b964bab 100644 --- a/tests/abut-rebar-parapet.test.ts +++ b/tests/abut-rebar-parapet.test.ts @@ -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', () => { diff --git a/tests/abut-rebar3d.test.ts b/tests/abut-rebar3d.test.ts index 07d42e0..48deb3d 100644 --- a/tests/abut-rebar3d.test.ts +++ b/tests/abut-rebar3d.test.ts @@ -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); diff --git a/tests/abut-scene.test.ts b/tests/abut-scene.test.ts index 0924a4f..7cb7cbb 100644 --- a/tests/abut-scene.test.ts +++ b/tests/abut-scene.test.ts @@ -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); diff --git a/tests/abut-summary.test.ts b/tests/abut-summary.test.ts index 93914bb..74d374a 100644 --- a/tests/abut-summary.test.ts +++ b/tests/abut-summary.test.ts @@ -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', () => { diff --git a/tests/abut-wall-svg.test.ts b/tests/abut-wall-svg.test.ts index a5438ce..cf9330c 100644 --- a/tests/abut-wall-svg.test.ts +++ b/tests/abut-wall-svg.test.ts @@ -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구간'); }); diff --git a/tests/abut-wings.test.ts b/tests/abut-wings.test.ts index 9b269e2..848e317 100644 --- a/tests/abut-wings.test.ts +++ b/tests/abut-wings.test.ts @@ -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'); diff --git a/verify/dump/abut-bearing1-heights.dump.ts b/verify/dump/abut-bearing1-heights.dump.ts new file mode 100644 index 0000000..605ca4e --- /dev/null +++ b/verify/dump/abut-bearing1-heights.dump.ts @@ -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)); +}); diff --git a/verify/dump/abut-parapet-svg.dump.ts b/verify/dump/abut-parapet-svg.dump.ts index a096644..2457333 100644 --- a/verify/dump/abut-parapet-svg.dump.ts +++ b/verify/dump/abut-parapet-svg.dump.ts @@ -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('