feat(abut-rebar): 교대 날개벽 배근 삽도 (전면도 · 배면도 · 헌치 · 모서리 평면)

- 전면도 · 배면도: 날개벽 방향 거리 × 높이, 교대 단면 · 저판 · 뒷굽 끝, 연직 배치 치수, 단 수
- 헌치 · 모서리 평면: WR7-1 · 7-2 · 7-3 · WR10 띠별 한 단, WR9 점
- verify/dump: 날개벽 삽도 덤프

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
ypshimandClaude Haiku 5.5 committed 2026-11-04 18:00:41 +09:00
1 parent e6869d17be
commit d15b237100
3 files changed
+171

No files matched your search

+122
View File
@@ -0,0 +1,122 @@
/**
* 교대 날개벽 배근 삽도 (2026-11-04, 시안 ③ 날개벽 카드, 교대날개벽철근.md §10): 전면도 · 배면도 (날개벽 방향 거리 d × 높이),
* 헌치 · 모서리 평면. 순수 문자열 함수, 철근은 배근 규칙의 경로(wing.ts paths, 교대 s · t · z)에서 그린다.
* 색: 연직 #0F5C66, 수평 #B4531F (등단면 실선 · 변화단면1 파선 · 변화단면2 점선), 헌치 · 모서리 #C0501A, WR6 · WR20 #6B3FA0.
*/
import type { AbutWingGeom, AbutWingRebar, WingBarPath, WingMark } from '../core/abut/rebar/wing';
import { balloon, CONC, MUTED, NO_PICK, opacity, text, WALL, type AbutRebarPick } from './abut-footing-rebar-svg';
export const ABUT_WING_COLORS = { vertical: '#0F5C66', horizontal: '#B4531F', haunch: '#C0501A', other: '#6B3FA0' } as const;
const C = ABUT_WING_COLORS;
const COLOR = (m: WingMark): string =>
['WR1', 'WR2', 'WR11', 'WR12'].includes(m) ? C.vertical : ['WR3', 'WR4', 'WR5', 'WR13', 'WR14', 'WR15'].includes(m) ? C.horizontal : m === 'WR6' || m === 'WR20' ? C.other : C.haunch;
const DASH: Partial<Record<WingMark, string>> = { WR4: '6 3', WR14: '6 3', WR5: '2 2', WR15: '2 2', WR10: '6 3', WR20: '2 2' };
/** Wing frame of a plan point: d along the wing from the axis line, depth from the front face toward the back. */
function frame(g: AbutWingGeom) {
const [ws, wt] = g.w;
const iw = g.inward[0] * ws + g.inward[1] * wt;
const nv = [g.inward[0] - iw * ws, g.inward[1] - iw * wt];
const T = Math.hypot(nv[0], nv[1]);
const n = [nv[0] / T, nv[1] / T];
return {
T,
d: (s: number, t: number) => (s - g.p0[0]) * ws + (t - g.p0[1]) * wt,
depth: (s: number, t: number) => (s - g.p0[0]) * n[0] + (t - g.p0[1]) * n[1],
};
}
/** 전면도 (face 'front', 바깥에서 봄) 또는 배면도 ('back', 흙 쪽에서 봄): 가로 d (왼쪽 교대 → 오른쪽 끝), 세로 높이. */
export function abutWingFaceSvg(g: AbutWingGeom, r: AbutWingRebar, face: 'front' | 'back', pick: AbutRebarPick = NO_PICK): string {
const f = frame(g);
const k = 0.075;
// 왼쪽 끝 = 벽체 전면 (변화단면2 수평철근이 벽체를 지나 전면 − c까지 감).
const dMin = -(g.wallFrontT + 150) / Math.abs(g.w[1]);
const zTopMax = g.topStart + g.slope * g.L;
const X = (d: number) => 60 + (d - dMin) * k;
const Y = (z: number) => 50 + (zTopMax + 100 - z) * k;
const parts: string[] = [];
const general = g.form === 'general' && g.heelD < g.L;
const outline = [
[g.wallBackD, g.footTop], [general ? g.heelD : g.L, g.footTop], [g.L, general ? g.bottomEnd : g.footTop], [g.L, zTopMax], [g.wallBackD, g.topStart + g.slope * g.wallBackD],
];
// 교대 단면 (벽체 · 흉벽, 날개벽 시작 쪽) · 저판.
parts.push(`<rect x="${X(dMin)}" y="${Y(g.topStart)}" width="${(g.wallBackD - dMin) * k}" height="${(g.topStart - g.footTop) * k}" fill="${WALL}" stroke="${MUTED}" stroke-dasharray="4 3"/>`);
parts.push(`<rect x="${X(dMin)}" y="${Y(g.footTop)}" width="${(g.heelD - dMin) * k}" height="${(g.footTop - g.zB68 + 200) * k}" fill="${CONC}" stroke="${MUTED}"/>`);
parts.push(`<polygon points="${outline.map(([d, z]) => `${X(d).toFixed(1)},${Y(z).toFixed(1)}`).join(' ')}" fill="${CONC}" stroke="${MUTED}"/>`);
parts.push(text(X((dMin + g.wallBackD) / 2), Y(g.topStart) - 6, '교대 단면', 'middle', MUTED, 10));
parts.push(`<line x1="${X(g.heelD)}" y1="${Y(g.footTop) + 4}" x2="${X(g.heelD)}" y2="${Y(g.footTop) + 40}" stroke="${MUTED}" stroke-dasharray="3 3"/>`, text(X(g.heelD), Y(g.footTop) + 52, '뒷굽 끝', 'middle', MUTED, 10));
const onFace = (p: WingBarPath) => {
const dep = p.pts.map(([s, t]) => f.depth(s, t));
const avg = dep.reduce((a, b) => a + b, 0) / dep.length;
return face === 'front' ? avg < f.T / 2 : avg >= f.T / 2;
};
const show: WingMark[] = face === 'front' ? ['WR11', 'WR12', 'WR13', 'WR14', 'WR15', 'WR6'] : ['WR1', 'WR2', 'WR3', 'WR4', 'WR5', 'WR6', 'WR9'];
for (const p of r.paths) {
if (!show.includes(p.mark) || !onFace(p)) continue;
const pts = p.pts.map(([s, t, z]) => `${X(f.d(s, t)).toFixed(1)},${Y(z).toFixed(1)}`).join(' ');
const w = ['WR1', 'WR2', 'WR11', 'WR12'].includes(p.mark) ? 0.8 : 1;
parts.push(`<polyline points="${pts}" fill="none" stroke="${COLOR(p.mark)}" stroke-width="${w}"${DASH[p.mark] ? ` stroke-dasharray="${DASH[p.mark]}"` : ''} opacity="${opacity(pick, [p.mark])}"/>`);
}
const v = r.vertical[face];
parts.push(text(X(g.L / 2), Y(g.footTop) + 70, `${face === 'front' ? '전면' : '배면'} 연직 (끝 → 교대) ${v.text}`, 'middle', MUTED, 10));
parts.push(text(X(g.L) + 6, Y(zTopMax - 400), `${r.levels[face].end}단`, 'start', MUTED, 10), text(X(g.wallBackD) + 6, Y(g.topStart - 400), `${r.levels[face].start}단`, 'start', MUTED, 10));
const vm = face === 'front' ? ['WR12', 'WR11'] : ['WR2', 'WR1'];
const hm = face === 'front' ? ['WR13', 'WR14', 'WR15'] : ['WR3', 'WR4', 'WR5'];
const top = 22;
parts.push(balloon(X(g.heelD - 300), top, vm[0], C.vertical, pick), balloon(X(g.heelD + 1500), top, vm[1], C.vertical, pick));
parts.push(balloon(X(g.L) + 30, Y(zTopMax - 200), hm[0], C.horizontal, pick), balloon(X(g.L) + 30, Y(g.seat + 400), hm[1], C.horizontal, pick), balloon(X(g.L) + 30, Y(g.footTop + 600), hm[2], C.horizontal, pick));
if (g.form === 'general') parts.push(balloon(X(g.L - 800), Y(g.bottomEnd) + 40, 'WR6', C.other, pick));
if (face === 'back' && g.haunch > 0) parts.push(balloon(X(g.wallBackD) - 4, top, 'WR9', C.haunch, pick));
const W = X(g.L) + 70;
const H = Y(g.zB68) + 80;
return `<svg viewBox="0 0 ${W.toFixed(0)} ${H.toFixed(0)}" width="100%" style="max-width:${Math.round(W * 1.2)}px" role="img" aria-label="날개벽 ${face === 'front' ? '전면도' : '배면도'}">${parts.join('')}</svg>`;
}
/** 헌치 · 모서리 평면 (교대 s · t, 날개벽이 교대와 만나는 모서리 둘레): 헌치 WR7-1 · 7-2 · 7-3과 WR10의 띠별 한 단, WR9 점. */
export function abutWingCornerSvg(g: AbutWingGeom, r: AbutWingRebar, pick: AbutRebarPick = NO_PICK): string {
const f = frame(g);
// 보는 범위: 모서리 둘레 ± 3 m.
const cornerT = g.wallBackT;
const sC = g.p0[0] + g.inward[0];
const span = 3200;
const sMin = sC - span;
const sMax = sC + span;
const tMin = cornerT - span;
const tMax = Math.min(g.wallFrontT, cornerT + span);
const k = 0.12;
const flip = g.inward[0] < 0 ? -1 : 1; // 날개벽이 왼쪽에 오도록
const X = (s: number) => 40 + (flip > 0 ? s - sMin : sMax - s) * k;
const Y = (t: number) => 30 + (tMax - t) * k;
const parts: string[] = [];
// 교대 (벽체 · 흉벽 배면 위쪽이 교대) · 날개벽 띠.
parts.push(`<rect x="${Math.min(X(sMin), X(sMax))}" y="${Y(tMax)}" width="${(sMax - sMin) * k}" height="${(tMax - g.wallBackT) * k}" fill="${WALL}" stroke="${MUTED}"/>`);
parts.push(`<line x1="${Math.min(X(sMin), X(sMax))}" y1="${Y(g.parapetBackT)}" x2="${Math.max(X(sMin), X(sMax))}" y2="${Y(g.parapetBackT)}" stroke="${MUTED}" stroke-dasharray="4 3"/>`);
const P = (d: number, dep: number) => {
const [ws, wt] = g.w;
const iw = g.inward[0] * ws + g.inward[1] * wt;
const n = [(g.inward[0] - iw * ws) / f.T, (g.inward[1] - iw * wt) / f.T];
return [g.p0[0] + d * ws + dep * n[0], g.p0[1] + d * wt + dep * n[1]];
};
const dEnd = Math.min(g.L, (cornerT - tMin) / Math.abs(g.w[1]));
const wing = [P(0, 0), P(dEnd, 0), P(dEnd, f.T), P(0, f.T)];
parts.push(`<polygon points="${wing.map(([s, t]) => `${X(s).toFixed(1)},${Y(t).toFixed(1)}`).join(' ')}" fill="${CONC}" stroke="${MUTED}"/>`);
parts.push(text(X(P(dEnd * 0.7, f.T / 2)[0]), Y(P(dEnd * 0.7, f.T / 2)[1]), '날개벽', 'middle', MUTED, 10));
parts.push(text(Math.min(X(sMin), X(sMax)) + 6, Y(g.wallBackT) - 6, '벽체 배면 (흉벽 배면 점선)', 'start', MUTED, 10));
// 한 단씩: 각 띠의 첫 단, WR10 첫 단 · 흉벽 구간 첫 단.
const firstOf = (mark: WingMark) => r.paths.find((p) => p.mark === mark);
const wr10Up = r.paths.find((p) => p.mark === 'WR10' && p.pts[0][2] > g.bracketTop);
for (const p of [firstOf('WR7-1'), firstOf('WR7-2'), firstOf('WR7-3'), firstOf('WR10'), wr10Up, firstOf('WR3'), firstOf('WR13')]) {
if (!p) continue;
const pts = p.pts.map(([s, t]) => `${X(s).toFixed(1)},${Y(t).toFixed(1)}`).join(' ');
parts.push(`<polyline points="${pts}" fill="none" stroke="${COLOR(p.mark)}" stroke-width="1.6"${DASH[p.mark] ? ` stroke-dasharray="${DASH[p.mark]}"` : ''} opacity="${opacity(pick, [p.mark])}"/>`);
}
for (const p of r.paths.filter((q) => q.mark === 'WR9')) parts.push(`<circle cx="${X(p.pts[1][0]).toFixed(1)}" cy="${Y(p.pts[1][1]).toFixed(1)}" r="3" fill="${C.haunch}" opacity="${opacity(pick, ['WR9'])}"/>`);
const wr7 = firstOf('WR7-3');
const at = wr7 ? [X(wr7.pts[2][0]), Y(wr7.pts[2][1])] : [X(sC), Y(cornerT)];
parts.push(balloon(at[0] + 40, at[1] + 40, 'WR7', C.haunch, pick), balloon(at[0] + 80, at[1] + 40, 'WR10', C.haunch, pick), balloon(at[0] + 120, at[1] + 40, 'WR9', C.haunch, pick));
parts.push(text(40, Y(tMin) + 18, `헌치 ${Math.round(g.haunch)} · WR7 띠: 위 ${r.haunchLevels['WR7-1']} · 가운데 ${r.haunchLevels['WR7-2']} · 아래 ${r.haunchLevels['WR7-3']}단 · 날개벽 두께 ${Math.round(f.T)}, 수평철근 사이 WR20`, 'start', MUTED, 10));
const W = (sMax - sMin) * k + 80;
const H = Y(tMin) + 30;
return `<svg viewBox="0 0 ${W.toFixed(0)} ${H.toFixed(0)}" width="100%" style="max-width:${Math.round(W * 1.3)}px" role="img" aria-label="날개벽 헌치 · 모서리 평면">${parts.join('')}</svg>`;
}
+19
View File
@@ -108,3 +108,22 @@ describe('③ 날개벽 배근 입력 저장 (좌우 공통)', () => {
expect(() => checkStoredAbutments([{ ...rec, rebar: { wing: { c: 'x' } } as never }])).toThrow('날개벽 배근');
});
});
import { abutWingCornerSvg, abutWingFaceSvg } from '../src/view/abut-wing-rebar-svg';
describe('③ 날개벽 삽도', () => {
it('전면도 · 배면도: 연직 배치 치수, 단 수, 번호 풍선; 선택하면 나머지 흐리게 / 숨김', () => {
const f = abutWingFaceSvg(left, L1, 'front');
expect(f).toContain('100 · 193 · 18@250=4.500 · 4@250=1.000 · 707');
for (const m of ['WR11', 'WR12', 'WR13', 'WR14', 'WR15', 'WR6']) expect(f).toContain(`data-pick="${m}"`);
const b = abutWingFaceSvg(left, L1, 'back', { marks: new Set(['WR2']), only: true });
for (const m of ['WR1', 'WR2', 'WR3', 'WR4', 'WR5', 'WR9']) expect(b).toContain(`data-pick="${m}"`);
expect(b).toContain('opacity="0"');
});
it('헌치 · 모서리 평면: WR7 띠별 단 수, WR10 · WR9 풍선', () => {
const svg = abutWingCornerSvg(left, L1);
expect(svg).toContain('위 3 · 가운데 6 · 아래 10단');
for (const m of ['WR7', 'WR10', 'WR9']) expect(svg).toContain(`data-pick="${m}"`);
});
});
+30
View File
@@ -0,0 +1,30 @@
// 교대 A1 좌 날개벽 배근 삽도 3장을 SVG로 저장한다 (PNG 확인: python3 -I ../verify/svg_to_png.py <svg> <png>).
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';
import { buildWings } from '../../src/core/abut/wings';
import { abutFootingGeom, abutFootingRebar } from '../../src/core/abut/rebar/footing';
import { skewAngle } from '../../src/core/abut/rebar/skew-layout';
import { abutWingGeom, abutWingRebar } from '../../src/core/abut/rebar/wing';
import { abutWingCornerSvg, abutWingFaceSvg } from '../../src/view/abut-wing-rebar-svg';
it('dump 교대 A1 날개벽 삽도', () => {
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 members = buildMembers(model, { bottomEl: rec.foundationBottomEl, pileLengthMm: rec.pileLengthMm });
const footing = abutFootingRebar(abutFootingGeom(model.length, members.shape, skewAngle(rec.skewDeg)));
const wing = buildWings(model, members).find((w) => w.side === (process.env.SIDE ?? 'left'))!;
const g = abutWingGeom(model, members, wing, footing.z.B68);
const r = abutWingRebar(g);
const ns = (svg: string) => svg.replace('<svg ', '<svg xmlns="http://www.w3.org/2000/svg" ');
writeFileSync(`${out}/wing-front.svg`, ns(abutWingFaceSvg(g, r, 'front')));
writeFileSync(`${out}/wing-back.svg`, ns(abutWingFaceSvg(g, r, 'back')));
writeFileSync(`${out}/wing-corner.svg`, ns(abutWingCornerSvg(g, r)));
});