From 9835fb42b363e7183531f77677047e9134b23686 Mon Sep 17 00:00:00 2001 From: ypshim Date: Wed, 4 Nov 2026 10:27:20 +0900 Subject: [PATCH] =?UTF-8?q?feat(abut):=20=EC=A0=91=EC=86=8D=EC=8A=AC?= =?UTF-8?q?=EB=9E=98=EB=B8=8C=20=EA=B2=BD=EC=82=AC=EB=8A=94=20=ED=98=95?= =?UTF-8?q?=EC=8B=9D=EC=9D=B4=20=EC=A0=95=ED=95=A8,=20=EB=8B=A8=EB=A9=B4?= =?UTF-8?q?=20=C2=B7=20=EC=A0=91=ED=95=A9=20=EC=83=81=EC=84=B8=EB=A5=BC=20?= =?UTF-8?q?=ED=8C=8C=EB=8B=A8=EC=84=A0=20=EB=8B=A8=EB=A9=B4=20=ED=95=98?= =?UTF-8?q?=EB=82=98=EB=A1=9C=20=ED=95=A9=EC=B9=A8,=20=ED=9D=89=EB=B2=BD?= =?UTF-8?q?=20=EB=86=92=EC=9D=B4=20=EC=9E=85=EB=A0=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 경사 선택 삭제: 매립형 = 하향 4 %, 노출형 = 종단경사 따라 (노트 표기). 예전 파일의 slope 값은 읽고 무시 - 단면 + 접합 상세: 흉벽 쪽과 먼 끝만 그리고 중간 생략, 부속재 · 치수(7000 · 450 · 850 · 295 · 헌치) · EL을 한 그림에 - 흉벽 높이 (브라켓 상면 ~ 흉벽 상단) 입력 = 벽체·흉벽 카드 브라켓 높이와 같은 값 - 자동 값: 브라켓 얹힘 길이 = 브라켓 폭 − 흉벽과 간격 (295) Co-Authored-By: Claude Haiku 5.5 --- src/core/abut/edit.ts | 17 ++-- src/core/abut/slab.ts | 24 ++---- src/core/project.ts | 4 +- src/main.ts | 94 ++++++++++---------- src/style.css | 3 +- src/view/abut-slab-svg.ts | 174 +++++++++++++++++--------------------- tests/abut-slab.test.ts | 40 ++++----- 7 files changed, 157 insertions(+), 199 deletions(-) diff --git a/src/core/abut/edit.ts b/src/core/abut/edit.ts index 758c29d..1a6f0a9 100644 --- a/src/core/abut/edit.ts +++ b/src/core/abut/edit.ts @@ -6,7 +6,7 @@ import type { AbutRecord } from './parse'; import type { WingForm } from './geometry'; import { WING_DIMS_DEFAULT, wingDimsIssues, type WingDims } from './wings'; import { ABUT_SHAPE_DEFAULT, normalizeShape, shapeIssues, type AbutShape } from './shape'; -import { resolveSlab, slabDefaultSlope, slabIssues, type AbutSlab, type SlabForm, type SlabSlope } from './slab'; +import { resolveSlab, slabIssues, type AbutSlab, type SlabForm } from './slab'; import { abutBearingRuleIssues, resolveBearingRule, type AbutBearingRule } from './bearing-table'; export type AbutEditKey = 'seatEl' | 'foundationBottomEl' | 'groundEl' | 'pileLengthMm' | 'skewDeg' | 'slopeI'; @@ -131,22 +131,15 @@ function slabFitIssues(slab: Partial | undefined, shape: Partial h ? [`브라켓 높이(${h})는 접속슬래브 두께(${s.thicknessMm}) 이상이어야 합니다.`] : []; } -/** 접속슬래브 형식 (2026-11-04): picking a form also sets its slope (매립형 하향 4 %, 노출형 종단경사). */ +/** 접속슬래브 형식 (2026-11-04); the form also sets the slope (매립형 하향 4 %, 노출형 종단경사). */ export function setSlabForm(rec: AbutRecord, form: SlabForm): AbutRecord { - const slab: Partial = { ...(rec.slab ?? {}), form }; - delete slab.slope; + const { slope: _old, ...kept } = (rec.slab ?? {}) as Partial & { slope?: unknown }; + const slab: Partial = { ...kept, form }; const issues = slabIssues(resolveSlab(slab), bracketHeightMm(rec.shape)); if (issues.length > 0) throw new Error(issues[0]); return { ...rec, slab }; } -/** 접속슬래브 경사; the form's own slope is stored as "not set". */ -export function setSlabSlope(rec: AbutRecord, slope: SlabSlope): AbutRecord { - const slab: Partial = { ...(rec.slab ?? {}), slope }; - if (slope === slabDefaultSlope(resolveSlab(rec.slab).form)) delete slab.slope; - return { ...rec, slab }; -} - /** 접속슬래브 입력칸 (② 접속슬래브 카드, 시안 순서). 헌치 길이는 노출형만. */ export const SLAB_EDIT_FIELDS: { key: 'lengthMm' | 'thicknessMm' | 'parapetGapMm' | 'wingGapMm' | 'haunchLengthMm'; label: string; exposedOnly?: boolean; note?: string }[] = [ { key: 'lengthMm', label: '길이 (mm)' }, @@ -172,7 +165,7 @@ export const SHAPE_EDIT_FIELDS: { key: keyof AbutShape; label: string; group: 'w { key: 'wallFrontMm', label: '교좌면 폭 (mm)', group: 'wall' }, { key: 'parapetHeightMm', label: '흉벽 높이 (mm)', group: 'wall' }, { key: 'parapetThicknessMm', label: '흉벽 두께 (mm)', group: 'wall' }, - { key: 'bracketHeightMm', label: '브라켓 높이 (mm)', group: 'wall' }, + { key: 'bracketHeightMm', label: '브라켓 높이 (mm)', group: 'wall', note: '= 접속슬래브 카드 흉벽 높이' }, { key: 'bracketWidthMm', label: '브라켓 폭 (mm)', group: 'wall' }, { key: 'shrinkJointCount', label: '수축줄눈 개수', group: 'wall' }, { key: 'parapetLevelLeftMm', label: '흉벽 상단 Level 좌 (mm)', group: 'wall', note: '기준 확인 QA-4' }, diff --git a/src/core/abut/slab.ts b/src/core/abut/slab.ts index 7699f33..882caa2 100644 --- a/src/core/abut/slab.ts +++ b/src/core/abut/slab.ts @@ -4,17 +4,15 @@ * - 매립형 (type-1, 기본): 하면 = 브라켓 상면, 두께 일정. 브라켓 위(벽체 배면까지)는 수평이고 그 뒤로 경사(기본 하향 4 %). * - 노출형 (type-2, 2026-11-04 삽도 기준, QA-8): 상면 = 흉벽 상단에서 경사(기본 종단경사)를 따른다. 끝은 브라켓 상면에 * 얹히고(끝 두께 = 브라켓 높이), 벽체 배면부터 헌치 길이 동안 두께가 줄어 그 뒤로는 두께가 일정하다. - * - 경사: 형식을 고르면 그 형식의 기본값(매립형 하향 4 %, 노출형 종단경사)으로 바뀌고, 따로 바꿀 수 있다. + * - 경사는 형식이 정한다 (2026-11-04): 매립형 = 하향 4 %, 노출형 = 종단경사 따라 (엑셀 A555 "type-1 = 하향경사 4 %, type-2 = 종단경사"). * - t: 교대축 직각 (전면 +, 배면 −, m). 슬래브는 흉벽 배면에서 "흉벽과 간격"만큼 띄워 시작하고 길이만큼 배면 쪽으로 간다. * - 단면의 EL은 그 s의 흉벽 상단 EL에서 잰 값이다 (흉벽 상단 경사는 호출하는 쪽에서 더한다). */ export type SlabForm = 'buried' | 'exposed'; -export type SlabSlope = 'down4' | 'grade'; export interface AbutSlab { form: SlabForm; - slope: SlabSlope; lengthMm: number; thicknessMm: number; parapetGapMm: number; @@ -25,7 +23,6 @@ export interface AbutSlab { export const ABUT_SLAB_DEFAULT: AbutSlab = { form: 'buried', - slope: 'down4', lengthMm: 7000, thicknessMm: 450, parapetGapMm: 5, @@ -38,31 +35,26 @@ export const SLAB_FALL_PCT = 4; export const SLAB_FORM_LABEL: Record = { buried: '매립형', exposed: '노출형' }; -/** The slope a form starts with: 매립형 하향 4 %, 노출형 종단경사 (엑셀 A555 "type-1 = 하향경사 4 %, type-2 = 종단경사"). */ -export function slabDefaultSlope(form: SlabForm): SlabSlope { - return form === 'exposed' ? 'grade' : 'down4'; -} - -/** The user's values over the defaults; the slope follows the form unless it was set. */ +/** The user's values over the defaults. A `slope` stored by an earlier version (2026-11-04 오전) is dropped: the form sets it. */ export function resolveSlab(changes?: Partial): AbutSlab { - const form = changes?.form ?? ABUT_SLAB_DEFAULT.form; - return { ...ABUT_SLAB_DEFAULT, slope: slabDefaultSlope(form), ...(changes ?? {}) }; + const { slope: _old, ...rest } = (changes ?? {}) as Partial & { slope?: unknown }; + return { ...ABUT_SLAB_DEFAULT, ...rest }; } -/** Keys whose value differs from the default (형식, 경사 and the dims), for the change marks. */ +/** Keys whose value differs from the default (형식 and the dims), for the change marks. */ export function slabChangedKeys(changes?: Partial): (keyof AbutSlab)[] { const s = resolveSlab(changes); return (Object.keys(ABUT_SLAB_DEFAULT) as (keyof AbutSlab)[]).filter((k) => s[k] !== ABUT_SLAB_DEFAULT[k]); } -/** Slope (%), + = rises away from the wall: 하향 4 % → −4, 종단경사 → the wing slope (A1 = −i, A2 = +i). */ +/** Slope (%), + = rises away from the wall: 매립형 하향 4 % → −4, 노출형 종단경사 → the wing slope (A1 = −i, A2 = +i). */ export function slabGradePct(slab: AbutSlab, wingSlopePct: number): number { - return slab.slope === 'down4' ? -SLAB_FALL_PCT : wingSlopePct; + return slab.form === 'buried' ? -SLAB_FALL_PCT : wingSlopePct; } /** Slope text as on the card: "하향 4 %" or "종단경사 +1.91 %". */ export function slabSlopeLabel(slab: AbutSlab, wingSlopePct?: number): string { - if (slab.slope === 'down4') return `하향 ${SLAB_FALL_PCT} %`; + if (slab.form === 'buried') return `하향 ${SLAB_FALL_PCT} %`; if (wingSlopePct === undefined) return '종단경사'; return `종단경사 ${wingSlopePct >= 0 ? '+' : ''}${wingSlopePct.toFixed(2)} %`; } diff --git a/src/core/project.ts b/src/core/project.ts index b446a6d..1b87f82 100644 --- a/src/core/project.ts +++ b/src/core/project.ts @@ -144,7 +144,9 @@ export function checkStoredAbutments(list: unknown): void { } if (a.slab !== undefined) { for (const [key, v] of Object.entries(a.slab)) { - const ok = key === 'form' ? v === 'buried' || v === 'exposed' : key === 'slope' ? v === 'down4' || v === 'grade' : finite(v); + // 'slope' was stored by an earlier version (2026-11-04 오전) and is ignored now: the form sets the slope. + const x: unknown = v; + const ok = key === 'form' ? x === 'buried' || x === 'exposed' : key === 'slope' ? x === 'down4' || x === 'grade' : finite(x); if (!ok) throw new Error(`${who}: 접속슬래브 값 ${key}이(가) 맞지 않습니다.`); } } diff --git a/src/main.ts b/src/main.ts index 5386597..0410f9d 100644 --- a/src/main.ts +++ b/src/main.ts @@ -24,11 +24,10 @@ import { setWingForm, applySlabEdit, setSlabForm, - setSlabSlope, SLAB_EDIT_FIELDS, } from './core/abut/edit'; -import { ABUT_SLAB_DEFAULT, resolveSlab, SLAB_FORM_LABEL, slabDefaultSlope, slabGradePct, slabSection, slabSlopeLabel, type SlabForm, type SlabSlope } from './core/abut/slab'; -import { abutSlabJointSvg, abutSlabPlanSvg, abutSlabSectionSvg } from './view/abut-slab-svg'; +import { ABUT_SLAB_DEFAULT, resolveSlab, SLAB_FORM_LABEL, slabGradePct, slabSection, slabSlopeLabel, type SlabForm } from './core/abut/slab'; +import { abutSlabPlanSvg, abutSlabSectionSvg } from './view/abut-slab-svg'; import { ABUT_SHAPE_DEFAULT, footingEnds, normalizeShape, pileRowsMm, resolveShape, wallThicknessMm, type AbutShape } from './core/abut/shape'; import { abutFootingPlanSvg } from './view/abut-footing-svg'; import { abutWallElevationSvg, abutWallSectionSvg, type AbutWallDrawing } from './view/abut-wall-svg'; @@ -1190,21 +1189,17 @@ function fillAbutWingCard( /** * 접속슬래브 타일 그림 (2026-11-04, 48 × 24, 사용자 삽도 기준): 오른쪽이 흉벽 · 브라켓 단차, 왼쪽으로 슬래브가 뻗는다. - * 매립형 = 흉벽 상단 아래 일정 두께, 노출형 = 상면이 흉벽 상단과 같고 끝에 헌치. 경사는 하향(왼쪽이 낮음) / 종단경사(높음). + * 매립형 = 흉벽 상단 아래 일정 두께, 하향 4 % (왼쪽이 낮음). 노출형 = 상면이 흉벽 상단과 같고 끝에 헌치, 종단경사 (왼쪽이 높음). */ const SLAB_ICON = { - section(form: SlabForm, slope: SlabSlope): string { + section(form: SlabForm): string { const wall = ''; - const fall = slope === 'down4'; - const slab = - form === 'buried' - ? fall ? 'M4 11L30 8v4L4 15z' : 'M4 6L30 8v4L4 10z' - : fall ? 'M4 4.5L30 2v10L23 6.5L4 8.5z' : 'M4 0.8L30 2v10L23 5.5L4 4.8z'; + const slab = form === 'buried' ? 'M4 11L30 8v4L4 15z' : 'M4 0.8L30 2v10L23 5.5L4 4.8z'; return `${wall}`; }, }; -/** ② 접속슬래브 카드 (시안, 2026-11-04): 형식 · 경사 타일 → 삽도 3개 → 입력 → 자동 값 → 메모. */ +/** ② 접속슬래브 카드 (시안, 2026-11-04): 형식 타일 (경사는 노트) → 평면 · 단면(접합 상세 합침) → 입력 → 자동 값 → 메모. */ function fillAbutSlabCard( box: HTMLDivElement, rec: AbutRecord, @@ -1215,13 +1210,24 @@ function fillAbutSlabCard( ): void { const slab = resolveSlab(rec.slab); const say = (err: unknown) => ($('abut-panel-plan-msg').textContent = (err as Error).message); - const tiles = el('div', undefined, 'abut-tile-row'); - tiles.append( + let model: AbutModel | null = null; + let members: AbutMembers | null = null; + let wings: WingSolid[] = []; + try { + model = buildAbut(toAbutInput(rec)); + members = buildMembers(model, { bottomEl: rec.foundationBottomEl, pileLengthMm: rec.pileLengthMm }, rec.shape); + wings = buildWings(model, members, rec.wings, rec.wingDims); + } catch { + // shown below the tiles + } + // 형식 (경사는 형식이 정한다, 2026-11-04): 매립형 = 하향 4 %, 노출형 = 종단경사 따라. + const grade = slabSlopeLabel(resolveSlab({ form: 'exposed' }), model?.wing?.slopePct); + box.append( optionTiles( '형식', (['buried', 'exposed'] as const).map((f) => ({ label: SLAB_FORM_LABEL[f], - icon: SLAB_ICON.section(f, slabDefaultSlope(f)), + icon: SLAB_ICON.section(f), on: slab.form === f, isDefault: f === 'buried', pick: () => { @@ -1233,29 +1239,10 @@ function fillAbutSlabCard( } }, })), - ), - optionTiles( - '경사', - (['down4', 'grade'] as const).map((s) => ({ - label: s === 'down4' ? '하향 4 %' : '종단경사 따라', - icon: SLAB_ICON.section(slab.form, s), - on: slab.slope === s, - isDefault: s === slabDefaultSlope(slab.form), - pick: () => { - if (slab.slope !== s) apply(setSlabSlope(rec, s)); - }, - })), + `경사 · 매립형 = 하향 4 %, 노출형 = ${grade.replace('종단경사', '종단경사 따라')}`, ), ); - box.append(tiles); - let model: AbutModel; - let members: AbutMembers; - let wings: WingSolid[]; - try { - model = buildAbut(toAbutInput(rec)); - members = buildMembers(model, { bottomEl: rec.foundationBottomEl, pileLengthMm: rec.pileLengthMm }, rec.shape); - wings = buildWings(model, members, rec.wings, rec.wingDims); - } catch { + if (!model || !members) { box.append(el('p', '교대 모델을 만들 수 없어 접속슬래브를 그리지 않습니다.', 'muted small')); return; } @@ -1269,7 +1256,7 @@ function fillAbutSlabCard( // 단면은 교대축 가운데의 흉벽 상단에서 잰다. const parapetTopMid = (members.parapetTopStartEl + members.parapetTopEndEl) / 2; const sh = members.shape; - const figs = el('div', undefined, 'abut-wall-figs abut-wing-figs'); + const figs = el('div', undefined, 'abut-wall-figs abut-slab-figs'); const addFig = (svg: string, caption: string) => { const fig = el('figure', undefined, 'abut-fig'); fig.innerHTML = svg; @@ -1300,20 +1287,26 @@ function fillAbutSlabCard( parapetGapMm: slab.parapetGapMm, haunchLengthMm: slab.haunchLengthMm, }), - `단면 · 교대축 직각 (교대축 가운데) · ${SLAB_FORM_LABEL[slab.form]} · 경사 ${slopeText} · 축척 같음`, - ); - addFig( - abutSlabJointSvg({ - form: slab.form, - gradePct, - parapetTopEl: parapetTopMid, - bracketHeightM: sh.bracketHeightM, - parapetGapMm: slab.parapetGapMm, - thicknessMm: slab.thicknessMm, - }), - '흉벽–접속슬래브 접합 상세 · 모식도 (부속재는 표시용)', + `단면 · 접합 상세 · 교대축 직각 (교대축 가운데) · ${SLAB_FORM_LABEL[slab.form]} · 경사 ${slopeText} · 축척 같음, 중간 생략 · 간격 · 고무판은 키워 그림, 부속재는 표시용`, ); box.append(figs); + // 흉벽 높이 = 브라켓 상면 ~ 흉벽 상단 (2026-11-04): 벽체·흉벽 카드의 "브라켓 높이"와 같은 값. + { + const id = idOf('slab-parapet'); + const lab = el('label', '흉벽 높이 · 브라켓 상면 ~ 흉벽 상단 (mm)'); + lab.htmlFor = id; + const now = Math.round(sh.bracketHeightM * 1000); + const input = numberInput(id, String(now)); + if (now !== ABUT_SHAPE_DEFAULT.bracketHeightMm) input.classList.add('changed-input'); + input.addEventListener('change', () => { + try { + apply(applyShapeEdit(rec, 'bracketHeightMm', Number(input.value))); + } catch (err) { + fail(err, input, String(now)); + } + }); + box.append(lab, input, el('span', `기본 ${ABUT_SHAPE_DEFAULT.bracketHeightMm} · = 벽체·흉벽 카드 브라켓 높이`, 'muted small')); + } for (const f of SLAB_EDIT_FIELDS) { const id = idOf(`slab-${f.key}`); const lab = el('label', f.label); @@ -1335,12 +1328,13 @@ function fillAbutSlabCard( const side = (mm: number, w: WingSolid | undefined) => (w ? (mm > 0 ? `${mm} × ${mm}` : '없음') : '날개벽 없음'); auto('모따기 (자동)', `좌 ${side(chamfer.left, leftWing)} · 우 ${side(chamfer.right, rightWing)}`, '= 그 측 날개벽 헌치'); auto('폭 (자동)', `${widthM.toFixed(3)} m`, '도면 11.670 · QA-6'); - auto('경사', slopeText, slab.slope === slabDefaultSlope(slab.form) ? `${SLAB_FORM_LABEL[slab.form]} 기본` : `변경 (${SLAB_FORM_LABEL[slab.form]} 기본 = ${slabDefaultSlope(slab.form) === 'down4' ? '하향 4 %' : '종단경사'})`); + const bearing = Math.round((sh.parapetBackM - sh.wallBackM) * 1000) - slab.parapetGapMm; + auto('브라켓 얹힘 길이 (자동)', `${bearing} mm`, '= 브라켓 폭 − 흉벽과 간격'); if (slab.form === 'exposed') auto('끝 두께 (자동)', `${Math.round(sh.bracketHeightM * 1000)} mm`, '= 흉벽 상단 − 브라켓 상면'); box.append( el( 'p', - '시안 메모 · 매립형은 브라켓 상면에 얹혀 두께가 일정하고, 벽체 배면 뒤에서 경사를 따릅니다. 노출형(2026-11-04 삽도 기준, QA-8 가정)은 상면이 흉벽 상단과 같고 끝은 브라켓 위에 얹히며, 벽체 배면부터 헌치 길이 동안 두께가 끝 두께에서 슬래브 두께로 줄어듭니다. 형식을 바꾸면 경사가 그 형식의 기본값으로 바뀝니다. 접합 부속재(고무판, 타르페이퍼, 다웰, 강관)는 표시용이고, 배근은 철근 단계에서 정합니다(노출형 배근 규칙 미정).', + '시안 메모 · 매립형은 브라켓 상면에 얹혀 두께가 일정하고, 벽체 배면 뒤에서 경사를 따릅니다. 노출형(2026-11-04 삽도 기준, QA-8 가정)은 상면이 흉벽 상단과 같고 끝은 브라켓 위에 얹히며, 벽체 배면부터 헌치 길이 동안 두께가 끝 두께에서 슬래브 두께로 줄어듭니다. 경사는 형식이 정합니다(매립형 하향 4 %, 노출형 종단경사). 흉벽 높이는 벽체·흉벽 카드의 브라켓 높이와 같은 값입니다. 접합 부속재(고무판, 타르페이퍼, 다웰, 강관)는 표시용이고, 배근은 철근 단계에서 정합니다(노출형 배근 규칙 미정).', 'muted small abut-memo', ), ); diff --git a/src/style.css b/src/style.css index a3286c6..f4e478c 100644 --- a/src/style.css +++ b/src/style.css @@ -948,5 +948,4 @@ body { background: #eef1f4; font-family: 'Noto Sans KR', system-ui, sans-serif; #abut-panel-plan .abut-wing-forms .abut-grp.editing { border-color: #1f5fa8; background: #f8fbfe; } #abut-panel-plan .abut-wing-forms .abut-grp .abut-grp-title { cursor: pointer; font-weight: 600; color: #18212b; } #abut-panel-plan .abut-grp.compact .abut-opt { width: 64px; min-height: 48px; padding: 4px 2px; } -#abut-panel-plan .abut-tile-row { grid-column: 1 / -1; display: flex; flex-wrap: wrap; align-items: flex-end; gap: 8px 28px; } -#abut-panel-plan .abut-tile-row .abut-grp { margin: 0; } +#abut-panel-plan-slab > p, #abut-panel-plan-slab > .abut-wall-figs { grid-column: 1 / -1; } diff --git a/src/view/abut-slab-svg.ts b/src/view/abut-slab-svg.ts index b5a5231..f58813f 100644 --- a/src/view/abut-slab-svg.ts +++ b/src/view/abut-slab-svg.ts @@ -1,9 +1,9 @@ /** - * 교대 접속슬래브 삽도 (2026-11-04, 시안 ② 접속슬래브 카드): 평면(날개벽 사이), 단면(교대축 직각, 매립형 / 노출형), 흉벽–슬래브 접합 상세. - * 순수 문자열 함수. 평면·단면은 생성본 값으로 그리고 가로·세로 축척이 같다. 접합 상세는 부속재를 보여 주는 모식도다(표시용). + * 교대 접속슬래브 삽도 (2026-11-04, 시안 ② 접속슬래브 카드): 평면(날개벽 사이)과 단면 + 접합 상세(매립형 / 노출형, 중간 생략). + * 순수 문자열 함수. 생성본 값으로 그리고 가로·세로 축척이 같다(단면은 파단선으로 중간을 생략). 접합 부속재는 표시용이다. */ import type { AbutPlanOutline } from './abut-scene'; -import type { SlabForm, SlabStation } from '../core/abut/slab'; +import { slabLevelAt, type SlabForm, type SlabStation } from '../core/abut/slab'; import { dimLine } from './abut-wing-svg'; const INK = '#18212b'; @@ -92,108 +92,92 @@ export interface AbutSlabSectionDrawing { haunchLengthMm: number; } -/** 단면: 날개벽 축 방향(교대축 직각), 왼쪽이 배면. 흉벽 · 브라켓 단차, 슬래브(매립형 일정 두께 / 노출형 헌치), 치수와 EL. */ +/** + * 단면 + 접합 상세 (2026-11-04, 그림 합침): 교대축 직각, 왼쪽이 배면. 흉벽 쪽(헌치 끝 너머까지)과 먼 끝 0.8 m만 같은 축척으로 + * 그리고 그 사이는 파단선으로 생략한다. 간격(5)과 고무판 두께는 보이도록 키워 그리고, 부속재(충진재 · 스티로폼 · 고무판 · + * 타르페이퍼 · 다웰 · 강관)는 표시용이다. 치수: 7000 (중간 생략), 450, 흉벽 높이(브라켓 상면 ~ 흉벽 상단), 헌치, 브라켓 얹힘 길이. + */ export function abutSlabSectionSvg(d: AbutSlabSectionDrawing): string { - const k = 56; + const k = 150; // px per m in both zones const st = d.section; const t0 = st[0].t; const t1 = st[st.length - 1].t; - const low = -d.bracketHeightM - 0.9; - const hi = Math.max(0, ...st.map((p) => p.top)) + 0.5; - const minBottom = Math.min(...st.map((p) => p.bottom)); - const left = t1 - 1.0; - const X = (t: number) => 20 + (t - left) * k; - const Y = (y: number) => 12 + (hi - y) * k; const bH = d.bracketHeightM; + const wb = d.wallBackM; + const pb = d.parapetBackM; + const he = wb - d.haunchLengthMm / 1000; + const farLen = 0.8; + const tz = Math.max(d.form === 'exposed' ? he - 0.5 : wb - 1.5, t1 + farLen); // left end of the near zone + const breakW = 34; + const xL = 70; + const xNear0 = xL + farLen * k + breakW; + const X = (t: number) => (t <= t1 + farLen + 1e-9 ? xL + (t - t1) * k : xNear0 + (t - tz) * k); + const gapPx = 4; // 간격 drawn 4 px wide whatever its size + const Xs = (t: number) => X(t) - (t >= t0 - 1e-9 ? gapPx - (d.parapetGapMm / 1000) * k : 0); + const low = -bH - 0.75; + const hi = Math.max(0, ...st.map((p) => p.top)) + 0.15; + const Y = (y: number) => 26 + (hi - y) * k; + const lv = (t: number) => slabLevelAt(st, t); + // Slab pieces: far (t1 … t1 + 0.8) and near (tz … t0), each closed by a zigzag at the cut. + const zig = (x: number, top: number, bottom: number, dir: 1 | -1): [number, number][] => { + const h = bottom - top; + return [[x, top - 4], [x, top + h * 0.3], [x + dir * 6, top + h * 0.45], [x - dir * 6, top + h * 0.6], [x, top + h * 0.72], [x, bottom + 4]]; + }; + const piece = (from: number, to: number, cutLeft: boolean) => { + const ts = [from, ...st.map((p) => p.t).filter((t) => t > from + 1e-9 && t < to - 1e-9), to].sort((a, b) => a - b); + const top = ts.map((t) => [Xs(t), Y(lv(t).top)] as [number, number]); + const bottom = ts.map((t) => [Xs(t), Y(lv(t).bottom)] as [number, number]).reverse(); + const cutT = cutLeft ? from : to; + const cut = zig(Xs(cutT), Y(lv(cutT).top), Y(lv(cutT).bottom), cutLeft ? 1 : -1); + return cutLeft ? [...top, ...bottom, ...cut.reverse()] : [...top, ...cut, ...bottom]; + }; + const slabStyle = `fill="${SLAB}" stroke="${SLAB_EDGE}" stroke-width="1.4"`; const parts = [ - // 흉벽 (t 흉벽 배면 … 0) and the 브라켓 단차 (벽체 배면 … 흉벽 배면), cut below. - poly( - [[X(d.wallBackM), Y(low)], [X(0), Y(low)], [X(0), Y(0)], [X(d.parapetBackM), Y(0)], [X(d.parapetBackM), Y(-bH)], [X(d.wallBackM), Y(-bH)]], - `fill="${WALL}" stroke="${MUTED}"`, - ), - poly([...st.map((p) => [X(p.t), Y(p.top)] as [number, number]), ...[...st].reverse().map((p) => [X(p.t), Y(p.bottom)] as [number, number])], `fill="${SLAB}" stroke="${SLAB_EDGE}" stroke-width="1.4"`), - ]; - // 다웰 D25 at the middle of the 브라켓 width. - const dowelT = (d.wallBackM + d.parapetBackM) / 2; - parts.push(line(X(dowelT), Y(-bH - 0.35), X(dowelT), Y(-bH + 0.2), `stroke="${INK}" stroke-width="1.6"`)); - parts.push(text(X(d.wallBackM) - 6, Y(-bH - 0.45), '다웰 D25 @400', 'end', MUTED)); - // 치수: 길이 (흉벽 · 벽체 아래), 끝 두께 (왼쪽 끝), 노출형은 흉벽 쪽 끝 두께와 헌치 길이. - const yDim = Math.max(Y(minBottom), Y(low)) + 18; - parts.push(dimLine([X(t1), Y(st[st.length - 1].bottom)], [X(t0), Y(st[0].bottom)], yDim, 'h', `${d.lengthMm}`, true)); - const far = st[st.length - 1]; - parts.push(dimLine([X(t1), Y(far.top)], [X(t1), Y(far.bottom)], X(t1) - 10, 'v', `${d.thicknessMm}`)); - if (d.form === 'exposed') { - const he = d.wallBackM - d.haunchLengthMm / 1000; - const heP = st.find((p) => Math.abs(p.t - he) < 1e-6); - if (heP) parts.push(dimLine([X(he), Y(heP.bottom)], [X(d.wallBackM), Y(-bH)], yDim + 22, 'h', `헌치 ${d.haunchLengthMm}`, true)); - parts.push(text(X(dowelT) - 6, Y((st[0].top + st[0].bottom) / 2) + 4, `끝 ${Math.round(bH * 1000)}`, 'end')); - } - // 경사 (상면 가운데 위), 간격, EL. - const mid = st[Math.floor((st.length - 1) / 2)]; - parts.push(text((X(t1) + X(mid.t)) / 2, Y(Math.max(far.top, mid.top)) - 8, `경사 ${d.slopeLabel}`, 'middle')); - parts.push(text(X(t0), Y(st[0].top) - 8, `간격 ${d.parapetGapMm}`, 'middle', MUTED)); - parts.push(text(X(0) + 6, Y(0) + 4, `흉벽 상단 ${el(d.parapetTopEl)}`, 'start')); - parts.push(text(X(0) + 6, Y(-bH) + 4, `브라켓 상면 ${el(d.parapetTopEl - bH)}`, 'start')); - parts.push(text(X(0) + 6, Y(low) - 4, '흉벽 · 벽체', 'start', MUTED)); - const W = X(0) + 190; - const H = yDim + (d.form === 'exposed' ? 44 : 22); - return [ - ``, - ...parts, - '', - ].join(''); -} - -export interface AbutSlabJointDrawing { - form: SlabForm; - /** 경사 (%, + = 배면 쪽으로 올라감). "S = 4.00 % (하향)"처럼 표시한다. */ - gradePct: number; - parapetTopEl: number; - bracketHeightM: number; - parapetGapMm: number; - thicknessMm: number; -} - -/** 흉벽–접속슬래브 접합 상세 (삽도 기준, 모식도): 간격 · 아스팔트 충진재 / 스티로폼, 고무판, 타르페이퍼, 다웰 · 강관. */ -export function abutSlabJointSvg(d: AbutSlabJointDrawing): string { - // Fixed schematic (px): 흉벽 x 300–360 from y 20, 브라켓 상면 y 150 from x 260. - const bracketY = 150; - const padH = 4; - const slabEnd = 296; - const bottomY = bracketY - padH; - const exposed = d.form === 'exposed'; - const topY = exposed ? 20 : 86; - const drop = d.gradePct < 0 ? 10 : -6; // left end lower for a fall, higher for a rise - const slab: [number, number][] = exposed - ? [[20, topY + drop + 60], [20, topY + drop], [slabEnd, topY], [slabEnd, bottomY], [150, topY + drop * 0.5 + 60]] - : [[20, bottomY + drop], [20, topY + drop], [slabEnd, topY], [slabEnd, bottomY]]; - const gapMid = topY + (bottomY - topY) * 0.3; - const parts = [ - poly([[260, 262], [360, 262], [360, 20], [300, 20], [300, bracketY], [260, bracketY]], `fill="${WALL}" stroke="${MUTED}"`), - poly(slab, `fill="${SLAB}" stroke="${SLAB_EDGE}" stroke-width="1.4"`), - // 간격: 아스팔트 충진재 (위), 스티로폼 (아래). - ``, - ``, - // 고무판 150 × 150 × 15, 타르페이퍼 T15, 다웰 D25 in 강관 Φ60 L300. - ``, - line(260, bracketY, 300, bracketY, 'stroke="#8a5a2b" stroke-width="2.4"'), - ``, - line(280, bottomY - 46, 280, bracketY + 48, `stroke="${INK}" stroke-width="1.8"`), + poly([[X(wb), Y(low)], [X(0), Y(low)], [X(0), Y(0)], [X(pb), Y(0)], [X(pb), Y(-bH)], [X(wb), Y(-bH)]], `fill="${WALL}" stroke="${MUTED}"`), + poly(piece(t1, t1 + farLen, false), slabStyle), + poly(piece(tz, t0, true), slabStyle), ]; + // 간격: 아스팔트 충진재 (위 30 %), 스티로폼 (아래). + const gTop = Y(st[0].top); + const gBot = Y(-bH); + const gMid = gTop + (gBot - gTop) * 0.3; + const gx = Xs(t0); + parts.push(``); + parts.push(``); + // 타르페이퍼 (브라켓 상면), 고무판 150 (가운데), 다웰 D25 in 강관 Φ60 L300. + const xc = (X(wb) + Xs(t0)) / 2; + parts.push(line(X(wb), Y(-bH), X(pb), Y(-bH), 'stroke="#8a5a2b" stroke-width="2.4"')); + parts.push(``); + parts.push(``); + parts.push(line(xc, Y(-bH) - 0.34 * k, xc, Y(-bH) + 0.3 * k, `stroke="${INK}" stroke-width="1.8"`)); + // 치수. + const yRow = Y(-bH) + 18; + if (d.form === 'exposed') parts.push(dimLine([X(he), Y(lv(he).bottom)], [X(wb), Y(-bH)], yRow, 'h', `헌치 ${d.haunchLengthMm}`, true)); + // 브라켓 얹힘 길이 = 브라켓 폭 − 흉벽과 간격 (예: 300 − 5 = 295). + parts.push(dimLine([X(wb), Y(-bH)], [Xs(t0), Y(-bH)], yRow, 'h', `${Math.round((t0 - wb) * 1000)}`, true)); + const yLen = Y(low) + 22; + parts.push(dimLine([X(t1), Y(lv(t1).bottom)], [Xs(t0), Y(st[0].bottom)], yLen, 'h', `${d.lengthMm} (중간 생략)`, true)); + parts.push(dimLine([X(t1), Y(lv(t1).top)], [X(t1), Y(lv(t1).bottom)], X(t1) - 12, 'v', `${d.thicknessMm}`)); + parts.push(dimLine([X(0), Y(0)], [X(0), Y(-bH)], X(0) + 16, 'v', `${Math.round(bH * 1000)}`, true)); + // 글자: 경사, EL, 부속재 (오른쪽 · 왼쪽 아래로 끌어냄). + const xMidNear = (X(tz) + Xs(t0)) / 2; + parts.push(text(xMidNear, Y(lv(tz).top) - 12, `경사 ${d.slopeLabel}`, 'middle')); + const xr = X(0) + 58; const lead = (x1: number, y1: number, x2: number, y2: number, s: string, anchor: 'start' | 'end') => line(x1, y1, x2, y2, `stroke="${MUTED}" stroke-width="0.7"`) + text(x2 + (anchor === 'start' ? 3 : -3), y2 + 4, s, anchor); - parts.push(lead(298, topY + 6, 372, 58, `간격 ${d.parapetGapMm} · 아스팔트 충진재`, 'start')); - parts.push(lead(298, gapMid + 12, 372, 100, '스티로폼', 'start')); - parts.push(lead(272, bottomY + 2, 190, 196, '고무판 150 × 150 × 15', 'end')); - parts.push(lead(264, bracketY + 1, 190, 214, '타르페이퍼 T = 15', 'end')); - parts.push(lead(280, bracketY + 40, 190, 232, '다웰 D25 · 강관 Φ60 L300', 'end')); - parts.push(text(130, exposed ? 120 : (topY + bottomY) / 2 + 4, exposed ? `노출형 · 끝 ${Math.round(d.bracketHeightM * 1000)}` : `접속슬래브 ${d.thicknessMm}`, 'middle')); - parts.push(text(40, topY + drop - 6, `S = ${Math.abs(d.gradePct).toFixed(2)} % (${d.gradePct < 0 ? '하향' : '상향'})`, 'start')); - parts.push(text(368, 24, `흉벽 상단 ${el(d.parapetTopEl)}`, 'start')); - parts.push(text(368, bracketY + 4, `브라켓 상면 ${el(d.parapetTopEl - d.bracketHeightM)}`, 'start')); - parts.push(text(330, 252, '흉벽 · 벽체', 'middle', MUTED)); + parts.push(text(xr, Y(0) + 4, `흉벽 상단 ${el(d.parapetTopEl)}`, 'start')); + parts.push(lead(gx + 2, gTop + 4, xr - 4, Y(0) + 26, `간격 ${d.parapetGapMm} · 아스팔트 충진재`, 'start')); + parts.push(lead(gx + 2, gMid + 8, xr - 4, Y(0) + 48, '스티로폼', 'start')); + parts.push(text(xr, Y(-bH) + 4, `브라켓 상면 ${el(d.parapetTopEl - bH)}`, 'start')); + const xl = X(wb) - 24; + parts.push(lead(xc - 6, Y(-bH) - 1, xl, Y(-bH) + 46, '고무판 150 × 150 × 15', 'end')); + parts.push(lead(X(wb) + 4, Y(-bH) + 1, xl, Y(-bH) + 64, '타르페이퍼 T = 15', 'end')); + parts.push(lead(xc, Y(-bH) + 0.25 * k, xl, Y(-bH) + 82, '다웰 D25 @400 · 강관 Φ60 L300', 'end')); + parts.push(text(X(0) + 6, Y(low) - 4, '흉벽 · 벽체', 'start', MUTED)); + const W = xr + 200; + const H = yLen + 24; return [ - ``, + ``, ...parts, '', ].join(''); diff --git a/tests/abut-slab.test.ts b/tests/abut-slab.test.ts index 8f71628..4a492f5 100644 --- a/tests/abut-slab.test.ts +++ b/tests/abut-slab.test.ts @@ -7,12 +7,12 @@ import { buildAbut } from '../src/core/abut/geometry'; import { parseAbutments, toAbutInput } from '../src/core/abut/parse'; import { buildMembers } from '../src/core/abut/members'; import { buildWings } from '../src/core/abut/wings'; -import { applyShapeEdit, applySlabEdit, setSlabForm, setSlabSlope } from '../src/core/abut/edit'; +import { applyShapeEdit, applySlabEdit, setSlabForm } from '../src/core/abut/edit'; import { abutMemberRows } from '../src/core/abut/member-list'; import { resolveSlab, slabChangedKeys, slabGradePct, slabIssues, slabSection, slabSlopeLabel } from '../src/core/abut/slab'; import { checkStoredAbutments } from '../src/core/project'; import { abutPlanOutline, buildAbutGroup } from '../src/view/abut-scene'; -import { abutSlabJointSvg, abutSlabPlanSvg, abutSlabSectionSvg } from '../src/view/abut-slab-svg'; +import { abutSlabPlanSvg, abutSlabSectionSvg } from '../src/view/abut-slab-svg'; const RAG = fileURLToPath(new URL('../../../RAG/운곡교P1입력.xlsx', import.meta.url)); const rec = parseAbutments(XLSX.read(readFileSync(RAG), { type: 'buffer' })).abutments[0]; @@ -25,14 +25,13 @@ const build = (r = rec) => { const SHAPE = { parapetBackM: -0.5, wallBackM: -0.8, bracketHeightM: 0.85 }; describe('접속슬래브 입력 (2026-11-04, spec_abut §4.8, 시안 ② 접속슬래브 카드)', () => { - it('defaults: 매립형 · 하향 4 % · 7000 × 450 · 간격 5 / 20; the slope follows the form unless set', () => { - expect(resolveSlab()).toEqual({ form: 'buried', slope: 'down4', lengthMm: 7000, thicknessMm: 450, parapetGapMm: 5, wingGapMm: 20, haunchLengthMm: 2000 }); - expect(resolveSlab({ form: 'exposed' }).slope).toBe('grade'); - const exposed = setSlabForm({ ...rec, slab: { slope: 'down4' } }, 'exposed'); - expect(exposed.slab).toEqual({ form: 'exposed' }); // picking a form resets the slope to the form's own - expect(setSlabSlope(exposed, 'down4').slab).toEqual({ form: 'exposed', slope: 'down4' }); - expect(setSlabSlope(exposed, 'grade').slab).toEqual({ form: 'exposed' }); - expect(slabChangedKeys(exposed.slab)).toEqual(['form', 'slope']); + it('defaults: 매립형 · 7000 × 450 · 간격 5 / 20; the form sets the slope, an old stored slope is dropped', () => { + expect(resolveSlab()).toEqual({ form: 'buried', lengthMm: 7000, thicknessMm: 450, parapetGapMm: 5, wingGapMm: 20, haunchLengthMm: 2000 }); + const old = { form: 'exposed', slope: 'down4' } as unknown as Parameters[0]; + expect(resolveSlab(old)).toEqual({ ...resolveSlab(), form: 'exposed' }); + expect(setSlabForm({ ...rec, slab: old }, 'exposed').slab).toEqual({ form: 'exposed' }); + expect(slabGradePct(resolveSlab(old), 1.91)).toBe(1.91); // 노출형 = 종단경사, whatever was stored + expect(slabChangedKeys({ form: 'exposed' })).toEqual(['form']); expect(slabChangedKeys(undefined)).toEqual([]); }); @@ -43,7 +42,7 @@ describe('접속슬래브 입력 (2026-11-04, spec_abut §4.8, 시안 ② 접속 expect(() => applyShapeEdit({ ...rec, slab: { thicknessMm: 600 } }, 'bracketHeightMm', 500)).toThrow('접속슬래브 두께'); }); - it('slope text and value: 하향 4 % = −4; 종단경사 = the wing slope (A1 = −i)', () => { + it('slope text and value: 매립형 하향 4 % = −4; 노출형 종단경사 = the wing slope (A1 = −i)', () => { const { model } = build(); expect(slabGradePct(resolveSlab(), model.wing!.slopePct)).toBe(-4); expect(slabGradePct(resolveSlab({ form: 'exposed' }), 1.91)).toBe(1.91); @@ -112,12 +111,13 @@ describe('접속슬래브 입력 (2026-11-04, spec_abut §4.8, 시안 ② 접속 it('the project file keeps the slab input and refuses a wrong form', () => { expect(() => checkStoredAbutments([{ ...rec, slab: { form: 'exposed', lengthMm: 8000 } }])).not.toThrow(); + expect(() => checkStoredAbutments([{ ...rec, slab: { form: 'exposed', slope: 'down4' } as never }])).not.toThrow(); // earlier files expect(() => checkStoredAbutments([{ ...rec, slab: { form: 'open' } }])).toThrow('접속슬래브'); expect(() => checkStoredAbutments([{ ...rec, slab: { lengthMm: 'x' } }])).toThrow('접속슬래브'); }); }); -describe('접속슬래브 삽도 (평면 · 단면 · 접합 상세)', () => { +describe('접속슬래브 삽도 (평면, 단면 + 접합 상세)', () => { const { model, members, wings } = build(); const sh = members.shape; const sectionOf = (form: 'buried' | 'exposed') => { @@ -137,19 +137,13 @@ describe('접속슬래브 삽도 (평면 · 단면 · 접합 상세)', () => { for (const s of [`폭 ${(model.wing!.slabWidthMm / 1000).toFixed(3)} m`, '>7000<', '모따기 300 × 300', '접속슬래브']) expect(svg).toContain(s); }); - it('단면 매립형: 7000, 450, 하향 4 %, EL labels; 노출형 adds 헌치 2000 and 끝 850', () => { + it('단면 + 접합 상세 (중간 생략): 7000, 450, 흉벽 높이 850, 얹힘 295, accessories and EL; 노출형 adds 헌치 2000', () => { const buried = sectionOf('buried'); - for (const s of ['>7000<', '>450<', '경사 하향 4 %', '흉벽 상단 EL.46.867', '브라켓 상면 EL.46.017', '간격 5', '다웰 D25 @400']) expect(buried).toContain(s); + for (const s of ['7000 (중간 생략)', '>450<', '>850<', '>295<', '경사 하향 4 %', '흉벽 상단 EL.46.867', '브라켓 상면 EL.46.017', + '간격 5 · 아스팔트 충진재', '스티로폼', '고무판 150 × 150 × 15', '타르페이퍼 T = 15', '다웰 D25 @400 · 강관 Φ60 L300']) expect(buried).toContain(s); expect(buried).not.toContain('헌치'); + expect(buried.match(/ { - const svg = abutSlabJointSvg({ form: 'buried', gradePct: -4, parapetTopEl: 46.867, bracketHeightM: 0.85, parapetGapMm: 5, thicknessMm: 450 }); - for (const s of ['간격 5 · 아스팔트 충진재', '스티로폼', '고무판 150 × 150 × 15', '타르페이퍼 T = 15', '다웰 D25 · 강관 Φ60 L300', 'S = 4.00 % (하향)', '흉벽 상단 EL.46.867', '브라켓 상면 EL.46.017', '접속슬래브 450']) { - expect(svg).toContain(s); - } - expect(abutSlabJointSvg({ form: 'exposed', gradePct: 1.91, parapetTopEl: 46.867, bracketHeightM: 0.85, parapetGapMm: 5, thicknessMm: 450 })).toContain('노출형 · 끝 850'); + for (const s of ['헌치 2000', '경사 종단경사 +', '>295<']) expect(exposed).toContain(s); }); });