feat(abut): 교대 벽체·흉벽 삽도와 수축줄눈·흉벽 Level 입력 추가
- 벽체·흉벽 개략 단면 SVG(src/view/abut-section-svg.ts)를 형상 카드 맨 위에 추가. 치수 글자는 카드 입력값을 mm로 표시하고, 값을 바꾸면 다시 그림 - 형상 입력에 수축줄눈 개수(정수 0 이상)와 흉벽 상단 Level 좌·우(450·995, 계산 미사용·QA-4)를 추가. 기하 계산에는 쓰지 않음 - 테스트 619건 통과, 타입 검사·빌드 통과 Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1d31d2f3a9
commit
5af88a511e
7 files changed
+110
-2
No files matched your search
@@ -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, 계산 미사용)' },
|
||||
];
|
||||
@@ -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<AbutShape>): 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;
|
||||
}
|
||||
+6
-1
@@ -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));
|
||||
|
||||
@@ -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; }
|
||||
@@ -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) =>
|
||||
`<rect class="${cls}" x="${X(t0)}" y="${Y(h1)}" width="${(t1 - t0) * S}" height="${(h1 - h0) * S}"/>`;
|
||||
const text = (x: number, y: number, s: string, anchor = 'start') =>
|
||||
`<text x="${x}" y="${y}" text-anchor="${anchor}">${s}</text>`;
|
||||
const wallMid = (r.wallBackM + r.wallFrontM) / 2;
|
||||
return [
|
||||
`<svg viewBox="0 0 ${W} ${H}" width="100%" role="img" aria-label="벽체·흉벽 개략 단면 (축척 아님)">`,
|
||||
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'),
|
||||
'</svg>',
|
||||
].join('');
|
||||
}
|
||||
@@ -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(/<rect /g)).toHaveLength(3);
|
||||
expect(svg.startsWith('<svg')).toBe(true);
|
||||
expect(svg.endsWith('</svg>')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in new issue
Block a user