diff --git a/src/main.ts b/src/main.ts index 0cc8a50..0977d9e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1094,6 +1094,14 @@ function fillAbutWingCard( bands: wing.bands, seatEl: rec.seatEl, footingTopEl: members.footingTopEl, + parapetTopEl: wing.topStartEl, + bracketHeightM: members.shape.bracketHeightM, + wallBackM: members.shape.wallBackM, + parapetBackM: members.shape.parapetBackM, + wallFrontM: members.shape.wallFrontM, + footingFrontM: members.shape.footingFrontM, + footingBackM: members.shape.footingBackM, + footingThicknessM: members.shape.footingThicknessM, }), `${sideKo(side)} 날개벽 측면 · ${NAME[wing.form]} (날개벽 축 방향으로 잘라 본 모습) · 축척 같음`, ); diff --git a/src/view/abut-bearing-svg.ts b/src/view/abut-bearing-svg.ts index 81f8a9a..35267ab 100644 --- a/src/view/abut-bearing-svg.ts +++ b/src/view/abut-bearing-svg.ts @@ -78,8 +78,8 @@ export function abutBearingSectionSvg(d: AbutBearingDetail): string { text(right, at(d.h1 + d.h2 + d.h3 / 2) + 4, `h3 ${d.h3}`, 'start'), text(right, at(E - d.h4 / 2) + 4, `h4 ${d.h4}`, 'start'), text(cx, at(E) - 10, `E = ${E}`), - text(24, base + 16, `교좌면 ${d.seatEl.toFixed(3)}`, 'start', MUTED), - text(24, at(d.h1 + d.h2) - 4, `z ${d.z.toFixed(3)}`, 'start', MUTED), + text(24, base + 16, `교좌면 EL.${d.seatEl.toFixed(3)}`, 'start', MUTED), + text(24, at(d.h1 + d.h2) - 4, `z EL.${d.z.toFixed(3)}`, 'start', MUTED), text(cx, 244, '교축 방향 · 높이 2배 과장', 'middle', MUTED), '', ].join(''); diff --git a/src/view/abut-wall-svg.ts b/src/view/abut-wall-svg.ts index 9ab81b3..d56117a 100644 --- a/src/view/abut-wall-svg.ts +++ b/src/view/abut-wall-svg.ts @@ -35,6 +35,8 @@ const CONC = '#d9c9a8'; const CONC_DARK = '#c7b48d'; const FOOT = '#b9c3ce'; const t3 = (v: number) => v.toFixed(3); +/** EL 표기 (2026-11-04): "EL.41.995". */ +const el = (v: number) => `EL.${v.toFixed(3)}`; const label = (x: number, y: number, s: string, anchor = 'middle', fill = INK) => `${s}`; const poly = (pts: [number, number][], style: string) => ``; @@ -75,12 +77,12 @@ export function abutWallSectionSvg(d: AbutWallDrawing): string { ``, label(X(0), 14, '교대축', 'middle', '#c0392b'), label((X(d.wallBackM) + X(d.wallFrontM)) / 2, Y(top) - 18, `${d.bracketWidthMm} + ${d.parapetThicknessMm} + ${d.seatWidthMm} = ${d.bracketWidthMm + d.parapetThicknessMm + d.seatWidthMm}`), - label(X(0) + 6, Y(top) - 4, `흉벽 상단 ${t3(top)}`, 'start'), - label(X(d.wallBackM) - 6, Y(bracket) + 4, `브라켓 상면 ${t3(bracket)}`, 'end'), - label(X(d.wallFrontM) + 6, Y(seg.seatEl) + 4, `교좌면 ${t3(seg.seatEl)}`, 'start'), + label(X(0) + 6, Y(top) - 4, `흉벽 상단 ${el(top)}`, 'start'), + label(X(d.wallBackM) - 6, Y(bracket) + 4, `브라켓 상면 ${el(bracket)}`, 'end'), + label(X(d.wallFrontM) + 6, Y(seg.seatEl) + 4, `교좌면 ${el(seg.seatEl)}`, 'start'), label(X(d.wallFrontM) + 6, Y((seg.seatEl + d.footingTopEl) / 2) + 4, `벽체 ${(seg.seatEl - d.footingTopEl).toFixed(3)}`, 'start'), - label(X(d.footingFrontM) + 6, Y(d.footingTopEl) + 4, `상면 ${t3(d.footingTopEl)}`, 'start'), - label(X(d.footingFrontM) + 6, Y(d.footingBottomEl) + 4, `기초 하단 ${t3(d.footingBottomEl)}`, 'start'), + label(X(d.footingFrontM) + 6, Y(d.footingTopEl) + 4, `상면 ${el(d.footingTopEl)}`, 'start'), + label(X(d.footingFrontM) + 6, Y(d.footingBottomEl) + 4, `기초 하단 ${el(d.footingBottomEl)}`, 'start'), label(X(d.footingBackM), H - 8, '← 배면', 'start', MUTED), label(X(d.footingFrontM), H - 8, '전면 →', 'end', MUTED), '', @@ -118,11 +120,11 @@ export function abutWallElevationSvg(d: AbutWallDrawing): string { } const first = d.segments.find((g) => d.lengthM >= g.sFrom && d.lengthM <= g.sTo) ?? d.segments[d.segments.length - 1]; const last = d.segments.find((g) => g.sFrom <= 0 && g.sTo >= 0) ?? d.segments[0]; - parts.push(label(X(d.lengthM), Y(d.parapetTopEndEl) - 6, t3(d.parapetTopEndEl), 'start')); - parts.push(label(X(0), Y(d.parapetTopStartEl) - 6, t3(d.parapetTopStartEl), 'end')); - parts.push(label(X(d.lengthM) - 4, Y(first.seatEl) + 4, t3(first.seatEl), 'end')); - parts.push(label(X(0) + 4, Y(last.seatEl) + 4, t3(last.seatEl), 'start')); - parts.push(label(X(d.lengthM) - 4, Y(d.footingBottomEl) + 4, t3(d.footingBottomEl), 'end')); + parts.push(label(X(d.lengthM), Y(d.parapetTopEndEl) - 6, el(d.parapetTopEndEl), 'start')); + parts.push(label(X(0), Y(d.parapetTopStartEl) - 6, el(d.parapetTopStartEl), 'end')); + parts.push(label(X(d.lengthM) - 4, Y(first.seatEl) + 4, el(first.seatEl), 'end')); + parts.push(label(X(0) + 4, Y(last.seatEl) + 4, el(last.seatEl), 'start')); + parts.push(label(X(d.lengthM) - 4, Y(d.footingBottomEl) + 4, el(d.footingBottomEl), 'end')); parts.push(label(X(d.lengthM), H - 8, 'IP3', 'middle', MUTED)); parts.push(label(X(0), H - 8, 'IP1', 'middle', MUTED)); parts.push(label((X(0) + X(d.lengthM)) / 2, H - 8, `${d.lengthM.toFixed(3)} m`)); diff --git a/src/view/abut-wing-svg.ts b/src/view/abut-wing-svg.ts index bae4e36..0e2dfc5 100644 --- a/src/view/abut-wing-svg.ts +++ b/src/view/abut-wing-svg.ts @@ -68,46 +68,77 @@ export interface AbutWingSideDrawing { 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 = 뒷굽 / 뒷굽 거리). + */ 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 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.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 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) + 30; + 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 = [ - // 벽체 (단면) 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"`), + // 저판 (뒷굽 끝 ~ 앞굽 끝), 벽체 (기초 상면 ~ 교좌면), 브라켓 단차 (벽체 배면 ~ 흉벽 배면, 브라켓 상면까지), 흉벽 (흉벽 상단까지). + 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(``); + parts.push(``); } 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 * 0.55), 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')); + 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')); if (d.heelDistanceM < d.lengthM) { - parts.push(``); + parts.push(``); } - parts.push(text(X(Math.min(d.heelDistanceM, d.lengthM)), Y(d.footingTopEl) + 30, `뒷굽 끝 ${d.heelDistanceM.toFixed(3)} m`, 'middle')); + parts.push(text(X(Math.min(d.heelDistanceM, d.lengthM)), Y(footingBottom) + 22, `뒷굽 끝 ${d.heelDistanceM.toFixed(3)} m`, 'middle')); return [ - ``, + ``, ...parts, '', ].join(''); diff --git a/tests/abut-bearing-svg.test.ts b/tests/abut-bearing-svg.test.ts index f897a51..cd02393 100644 --- a/tests/abut-bearing-svg.test.ts +++ b/tests/abut-bearing-svg.test.ts @@ -18,7 +18,7 @@ describe('교대 받침 삽도 (시안 ② 교량받침 카드)', () => { it('단면: the four layers and E = h1 + h2 + h3 + h4 = 400', () => { const svg = abutBearingSectionSvg(d); - for (const s of ['h1 75', 'h2 50', 'h3 250', 'h4 25', 'E = 400', '교좌면 44.367', 'z 44.492']) expect(svg).toContain(s); + for (const s of ['h1 75', 'h2 50', 'h3 250', 'h4 25', 'E = 400', '교좌면 EL.44.367', 'z EL.44.492']) expect(svg).toContain(s); expect(svg.match(/ { it('단면 (① 구간): the width line and the levels of the generated model', () => { const svg = abutWallSectionSvg(d); - for (const s of ['300 + 500 + 1600 = 2400', '교좌면 44.367', '벽체 2.372', '상면 41.995', '기초 하단 40.895', '교대축', '← 배면', '전면 →']) { + for (const s of ['300 + 500 + 1600 = 2400', '교좌면 EL.44.367', '벽체 2.372', '상면 EL.41.995', '기초 하단 EL.40.895', '교대축', '← 배면', '전면 →']) { expect(svg).toContain(s); } - expect(svg).toMatch(/흉벽 상단 4[67]\.\d{3}/); - expect(svg).toMatch(/브라켓 상면 4[56]\.\d{3}/); + expect(svg).toMatch(/흉벽 상단 EL\.4[67]\.\d{3}/); + expect(svg).toMatch(/브라켓 상면 EL\.4[56]\.\d{3}/); }); it('정면·배면도: five steps, five bearings, the sloped 흉벽 top (IP3 46.835 → IP1 47.180) and the 수축줄눈', () => { const svg = abutWallElevationSvg(d); - for (const s of ['IP3', 'IP1', `${model.length.toFixed(3)} m`, '수축줄눈 1', '46.835', '47.180', '①', '⑤']) expect(svg).toContain(s); + for (const s of ['IP3', 'IP1', `${model.length.toFixed(3)} m`, '수축줄눈 1', 'EL.46.835', 'EL.47.180', '①', '⑤']) expect(svg).toContain(s); expect(svg).toContain('교좌면 계단 5구간'); }); diff --git a/tests/abut-wing-card.test.ts b/tests/abut-wing-card.test.ts index c7ec775..8748aca 100644 --- a/tests/abut-wing-card.test.ts +++ b/tests/abut-wing-card.test.ts @@ -51,14 +51,20 @@ describe('날개벽 삽도 (시안 ② 날개벽 카드)', () => { expect(svg.match(/ { + it('측면: the bands, the length, the end height, the 뒷굽 끝, and the 벽체 · 흉벽 · 브라켓 · 저판 with EL. labels', () => { const { members, wings } = build(); const w = wings[0]; const svg = abutWingSideSvg({ name: '좌 날개벽', form: w.form, lengthM: w.lengthM, endHeightM: w.endHeightM, heelDistanceM: w.heelDistanceM, topLine: w.topLine, bottomLine: w.bottomLine, bands: w.bands, seatEl: rec.seatEl, footingTopEl: members.footingTopEl, + parapetTopEl: w.topStartEl, bracketHeightM: members.shape.bracketHeightM, wallBackM: members.shape.wallBackM, + parapetBackM: members.shape.parapetBackM, wallFrontM: members.shape.wallFrontM, footingFrontM: members.shape.footingFrontM, + footingBackM: members.shape.footingBackM, footingThicknessM: members.shape.footingThicknessM, }); - for (const s of ['6500 (교좌면 끝에서, 날개벽 축 방향)', '1500', '등단면', '변화단면1', '변화단면2', '뒷굽 끝 1.807 m', '교좌면 44.367']) expect(svg).toContain(s); + for (const s of ['6500 (교좌면 끝에서, 날개벽 축 방향)', '1500', '등단면', '변화단면1', '변화단면2', '뒷굽 끝 1.807 m', '교좌면 EL.44.367', 'EL.41.995', '기초 하단 EL.40.895']) expect(svg).toContain(s); + expect(svg).toMatch(/흉벽 상단 EL\.4[67]\.\d{3}/); + expect(svg).toMatch(/브라켓 상면 EL\.4[56]\.\d{3}/); + expect(svg.match(/ {