- 노출형 (삽도 기준, QA-8 가정): 상면 = 흉벽 상단, 끝은 브라켓 위(끝 두께 = 브라켓 높이), 헌치 길이 기본 2000 - 형식을 고르면 경사가 기본값으로 바뀜 (매립형 하향 4 %, 노출형 종단경사), 경사 타일로 변경 가능 - 길이 · 두께 · 흉벽과 간격 · 날개벽과 간격 입력이 3D, 폭, 날개벽 평면에 반영 - 슬래브 모따기 = 날개벽 헌치 (3D와 평면), 날개벽 없는 측은 skew 교좌면 끝선 - 3D 슬래브를 단면 기준 입체로 다시 만듦, 프로젝트 파일 저장 · 검증 Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
218 lines
12 KiB
TypeScript
218 lines
12 KiB
TypeScript
/**
|
||
* 교대 날개벽 삽도 (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('');
|
||
}
|