feat(abut): 접속슬래브 경사는 형식이 정함, 단면 · 접합 상세를 파단선 단면 하나로 합침, 흉벽 높이 입력

- 경사 선택 삭제: 매립형 = 하향 4 %, 노출형 = 종단경사 따라 (노트 표기). 예전 파일의 slope 값은 읽고 무시
- 단면 + 접합 상세: 흉벽 쪽과 먼 끝만 그리고 중간 생략, 부속재 · 치수(7000 · 450 · 850 · 295 · 헌치) · EL을 한 그림에
- 흉벽 높이 (브라켓 상면 ~ 흉벽 상단) 입력 = 벽체·흉벽 카드 브라켓 높이와 같은 값
- 자동 값: 브라켓 얹힘 길이 = 브라켓 폭 − 흉벽과 간격 (295)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
ypshimandClaude Haiku 5.5 committed 2026-11-04 10:27:20 +09:00
1 parent 4b8b80ae97
commit 9835fb42b3
7 files changed
+157 -199

No files matched your search

+5 -12
View File
@@ -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<AbutSlab> | undefined, shape: Partial<AbutS
return s.thicknessMm > 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<AbutSlab> = { ...(rec.slab ?? {}), form };
delete slab.slope;
const { slope: _old, ...kept } = (rec.slab ?? {}) as Partial<AbutSlab> & { slope?: unknown };
const slab: Partial<AbutSlab> = { ...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<AbutSlab> = { ...(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' },
+8 -16
View File
@@ -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<SlabForm, string> = { 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>): AbutSlab {
const form = changes?.form ?? ABUT_SLAB_DEFAULT.form;
return { ...ABUT_SLAB_DEFAULT, slope: slabDefaultSlope(form), ...(changes ?? {}) };
const { slope: _old, ...rest } = (changes ?? {}) as Partial<AbutSlab> & { 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<AbutSlab>): (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)} %`;
}
+3 -1
View File
@@ -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}이(가) 맞지 않습니다.`);
}
}
+44 -50
View File
@@ -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 = '<path d="M31 22V12h3V2h4v20z" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/>';
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}<path d="${slab}" fill="currentColor" fill-opacity="0.18" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/>`;
},
};
/** ② 접속슬래브 카드 (시안, 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) => ($<HTMLElement>('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',
),
);
+1 -2
View File
@@ -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; }
+79 -95
View File
@@ -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 [
`<svg viewBox="0 0 ${W.toFixed(0)} ${H.toFixed(0)}" width="100%" role="img" aria-label="접속슬래브 단면 · ${d.form === 'exposed' ? '노출형' : '매립형'} · 경사 ${d.slopeLabel} · 길이 ${d.lengthMm} · 두께 ${d.thicknessMm}">`,
...parts,
'</svg>',
].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"`),
// 간격: 아스팔트 충진재 (위), 스티로폼 (아래).
`<rect x="${slabEnd}" y="${topY}" width="4" height="${(gapMid - topY).toFixed(1)}" fill="#3b3f45"/>`,
`<rect x="${slabEnd}" y="${gapMid.toFixed(1)}" width="4" height="${(bottomY - gapMid).toFixed(1)}" fill="#ffffff" stroke="${MUTED}" stroke-width="0.6"/>`,
// 고무판 150 × 150 × 15, 타르페이퍼 T15, 다웰 D25 in 강관 Φ60 L300.
`<rect x="268" y="${bottomY}" width="24" height="${padH}" fill="#2b2b2b"/>`,
line(260, bracketY, 300, bracketY, 'stroke="#8a5a2b" stroke-width="2.4"'),
`<rect x="276" y="${bottomY - 40}" width="8" height="40" fill="none" stroke="${INK}" stroke-width="0.9"/>`,
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(`<rect x="${gx.toFixed(1)}" y="${gTop.toFixed(1)}" width="${(X(pb) - gx).toFixed(1)}" height="${(gMid - gTop).toFixed(1)}" fill="#3b3f45"/>`);
parts.push(`<rect x="${gx.toFixed(1)}" y="${gMid.toFixed(1)}" width="${(X(pb) - gx).toFixed(1)}" height="${(gBot - gMid).toFixed(1)}" fill="#ffffff" stroke="${MUTED}" stroke-width="0.5"/>`);
// 타르페이퍼 (브라켓 상면), 고무판 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(`<rect x="${(xc - 0.075 * k).toFixed(1)}" y="${(Y(-bH) - 3).toFixed(1)}" width="${(0.15 * k).toFixed(1)}" height="3" fill="#2b2b2b"/>`);
parts.push(`<rect x="${(xc - 0.03 * k).toFixed(1)}" y="${(Y(-bH) - 3 - 0.3 * k).toFixed(1)}" width="${(0.06 * k).toFixed(1)}" height="${(0.3 * k).toFixed(1)}" fill="none" stroke="${INK}" stroke-width="0.9"/>`);
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 [
`<svg viewBox="0 -22 560 292" width="100%" role="img" aria-label="흉벽–접속슬래브 접합 상세 (모식도): 간격 ${d.parapetGapMm}, 고무판, 타르페이퍼, 다웰 D25">`,
`<svg viewBox="0 0 ${W.toFixed(0)} ${H.toFixed(0)}" width="100%" role="img" aria-label="접속슬래브 단면 · 접합 상세 · ${d.form === 'exposed' ? '노출형' : '매립형'} · 경사 ${d.slopeLabel} · 길이 ${d.lengthMm} (중간 생략) · 두께 ${d.thicknessMm} · 흉벽 높이 ${Math.round(bH * 1000)}">`,
...parts,
'</svg>',
].join('');
+17 -23
View File
@@ -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<typeof resolveSlab>[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(/<polygon /g)).toHaveLength(3); // 흉벽 · 벽체, slab far piece, slab near piece
const exposed = sectionOf('exposed');
for (const s of ['헌치 2000', '끝 850', '경사 종단경사 +']) expect(exposed).toContain(s);
});
it('접합 상세: the accessories and the EL labels', () => {
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);
});
});