diff --git a/src/core/abut/edit.ts b/src/core/abut/edit.ts index 70df289..0018a8e 100644 --- a/src/core/abut/edit.ts +++ b/src/core/abut/edit.ts @@ -81,4 +81,7 @@ export const SHAPE_EDIT_FIELDS: { key: keyof AbutShape; label: string }[] = [ { key: 'footingBackMm', label: '저판 뒷굽 끝 (mm)' }, { key: 'pileEdgeMm', label: '말뚝 연단 (mm)' }, { key: 'pileSpacingMm', label: '말뚝 간격 (mm)' }, + { key: 'shrinkJointCount', label: '수축줄눈 개수 (개)' }, + { key: 'parapetLevelLeftMm', label: '흉벽 상단 Level 좌 (mm, 계산 미사용)' }, + { key: 'parapetLevelRightMm', label: '흉벽 상단 Level 우 (mm, 계산 미사용)' }, ]; diff --git a/src/core/abut/shape.ts b/src/core/abut/shape.ts index 92a9f95..391620d 100644 --- a/src/core/abut/shape.ts +++ b/src/core/abut/shape.ts @@ -16,6 +16,11 @@ export interface AbutShape { footingBackMm: number; pileEdgeMm: number; pileSpacingMm: number; + /** 수축줄눈 개수 (개). Shown in the 삽도; not used by the geometry yet. */ + shrinkJointCount: number; + /** 흉벽 상단 Level 좌·우 (mm). Not used by the geometry (QA-4, 가정). */ + parapetLevelLeftMm: number; + parapetLevelRightMm: number; } export const ABUT_SHAPE_DEFAULT: AbutShape = { @@ -29,6 +34,9 @@ export const ABUT_SHAPE_DEFAULT: AbutShape = { footingBackMm: -1800, pileEdgeMm: 650, pileSpacingMm: 2000, + shrinkJointCount: 1, + parapetLevelLeftMm: 450, + parapetLevelRightMm: 995, }; /** The same values in m, for the geometry (the model works in m). */ @@ -81,5 +89,6 @@ export function shapeIssues(changes: Partial): string[] { const wallBack = v.wallFrontMm - v.wallThicknessMm; if (!(v.footingFrontMm > v.wallFrontMm)) out.push('저판 앞굽 끝은 벽체 전면보다 앞에 있어야 합니다.'); if (!(v.footingBackMm < wallBack)) out.push('저판 뒷굽 끝은 벽체 배면보다 뒤에 있어야 합니다.'); + if (!(Number.isInteger(v.shrinkJointCount) && v.shrinkJointCount >= 0)) out.push('수축줄눈 개수는 0 이상의 정수여야 합니다.'); return out; } diff --git a/src/main.ts b/src/main.ts index dec5732..f86d71f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -9,7 +9,8 @@ import { buildAbut } from './core/abut/geometry'; import { structureOrder } from './core/abut/order'; import { ABUT_DETAIL_LABELS, abutInputRows, abutSummaryRows } from './core/abut/summary'; import { applyAbutEdit, applyBearingEdit, applyShapeEdit, ABUT_EDIT_FIELDS, SHAPE_EDIT_FIELDS, restoreAbutDefault, setWingForm } from './core/abut/edit'; -import { ABUT_SHAPE_DEFAULT } from './core/abut/shape'; +import { ABUT_SHAPE_DEFAULT, resolveShape } from './core/abut/shape'; +import { abutSectionSvg } from './view/abut-section-svg'; import type { WingForm } from './core/abut/geometry'; import { buildMembers } from './core/abut/members'; import { buildWings } from './core/abut/wings'; @@ -645,6 +646,10 @@ function fillAbutSlot(slot: (typeof ABUT_SLOTS)[keyof typeof ABUT_SLOTS], rec: A basic.replaceChildren(); bearings.replaceChildren(); shape.replaceChildren(); + // 삽도 (벽체·흉벽 개략 단면): the values it names are the card's values, so the drawing follows each change. + const drawing = el('div', undefined, 'abut-section'); + drawing.innerHTML = abutSectionSvg(resolveShape(rec.shape), { ...ABUT_SHAPE_DEFAULT, ...(rec.shape ?? {}) }); + shape.append(drawing); msg.textContent = ''; const apply = (next: AbutRecord) => { state.abutments = state.abutments.map((a) => (a.name === next.name ? next : a)); diff --git a/src/style.css b/src/style.css index 8b111ea..cca3f77 100644 --- a/src/style.css +++ b/src/style.css @@ -867,3 +867,12 @@ body { background: #eef1f4; font-family: 'Noto Sans KR', system-ui, sans-serif; .copy-panel .cp-chips .chk:has(input:disabled) { color: #8a94a3; background: #f4f6f9; } .quantity-total { margin-bottom: 16px; } + +/* 교대 삽도 (벽체·흉벽 개략 단면): fills the panel width; the labels follow the card's values. */ +.abut-section { margin-bottom: 12px; background: #f6f8fa; border: 1px solid #d5dbe2; border-radius: 6px; padding: 8px; } +.abut-section svg { display: block; width: 100%; height: auto; } +.abut-section text { font-family: 'JetBrains Mono', ui-monospace, monospace; font-size: 12px; fill: #18212b; } +.abut-section rect { stroke: #55606e; stroke-width: 1; } +.abut-section .abut-footing { fill: #b9c3ce; } +.abut-section .abut-wall { fill: #d8dee6; } +.abut-section .abut-parapet { fill: #9aa7b5; } diff --git a/src/view/abut-section-svg.ts b/src/view/abut-section-svg.ts new file mode 100644 index 0000000..f893de5 --- /dev/null +++ b/src/view/abut-section-svg.ts @@ -0,0 +1,43 @@ +import { type AbutShape, type ResolvedShape } from '../core/abut/shape'; + +/** + * 벽체·흉벽 개략 단면 (시안 ② 삽도). Not to scale: the heights are fixed for legibility, + * and the labels show the card's values in mm, so the numbers the user types are what the drawing names. + * t runs along the 교대 직각 방향 (배면 −, 전면 +); the 교대축 is t = 0. + */ +export function abutSectionSvg(r: ResolvedShape, v: AbutShape): string { + const S = 100; // px per m + const YMAX = 4.6; // highest drawn height (m above the footing bottom) + const tMin = r.footingBackM - 0.4; + const tMax = r.footingFrontM + 0.6; + const W = (tMax - tMin) * S; + const H = (YMAX + 0.5) * S; + const X = (t: number) => (t - tMin) * S; + const Y = (h: number) => (YMAX - h) * S; + const fT = r.footingThicknessM; + const wallTop = fT + 2.0; // illustrative wall height + const parapetTop = wallTop + 0.9; // illustrative parapet height + const rect = (t0: number, t1: number, h0: number, h1: number, cls: string) => + ``; + const text = (x: number, y: number, s: string, anchor = 'start') => + `${s}`; + const wallMid = (r.wallBackM + r.wallFrontM) / 2; + return [ + ``, + rect(r.footingBackM, r.footingFrontM, 0, fT, 'abut-footing'), + rect(r.wallBackM, r.wallFrontM, fT, wallTop, 'abut-wall'), + rect(r.parapetBackM, 0, wallTop, parapetTop, 'abut-parapet'), + text(4, Y(wallTop + 0.45), `흉벽 높이 ${v.parapetHeightMm}`), + text(X(r.parapetBackM) - 4, Y(parapetTop + 0.2), `흉벽 두께 ${v.parapetThicknessMm}`, 'start'), + text(X(wallMid), Y(fT + 1.0), `벽체 두께 ${v.wallThicknessMm}`, 'middle'), + text(X(r.wallFrontM) + 6, Y(wallTop - 0.3), `벽체 전면 ${v.wallFrontMm}`), + text(X(r.wallFrontM) + 6, Y(wallTop - 0.8), `브라켓 높이 ${v.bracketHeightMm}`), + text(X(r.wallFrontM) + 6, Y(fT + 0.4), `수축줄눈 ${v.shrinkJointCount}개`), + text(4, Y(fT / 2) + 4, `저판 두께 ${v.footingThicknessMm}`), + text(X(r.footingBackM), Y(0) + 18, `저판 뒷굽 ${v.footingBackMm}`), + text(X(r.footingFrontM), Y(0) + 18, `저판 앞굽 ${v.footingFrontMm}`, 'end'), + text(4, H - 22, '개략 도시 · 축척 아님', 'start'), + text(4, H - 6, `흉벽 상단 Level 좌 ${v.parapetLevelLeftMm} · 우 ${v.parapetLevelRightMm} (계산 미사용, QA-4)`, 'start'), + '', + ].join(''); +} diff --git a/tests/abut-section-svg.test.ts b/tests/abut-section-svg.test.ts new file mode 100644 index 0000000..367de05 --- /dev/null +++ b/tests/abut-section-svg.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; +import { ABUT_SHAPE_DEFAULT, resolveShape } from '../src/core/abut/shape'; +import { abutSectionSvg } from '../src/view/abut-section-svg'; + +describe('교대 삽도 (벽체·흉벽 개략 단면, 시안 ②)', () => { + it('names the card values in mm, including the new ones', () => { + const svg = abutSectionSvg(resolveShape(), ABUT_SHAPE_DEFAULT); + expect(svg).toContain('흉벽 높이 2500'); + expect(svg).toContain('벽체 두께 2400'); + expect(svg).toContain('저판 뒷굽 -1800'); + expect(svg).toContain('수축줄눈 1개'); + expect(svg).toContain('Level 좌 450 · 우 995'); + }); + + it('follows a changed value, and says it is not to scale', () => { + const v = { ...ABUT_SHAPE_DEFAULT, shrinkJointCount: 3, parapetHeightMm: 2200 }; + const svg = abutSectionSvg(resolveShape(v), v); + expect(svg).toContain('수축줄눈 3개'); + expect(svg).toContain('흉벽 높이 2200'); + expect(svg).toContain('축척 아님'); + }); + + it('draws the footing, wall and parapet as separate shapes', () => { + const svg = abutSectionSvg(resolveShape(), ABUT_SHAPE_DEFAULT); + expect(svg.match(/')).toBe(true); + }); +}); diff --git a/tests/abut-shape.test.ts b/tests/abut-shape.test.ts index 8adb988..814eb39 100644 --- a/tests/abut-shape.test.ts +++ b/tests/abut-shape.test.ts @@ -40,7 +40,17 @@ const rec = parseAbutments(XLSX.read(readFileSync(RAG), { type: 'buffer' })).abu describe('형상 입력이 계산에 반영됨 (시안 ② 카드)', () => { it('the card has one field per shape value, in the mockup order', () => { expect(SHAPE_EDIT_FIELDS.map((f) => f.key)[0]).toBe('wallFrontMm'); - expect(SHAPE_EDIT_FIELDS).toHaveLength(10); + expect(SHAPE_EDIT_FIELDS).toHaveLength(13); + }); + + it('the new drawing values do not change the geometry (the model ignores them)', () => { + expect(resolveShape({ shrinkJointCount: 3, parapetLevelLeftMm: 800 })).toEqual(resolveShape()); + }); + + it('the shrink-joint count must be a whole number, not negative', () => { + expect(shapeIssues({ shrinkJointCount: 1.5 }).join(' ')).toContain('수축줄눈'); + expect(shapeIssues({ shrinkJointCount: -1 }).join(' ')).toContain('수축줄눈'); + expect(shapeIssues({ shrinkJointCount: 0 })).toEqual([]); }); it('a footing that does not cover the wall is refused by the edit rule', () => {