feat(abut): ② 날개벽 카드를 시안대로 작성 (좌우 같게 · 측별 치수 · 삽도 3개)
- 선택 줄 (타일): 좌우 같게(기본) / 따로 정하기, 편집할 측 (좌 · ① 쪽 / 우 · ⑤ 쪽), 형식 일반형(기본) / 단축형 / 없음 - 측별 치수 입력 (WingDims, 교대 기록 wingDims): 길이 6500, 두께 450, 끝 높이 1500(일반형), 헌치 300. 단축형 길이 기본 = 뒷굽 끝(1.807 m). 좌우 같게이면 양쪽에 함께 적용, 켤 때 좌측 값을 우측에 복사 - 연동: 두께 → 날개벽 3D · 접속슬래브 폭(geometry slabWidthMm) · 슬래브 평면, 헌치 → 연직 헌치 · 저판 상면 헌치 (0이면 없음) - 자동 값: 경사 (A1 = −i), 등단면 · 변화단면 경계(뒷굽 끝), 끝 EL 상단 / 하단 (가정), 단축형 하단 = 확대기초 상면 (뒷굽 밖은 AI02 안내) - 삽도 3개 (src/view/abut-wing-svg.ts): 평면(좌우 날개벽 · 접속슬래브, abutPlanOutline 공유), 측면(수평 단 · 뒷굽 끝), 날개벽–흉벽 모서리 모식도(헌치 · 브라켓 · 슬래브 모따기 · 간격) - "생성되는 날개벽" 줄과 시안 메모. 프로젝트 파일 검사에 wingDims · wingsLinked 추가 - 테스트 7건 추가 · 갱신. 전체 669건 통과, 타입 검사 · 빌드 통과. 브라우저 시각 확인은 미실시 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a025a61209
commit
80e5302192
12 files changed
+610
-53
No files matched your search
+42
-23
@@ -8,7 +8,7 @@
|
||||
import * as THREE from 'three';
|
||||
import type { AbutModel, AbutPoint } from '../core/abut/geometry';
|
||||
import { buildMembers, type AbutMembers } from '../core/abut/members';
|
||||
import { buildWings, HAUNCH_LEG_M, WING_THICKNESS_M, type WingSolid } from '../core/abut/wings';
|
||||
import { buildWings, type WingSolid } from '../core/abut/wings';
|
||||
import type { TerrainGrid } from '../core/ground-model';
|
||||
import { terrainGeometry } from './terrain';
|
||||
|
||||
@@ -120,6 +120,39 @@ function wingDirectionAxis(model: AbutModel): { ws: number; wt: number } {
|
||||
return { ws: w.e * model.u.e + w.n * model.u.n, wt: w.e * front.e + w.n * front.n };
|
||||
}
|
||||
|
||||
/** Plan (s, t) outlines of the abutment: the 벽체 (skew), each wing, and the 접속슬래브 between the wings (spec §4.8). */
|
||||
export interface AbutPlanOutline {
|
||||
wall: [number, number][];
|
||||
wings: { side: 'left' | 'right'; sEnd: number; poly: [number, number][] }[];
|
||||
slab: [number, number][];
|
||||
}
|
||||
|
||||
export function abutPlanOutline(model: AbutModel, members: AbutMembers, wings: WingSolid[]): AbutPlanOutline {
|
||||
const L = model.length;
|
||||
const k = abutSkewK(model);
|
||||
const tb = members.shape.wallBackM;
|
||||
const tf = members.shape.wallFrontM;
|
||||
const near = members.shape.parapetBackM - 0.005;
|
||||
const far = near - SLAB_LENGTH_M;
|
||||
const innerS = (sEnd: number, t: number): number => {
|
||||
const w = wings.find((x) => x.sEnd === sEnd);
|
||||
if (!w) return sEnd; // no wing on this side: the slab ends at the 교좌면 끝선
|
||||
const a = w.sEnd === 0 ? 1 : -1;
|
||||
const p0 = toAxis(model, w.corners[0]);
|
||||
const p1 = toAxis(model, w.corners[1]);
|
||||
return sEnd + a * (w.thicknessM + SLAB_CLEARANCE_M) + ((p1.s - p0.s) / (p1.t - p0.t)) * t;
|
||||
};
|
||||
const axis = (c: AbutPoint): [number, number] => {
|
||||
const p = toAxis(model, c);
|
||||
return [p.s, p.t];
|
||||
};
|
||||
return {
|
||||
wall: [[k * tb, tb], [L + k * tb, tb], [L + k * tf, tf], [k * tf, tf]],
|
||||
wings: wings.map((w) => ({ side: w.side, sEnd: w.sEnd, poly: w.corners.map(axis) })),
|
||||
slab: [[innerS(0, near), near], [innerS(L, near), near], [innerS(L, far), far], [innerS(0, far), far]],
|
||||
};
|
||||
}
|
||||
|
||||
/** Skew of the plan (s per m of t along the wing), the same one the 저판 · 벽체 · 말뚝 열 use. */
|
||||
export function abutSkewK(model: AbutModel): number {
|
||||
const d = wingDirectionAxis(model);
|
||||
@@ -257,27 +290,13 @@ export function buildAbutGroup(
|
||||
}
|
||||
|
||||
// 접속슬래브 (spec §4.8): bottom on the 브라켓 top (흉벽 상단 − 0.85), 5 mm behind the 흉벽 back face, 7 m back, falling 4 % away from the wall.
|
||||
// Plan: between the wing inner faces, each 20 mm inside (spec §4.8 "측면 = 날개벽 안쪽면 + 20"), so it never meets a wing.
|
||||
// The wing is drawn as a prism over the wing direction with its 0.45 m thickness along s, so its inner face at t is s = sEnd + a·0.45 + (ws/wt)·t.
|
||||
// Plan: between the wing inner faces, each 20 mm inside (spec §4.8), from abutPlanOutline (shared with the ② 날개벽 plan).
|
||||
const slabNear = members.shape.parapetBackM - 0.005;
|
||||
const slabFar = slabNear - SLAB_LENGTH_M;
|
||||
const innerS = (sEnd: number, t: number): number => {
|
||||
const w = wings.find((x) => x.sEnd === sEnd);
|
||||
if (!w) return sEnd; // no wing on this side: the slab ends at the 교좌면 끝선
|
||||
const a = w.sEnd === 0 ? 1 : -1;
|
||||
const p0 = toAxis(model, w.corners[0]);
|
||||
const p1 = toAxis(model, w.corners[1]);
|
||||
return sEnd + a * (WING_THICKNESS_M + SLAB_CLEARANCE_M) + ((p1.s - p0.s) / (p1.t - p0.t)) * t;
|
||||
};
|
||||
const slabPlan = abutPlanOutline(model, members, wings).slab;
|
||||
const bracketTopEl = (s: number) => members.parapetTopStartEl + ((members.parapetTopEndEl - members.parapetTopStartEl) * s) / L - members.shape.bracketHeightM;
|
||||
const slabMesh = new THREE.Mesh(
|
||||
slabGeometry(
|
||||
[
|
||||
[innerS(0, slabNear), slabNear],
|
||||
[innerS(L, slabNear), slabNear],
|
||||
[innerS(L, slabFar), slabFar],
|
||||
[innerS(0, slabFar), slabFar],
|
||||
],
|
||||
slabPlan,
|
||||
// Flat on the bracket where it lies over the back strip (t ≥ 벽체 배면), falling 4 % only behind the wall.
|
||||
(s, t) => bracketTopEl(s) - SLAB_FALL * Math.max(0, members.shape.wallBackM - t),
|
||||
),
|
||||
@@ -319,21 +338,21 @@ export function buildAbutGroup(
|
||||
// The part in front of those faces lies inside the 벽체 or 흉벽, so it is cut off. The bracket falls along s, so the cut follows it.
|
||||
// The wing is 0.45 thick along s, so its footprint spans two s values at each dM; the cut uses the end where the 브라켓 is highest.
|
||||
const slopeS = (members.parapetTopEndEl - members.parapetTopStartEl) / L;
|
||||
const highEnd = slopeS < 0 ? Math.min(0, a * WING_THICKNESS_M) : Math.max(0, a * WING_THICKNESS_M);
|
||||
const highEnd = slopeS < 0 ? Math.min(0, a * wing.thicknessM) : Math.max(0, a * wing.thicknessM);
|
||||
const bracketAtDm = (dM: number) => members.parapetTopStartEl + slopeS * (sEnd + dir.ws * dM + highEnd) - members.shape.bracketHeightM;
|
||||
const above = clipByLine(profile, (x, y) => y - bracketAtDm(x));
|
||||
const below = clipByLine(profile, (x, y) => bracketAtDm(x) - y);
|
||||
const upper = prism(clipBelowX(above, members.shape.parapetBackM / dir.wt), WING_THICKNESS_M, new THREE.Vector3(sEnd, 0, 0), ax, ey, new THREE.Vector3(a, 0, 0));
|
||||
const lower = prism(clipBelowX(below, members.shape.wallBackM / dir.wt), WING_THICKNESS_M, new THREE.Vector3(sEnd, 0, 0), ax, ey, new THREE.Vector3(a, 0, 0));
|
||||
const upper = prism(clipBelowX(above, members.shape.parapetBackM / dir.wt), wing.thicknessM, new THREE.Vector3(sEnd, 0, 0), ax, ey, new THREE.Vector3(a, 0, 0));
|
||||
const lower = prism(clipBelowX(below, members.shape.wallBackM / dir.wt), wing.thicknessM, new THREE.Vector3(sEnd, 0, 0), ax, ey, new THREE.Vector3(a, 0, 0));
|
||||
add('wings', upper, 0xc9a77c);
|
||||
add('wings', lower, 0xc9a77c);
|
||||
// 저판 상면 헌치 (수평, 2026-11-03): a right triangle in the plane EL–s on the 저판 상면, next to the 날개벽 내측면,
|
||||
// extruded along the wing from the 벽체 배면 to the 뒷굽.
|
||||
if (wing.footingHaunch) {
|
||||
const f = wing.footingHaunch;
|
||||
const leg = HAUNCH_LEG_M;
|
||||
const leg = wing.haunchM;
|
||||
const triangle: [number, number][] = [[0, 0], [leg, 0], [0, leg]];
|
||||
const origin = new THREE.Vector3(sEnd + a * WING_THICKNESS_M + dir.ws * f.dMFrom, f.el, dir.wt * f.dMFrom);
|
||||
const origin = new THREE.Vector3(sEnd + a * wing.thicknessM + dir.ws * f.dMFrom, f.el, dir.wt * f.dMFrom);
|
||||
add('haunches', prism(triangle, f.dMTo - f.dMFrom, origin, new THREE.Vector3(a, 0, 0), ey, new THREE.Vector3(dir.ws, 0, dir.wt)), 0xc9a77c);
|
||||
}
|
||||
for (const h of wing.haunches) {
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* 교대 날개벽 삽도 (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 / 4, a[1] + t / 4], [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;
|
||||
}
|
||||
|
||||
/** 측면 (날개벽 축 방향으로 잘라 본 모습): 벽체 쪽이 오른쪽, 날개벽 끝이 왼쪽. 수평 단 경계와 뒷굽 끝을 표시한다. */
|
||||
export function abutWingSideSvg(d: AbutWingSideDrawing): string {
|
||||
const k = 40;
|
||||
const els = [...d.topLine.map((p) => p.el), ...d.bottomLine.map((p) => p.el), d.footingTopEl];
|
||||
const elMax = Math.max(...els) + 0.8;
|
||||
const elMin = Math.min(...els) - 0.6;
|
||||
const X = (dM: number) => 40 + (d.lengthM - dM) * k; // wall (dM 0) on the right
|
||||
const Y = (el: number) => 14 + (elMax - el) * k;
|
||||
const W = X(-1.2) + 70;
|
||||
const H = Y(elMin) + 26;
|
||||
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 parts = [
|
||||
// 벽체 (단면) on the right, 저판 under it.
|
||||
poly([[X(0), Y(d.footingTopEl)], [X(-1.0), Y(d.footingTopEl)], [X(-1.0), Y(d.seatEl)], [X(0), Y(d.seatEl)]], `fill="${WALL}" stroke="${MUTED}"`),
|
||||
poly([[X(Math.min(d.heelDistanceM, d.lengthM)), Y(d.footingTopEl)], [X(-1.2), Y(d.footingTopEl)], [X(-1.2), Y(d.footingTopEl - 0.5)], [X(Math.min(d.heelDistanceM, d.lengthM)), Y(d.footingTopEl - 0.5)]], `fill="#c9d1da" stroke="${MUTED}"`),
|
||||
poly(outline, `fill="${WING}" stroke="#9c8763"`),
|
||||
];
|
||||
// 수평 단 경계 (등단면 / 변화단면) 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(0).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.45), Y(mid) + 4, b.name, 'middle', MUTED));
|
||||
}
|
||||
parts.push(text((X(d.lengthM) + X(0)) / 2, 11, `${Math.round(d.lengthM * 1000)} (교좌면 끝에서, 날개벽 축 방향)`, 'middle'));
|
||||
if (d.form === 'general') parts.push(text(X(d.lengthM) - 4, (Y(d.topLine[d.topLine.length - 1].el) + Y(d.bottomLine[d.bottomLine.length - 1].el)) / 2 + 4, `${Math.round(d.endHeightM * 1000)}`, 'end'));
|
||||
parts.push(text(X(-1.0) + 4, Y(d.seatEl) + 4, `교좌면 ${d.seatEl.toFixed(3)}`, 'start'));
|
||||
parts.push(text(X(-1.2) + 4, Y(d.footingTopEl) + 14, d.footingTopEl.toFixed(3), 'start'));
|
||||
if (d.heelDistanceM < d.lengthM) {
|
||||
parts.push(`<line x1="${X(d.heelDistanceM).toFixed(1)}" y1="${Y(d.footingTopEl).toFixed(1)}" x2="${X(d.heelDistanceM).toFixed(1)}" y2="${(Y(d.footingTopEl) + 18).toFixed(1)}" stroke="${INK}"/>`);
|
||||
}
|
||||
parts.push(text(X(Math.min(d.heelDistanceM, d.lengthM)), Y(d.footingTopEl) + 30, `뒷굽 끝 ${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)}, 뒷굽 끝 ${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('');
|
||||
}
|
||||
Reference in new issue
Block a user