Files
Pier_v1/src/view/abut-slab-svg.ts
T
ypshimandClaude Haiku 5.5 9835fb42b3 feat(abut): 접속슬래브 경사는 형식이 정함, 단면 · 접합 상세를 파단선 단면 하나로 합침, 흉벽 높이 입력
- 경사 선택 삭제: 매립형 = 하향 4 %, 노출형 = 종단경사 따라 (노트 표기). 예전 파일의 slope 값은 읽고 무시
- 단면 + 접합 상세: 흉벽 쪽과 먼 끝만 그리고 중간 생략, 부속재 · 치수(7000 · 450 · 850 · 295 · 헌치) · EL을 한 그림에
- 흉벽 높이 (브라켓 상면 ~ 흉벽 상단) 입력 = 벽체·흉벽 카드 브라켓 높이와 같은 값
- 자동 값: 브라켓 얹힘 길이 = 브라켓 폭 − 흉벽과 간격 (295)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-04 10:27:20 +09:00

185 lines
11 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.
/**
* 교대 접속슬래브 삽도 (2026-11-04, 시안 ② 접속슬래브 카드): 평면(날개벽 사이)과 단면 + 접합 상세(매립형 / 노출형, 중간 생략).
* 순수 문자열 함수. 생성본 값으로 그리고 가로·세로 축척이 같다(단면은 파단선으로 중간을 생략). 접합 부속재는 표시용이다.
*/
import type { AbutPlanOutline } from './abut-scene';
import { slabLevelAt, type SlabForm, type SlabStation } from '../core/abut/slab';
import { dimLine } from './abut-wing-svg';
const INK = '#18212b';
const MUTED = '#55606e';
const WING = '#d6bb8c';
const WALL = '#b7c7d3';
const SLAB = '#f3eee4';
const SLAB_EDGE = '#b49a6b';
const text = (x: number, y: number, s: string, anchor = 'start', fill = INK) =>
`<text x="${x.toFixed(1)}" y="${y.toFixed(1)}" text-anchor="${anchor}" font-size="11" fill="${fill}" font-family="JetBrains Mono, monospace">${s}</text>`;
const poly = (pts: [number, number][], style: string) => `<polygon points="${pts.map(([x, y]) => `${x.toFixed(1)},${y.toFixed(1)}`).join(' ')}" ${style}/>`;
const line = (x1: number, y1: number, x2: number, y2: number, style = `stroke="${MUTED}"`) =>
`<line x1="${x1.toFixed(1)}" y1="${y1.toFixed(1)}" x2="${x2.toFixed(1)}" y2="${y2.toFixed(1)}" ${style}/>`;
const el = (v: number) => `EL.${v.toFixed(3)}`;
export interface AbutSlabPlanDrawing {
outline: AbutPlanOutline;
/** 좌 = ① 쪽 끝 (sEnd); 날개벽 평면과 같은 좌우. */
leftSEnd: number;
widthM: number;
lengthMm: number;
/** 측별 모따기 (mm, 0 = 없음). */
chamferMm: { left: number; right: number };
}
/** 평면: 벽체, 날개벽, 접속슬래브(모따기), 폭 · 길이 치수. 왼쪽이 ① 쪽(좌), 위가 전면. */
export function abutSlabPlanSvg(d: AbutSlabPlanDrawing): string {
const pts = [...d.outline.wall, ...d.outline.slab, ...d.outline.wings.flatMap((w) => w.poly)];
const flip = d.leftSEnd !== 0;
const xs = pts.map(([s]) => (flip ? -s : s));
const ts = pts.map(([, t]) => t);
const k = 30;
const xMin = Math.min(...xs) - 1.6;
const tMax = Math.max(...ts) + 0.4;
const X = (s: number) => 20 + ((flip ? -s : s) - xMin) * k;
const Y = (t: number) => 18 + (tMax - t) * k;
const W = 40 + (Math.max(...xs) - xMin + 0.6) * k;
const tMin = Math.min(...d.outline.slab.map(([, t]) => t));
const H = Y(tMin) + 46;
const map = (p: [number, number][]) => p.map(([s, t]) => [X(s), Y(t)] as [number, number]);
const slab = d.outline.slab;
const parts = [
poly(map(d.outline.wall), `fill="${WALL}" stroke="${MUTED}"`),
...d.outline.wings.map((w) => poly(map(w.poly), `fill="${WING}" stroke="#9c8763"`)),
poly(map(slab), `fill="${SLAB}" stroke="${SLAB_EDGE}" stroke-width="1.4"`),
];
// 폭: the far edge (the last two corners), below it. 길이: the left side, from the near edge to the far edge.
const farA = slab[slab.length - 1];
const farB = slab[slab.length - 2];
const [fl, fr] = X(farA[0]) < X(farB[0]) ? [farA, farB] : [farB, farA];
parts.push(dimLine([X(fl[0]), Y(fl[1])], [X(fr[0]), Y(fr[1])], Y(tMin) + 16, 'h', `폭 ${d.widthM.toFixed(3)} m`, true));
const tNear = Math.max(...slab.map(([, t]) => t));
const xLeft = Math.min(...slab.map(([s]) => X(s)));
const nearLeft = slab.filter(([, t]) => Math.abs(t - tNear) < 1e-9).map(([s]) => X(s)).sort((a, b) => a - b)[0];
parts.push(dimLine([nearLeft, Y(tNear)], [X(fl[0]), Y(fl[1])], xLeft - 12, 'v', `${d.lengthMm}`));
const c = slab.reduce((a, [s, t]) => [a[0] + s / slab.length, a[1] + t / slab.length], [0, 0]);
parts.push(text(X(c[0]), Y(c[1]), '접속슬래브', 'middle'));
for (const w of d.outline.wings) {
const mm = w.side === 'left' ? d.chamferMm.left : d.chamferMm.right;
if (!(mm > 0)) continue;
const x = X(w.sEnd) + (X(c[0]) > X(w.sEnd) ? 1 : -1) * 34;
parts.push(text(x, Y(tNear) + 26, `모따기 ${mm} × ${mm}`, X(c[0]) > X(w.sEnd) ? 'start' : 'end', MUTED));
}
parts.push(text((X(0) + X(Math.max(...d.outline.wall.map(([s]) => s)))) / 2, 12, '흉벽 · 벽체 (위 = 전면)', 'middle', MUTED));
return [
`<svg viewBox="0 0 ${W.toFixed(0)} ${H.toFixed(0)}" width="100%" role="img" aria-label="접속슬래브 평면: 폭 ${d.widthM.toFixed(3)} m, 길이 ${d.lengthMm}">`,
...parts,
'</svg>',
].join('');
}
export interface AbutSlabSectionDrawing {
form: SlabForm;
/** "하향 4 %" / "종단경사 +1.91 %". */
slopeLabel: string;
/** 단면 (t, 흉벽 상단에서 잰 상면 · 하면), core/abut/slab.ts slabSection. */
section: SlabStation[];
/** 이 단면 위치의 흉벽 상단 EL (m). */
parapetTopEl: number;
bracketHeightM: number;
parapetBackM: number;
wallBackM: number;
lengthMm: number;
thicknessMm: number;
parapetGapMm: number;
haunchLengthMm: number;
}
/**
* 단면 + 접합 상세 (2026-11-04, 그림 합침): 교대축 직각, 왼쪽이 배면. 흉벽 쪽(헌치 끝 너머까지)과 먼 끝 0.8 m만 같은 축척으로
* 그리고 그 사이는 파단선으로 생략한다. 간격(5)과 고무판 두께는 보이도록 키워 그리고, 부속재(충진재 · 스티로폼 · 고무판 ·
* 타르페이퍼 · 다웰 · 강관)는 표시용이다. 치수: 7000 (중간 생략), 450, 흉벽 높이(브라켓 상면 ~ 흉벽 상단), 헌치, 브라켓 얹힘 길이.
*/
export function abutSlabSectionSvg(d: AbutSlabSectionDrawing): string {
const k = 150; // px per m in both zones
const st = d.section;
const t0 = st[0].t;
const t1 = st[st.length - 1].t;
const bH = d.bracketHeightM;
const wb = d.wallBackM;
const pb = d.parapetBackM;
const he = wb - d.haunchLengthMm / 1000;
const farLen = 0.8;
const tz = Math.max(d.form === 'exposed' ? he - 0.5 : wb - 1.5, t1 + farLen); // left end of the near zone
const breakW = 34;
const xL = 70;
const xNear0 = xL + farLen * k + breakW;
const X = (t: number) => (t <= t1 + farLen + 1e-9 ? xL + (t - t1) * k : xNear0 + (t - tz) * k);
const gapPx = 4; // 간격 drawn 4 px wide whatever its size
const Xs = (t: number) => X(t) - (t >= t0 - 1e-9 ? gapPx - (d.parapetGapMm / 1000) * k : 0);
const low = -bH - 0.75;
const hi = Math.max(0, ...st.map((p) => p.top)) + 0.15;
const Y = (y: number) => 26 + (hi - y) * k;
const lv = (t: number) => slabLevelAt(st, t);
// Slab pieces: far (t1 … t1 + 0.8) and near (tz … t0), each closed by a zigzag at the cut.
const zig = (x: number, top: number, bottom: number, dir: 1 | -1): [number, number][] => {
const h = bottom - top;
return [[x, top - 4], [x, top + h * 0.3], [x + dir * 6, top + h * 0.45], [x - dir * 6, top + h * 0.6], [x, top + h * 0.72], [x, bottom + 4]];
};
const piece = (from: number, to: number, cutLeft: boolean) => {
const ts = [from, ...st.map((p) => p.t).filter((t) => t > from + 1e-9 && t < to - 1e-9), to].sort((a, b) => a - b);
const top = ts.map((t) => [Xs(t), Y(lv(t).top)] as [number, number]);
const bottom = ts.map((t) => [Xs(t), Y(lv(t).bottom)] as [number, number]).reverse();
const cutT = cutLeft ? from : to;
const cut = zig(Xs(cutT), Y(lv(cutT).top), Y(lv(cutT).bottom), cutLeft ? 1 : -1);
return cutLeft ? [...top, ...bottom, ...cut.reverse()] : [...top, ...cut, ...bottom];
};
const slabStyle = `fill="${SLAB}" stroke="${SLAB_EDGE}" stroke-width="1.4"`;
const parts = [
poly([[X(wb), Y(low)], [X(0), Y(low)], [X(0), Y(0)], [X(pb), Y(0)], [X(pb), Y(-bH)], [X(wb), Y(-bH)]], `fill="${WALL}" stroke="${MUTED}"`),
poly(piece(t1, t1 + farLen, false), slabStyle),
poly(piece(tz, t0, true), slabStyle),
];
// 간격: 아스팔트 충진재 (위 30 %), 스티로폼 (아래).
const gTop = Y(st[0].top);
const gBot = Y(-bH);
const gMid = gTop + (gBot - gTop) * 0.3;
const gx = Xs(t0);
parts.push(`<rect x="${gx.toFixed(1)}" y="${gTop.toFixed(1)}" width="${(X(pb) - gx).toFixed(1)}" height="${(gMid - gTop).toFixed(1)}" fill="#3b3f45"/>`);
parts.push(`<rect x="${gx.toFixed(1)}" y="${gMid.toFixed(1)}" width="${(X(pb) - gx).toFixed(1)}" height="${(gBot - gMid).toFixed(1)}" fill="#ffffff" stroke="${MUTED}" stroke-width="0.5"/>`);
// 타르페이퍼 (브라켓 상면), 고무판 150 (가운데), 다웰 D25 in 강관 Φ60 L300.
const xc = (X(wb) + Xs(t0)) / 2;
parts.push(line(X(wb), Y(-bH), X(pb), Y(-bH), 'stroke="#8a5a2b" stroke-width="2.4"'));
parts.push(`<rect x="${(xc - 0.075 * k).toFixed(1)}" y="${(Y(-bH) - 3).toFixed(1)}" width="${(0.15 * k).toFixed(1)}" height="3" fill="#2b2b2b"/>`);
parts.push(`<rect x="${(xc - 0.03 * k).toFixed(1)}" y="${(Y(-bH) - 3 - 0.3 * k).toFixed(1)}" width="${(0.06 * k).toFixed(1)}" height="${(0.3 * k).toFixed(1)}" fill="none" stroke="${INK}" stroke-width="0.9"/>`);
parts.push(line(xc, Y(-bH) - 0.34 * k, xc, Y(-bH) + 0.3 * k, `stroke="${INK}" stroke-width="1.8"`));
// 치수.
const yRow = Y(-bH) + 18;
if (d.form === 'exposed') parts.push(dimLine([X(he), Y(lv(he).bottom)], [X(wb), Y(-bH)], yRow, 'h', `헌치 ${d.haunchLengthMm}`, true));
// 브라켓 얹힘 길이 = 브라켓 폭 − 흉벽과 간격 (예: 300 − 5 = 295).
parts.push(dimLine([X(wb), Y(-bH)], [Xs(t0), Y(-bH)], yRow, 'h', `${Math.round((t0 - wb) * 1000)}`, true));
const yLen = Y(low) + 22;
parts.push(dimLine([X(t1), Y(lv(t1).bottom)], [Xs(t0), Y(st[0].bottom)], yLen, 'h', `${d.lengthMm} (중간 생략)`, true));
parts.push(dimLine([X(t1), Y(lv(t1).top)], [X(t1), Y(lv(t1).bottom)], X(t1) - 12, 'v', `${d.thicknessMm}`));
parts.push(dimLine([X(0), Y(0)], [X(0), Y(-bH)], X(0) + 16, 'v', `${Math.round(bH * 1000)}`, true));
// 글자: 경사, EL, 부속재 (오른쪽 · 왼쪽 아래로 끌어냄).
const xMidNear = (X(tz) + Xs(t0)) / 2;
parts.push(text(xMidNear, Y(lv(tz).top) - 12, `경사 ${d.slopeLabel}`, 'middle'));
const xr = X(0) + 58;
const lead = (x1: number, y1: number, x2: number, y2: number, s: string, anchor: 'start' | 'end') =>
line(x1, y1, x2, y2, `stroke="${MUTED}" stroke-width="0.7"`) + text(x2 + (anchor === 'start' ? 3 : -3), y2 + 4, s, anchor);
parts.push(text(xr, Y(0) + 4, `흉벽 상단 ${el(d.parapetTopEl)}`, 'start'));
parts.push(lead(gx + 2, gTop + 4, xr - 4, Y(0) + 26, `간격 ${d.parapetGapMm} · 아스팔트 충진재`, 'start'));
parts.push(lead(gx + 2, gMid + 8, xr - 4, Y(0) + 48, '스티로폼', 'start'));
parts.push(text(xr, Y(-bH) + 4, `브라켓 상면 ${el(d.parapetTopEl - bH)}`, 'start'));
const xl = X(wb) - 24;
parts.push(lead(xc - 6, Y(-bH) - 1, xl, Y(-bH) + 46, '고무판 150 × 150 × 15', 'end'));
parts.push(lead(X(wb) + 4, Y(-bH) + 1, xl, Y(-bH) + 64, '타르페이퍼 T = 15', 'end'));
parts.push(lead(xc, Y(-bH) + 0.25 * k, xl, Y(-bH) + 82, '다웰 D25 @400 · 강관 Φ60 L300', 'end'));
parts.push(text(X(0) + 6, Y(low) - 4, '흉벽 · 벽체', 'start', MUTED));
const W = xr + 200;
const H = yLen + 24;
return [
`<svg viewBox="0 0 ${W.toFixed(0)} ${H.toFixed(0)}" width="100%" role="img" aria-label="접속슬래브 단면 · 접합 상세 · ${d.form === 'exposed' ? '노출형' : '매립형'} · 경사 ${d.slopeLabel} · 길이 ${d.lengthMm} (중간 생략) · 두께 ${d.thicknessMm} · 흉벽 높이 ${Math.round(bH * 1000)}">`,
...parts,
'</svg>',
].join('');
}