Files
Pier_v1/src/view/abut-wing-svg.ts
T
ypshimandClaude Haiku 5.5 4b8b80ae97 feat(abut): 접속슬래브 카드 — 매립형 / 노출형 형식, 경사, 치수 입력, 삽도 3개, 모따기 3D 반영
- 노출형 (삽도 기준, QA-8 가정): 상면 = 흉벽 상단, 끝은 브라켓 위(끝 두께 = 브라켓 높이), 헌치 길이 기본 2000
- 형식을 고르면 경사가 기본값으로 바뀜 (매립형 하향 4 %, 노출형 종단경사), 경사 타일로 변경 가능
- 길이 · 두께 · 흉벽과 간격 · 날개벽과 간격 입력이 3D, 폭, 날개벽 평면에 반영
- 슬래브 모따기 = 날개벽 헌치 (3D와 평면), 날개벽 없는 측은 skew 교좌면 끝선
- 3D 슬래브를 단면 기준 입체로 다시 만듦, 프로젝트 파일 저장 · 검증

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

218 lines
12 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';
const INK = '#18212b';
const MUTED = '#55606e';
const WING = '#d6bb8c';
const WALL = '#b7c7d3';
const SLAB = '#f3eee4';
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}/>`;
export interface AbutWingPlanDrawing {
lengthM: number;
outline: AbutPlanOutline;
/** 좌 = ① 쪽 끝 (sEnd), 우 = 반대 끝. 이름에 형식을 붙여 표시한다. */
leftLabel: string;
rightLabel: string;
leftSEnd: number;
slabWidthM: number;
}
/** 평면: 벽체(skew), 좌우 날개벽, 그 사이 접속슬래브. 왼쪽이 ① 쪽(좌), 위가 전면. */
export function abutWingPlanSvg(d: AbutWingPlanDrawing): string {
const pts = [...d.outline.wall, ...d.outline.slab, ...d.outline.wings.flatMap((w) => w.poly)];
// ① 쪽(좌)을 왼쪽에 둔다: 좌가 s = L이면 s를 뒤집는다.
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) - 0.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 H = Y(Math.min(...ts) - 0.4) + 30;
const map = (p: [number, number][]) => p.map(([s, t]) => [X(s), Y(t)] as [number, number]);
const parts = [
poly(map(d.outline.slab), `fill="${SLAB}" stroke="#b49a6b" stroke-dasharray="5 3"`),
poly(map(d.outline.wall), `fill="${WALL}" stroke="${MUTED}"`),
...d.outline.wings.map((w) => poly(map(w.poly), `fill="${WING}" stroke="#9c8763"`)),
];
const slabC = d.outline.slab.reduce((a, [s, t]) => [a[0] + s / d.outline.slab.length, a[1] + t / d.outline.slab.length], [0, 0]);
parts.push(text(X(slabC[0]), Y(slabC[1]), `접속슬래브 폭 ${d.slabWidthM.toFixed(3)} m`, 'middle'));
for (const w of d.outline.wings) {
const far = w.poly[1];
parts.push(text(X(far[0]), Y(far[1]) + 16, w.side === 'left' ? d.leftLabel : d.rightLabel, 'middle'));
}
parts.push(text(X(d.lengthM / 2), 12, '흉벽 · 벽체 (위 = 전면)', 'middle', MUTED));
return [
`<svg viewBox="0 0 ${W.toFixed(0)} ${H.toFixed(0)}" width="100%" role="img" aria-label="평면: ${d.leftLabel}, ${d.rightLabel}, 접속슬래브 폭 ${d.slabWidthM.toFixed(3)} m">`,
...parts,
'</svg>',
].join('');
}
export interface AbutWingSideDrawing {
name: string;
form: 'general' | 'short';
lengthM: number;
endHeightM: number;
heelDistanceM: number;
topLine: { dM: number; el: number }[];
bottomLine: { dM: number; el: number }[];
bands: { name: string; zTopEl: number; zBottomEl: number }[];
seatEl: number;
footingTopEl: number;
/** 흉벽 상단 EL at this wing's end, and the 브라켓 높이 (m): 브라켓 상면 = 흉벽 상단 − 브라켓 높이. */
parapetTopEl: number;
bracketHeightM: number;
/** 단면 위치 t (m, 전면 +): 벽체 배면 · 흉벽 배면 · 벽체 전면, 저판 앞굽 끝 · 뒷굽 끝, 저판 두께. */
wallBackM: number;
parapetBackM: number;
wallFrontM: number;
footingFrontM: number;
footingBackM: number;
footingThicknessM: number;
}
/** EL 표기 (2026-11-04): "EL.41.995". */
const el = (v: number) => `EL.${v.toFixed(3)}`;
/**
* 측면 (날개벽 축 방향으로 잘라 본 모습): 벽체 쪽이 오른쪽, 날개벽 끝이 왼쪽.
* 벽체 · 흉벽 · 브라켓 단차와 저판을 단면으로 함께 그린다. 가로 위치는 t를 날개벽 거리로 바꾼 값(dM = −t / k, k = 뒷굽 / 뒷굽 거리).
*/
/** 치수선 (2026-11-04): 점 a, b에서 치수선 위치 at(가로면 y, 세로면 x)까지 보조선, 치수선, 양 끝 사선 틱, 치수. after = 치수를 선의 아래 · 오른쪽에. */
export function dimLine(a: [number, number], b: [number, number], at: number, dir: 'h' | 'v', label: string, after = false): string {
const line = (x1: number, y1: number, x2: number, y2: number) =>
`<line x1="${x1.toFixed(1)}" y1="${y1.toFixed(1)}" x2="${x2.toFixed(1)}" y2="${y2.toFixed(1)}" stroke="${INK}" stroke-width="0.8"/>`;
const tick = (x: number, y: number) => line(x - 3, y + 3, x + 3, y - 3);
if (dir === 'h') {
const ext = (x: number, y: number) => line(x, y + Math.sign(at - y) * 3, x, at + Math.sign(at - y) * 3);
const lab = text((a[0] + b[0]) / 2, after ? at + 13 : at - 4, label, 'middle');
return [ext(a[0], a[1]), ext(b[0], b[1]), line(a[0], at, b[0], at), tick(a[0], at), tick(b[0], at), lab].join('');
}
const ext = (x: number, y: number) => line(x + Math.sign(at - x) * 3, y, at + Math.sign(at - x) * 3, y);
const lab = after ? text(at + 4, (a[1] + b[1]) / 2 + 4, label, 'start') : text(at - 4, (a[1] + b[1]) / 2 + 4, label, 'end');
return [ext(a[0], a[1]), ext(b[0], b[1]), line(at, a[1], at, b[1]), tick(at, a[1]), tick(at, b[1]), lab].join('');
}
export function abutWingSideSvg(d: AbutWingSideDrawing): string {
const k = 40;
const kt = Math.abs(d.footingBackM) / d.heelDistanceM; // |t| per m along the wing
const dmOf = (tM: number) => -tM / kt;
const bracketTop = d.parapetTopEl - d.bracketHeightM;
const footingBottom = d.footingTopEl - d.footingThicknessM;
const els = [...d.topLine.map((p) => p.el), ...d.bottomLine.map((p) => p.el), d.parapetTopEl, footingBottom];
const elMax = Math.max(...els) + 0.8;
const elMin = Math.min(...els) - 0.5;
const dmRight = dmOf(d.footingFrontM); // 저판 앞굽 끝 (rightmost)
const X = (dM: number) => 40 + (d.lengthM - dM) * k; // wall (dM 0) toward the right
const Y = (y: number) => 14 + (elMax - y) * k;
const W = X(dmRight) + 110;
const H = Y(elMin) + 34;
const rect = (dm0: number, dm1: number, e0: number, e1: number, style: string) =>
poly([[X(dm0), Y(e0)], [X(dm1), Y(e0)], [X(dm1), Y(e1)], [X(dm0), Y(e1)]], style);
const outline: [number, number][] = [
...d.topLine.map((p) => [X(p.dM), Y(p.el)] as [number, number]),
...[...d.bottomLine].reverse().map((p) => [X(p.dM), Y(p.el)] as [number, number]),
];
const wb = dmOf(d.wallBackM);
const pb = dmOf(d.parapetBackM);
const wf = dmOf(d.wallFrontM);
const parts = [
poly(outline, `fill="${WING}" stroke="#9c8763"`),
// 저판 (뒷굽 끝 ~ 앞굽 끝), 벽체 (기초 상면 ~ 교좌면), 브라켓 단차 (벽체 배면 ~ 흉벽 배면, 브라켓 상면까지), 흉벽 (흉벽 상단까지).
rect(dmOf(d.footingBackM), dmRight, footingBottom, d.footingTopEl, `fill="#c9d1da" stroke="${MUTED}"`),
rect(wb, wf, d.footingTopEl, d.seatEl, `fill="${WALL}" stroke="${MUTED}"`),
rect(wb, pb, d.seatEl, bracketTop, `fill="#9fb1c0" stroke="${MUTED}"`),
rect(pb, 0, d.seatEl, d.parapetTopEl, `fill="#9fb1c0" stroke="${MUTED}"`),
];
// 수평 단 경계 (등단면 / 변화단면) as dashed lines across the wing.
for (const b of d.bands.slice(0, -1)) {
parts.push(`<line x1="${X(d.lengthM).toFixed(1)}" y1="${Y(b.zBottomEl).toFixed(1)}" x2="${X(wb).toFixed(1)}" y2="${Y(b.zBottomEl).toFixed(1)}" stroke="${MUTED}" stroke-dasharray="4 3"/>`);
}
for (const b of d.bands) {
const mid = (b.zTopEl + b.zBottomEl) / 2;
parts.push(text(X(d.lengthM * 0.55), Y(mid) + 4, b.name, 'middle', MUTED));
}
// 치수선: 길이 (날개벽 끝 ~ 교좌면 끝), 끝단 높이 (일반형), 뒷굽 끝 (교좌면 끝 ~ 뒷굽 끝).
const endTop = d.topLine[d.topLine.length - 1].el;
const endBottom = d.bottomLine[d.bottomLine.length - 1].el;
parts.push(dimLine([X(d.lengthM), Y(endTop)], [X(0), Y(d.parapetTopEl)], Y(elMax) + 12, 'h', `${Math.round(d.lengthM * 1000)} (교좌면 끝에서, 날개벽 축 방향)`));
if (d.form === 'general') parts.push(dimLine([X(d.lengthM), Y(endTop)], [X(d.lengthM), Y(endBottom)], X(d.lengthM) - 12, 'v', `${Math.round(d.endHeightM * 1000)}`));
parts.push(text(X(0) + 4, Y(d.parapetTopEl) + 4, `흉벽 상단 ${el(d.parapetTopEl)}`, 'start'));
parts.push(text(X(pb) + 4, Y(bracketTop) - 4, `브라켓 상면 ${el(bracketTop)}`, 'start'));
parts.push(text(X(wf) + 4, Y(d.seatEl) + 4, `교좌면 ${el(d.seatEl)}`, 'start'));
parts.push(text(X(dmRight) + 4, Y(d.footingTopEl) + 4, el(d.footingTopEl), 'start'));
parts.push(text(X(dmRight) + 4, Y(footingBottom) + 4, `기초 하단 ${el(footingBottom)}`, 'start'));
parts.push(dimLine([X(0), Y(footingBottom)], [X(d.heelDistanceM), Y(footingBottom)], Y(footingBottom) + 18, 'h', ''));
parts.push(text((X(0) + X(d.heelDistanceM)) / 2, Y(footingBottom) + 31, `뒷굽 끝 ${d.heelDistanceM.toFixed(3)} m`, 'middle'));
return [
`<svg viewBox="0 0 ${W.toFixed(0)} ${H.toFixed(0)}" width="100%" role="img" aria-label="${d.name} 측면: 길이 ${Math.round(d.lengthM * 1000)}, 흉벽 상단 ${el(d.parapetTopEl)}, 브라켓 상면 ${el(bracketTop)}, 뒷굽 끝 ${d.heelDistanceM.toFixed(3)} m">`,
...parts,
'</svg>',
].join('');
}
export interface AbutWingCornerDrawing {
parapetMm: number;
bracketMm: number;
wingThicknessMm: number;
haunchMm: number;
/** 접속슬래브 간격: 날개벽과 20, 흉벽과 5. 모따기 300 × 300 (spec §4.8). */
slabGapWingMm: number;
slabGapParapetMm: number;
chamferMm: number;
}
/** 좌 날개벽–흉벽 모서리 평면 (모식도): 흉벽, 브라켓(슬래브 아래), 날개벽, 헌치, 접속슬래브(모따기, 간격). */
export function abutWingCornerSvg(d: AbutWingCornerDrawing): string {
const k = 0.14; // px per mm
const x0 = 70; // 날개벽 내측면
const y0 = 60; // 흉벽 배면
const X = (mm: number) => x0 + mm * k;
const Y = (mm: number) => y0 + mm * k;
const span = 2200; // drawn extent from the corner (mm)
const h = d.haunchMm;
const c = d.chamferMm;
const sx = d.slabGapWingMm;
const sy = d.slabGapParapetMm;
const parts = [
// 흉벽 (above the 배면 line) and the 교대축 at its front face.
poly([[X(-d.wingThicknessMm), Y(-d.parapetMm)], [X(span), Y(-d.parapetMm)], [X(span), Y(0)], [X(-d.wingThicknessMm), Y(0)]], `fill="${WALL}" stroke="${MUTED}"`),
`<line x1="${X(-d.wingThicknessMm - 200).toFixed(1)}" y1="${Y(-d.parapetMm).toFixed(1)}" x2="${X(span).toFixed(1)}" y2="${Y(-d.parapetMm).toFixed(1)}" stroke="#c0392b" stroke-dasharray="8 3 2 3"/>`,
// 브라켓 (under the slab, dashed).
`<rect x="${X(0).toFixed(1)}" y="${Y(0).toFixed(1)}" width="${(span * k).toFixed(1)}" height="${(d.bracketMm * k).toFixed(1)}" fill="none" stroke="${MUTED}" stroke-dasharray="4 3"/>`,
// 날개벽 (left, running back = down).
poly([[X(-d.wingThicknessMm), Y(0)], [X(0), Y(0)], [X(0), Y(span)], [X(-d.wingThicknessMm), Y(span)]], `fill="${WING}" stroke="#9c8763"`),
];
if (h > 0) parts.push(poly([[X(0), Y(0)], [X(h), Y(0)], [X(0), Y(h)]], `fill="${WING}" stroke="#9c8763"`));
// 접속슬래브 with the corner cut (모따기) and the gaps.
parts.push(
poly(
[[X(sx + c), Y(sy)], [X(span), Y(sy)], [X(span), Y(span)], [X(sx), Y(span)], [X(sx), Y(sy + c)]],
`fill="${SLAB}" stroke="#b49a6b"`,
),
);
parts.push(text(X(-d.wingThicknessMm / 2), Y(span) - 6, `날개벽 ${d.wingThicknessMm}`, 'middle'));
parts.push(text(X(span / 2), Y(-d.parapetMm / 2) + 4, `흉벽 ${d.parapetMm}`, 'middle'));
parts.push(text(X(span) - 4, Y(-d.parapetMm) - 6, '교대축 (흉벽 전면)', 'end', '#9b2c1f'));
parts.push(text(X(span) - 4, Y(d.bracketMm) - 4, `브라켓 ${d.bracketMm} (슬래브 아래)`, 'end', MUTED));
if (h > 0) parts.push(text(X(h) + 6, Y(h / 2) + 4, `헌치 ${h} × ${h}`, 'start'));
parts.push(text(X(sx + c) + 8, Y(sy + c) + 14, `슬래브 모따기 ${c} × ${c}`, 'start'));
parts.push(text(X(span / 2), Y(span) - 6, `접속슬래브 · 날개벽과 ${sx} · 흉벽과 ${sy}`, 'middle'));
const W = X(span) + 20;
const H = Y(span) + 16;
return [
`<svg viewBox="0 0 ${W.toFixed(0)} ${H.toFixed(0)}" width="100%" role="img" aria-label="날개벽–흉벽 모서리: 헌치 ${h} × ${h}, 브라켓 ${d.bracketMm}, 슬래브 모따기 ${c} × ${c}, 간격 ${sx} / ${sy}">`,
...parts,
'</svg>',
].join('');
}