diff --git a/index.html b/index.html
index ba2aa1d..b71b739 100644
--- a/index.html
+++ b/index.html
@@ -199,6 +199,7 @@
+
diff --git a/src/core/abut/edit.ts b/src/core/abut/edit.ts
index c0d935e..0535fc5 100644
--- a/src/core/abut/edit.ts
+++ b/src/core/abut/edit.ts
@@ -71,20 +71,23 @@ export function applyShapeEdit(rec: AbutRecord, key: keyof AbutShape, value: num
/** 형상 입력 fields on the 교대 카드 (시안 ② 벽체·흉벽, 확대기초·말뚝), in the order of the mockup. */
-export const SHAPE_EDIT_FIELDS: { key: keyof AbutShape; label: string }[] = [
- { key: 'wallFrontMm', label: '교좌면 폭 (흉벽 전면 기준, mm)' },
- { key: 'wallThicknessMm', label: '벽체 두께 (mm)' },
- { key: 'parapetHeightMm', label: '흉벽 높이 (mm)' },
- { key: 'parapetThicknessMm', label: '흉벽 두께 (mm)' },
- { key: 'bracketHeightMm', label: '브라켓 높이 (mm)' },
- { key: 'footingThicknessMm', label: '저판 두께 (mm)' },
- { key: 'footingFrontMm', label: '저판 앞굽 끝 (mm)' },
- { 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, 계산 미사용)' },
+export const SHAPE_EDIT_FIELDS: { key: keyof AbutShape; label: string; group: 'wall' | 'footing'; note?: string }[] = [
+ // 벽체 · 흉벽 (시안 입력 격자 순서). 벽체 두께는 자동 = 브라켓 폭 + 흉벽 두께 + 교좌면 폭.
+ { 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: 'bracketWidthMm', label: '브라켓 폭 (mm)', group: 'wall' },
+ { key: 'shrinkJointCount', label: '수축줄눈 개수', group: 'wall' },
+ { key: 'parapetLevelLeftMm', label: '흉벽 상단 Level 좌 (mm)', group: 'wall', note: '기준 확인 QA-4' },
+ { key: 'parapetLevelRightMm', label: '흉벽 상단 Level 우 (mm)', group: 'wall', note: '기준 확인 QA-4' },
+ { key: 'seatStepIgnoreMm', label: '인접 무시 기준 (mm)', group: 'wall' },
+ // 확대기초 · 말뚝 (분리 카드).
+ { key: 'footingThicknessMm', label: '저판 두께 (mm)', group: 'footing' },
+ { key: 'footingFrontMm', label: '저판 앞굽 끝 (mm)', group: 'footing' },
+ { key: 'footingBackMm', label: '저판 뒷굽 끝 (mm)', group: 'footing' },
+ { key: 'pileEdgeMm', label: '말뚝 연단 (mm)', group: 'footing' },
+ { key: 'pileSpacingMm', label: '말뚝 간격 (mm)', group: 'footing' },
];
/** 받침 규칙 입력칸 (② 교량받침 카드): 교각 솔플레이트 규칙과 같은 칸. 받침높이 H는 교대 받침 시트 값으로 고정 (2026-11-03). */
diff --git a/src/core/abut/member-list.ts b/src/core/abut/member-list.ts
index 6e220f9..74297e0 100644
--- a/src/core/abut/member-list.ts
+++ b/src/core/abut/member-list.ts
@@ -1,13 +1,18 @@
import type { WingForm } from './geometry';
-import { ABUT_SHAPE_DEFAULT, type AbutShape } from './shape';
+import { ABUT_SHAPE_DEFAULT, normalizeShape, wallThicknessMm, type AbutShape } from './shape';
+
+/** 형상 입력 중 벽체·흉벽 카드와 확대기초·말뚝 카드의 항목 (변경 표시를 카드별로 나눈다). */
+const WALL_KEYS: (keyof AbutShape)[] = ['wallFrontMm', 'parapetHeightMm', 'parapetThicknessMm', 'bracketHeightMm', 'bracketWidthMm', 'shrinkJointCount', 'parapetLevelLeftMm', 'parapetLevelRightMm', 'seatStepIgnoreMm'];
+const FOOTING_KEYS: (keyof AbutShape)[] = ['footingThicknessMm', 'footingFrontMm', 'footingBackMm', 'pileEdgeMm', 'pileSpacingMm'];
/** 부재 · 교대 list in ② (시안): the card each row opens. */
-export type AbutMemberId = 'bearing' | 'shape' | 'wing' | 'slab' | 'ground';
+export type AbutMemberId = 'bearing' | 'shape' | 'footing' | 'wing' | 'slab' | 'ground';
/** Cards that take input in this step; slab and ground only show their values (phase 1, 2026-11-03). */
export const ABUT_MEMBER_EDITABLE: Record = {
bearing: true,
shape: true,
+ footing: true,
wing: true,
slab: false,
ground: false,
@@ -32,14 +37,13 @@ export interface AbutMemberRow {
}
/**
- * The five rows of the 부재 · 교대 list. `slabWidth` is the computed 접속슬래브 폭 text, passed in
+ * The six rows of the 부재 · 교대 list (시안 순서). `slabWidth` is the computed 접속슬래브 폭 text, passed in
* so this stays a pure function of the record.
*/
export function abutMemberRows(rec: AbutMemberInput, slabWidth: string): AbutMemberRow[] {
- const shape = { ...ABUT_SHAPE_DEFAULT, ...(rec.shape ?? {}) };
- const shapeChanged = (Object.keys(ABUT_SHAPE_DEFAULT) as (keyof AbutShape)[]).some(
- (key) => (rec.shape?.[key] ?? ABUT_SHAPE_DEFAULT[key]) !== ABUT_SHAPE_DEFAULT[key],
- );
+ const changes = normalizeShape(rec.shape);
+ const shape = { ...ABUT_SHAPE_DEFAULT, ...changes };
+ const changed = (keys: (keyof AbutShape)[]) => keys.some((key) => (changes[key] ?? ABUT_SHAPE_DEFAULT[key]) !== ABUT_SHAPE_DEFAULT[key]);
const wings = rec.wings ?? { left: 'general' as WingForm, right: 'general' as WingForm };
return [
{
@@ -51,8 +55,14 @@ export function abutMemberRows(rec: AbutMemberInput, slabWidth: string): AbutMem
{
id: 'shape',
label: '벽체·흉벽',
- summary: `벽체 두께 ${shape.wallThicknessMm} · 흉벽 ${shape.parapetHeightMm}`,
- changed: shapeChanged,
+ summary: `벽체 두께 ${wallThicknessMm(shape)} (자동) · 흉벽 ${shape.parapetHeightMm}`,
+ changed: changed(WALL_KEYS),
+ },
+ {
+ id: 'footing',
+ label: '확대기초 · 말뚝',
+ summary: `저판 ${shape.footingThicknessMm} · 말뚝 연단 ${shape.pileEdgeMm} · 간격 ${shape.pileSpacingMm}`,
+ changed: changed(FOOTING_KEYS),
},
{
id: 'wing',
diff --git a/src/core/abut/members.ts b/src/core/abut/members.ts
index 77bd738..287d95c 100644
--- a/src/core/abut/members.ts
+++ b/src/core/abut/members.ts
@@ -55,12 +55,17 @@ export function buildMembers(model: AbutModel, foundation: { bottomEl?: number;
const footingBottomEl = foundation.bottomEl;
const footingTopEl = footingBottomEl === undefined ? NaN : footingBottomEl + shape.footingThicknessM;
- const wallSegments: WallSegment[] = sorted.map((b, k) => ({
- sFrom: bounds[k],
- sTo: bounds[k + 1],
- seatEl: b.seatEl,
- heightM: b.seatEl - footingTopEl,
- }));
+ // 인접 무시 기준 (2026-11-03): a step smaller than shape.seatStepIgnoreM joins the segment before it (one 교좌면 EL).
+ const wallSegments: WallSegment[] = [];
+ sorted.forEach((b, k) => {
+ const prev = wallSegments[wallSegments.length - 1];
+ // Compared with the neighbouring bearing (인접), not with the first step of the joined run.
+ if (prev && k > 0 && Math.abs(b.seatEl - sorted[k - 1].seatEl) < shape.seatStepIgnoreM) {
+ prev.sTo = bounds[k + 1];
+ return;
+ }
+ wallSegments.push({ sFrom: bounds[k], sTo: bounds[k + 1], seatEl: b.seatEl, heightM: b.seatEl - footingTopEl });
+ });
// 받침 ①(첫 입력)과 마지막 입력 받침이 흉벽 상단선을 정한다 (spec §4.4).
const first = model.bearings[0];
diff --git a/src/core/abut/shape.ts b/src/core/abut/shape.ts
index 391620d..f636c17 100644
--- a/src/core/abut/shape.ts
+++ b/src/core/abut/shape.ts
@@ -2,15 +2,17 @@
* 교대 형상 입력 (2026-11-03, 시안 ② 벽체·흉벽 · 확대기초·말뚝 카드): the values that used to be constants, in mm.
* The defaults are the spec_abut values. A record keeps only the values the user changed; the rest come from here.
*
- * Axis frame: t (교대축 직각) is + toward the front. 벽체 전면 = wallFrontMm; 배면 = wallFrontMm − wallThicknessMm.
- * 흉벽 sits behind the axis (t −parapetThicknessMm … 0). 저판: footingFrontMm (앞굽 끝) … footingBackMm (뒷굽 끝).
+ * Axis frame: t (교대축 직각) is + toward the front. 벽체 전면 = wallFrontMm (교좌면 폭, 흉벽 전면 t 0에서).
+ * 흉벽 sits behind the axis (t −parapetThicknessMm … 0); the 브라켓 (bracketWidthMm) behind it. 벽체 배면 = −(흉벽 두께 + 브라켓 폭),
+ * so 벽체 두께 = 브라켓 폭 + 흉벽 두께 + 교좌면 폭 (시안: 300 + 500 + 1600 = 2400, 자동). 저판: footingFrontMm … footingBackMm.
*/
export interface AbutShape {
wallFrontMm: number;
- wallThicknessMm: number;
parapetHeightMm: number;
parapetThicknessMm: number;
bracketHeightMm: number;
+ /** 브라켓 폭 (mm): 흉벽 배면에서 벽체 배면까지 (시안 300). */
+ bracketWidthMm: number;
footingThicknessMm: number;
footingFrontMm: number;
footingBackMm: number;
@@ -21,14 +23,16 @@ export interface AbutShape {
/** 흉벽 상단 Level 좌·우 (mm). Not used by the geometry (QA-4, 가정). */
parapetLevelLeftMm: number;
parapetLevelRightMm: number;
+ /** 인접 무시 기준 (mm): 이웃 교좌면 EL 차이가 이 값보다 작으면 계단을 두지 않고 한 구간으로 묶는다 (교각 Q2와 같은 뜻). */
+ seatStepIgnoreMm: number;
}
export const ABUT_SHAPE_DEFAULT: AbutShape = {
wallFrontMm: 1600,
- wallThicknessMm: 2400,
parapetHeightMm: 2500,
parapetThicknessMm: 500,
bracketHeightMm: 850,
+ bracketWidthMm: 300,
footingThicknessMm: 1100,
footingFrontMm: 2800,
footingBackMm: -1800,
@@ -37,8 +41,27 @@ export const ABUT_SHAPE_DEFAULT: AbutShape = {
shrinkJointCount: 1,
parapetLevelLeftMm: 450,
parapetLevelRightMm: 995,
+ seatStepIgnoreMm: 5,
};
+/**
+ * The user's changes with the old 벽체 두께 input (before 2026-11-03) turned into the 브라켓 폭:
+ * 브라켓 폭 = 벽체 두께 − 교좌면 폭 − 흉벽 두께. Saved projects keep opening with the same wall.
+ */
+export function normalizeShape(changes?: Partial & { wallThicknessMm?: number }): Partial {
+ if (!changes) return {};
+ const { wallThicknessMm, ...rest } = changes;
+ if (wallThicknessMm === undefined || rest.bracketWidthMm !== undefined) return rest;
+ const front = rest.wallFrontMm ?? ABUT_SHAPE_DEFAULT.wallFrontMm;
+ const parapet = rest.parapetThicknessMm ?? ABUT_SHAPE_DEFAULT.parapetThicknessMm;
+ return { ...rest, bracketWidthMm: wallThicknessMm - front - parapet };
+}
+
+/** 벽체 두께 (mm, 자동) = 브라켓 폭 + 흉벽 두께 + 교좌면 폭. */
+export function wallThicknessMm(v: AbutShape): number {
+ return v.bracketWidthMm + v.parapetThicknessMm + v.wallFrontMm;
+}
+
/** The same values in m, for the geometry (the model works in m). */
export interface ResolvedShape {
wallFrontM: number;
@@ -52,6 +75,8 @@ export interface ResolvedShape {
pileEdgeM: number;
pileSpacingM: number;
pileRowsT: number[];
+ /** 인접 무시 기준 (m). */
+ seatStepIgnoreM: number;
}
/** The pile rows (t, m) of the spec: 배면 열, 중간 열, 전면 열. Not an input on the card. */
@@ -59,8 +84,8 @@ export const PILE_ROWS_T = [-1.15, 0.85, 2.15];
/** Merges the user's changes over the defaults and converts to m. */
export function resolveShape(changes?: Partial): ResolvedShape {
- const v: AbutShape = { ...ABUT_SHAPE_DEFAULT, ...(changes ?? {}) };
- const wallBackMm = v.wallFrontMm - v.wallThicknessMm;
+ const v: AbutShape = { ...ABUT_SHAPE_DEFAULT, ...normalizeShape(changes) };
+ const wallBackMm = -(v.parapetThicknessMm + v.bracketWidthMm);
return {
wallFrontM: v.wallFrontMm / 1000,
wallBackM: wallBackMm / 1000,
@@ -73,22 +98,25 @@ export function resolveShape(changes?: Partial): ResolvedShape {
pileEdgeM: v.pileEdgeMm / 1000,
pileSpacingM: v.pileSpacingMm / 1000,
pileRowsT: PILE_ROWS_T,
+ seatStepIgnoreM: v.seatStepIgnoreMm / 1000,
};
}
/** Input problems in Korean; empty when the shape can be built. */
export function shapeIssues(changes: Partial): string[] {
- const v: AbutShape = { ...ABUT_SHAPE_DEFAULT, ...changes };
+ const v: AbutShape = { ...ABUT_SHAPE_DEFAULT, ...normalizeShape(changes) };
const out: string[] = [];
- if (!(v.wallThicknessMm > 0)) out.push('벽체 두께는 0보다 커야 합니다.');
+ if (!(v.wallFrontMm > 0)) out.push('교좌면 폭은 0보다 커야 합니다.');
+ if (!(v.bracketWidthMm >= 0)) out.push('브라켓 폭은 0 이상이어야 합니다.');
if (!(v.parapetHeightMm > 0)) out.push('흉벽 높이는 0보다 커야 합니다.');
if (!(v.parapetThicknessMm > 0)) out.push('흉벽 두께는 0보다 커야 합니다.');
if (!(v.bracketHeightMm >= 0)) out.push('브라켓 높이는 0 이상이어야 합니다.');
if (!(v.footingThicknessMm > 0)) out.push('저판 두께는 0보다 커야 합니다.');
if (!(v.pileEdgeMm > 0) || !(v.pileSpacingMm > 0)) out.push('말뚝 연단과 간격은 0보다 커야 합니다.');
- const wallBack = v.wallFrontMm - v.wallThicknessMm;
+ const wallBack = -(v.parapetThicknessMm + v.bracketWidthMm);
if (!(v.footingFrontMm > v.wallFrontMm)) out.push('저판 앞굽 끝은 벽체 전면보다 앞에 있어야 합니다.');
if (!(v.footingBackMm < wallBack)) out.push('저판 뒷굽 끝은 벽체 배면보다 뒤에 있어야 합니다.');
if (!(Number.isInteger(v.shrinkJointCount) && v.shrinkJointCount >= 0)) out.push('수축줄눈 개수는 0 이상의 정수여야 합니다.');
+ if (!(v.seatStepIgnoreMm >= 0)) out.push('인접 무시 기준은 0 이상이어야 합니다.');
return out;
}
diff --git a/src/main.ts b/src/main.ts
index 80887b6..74cea2e 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, ABUT_SEGMENT_HEADER, abutInputRows, abutSegmentRows, abutSummaryRows } from './core/abut/summary';
import { applyAbutEdit, applyBearingEdit, applyBearingRule, applyShapeEdit, ABUT_EDIT_FIELDS, BEARING_RULE_FIELDS, SHAPE_EDIT_FIELDS, restoreAbutDefault, setWingForm } from './core/abut/edit';
-import { ABUT_SHAPE_DEFAULT, resolveShape } from './core/abut/shape';
+import { ABUT_SHAPE_DEFAULT, normalizeShape, resolveShape, wallThicknessMm } from './core/abut/shape';
+import { abutWallElevationSvg, abutWallSectionSvg, type AbutWallDrawing } from './view/abut-wall-svg';
import { abutShown, piersShown } from './core/abut/visibility';
import { abutBearingPlanSvg, abutBearingSectionSvg, abutSolplateSvg, type AbutBearingDetail } from './view/abut-bearing-svg';
import {
@@ -718,7 +719,7 @@ const ABUT_QA_NOTES = [
const ABUT_SLOTS = {
shape: { root: 'abut-panel', name: 'abut-panel-name', input: 'abut-panel-input', toggle: 'abut-panel-edit-toggle', editor: 'abut-panel-editor', summary: 'abut-panel-summary', basic: 'abut-panel-basic', bearings: 'abut-panel-bearings', shape: 'abut-panel-shape', msg: 'abut-panel-msg', prefix: 'abs' },
// ② has no 편집 toggle and no basic items: the card sits under the 3D view and shows the picked member (2026-11-03).
- plan: { wing: 'abut-panel-plan-wing', root: 'abut-panel-plan', name: 'abut-panel-plan-name', bearings: 'abut-panel-plan-bearings', shape: 'abut-panel-plan-shape', msg: 'abut-panel-plan-msg', prefix: 'abp' },
+ plan: { wing: 'abut-panel-plan-wing', footing: 'abut-panel-plan-footing', root: 'abut-panel-plan', name: 'abut-panel-plan-name', bearings: 'abut-panel-plan-bearings', shape: 'abut-panel-plan-shape', msg: 'abut-panel-plan-msg', prefix: 'abp' },
} as const;
/** Shows the 교대 입력 in place of the pier info when an abutment is selected (2026-11-03). */
@@ -896,6 +897,7 @@ function applyAbutMemberCard(rec: AbutRecord): void {
const member = state.abutMember;
$('abut-panel-plan-bearings').hidden = member !== 'bearing';
$('abut-panel-plan-shape').hidden = member !== 'shape';
+ $('abut-panel-plan-footing').hidden = member !== 'footing';
$('abut-panel-plan-wing').hidden = member !== 'wing';
// The card title is the picked row; its 요약 follows the name (시안 머리줄).
const slabWidth = abutSummaryRows(rec).find(([label]) => label === '접속슬래브 폭')?.[1] ?? '—';
@@ -1022,8 +1024,69 @@ function fillAbutSlot(slot: (typeof ABUT_SLOTS)[keyof typeof ABUT_SLOTS], rec: A
bearings.append(zLab, zIn, sLab, sIn);
}
+ // ② 벽체·흉벽 카드 (시안 순서, 2026-11-03): 벽체 형식 → 삽도 2개 → 입력 격자 → 자동 값 → 구간 표 → 확인 필요 → 메모.
+ // 저판·말뚝 입력은 확대기초 · 말뚝 카드로 나눈다. ①은 모든 형상 입력을 한 곳에 둔다.
+ const footingBox = 'footing' in slot ? $(slot.footing) : shape;
+ footingBox.replaceChildren();
+ let built: { model: AbutModel; members: AbutMembers } | null = null;
+ if (planCard) {
+ try {
+ const model = buildAbut(toAbutInput(rec));
+ built = { model, members: buildMembers(model, { bottomEl: rec.foundationBottomEl, pileLengthMm: rec.pileLengthMm }, rec.shape) };
+ } catch {
+ // A model that does not build shows no 삽도 or 구간 표 (the errors are reported on import).
+ }
+ const kinds = el('div', undefined, 'abut-bearing-pick');
+ kinds.append(el('span', '벽체 형식', 'muted small'));
+ for (const [name, on] of [['기본형', true], ['브라켓형', false], ['오목형', false], ['반일체식', false]] as const) {
+ const b = el('button', name, 'btn small');
+ b.type = 'button';
+ b.setAttribute('aria-pressed', String(on));
+ if (!on) {
+ b.disabled = true;
+ b.title = '규칙 미정 (QA-8)';
+ }
+ kinds.append(b);
+ }
+ kinds.append(el('span', '브라켓형 · 오목형 · 반일체식은 규칙 미정 (QA-8)', 'muted small'));
+ shape.append(kinds);
+ if (built) {
+ const v = { ...ABUT_SHAPE_DEFAULT, ...normalizeShape(rec.shape) };
+ const s = built.members.shape;
+ const drawing: AbutWallDrawing = {
+ lengthM: built.model.length,
+ segments: built.members.wallSegments,
+ bearings: built.model.bearings,
+ footingTopEl: built.members.footingTopEl,
+ footingBottomEl: built.members.footingBottomEl ?? built.members.footingTopEl - s.footingThicknessM,
+ parapetTopStartEl: built.members.parapetTopStartEl,
+ parapetTopEndEl: built.members.parapetTopEndEl,
+ wallFrontM: s.wallFrontM,
+ parapetBackM: s.parapetBackM,
+ wallBackM: s.wallBackM,
+ footingFrontM: s.footingFrontM,
+ footingBackM: s.footingBackM,
+ bracketHeightM: s.bracketHeightM,
+ shrinkJointCount: v.shrinkJointCount,
+ bracketWidthMm: v.bracketWidthMm,
+ parapetThicknessMm: v.parapetThicknessMm,
+ seatWidthMm: v.wallFrontMm,
+ };
+ const figs = el('div', undefined, 'abut-wall-figs');
+ for (const [svg, caption] of [
+ [abutWallSectionSvg(drawing), '교대축 직각 단면 · ① 구간 · 축척 같음'],
+ [abutWallElevationSvg(drawing), '정면 · 배면에서 본 모습 · 왼쪽 IP3(① 쪽) → 오른쪽 IP1 · 수축줄눈 표시'],
+ ]) {
+ const fig = el('figure', undefined, 'abut-fig');
+ fig.innerHTML = svg;
+ fig.append(el('figcaption', caption, 'muted small'));
+ figs.append(fig);
+ }
+ shape.append(figs);
+ }
+ }
for (const f of SHAPE_EDIT_FIELDS) {
- const current = rec.shape?.[f.key] ?? ABUT_SHAPE_DEFAULT[f.key];
+ const current = normalizeShape(rec.shape)[f.key] ?? ABUT_SHAPE_DEFAULT[f.key];
const id = idOf(`shape-${f.key}`);
const lab = el('label', f.label);
lab.htmlFor = id;
@@ -1036,33 +1099,46 @@ function fillAbutSlot(slot: (typeof ABUT_SLOTS)[keyof typeof ABUT_SLOTS], rec: A
fail(err, input, String(current));
}
});
- shape.append(lab, input, el('span', `기본 ${ABUT_SHAPE_DEFAULT[f.key]}`, 'muted small'));
+ const note = el('span', `기본 ${ABUT_SHAPE_DEFAULT[f.key]}${f.note ? ` · ${f.note}` : ''}`, f.note ? 'muted small abut-note-warn' : 'muted small');
+ (planCard && f.group === 'footing' ? footingBox : shape).append(lab, input, note);
}
- // 구간 표 (시안 하단): one row per 교좌면 step, from the model (2026-11-03).
if (planCard) {
- try {
- const model = buildAbut(toAbutInput(rec));
- const members = buildMembers(model, { bottomEl: rec.foundationBottomEl, pileLengthMm: rec.pileLengthMm }, rec.shape);
+ // 자동 값 (시안 입력 격자 마지막 줄): 벽체 두께, 흉벽 상단 IP3 / IP1 끝 (가정, QA-4), 교대축.
+ const v = { ...ABUT_SHAPE_DEFAULT, ...normalizeShape(rec.shape) };
+ shape.append(el('label', '벽체 두께 (자동)'), el('span', `${wallThicknessMm(v)} mm`, 'num'), el('span', '= 브라켓 폭 + 흉벽 두께 + 교좌면 폭', 'muted small'));
+ if (built) {
+ shape.append(
+ el('label', '흉벽 상단 IP3 / IP1 끝 (m)'),
+ el('span', `${built.members.parapetTopEndEl.toFixed(3)} / ${built.members.parapetTopStartEl.toFixed(3)}`, 'num'),
+ el('span', '가정 (QA-4)', 'muted small abut-note-warn'),
+ el('label', '교대축 (자동)'),
+ el('span', `${built.model.length.toFixed(3)} m`, 'num'),
+ el('span', 'IP1 → IP3', 'muted small'),
+ );
+ // 구간 표 (시안 하단): one row per 교좌면 step.
const table = document.createElement('table');
table.className = 'abut-seg';
const thead = table.createTHead().insertRow();
for (const h of ABUT_SEGMENT_HEADER) thead.append(el('th', h));
const tbody = table.createTBody();
- for (const row of abutSegmentRows(model, members)) {
+ for (const row of abutSegmentRows(built.model, built.members)) {
const tr = tbody.insertRow();
for (const cell of row) tr.append(el('td', cell));
}
- shape.append(table);
- } catch {
- // A model that does not build shows no 구간 표 (the errors are reported on import).
+ shape.append(el('p', '교좌면 계단 구간 · IP3(① 쪽)에서 잰 거리', 'lbl'), table);
}
- }
- // 확인 필요 (spec_abut, 시안 오른쪽 패널): the open 교대 questions that touch the shape (2026-11-03).
- if (planCard) {
+ // 확인 필요 (spec_abut, 시안 오른쪽 패널): the open 교대 questions that touch the shape (2026-11-03).
const qa = el('div', undefined, 'abut-qa');
qa.append(el('p', '확인 필요 (spec_abut)', 'lbl'));
for (const line of ABUT_QA_NOTES) qa.append(el('p', line, 'muted small'));
shape.append(qa);
+ shape.append(
+ el(
+ 'p',
+ '시안 메모 · 벽체는 교좌면 계단 구간마다 높이가 다릅니다. 흉벽 높이 2500은 ① 구간 교좌면 기준(가정, QA-4)이고, Level 좌·우는 계산에 쓰지 않습니다. 인접 무시 기준보다 작은 교좌면 단차는 한 구간으로 묶습니다.',
+ 'muted small abut-memo',
+ ),
+ );
}
}
diff --git a/src/style.css b/src/style.css
index 6da6275..ffc6394 100644
--- a/src/style.css
+++ b/src/style.css
@@ -910,3 +910,11 @@ body { background: #eef1f4; font-family: 'Noto Sans KR', system-ui, sans-serif;
#abut-panel-plan .abut-fig svg { display: block; width: 100%; height: auto; }
#abut-panel-plan > .edit-row { padding: 0 12px 10px; }
@media (max-width: 900px) { #abut-panel-plan .abut-bearing-figs { grid-template-columns: minmax(0, 1fr); } }
+
+/* ② 벽체·흉벽 / 확대기초·말뚝 카드 (2026-11-03): the two 삽도 side by side, notes and tables across the card. */
+#abut-panel-plan-footing.abut-shape-view { display: grid; grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr) minmax(0, 1fr); gap: 6px 10px; align-items: center; }
+#abut-panel-plan .abut-wall-figs { grid-column: 1 / -1; display: grid; grid-template-columns: minmax(0, 2fr) minmax(0, 3fr); gap: 10px; }
+#abut-panel-plan-shape > p, #abut-panel-plan-shape > .abut-wall-figs { grid-column: 1 / -1; }
+#abut-panel-plan .abut-note-warn { color: #8a5a00; }
+#abut-panel-plan .abut-memo { background: #f6f8fa; border: 1px solid #d5dbe2; border-radius: 6px; padding: 8px; }
+@media (max-width: 900px) { #abut-panel-plan .abut-wall-figs { grid-template-columns: minmax(0, 1fr); } }
diff --git a/src/view/abut-wall-svg.ts b/src/view/abut-wall-svg.ts
new file mode 100644
index 0000000..9ab81b3
--- /dev/null
+++ b/src/view/abut-wall-svg.ts
@@ -0,0 +1,134 @@
+/**
+ * 교대 벽체·흉벽 삽도 (2026-11-03, 시안 ② 벽체·흉벽 카드): 교대축 직각 단면(① 구간)과 정면·배면도. 순수 문자열 함수.
+ * 생성본(교대 모델·부재)의 값으로 그린다. 두 그림 모두 가로·세로 축척이 같다(시안 "축척 같음").
+ */
+
+export interface AbutWallDrawing {
+ /** 교대축 길이 L (m), IP1 → IP3. */
+ lengthM: number;
+ /** 교좌면 계단 구간 (s, m는 IP1에서), 구간마다 교좌면 EL. */
+ segments: { sFrom: number; sTo: number; seatEl: number }[];
+ /** 받침 (s, m), 받침 번호, 패드 상면 z (m), 교좌면 EL (m). */
+ bearings: { no: number; s: number; z: number; seatEl: number }[];
+ footingTopEl: number;
+ footingBottomEl: number;
+ /** 흉벽 상단 EL at s = 0 (IP1 끝) and s = L (IP3 끝). */
+ parapetTopStartEl: number;
+ parapetTopEndEl: number;
+ /** t (m, 전면 +): 벽체 전면, 흉벽 배면, 벽체 배면, 저판 앞굽 끝·뒷굽 끝. */
+ wallFrontM: number;
+ parapetBackM: number;
+ wallBackM: number;
+ footingFrontM: number;
+ footingBackM: number;
+ bracketHeightM: number;
+ shrinkJointCount: number;
+ /** 시안 치수 줄 "브라켓 폭 + 흉벽 두께 + 교좌면 폭 = 벽체 두께" (mm). */
+ bracketWidthMm: number;
+ parapetThicknessMm: number;
+ seatWidthMm: number;
+}
+
+const INK = '#18212b';
+const MUTED = '#55606e';
+const CONC = '#d9c9a8';
+const CONC_DARK = '#c7b48d';
+const FOOT = '#b9c3ce';
+const t3 = (v: number) => v.toFixed(3);
+const label = (x: number, y: number, s: string, anchor = 'middle', fill = INK) =>
+ `${s}`;
+const poly = (pts: [number, number][], style: string) => ``;
+
+/** 흉벽 상단 EL at s (linear between the two ends). */
+function parapetTopAt(d: AbutWallDrawing, s: number): number {
+ return d.parapetTopStartEl + ((d.parapetTopEndEl - d.parapetTopStartEl) * s) / d.lengthM;
+}
+
+/**
+ * 교대축 직각 단면 (① 구간 = 받침 1번이 놓인 구간): 저판, 벽체(기초 상면 ~ 교좌면), 흉벽 배면 단차부(브라켓 상면까지),
+ * 흉벽(흉벽 상단까지), 받침 블럭. 왼쪽이 배면, 오른쪽이 전면.
+ */
+export function abutWallSectionSvg(d: AbutWallDrawing): string {
+ const b1 = d.bearings.find((b) => b.no === 1) ?? d.bearings[0];
+ const seg = d.segments.find((g) => b1.s >= g.sFrom && b1.s <= g.sTo) ?? d.segments[0];
+ const sAt = b1.s;
+ const top = parapetTopAt(d, sAt);
+ const bracket = top - d.bracketHeightM;
+ const k = 46; // px per m, both directions
+ const tMin = d.footingBackM - 0.6;
+ const elMax = top + 0.7;
+ const X = (t: number) => (t - tMin) * k;
+ const Y = (el: number) => (elMax - el) * k + 10;
+ const W = (d.footingFrontM + 1.4 - tMin) * k;
+ const H = Y(d.footingBottomEl) + 40;
+ const rect = (t0: number, t1: number, e0: number, e1: number, style: string) =>
+ poly([[X(t0), Y(e0)], [X(t1), Y(e0)], [X(t1), Y(e1)], [X(t0), Y(e1)]], style);
+ const halfBlock = 0.6;
+ const blockT = (d.wallFrontM + 0) / 2; // drawn at the middle of the 교좌면 width
+ return [
+ `',
+ ].join('');
+}
+
+/**
+ * 정면·배면도: 교좌면 계단(구간별 교좌면 EL), 흉벽 상단 경사, 받침 블럭, 수축줄눈, 저판. 왼쪽 IP3(① 쪽), 오른쪽 IP1.
+ */
+export function abutWallElevationSvg(d: AbutWallDrawing): string {
+ const k = 46;
+ const top = Math.max(d.parapetTopStartEl, d.parapetTopEndEl);
+ const elMax = top + 0.6;
+ const pad = 40;
+ const X = (s: number) => pad + (d.lengthM - s) * k; // IP3 (s = L) on the left
+ const Y = (el: number) => (elMax - el) * k + 10;
+ const W = d.lengthM * k + 2 * pad;
+ const H = Y(d.footingBottomEl) + 40;
+ const parts: string[] = [];
+ parts.push(poly([[X(d.lengthM), Y(d.footingTopEl)], [X(0), Y(d.footingTopEl)], [X(0), Y(d.footingBottomEl)], [X(d.lengthM), Y(d.footingBottomEl)]], `fill="${FOOT}" stroke="${MUTED}"`));
+ // 흉벽 (behind) from the 교좌면 steps up to the sloped top line.
+ parts.push(poly([[X(d.lengthM), Y(d.footingTopEl)], [X(0), Y(d.footingTopEl)], [X(0), Y(d.parapetTopStartEl)], [X(d.lengthM), Y(d.parapetTopEndEl)]], `fill="${CONC_DARK}" stroke="${MUTED}"`));
+ // 벽체 front: one block per 교좌면 step.
+ for (const g of d.segments) {
+ parts.push(poly([[X(g.sTo), Y(d.footingTopEl)], [X(g.sFrom), Y(d.footingTopEl)], [X(g.sFrom), Y(g.seatEl)], [X(g.sTo), Y(g.seatEl)]], `fill="${CONC}" stroke="${MUTED}"`));
+ }
+ for (const b of d.bearings) {
+ parts.push(poly([[X(b.s + 0.6), Y(b.seatEl)], [X(b.s - 0.6), Y(b.seatEl)], [X(b.s - 0.6), Y(b.z)], [X(b.s + 0.6), Y(b.z)]], `fill="#9aa7b5" stroke="${INK}"`));
+ parts.push(label(X(b.s), Y(b.z) - 6, ['①', '②', '③', '④', '⑤', '⑥', '⑦', '⑧'][b.no - 1] ?? String(b.no)));
+ }
+ for (let j = 1; j <= d.shrinkJointCount; j++) {
+ const s = (d.lengthM * j) / (d.shrinkJointCount + 1);
+ parts.push(``);
+ parts.push(label(X(s) + 4, Y(d.footingTopEl) - 8, `수축줄눈 ${j}`, 'start'));
+ }
+ 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), 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`));
+ return [
+ `',
+ ].join('');
+}
diff --git a/tests/abut-member-list.test.ts b/tests/abut-member-list.test.ts
index 419998e..e72812f 100644
--- a/tests/abut-member-list.test.ts
+++ b/tests/abut-member-list.test.ts
@@ -7,9 +7,9 @@ const base = {
};
describe('교대 부재 목록 (시안 ② 부재 · 교대)', () => {
- it('has the five rows of the mockup, in order', () => {
+ it('has the six rows of the mockup, in order', () => {
const rows = abutMemberRows(base, '11.253 m');
- expect(rows.map((r) => r.id)).toEqual(['bearing', 'shape', 'wing', 'slab', 'ground']);
+ expect(rows.map((r) => r.id)).toEqual(['bearing', 'shape', 'footing', 'wing', 'slab', 'ground']);
expect(rows[0].label).toBe('교량받침·받침블럭·솔플레이트');
});
@@ -20,7 +20,11 @@ describe('교대 부재 목록 (시안 ② 부재 · 교대)', () => {
it('marks the shape when a value differs from the default', () => {
const rows = abutMemberRows({ ...base, shape: { parapetHeightMm: 2200 } }, '—');
expect(rows.find((r) => r.id === 'shape')?.changed).toBe(true);
- expect(rows.find((r) => r.id === 'shape')?.summary).toBe('벽체 두께 2400 · 흉벽 2200');
+ expect(rows.find((r) => r.id === 'shape')?.summary).toBe('벽체 두께 2400 (자동) · 흉벽 2200');
+ expect(rows.find((r) => r.id === 'footing')?.changed).toBe(false);
+ const foot = abutMemberRows({ ...base, shape: { footingThicknessMm: 1300 } }, '—');
+ expect(foot.find((r) => r.id === 'footing')?.changed).toBe(true);
+ expect(foot.find((r) => r.id === 'shape')?.changed).toBe(false);
});
it('marks the wing when a side is not the general form, and names both sides', () => {
@@ -41,7 +45,7 @@ describe('교대 부재 목록 (시안 ② 부재 · 교대)', () => {
expect(rows.find((r) => r.id === 'ground')?.summary).toBe('지반고 33.984 m');
});
- it('only the bearing, shape and wing cards take input in this step', () => {
- expect(ABUT_MEMBER_EDITABLE).toEqual({ bearing: true, shape: true, wing: true, slab: false, ground: false });
+ it('only the bearing, shape, footing and wing cards take input in this step', () => {
+ expect(ABUT_MEMBER_EDITABLE).toEqual({ bearing: true, shape: true, footing: true, wing: true, slab: false, ground: false });
});
});
diff --git a/tests/abut-shape.test.ts b/tests/abut-shape.test.ts
index 814eb39..4ccae87 100644
--- a/tests/abut-shape.test.ts
+++ b/tests/abut-shape.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
-import { ABUT_SHAPE_DEFAULT, resolveShape, shapeIssues } from '../src/core/abut/shape';
+import { ABUT_SHAPE_DEFAULT, normalizeShape, resolveShape, shapeIssues, wallThicknessMm, type AbutShape } from '../src/core/abut/shape';
describe('교대 형상 입력 (시안 ② 벽체·흉벽 · 확대기초·말뚝)', () => {
it('the defaults equal the spec values (wall +1.6 … −0.8, footing +2.8 … −1.8, piles 650 / 2000)', () => {
@@ -11,17 +11,21 @@ describe('교대 형상 입력 (시안 ② 벽체·흉벽 · 확대기초·말
expect(r.footingBackM).toBeCloseTo(-1.8, 9);
expect(r.pileEdgeM).toBeCloseTo(0.65, 9);
expect(shapeIssues({})).toEqual([]);
- expect(ABUT_SHAPE_DEFAULT.wallThicknessMm).toBe(2400);
+ expect(ABUT_SHAPE_DEFAULT.bracketWidthMm).toBe(300);
+ expect(wallThicknessMm(ABUT_SHAPE_DEFAULT)).toBe(2400); // 300 + 500 + 1600 (자동)
});
- it('a changed wall thickness moves the back face (front − thickness)', () => {
- expect(resolveShape({ wallThicknessMm: 2000 }).wallBackM).toBeCloseTo(-0.4, 9);
+ it('a changed 브라켓 폭 moves the back face; an old saved 벽체 두께 still opens as the same wall', () => {
+ expect(resolveShape({ bracketWidthMm: 0 }).wallBackM).toBeCloseTo(-0.5, 9);
+ expect(normalizeShape({ wallThicknessMm: 2000 } as Partial)).toEqual({ bracketWidthMm: -100 });
+ expect(resolveShape({ wallThicknessMm: 2000 } as Partial).wallBackM).toBeCloseTo(-0.4, 9);
});
it('refuses a footing that does not cover the wall, and non-positive sizes', () => {
expect(shapeIssues({ footingBackMm: -500 }).join(' ')).toContain('뒷굽');
expect(shapeIssues({ footingFrontMm: 1000 }).join(' ')).toContain('앞굽');
- expect(shapeIssues({ wallThicknessMm: 0 }).join(' ')).toContain('벽체 두께');
+ expect(shapeIssues({ bracketWidthMm: -1 }).join(' ')).toContain('브라켓 폭');
+ expect(shapeIssues({ seatStepIgnoreMm: -1 }).join(' ')).toContain('인접 무시 기준');
expect(shapeIssues({ pileSpacingMm: 0 }).join(' ')).toContain('말뚝');
});
});
@@ -40,7 +44,8 @@ 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(13);
+ expect(SHAPE_EDIT_FIELDS).toHaveLength(14);
+ expect(SHAPE_EDIT_FIELDS.filter((f) => f.group === 'footing').map((f) => f.key)).toEqual(['footingThicknessMm', 'footingFrontMm', 'footingBackMm', 'pileEdgeMm', 'pileSpacingMm']);
});
it('the new drawing values do not change the geometry (the model ignores them)', () => {
@@ -71,4 +76,11 @@ describe('형상 입력이 계산에 반영됨 (시안 ② 카드)', () => {
const wider = buildMembers(model, {}, { pileSpacingMm: 3000 });
expect(wider.piles[0].s.length).toBeLessThan(6);
});
+
+ it('인접 무시 기준: steps smaller than the value join one segment (A1 steps are 71 mm)', () => {
+ const model = buildAbut({ name: 'A1', type: 'A1', seatEl: rec.seatEl, ip1: rec.ip1, ip2: rec.ip2, ip3: rec.ip3, bearings: rec.bearings.map((b) => ({ no: b.no, pos: b.pos, z: b.z, seatEl: b.seatEl })) });
+ const foundation = { bottomEl: rec.foundationBottomEl, pileLengthMm: rec.pileLengthMm };
+ expect(buildMembers(model, foundation).wallSegments).toHaveLength(5);
+ expect(buildMembers(model, foundation, { seatStepIgnoreMm: 100 }).wallSegments).toHaveLength(1);
+ });
});
diff --git a/tests/abut-wall-svg.test.ts b/tests/abut-wall-svg.test.ts
new file mode 100644
index 0000000..4f623d4
--- /dev/null
+++ b/tests/abut-wall-svg.test.ts
@@ -0,0 +1,56 @@
+import { readFileSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+import * as XLSX from 'xlsx';
+import { describe, expect, it } from 'vitest';
+import { buildAbut } from '../src/core/abut/geometry';
+import { parseAbutments, toAbutInput } from '../src/core/abut/parse';
+import { buildMembers } from '../src/core/abut/members';
+import { abutWallElevationSvg, abutWallSectionSvg, type AbutWallDrawing } from '../src/view/abut-wall-svg';
+
+const RAG = fileURLToPath(new URL('../../../RAG/운곡교P1입력.xlsx', import.meta.url));
+const rec = parseAbutments(XLSX.read(readFileSync(RAG), { type: 'buffer' })).abutments[0];
+const input = toAbutInput(rec);
+const model = buildAbut(input);
+const m = buildMembers(model, { bottomEl: input.foundationBottomEl, pileLengthMm: input.pileLengthMm });
+const d: AbutWallDrawing = {
+ lengthM: model.length,
+ segments: m.wallSegments,
+ bearings: model.bearings,
+ footingTopEl: m.footingTopEl,
+ footingBottomEl: m.footingBottomEl!,
+ parapetTopStartEl: m.parapetTopStartEl,
+ parapetTopEndEl: m.parapetTopEndEl,
+ wallFrontM: m.shape.wallFrontM,
+ parapetBackM: m.shape.parapetBackM,
+ wallBackM: m.shape.wallBackM,
+ footingFrontM: m.shape.footingFrontM,
+ footingBackM: m.shape.footingBackM,
+ bracketHeightM: m.shape.bracketHeightM,
+ shrinkJointCount: 1,
+ bracketWidthMm: 300,
+ parapetThicknessMm: 500,
+ seatWidthMm: 1600,
+};
+
+describe('교대 벽체·흉벽 삽도 (시안 ② 벽체·흉벽 카드)', () => {
+ 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', '교대축', '← 배면', '전면 →']) {
+ expect(svg).toContain(s);
+ }
+ expect(svg).toMatch(/흉벽 상단 4[67]\.\d{3}/);
+ expect(svg).toMatch(/브라켓 상면 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);
+ expect(svg).toContain('교좌면 계단 5구간');
+ });
+
+ it('the 수축줄눈 count follows the input', () => {
+ const svg = abutWallElevationSvg({ ...d, shrinkJointCount: 2 });
+ expect(svg).toContain('수축줄눈 2');
+ expect(svg.match(/stroke-dasharray="5 3"/g)).toHaveLength(2);
+ });
+});