From d15b2371009f78cd53fdad2bb8d657ed49cb5e53 Mon Sep 17 00:00:00 2001 From: ypshim Date: Wed, 4 Nov 2026 18:00:41 +0900 Subject: [PATCH] =?UTF-8?q?feat(abut-rebar):=20=EA=B5=90=EB=8C=80=20?= =?UTF-8?q?=EB=82=A0=EA=B0=9C=EB=B2=BD=20=EB=B0=B0=EA=B7=BC=20=EC=82=BD?= =?UTF-8?q?=EB=8F=84=20(=EC=A0=84=EB=A9=B4=EB=8F=84=20=C2=B7=20=EB=B0=B0?= =?UTF-8?q?=EB=A9=B4=EB=8F=84=20=C2=B7=20=ED=97=8C=EC=B9=98=20=C2=B7=20?= =?UTF-8?q?=EB=AA=A8=EC=84=9C=EB=A6=AC=20=ED=8F=89=EB=A9=B4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 전면도 · 배면도: 날개벽 방향 거리 × 높이, 교대 단면 · 저판 · 뒷굽 끝, 연직 배치 치수, 단 수 - 헌치 · 모서리 평면: WR7-1 · 7-2 · 7-3 · WR10 띠별 한 단, WR9 점 - verify/dump: 날개벽 삽도 덤프 Co-Authored-By: Claude Haiku 5.5 --- src/view/abut-wing-rebar-svg.ts | 122 ++++++++++++++++++++++++++++++ tests/abut-rebar-wing.test.ts | 19 +++++ verify/dump/abut-wing-svg.dump.ts | 30 ++++++++ 3 files changed, 171 insertions(+) create mode 100644 src/view/abut-wing-rebar-svg.ts create mode 100644 verify/dump/abut-wing-svg.dump.ts diff --git a/src/view/abut-wing-rebar-svg.ts b/src/view/abut-wing-rebar-svg.ts new file mode 100644 index 0000000..ddd6b67 --- /dev/null +++ b/src/view/abut-wing-rebar-svg.ts @@ -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> = { 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(``); + parts.push(``); + parts.push(``); + parts.push(text(X((dMin + g.wallBackD) / 2), Y(g.topStart) - 6, '교대 단면', 'middle', MUTED, 10)); + parts.push(``, 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(``); + } + 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 `${parts.join('')}`; +} + +/** 헌치 · 모서리 평면 (교대 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(``); + parts.push(``); + 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(``); + 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(``); + } + for (const p of r.paths.filter((q) => q.mark === 'WR9')) parts.push(``); + 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 `${parts.join('')}`; +} diff --git a/tests/abut-rebar-wing.test.ts b/tests/abut-rebar-wing.test.ts index 87efcfa..3014091 100644 --- a/tests/abut-rebar-wing.test.ts +++ b/tests/abut-rebar-wing.test.ts @@ -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}"`); + }); +}); diff --git a/verify/dump/abut-wing-svg.dump.ts b/verify/dump/abut-wing-svg.dump.ts new file mode 100644 index 0000000..55eeca0 --- /dev/null +++ b/verify/dump/abut-wing-svg.dump.ts @@ -0,0 +1,30 @@ +// 교대 A1 좌 날개벽 배근 삽도 3장을 SVG로 저장한다 (PNG 확인: python3 -I ../verify/svg_to_png.py ). +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('