diff --git a/index.html b/index.html
index 122d174..409d27f 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 1a6f0a9..e661427 100644
--- a/src/core/abut/edit.ts
+++ b/src/core/abut/edit.ts
@@ -7,6 +7,7 @@ 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, slabIssues, type AbutSlab, type SlabForm } from './slab';
+import type { AbutGround } from './ground';
import { abutBearingRuleIssues, resolveBearingRule, type AbutBearingRule } from './bearing-table';
export type AbutEditKey = 'seatEl' | 'foundationBottomEl' | 'groundEl' | 'pileLengthMm' | 'skewDeg' | 'slopeI';
@@ -149,6 +150,20 @@ export const SLAB_EDIT_FIELDS: { key: 'lengthMm' | 'thicknessMm' | 'parapetGapMm
{ key: 'haunchLengthMm', label: '헌치 길이 (mm)', exposedOnly: true, note: '가정 · 삽도 비율' },
];
+/**
+ * 지반·지층 입력 (2026-11-04): merges the given values into the record's 지반; undefined removes a value. Numbers must be finite.
+ * The values are checked by the card (G01–G14), not refused here, as on the pier card.
+ */
+export function setAbutGround(rec: AbutRecord, patch: Partial): AbutRecord {
+ for (const [key, v] of Object.entries(patch)) {
+ if (typeof v === 'number' && !Number.isFinite(v)) throw new Error('숫자를 입력하세요.');
+ if (key === 'strataRows' && (v as { bottomEl: number }[]).some((r) => !Number.isFinite(r.bottomEl))) throw new Error('지층 하단 EL은 숫자여야 합니다.');
+ }
+ const ground: AbutGround = { ...(rec.ground ?? {}), ...patch };
+ for (const key of Object.keys(ground) as (keyof AbutGround)[]) if (ground[key] === undefined) delete ground[key];
+ return { ...rec, ground };
+}
+
/** Changes one 접속슬래브 dim, refused with a Korean message when the slab would not build. */
export function applySlabEdit(rec: AbutRecord, key: (typeof SLAB_EDIT_FIELDS)[number]['key'], value: number): AbutRecord {
if (!Number.isFinite(value)) throw new Error('숫자를 입력하세요.');
diff --git a/src/core/abut/ground.ts b/src/core/abut/ground.ts
new file mode 100644
index 0000000..ad1375c
--- /dev/null
+++ b/src/core/abut/ground.ts
@@ -0,0 +1,67 @@
+/**
+ * 교대 지반·지층 (2026-11-04, 시안 ② 지반·지층 카드 = 교각과 같은 카드). 순수 함수.
+ *
+ * - 입력은 교각과 같은 이름이다: 위치(육상 · 하상 · 해상), 지하수위, 설계홍수위 · 세굴, 고조위 · 저조위, 지층 행.
+ * 지반고는 엑셀 입력(①에서 수정)이다. 표시만 하고 형상 계산에는 쓰지 않는다.
+ * - 검사는 교각과 같다(G01–G14, 말뚝 근입 G04). 지반고가 저판 하면보다 낮으면 흙덮개가 없다는 정보 AI01을 더한다.
+ */
+import { SUPPORT_STRATUM, validateGround, validatePileEmbedment, validateStrataNValues, type GroundCheckInput } from '../ground';
+import type { Issue, PierInput } from '../types';
+import type { AbutMembers } from './members';
+import type { AbutRecord } from './parse';
+
+export type AbutGround = Pick;
+
+/** 말뚝 두께 (mm): spec_abut 말뚝 Φ600 × 12T. Used only by the 해상 부식 여유 check. */
+const ABUT_PILE_THICKNESS_MM = 12;
+
+/** The check input of an abutment: its 지반 values, 지반고 and 저판 하면 (기초 하단 when the 저판 is not built). */
+export function abutGroundCheckInput(rec: AbutRecord, members?: AbutMembers): GroundCheckInput {
+ return {
+ name: rec.name,
+ groundEl: rec.groundEl,
+ footingBottomEl: members?.footingBottomEl ?? rec.foundationBottomEl,
+ pileThicknessMm: ABUT_PILE_THICKNESS_MM,
+ ...(rec.ground ?? {}),
+ };
+}
+
+/** 연암 상면 EL: the bottom of the row above the 연암 row (지반고 for the first row); undefined without a 연암 row. */
+export function supportTopEl(groundEl: number, rows: { name: string; bottomEl: number }[] | undefined): number | undefined {
+ const list = rows ?? [];
+ const k = list.findIndex((r) => r.name === SUPPORT_STRATUM);
+ if (k < 0) return undefined;
+ return k === 0 ? groundEl : list[k - 1].bottomEl;
+}
+
+/** Issues of the 지반·지층 card: the pier checks plus AI01 (지반고 below the 저판 하면). */
+export function abutGroundIssues(rec: AbutRecord, members?: AbutMembers): Issue[] {
+ const input = abutGroundCheckInput(rec, members);
+ const issues: Issue[] = [];
+ validateGround(input, issues);
+ validateStrataNValues(input, issues);
+ if (members && members.pileBottomEl !== undefined && members.piles.length > 0) {
+ validatePileEmbedment(input, { piles: [{ bottom: members.pileBottomEl, diameter: members.shape.pileDiameterM }] }, issues);
+ }
+ if (rec.groundEl < input.footingBottomEl) {
+ issues.push({
+ code: 'AI01',
+ level: 'I',
+ pier: rec.name,
+ message: `지반고 ${rec.groundEl.toFixed(3)}이 저판 하면 ${input.footingBottomEl.toFixed(3)}보다 ${(input.footingBottomEl - rec.groundEl).toFixed(3)} 낮아 흙덮개가 없습니다.`,
+ });
+ }
+ return issues;
+}
+
+/** True when any 지반 value differs from the default (육상, 지하수위 없음, 지층 행 없음). */
+export function abutGroundChanged(ground: AbutGround | undefined): boolean {
+ if (!ground) return false;
+ return Object.entries(ground).some(([key, v]) => {
+ if (v === undefined) return false;
+ if (key === 'groundPosition') return v !== '육상';
+ if (key === 'groundWaterOn') return v === true;
+ if (key === 'strataRows') return (v as unknown[]).length > 0;
+ return true;
+ });
+}
diff --git a/src/core/abut/member-list.ts b/src/core/abut/member-list.ts
index f8c4ea0..39aeb48 100644
--- a/src/core/abut/member-list.ts
+++ b/src/core/abut/member-list.ts
@@ -1,6 +1,7 @@
import type { WingForm } from './geometry';
import { ABUT_SHAPE_DEFAULT, normalizeShape, wallThicknessMm, type AbutShape } from './shape';
import { resolveSlab, SLAB_FORM_LABEL, slabChangedKeys, slabSlopeLabel, type AbutSlab } from './slab';
+import { abutGroundChanged, type AbutGround } from './ground';
/** 형상 입력 중 벽체·흉벽 카드와 확대기초·말뚝 카드의 항목 (변경 표시를 카드별로 나눈다). */
const WALL_KEYS: (keyof AbutShape)[] = ['wallFrontMm', 'parapetHeightMm', 'parapetThicknessMm', 'bracketHeightMm', 'bracketWidthMm', 'shrinkJointCount', 'parapetLevelLeftMm', 'parapetLevelRightMm', 'seatStepIgnoreMm'];
@@ -12,14 +13,14 @@ const FOOTING_KEYS: (keyof AbutShape)[] = [
/** 부재 · 교대 list in ② (시안): the card each row opens. */
export type AbutMemberId = 'bearing' | 'shape' | 'footing' | 'wing' | 'slab' | 'ground';
-/** Cards that take input in this step; ground only shows its values (접속슬래브 입력 2026-11-04). */
+/** Cards that take input in this step: all six (접속슬래브 · 지반 입력 2026-11-04). */
export const ABUT_MEMBER_EDITABLE: Record = {
bearing: true,
shape: true,
footing: true,
wing: true,
slab: true,
- ground: false,
+ ground: true,
};
const WING_LABEL: Record = { general: '일반형', short: '단축형', none: '없음' };
@@ -30,6 +31,7 @@ export interface AbutMemberInput {
shape?: Partial;
wings?: { left: WingForm; right: WingForm };
slab?: Partial;
+ ground?: AbutGround;
groundEl: number;
}
@@ -82,6 +84,11 @@ export function abutMemberRows(rec: AbutMemberInput, slabWidth: string): AbutMem
summary: `${SLAB_FORM_LABEL[slab.form]} · ${slabSlopeLabel(slab)} · 폭 ${slabWidth}`,
changed: slabChangedKeys(rec.slab).length > 0,
},
- { id: 'ground', label: '지반·지층', summary: `지반고 ${rec.groundEl.toFixed(3)} m`, changed: false },
+ {
+ id: 'ground',
+ label: '지반·지층',
+ summary: `${rec.ground?.groundPosition ?? '육상'} · 지층 ${rec.ground?.strataRows?.length ?? 0} · 지반고 EL.${rec.groundEl.toFixed(3)}`,
+ changed: abutGroundChanged(rec.ground),
+ },
];
}
diff --git a/src/core/abut/parse.ts b/src/core/abut/parse.ts
index f5034b4..093f595 100644
--- a/src/core/abut/parse.ts
+++ b/src/core/abut/parse.ts
@@ -14,6 +14,7 @@ import type { AbutShape } from './shape';
import type { AbutBearingRule } from './bearing-table';
import type { WingDims } from './wings';
import type { AbutSlab } from './slab';
+import type { AbutGround } from './ground';
type Cell = string | number | null | undefined;
type Grid = Cell[][];
@@ -62,6 +63,8 @@ export interface AbutRecord {
wingDims?: { left?: Partial; right?: Partial };
/** 접속슬래브 입력 (② 접속슬래브 카드: 형식, 경사, 치수 mm). Only the changed values. Absent = 매립형 · 하향 4 % 기본값. */
slab?: Partial;
+ /** 지반·지층 입력 (② 지반·지층 카드, 교각과 같은 필드). Absent = 육상, 지하수위 없음, 지층 행 없음. */
+ ground?: AbutGround;
/** 예전 "좌우 같게" 값 (2026-11-04 이전 파일). 지금은 읽기만 하고 쓰지 않는다. */
wingsLinked?: boolean;
}
diff --git a/src/core/ground.ts b/src/core/ground.ts
index 28586af..1320483 100644
--- a/src/core/ground.ts
+++ b/src/core/ground.ts
@@ -1,6 +1,14 @@
-import type { PierModel } from './geometry';
import type { Issue, PierInput } from './types';
+/**
+ * The values the 지반·지층 checks read (2026-11-04: shared by the pier and the abutment card). A pier passes its input;
+ * an abutment passes its record's 지반 values with its 지반고 and 저판 하면.
+ */
+export type GroundCheckInput = Pick<
+ PierInput,
+ 'name' | 'groundEl' | 'footingBottomEl' | 'groundPosition' | 'groundWaterOn' | 'groundWaterEl' | 'floodLevelEl' | 'scourDepthM' | 'highWaterEl' | 'lowWaterEl' | 'strataRows' | 'pileThicknessMm'
+>;
+
/** 위치 choices of the 지반·지층 card (시안 2026-10-26). */
export const GROUND_POSITIONS = ['육상', '하상', '해상'] as const;
@@ -28,7 +36,7 @@ export const CORROSION_ALLOWANCE_MM = 1;
* G03: 층 이름은 지층 목록에 있어야 함. 하상: G07 DHWL 없음, G08 DHWL이 지반고보다 낮음, G09 세굴선 확인.
* Ground values are inputs only; they do not change the 3D model.
*/
-export function validateGround(input: PierInput, issues: Issue[]): void {
+export function validateGround(input: GroundCheckInput, issues: Issue[]): void {
const rows = input.strataRows ?? [];
let upper = input.groundEl;
rows.forEach((row, i) => {
@@ -70,7 +78,7 @@ export function validateGround(input: PierInput, issues: Issue[]): void {
* G04: a tip above that limit. G05 (info): piles but no 연암 row, so the embedment is not checked.
* The 연암 상면 is the bottom of the row above the 연암 row (ground EL for the first row).
*/
-export function validatePileEmbedment(input: PierInput, model: PierModel, issues: Issue[]): void {
+export function validatePileEmbedment(input: GroundCheckInput, model: { piles: { bottom: number; diameter: number }[] }, issues: Issue[]): void {
if (model.piles.length === 0) return;
const rows = input.strataRows ?? [];
const k = rows.findIndex((r) => r.name === SUPPORT_STRATUM);
@@ -123,7 +131,7 @@ export function parseNValue(text: string | undefined): NValue {
* G06 (경고): an N value that is not a blow count or a refusal in the accepted range. Empty is allowed.
* The values are not used in the calculation yet; they will feed the 지지력 and 액상화 checks, so the format is strict.
*/
-export function validateStrataNValues(input: PierInput, issues: Issue[]): void {
+export function validateStrataNValues(input: GroundCheckInput, issues: Issue[]): void {
(input.strataRows ?? []).forEach((row, i) => {
if (parseNValue(row.nValue).kind === 'invalid') {
issues.push({
@@ -137,7 +145,7 @@ export function validateStrataNValues(input: PierInput, issues: Issue[]): void {
}
/** G09: the 세굴 깊이 is not negative, and the scour line stays above the footing bottom (하상·해상). */
-function validateScour(input: PierInput, issues: Issue[]): void {
+function validateScour(input: GroundCheckInput, issues: Issue[]): void {
const scour = input.scourDepthM;
if (scour === undefined) return;
if (!(scour >= 0)) {
@@ -152,7 +160,7 @@ function validateScour(input: PierInput, issues: Issue[]): void {
* 저조위 is below 고조위. G13 missing; G10 해저면이 고조위보다 낮지 않음; G11 저조위가 고조위보다 낮지 않음;
* G14 강관 두께가 부식 여유 기준 이하. 파랑과 선박 하중은 이번 범위에서 제외.
*/
-function validateSea(input: PierInput, issues: Issue[]): void {
+function validateSea(input: GroundCheckInput, issues: Issue[]): void {
const hw = input.highWaterEl;
const lw = input.lowWaterEl;
if (hw === undefined || lw === undefined || !Number.isFinite(hw) || !Number.isFinite(lw)) {
diff --git a/src/core/project.ts b/src/core/project.ts
index 1b87f82..0be8dc5 100644
--- a/src/core/project.ts
+++ b/src/core/project.ts
@@ -150,6 +150,18 @@ export function checkStoredAbutments(list: unknown): void {
if (!ok) throw new Error(`${who}: 접속슬래브 값 ${key}이(가) 맞지 않습니다.`);
}
}
+ if (a.ground !== undefined) {
+ const g = a.ground as Record;
+ for (const [key, v] of Object.entries(g)) {
+ const ok =
+ key === 'groundPosition' ? v === '육상' || v === '하상' || v === '해상'
+ : key === 'groundWaterOn' ? typeof v === 'boolean'
+ : key === 'strataRows'
+ ? Array.isArray(v) && v.every((r) => r && typeof r.name === 'string' && finite(r.bottomEl) && (r.nValue === undefined || typeof r.nValue === 'string'))
+ : finite(v);
+ if (!ok) throw new Error(`${who}: 지반 값 ${key}이(가) 맞지 않습니다.`);
+ }
+ }
if (a.wingsLinked !== undefined && typeof a.wingsLinked !== 'boolean') throw new Error(`${who}: 좌우 같게 값이 맞지 않습니다.`);
if (a.wings !== undefined) {
const forms = ['general', 'short', 'none'];
diff --git a/src/main.ts b/src/main.ts
index 0410f9d..a65296d 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -25,7 +25,9 @@ import {
applySlabEdit,
setSlabForm,
SLAB_EDIT_FIELDS,
+ setAbutGround,
} from './core/abut/edit';
+import { abutGroundIssues, supportTopEl, type AbutGround } from './core/abut/ground';
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';
@@ -66,7 +68,7 @@ import { buildRebar3D, type RebarColorMode, type RebarScene } from './view/rebar
import { DEFAULT_MATERIAL, FCK_MEMBERS, fckChanged, fyOf, GRADE_CHOICES, IMPORTED_FCK, isAssumedMaterial, materialFrom, type BendFactors, type FckByMember, type FckMember, type GradeKey, type Material } from './core/rebar/common';
import { buildQuantity, quantityRows, type Quantity, type QuantityLine } from './core/rebar/quantity';
import { groundElFor, parseGroundModel, strataRowsFor, type GroundMapLayer, type GroundModelFile } from './core/ground-model';
-import { CORROSION_ALLOWANCE_MM, parseNValue, SEA_DATUM, STRATA_NAMES } from './core/ground';
+import { CORROSION_ALLOWANCE_MM, GROUND_POSITIONS, parseNValue, SEA_DATUM, STRATA_NAMES } from './core/ground';
import { bearingEdgeDistances, integratedCheck, PAIR_ECCENTRICITY_LIMIT_MM, pairEccentricities } from './core/bearing-layout';
import { bearingPlanSvg, PAIR_CHIP_LABELS } from './view/bearing-plan-svg';
import { bearingSectionSvg } from './view/bearing-section-svg';
@@ -86,7 +88,7 @@ import { footingCornerSvg } from './view/footing-corner-svg';
import { footingLegRows } from './core/rebar/footing-legs';
import { columnElevationSvg } from './view/column-elevation-svg';
import { type ColumnSection, columnSectionSvg } from './view/column-section-svg';
-import { groundSectionSvg } from './view/ground-svg';
+import { abutGroundSectionSvg, groundSectionSvg } from './view/ground-svg';
import { columnDiameterIssues } from './core/column-check';
import { findPileSpec, PHC_PILES, STEEL_PILES } from './core/pile-spec';
import { footingPlanSvg, footingSideSvg } from './view/footing-svg';
@@ -742,7 +744,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', slab: 'abut-panel-plan-slab', 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' },
+ plan: { wing: 'abut-panel-plan-wing', slab: 'abut-panel-plan-slab', ground: 'abut-panel-plan-ground', 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). */
@@ -1340,6 +1342,122 @@ function fillAbutSlabCard(
);
}
+/**
+ * ② 지반·지층 카드 (교대, 2026-11-04: 교각 카드와 같은 입력): 위치 → 위치별 수위 입력 → 단면 삽도 → 자동 값 → 지층 행 →
+ * 지반 모델 → 검사 → 메모. 표시만 하고 형상 계산에는 쓰지 않는다. 지반고는 ①에서 고친다.
+ */
+function fillAbutGroundCard(
+ box: HTMLDivElement,
+ rec: AbutRecord,
+ apply: (next: AbutRecord) => void,
+ fail: (err: unknown, input: HTMLInputElement, back: string) => void,
+ idOf: (suffix: string) => string,
+ numberInput: (id: string, value: string) => HTMLInputElement,
+): void {
+ const g: AbutGround = rec.ground ?? {};
+ const position = g.groundPosition ?? '육상';
+ const set = (patch: Partial) => apply(setAbutGround(rec, patch));
+ box.append(
+ optionTiles(
+ '위치',
+ GROUND_POSITIONS.map((p) => ({ label: p, on: position === p, isDefault: p === '육상', pick: () => position !== p && set({ groundPosition: p }) })),
+ '표시만 합니다 (형상 계산에 쓰지 않음)',
+ ),
+ );
+ // 위치별 수위 입력 (교각과 같음): 육상 지하수위, 하상 설계홍수위 · 세굴, 해상 고조위 · 저조위 · 세굴. 비우면 값 없음.
+ const level = (key: 'groundWaterEl' | 'floodLevelEl' | 'scourDepthM' | 'highWaterEl' | 'lowWaterEl', label: string, note: string, disabled = false) => {
+ const id = idOf(`ground-${key}`);
+ const lab = el('label', label);
+ lab.htmlFor = id;
+ const v = g[key];
+ const input = numberInput(id, v === undefined ? '' : String(v));
+ input.disabled = disabled;
+ input.addEventListener('change', () => {
+ try {
+ set({ [key]: input.value === '' ? undefined : Number(input.value) });
+ } catch (err) {
+ fail(err, input, v === undefined ? '' : String(v));
+ }
+ });
+ box.append(lab, input, el('span', note, 'muted small'));
+ };
+ if (position === '하상') {
+ level('floodLevelEl', '설계홍수위 DHWL EL (m)', '하상');
+ level('scourDepthM', '세굴 깊이 (m)', '지반고 아래');
+ } else if (position === '해상') {
+ level('highWaterEl', '고조위 HWL (m)', `기준면 ${SEA_DATUM}`);
+ level('lowWaterEl', '저조위 LWL (m)', `기준면 ${SEA_DATUM}`);
+ level('scourDepthM', '세굴 깊이 (m)', `지반고 칸 = 해저면 EL · 부식 여유 ${CORROSION_ALLOWANCE_MM} mm (확인 필요)`);
+ } else {
+ const id = idOf('ground-water-on');
+ const check = el('input');
+ check.type = 'checkbox';
+ check.id = id;
+ check.checked = g.groundWaterOn === true;
+ check.addEventListener('change', () => set({ groundWaterOn: check.checked }));
+ const lab = el('label', '지하수위 고려');
+ lab.htmlFor = id;
+ box.append(lab, check, el('span', '', 'muted small'));
+ level('groundWaterEl', '지하수위 EL (m)', g.groundWaterOn ? '' : '지하수위 고려를 켜면 입력', g.groundWaterOn !== true);
+ }
+ let members: AbutMembers | undefined;
+ try {
+ const model = buildAbut(toAbutInput(rec));
+ members = buildMembers(model, { bottomEl: rec.foundationBottomEl, pileLengthMm: rec.pileLengthMm }, rec.shape);
+ } catch {
+ members = undefined;
+ }
+ if (members && members.footingBottomEl !== undefined) {
+ const s = members.shape;
+ const fig = el('figure', undefined, 'ground-fig');
+ fig.innerHTML = abutGroundSectionSvg({
+ ground: { ...g, groundEl: rec.groundEl },
+ footingTopEl: members.footingTopEl,
+ footingBottomEl: members.footingBottomEl,
+ seatEl: rec.seatEl,
+ parapetTopEl: members.parapetTopStartEl,
+ bracketHeightM: s.bracketHeightM,
+ wallFrontM: s.wallFrontM,
+ wallBackM: s.wallBackM,
+ parapetBackM: s.parapetBackM,
+ footingFrontM: s.footingFrontM,
+ footingBackM: s.footingBackM,
+ pileRowsT: members.piles.map((r) => r.t),
+ pileBottomEl: members.pileBottomEl,
+ });
+ fig.append(el('figcaption', '단면 · 교대축 직각 · 저판 · 벽체 · 흉벽 · 말뚝은 생성본, 지층과 수위는 입력값 · 세로는 EL 축척, 가로는 모식', 'muted small'));
+ box.append(fig);
+ }
+ const rows = g.strataRows ?? [];
+ const top = supportTopEl(rec.groundEl, rows);
+ const auto = (name: string, value: string, note: string) => box.append(el('label', name), el('span', value, 'num'), el('span', note, 'muted small'));
+ auto('지반고 EL (엑셀 입력)', `EL.${rec.groundEl.toFixed(3)}`, '① 기본 정보에서 수정');
+ auto('연암층 상면 EL (자동)', top === undefined ? '연암 행 없음' : `EL.${top.toFixed(3)}`, '지층 행에서');
+ auto('지층 수 (자동)', `${rows.length}`, '지층 행에서');
+ box.append(...strataRowsEditor(rows, rec.groundEl, (next) => set({ strataRows: next.length ? next : undefined })));
+ // 지반 모델의 지층 (ground_model.json에 이 교대가 있을 때): one click fills the rows.
+ if (groundModel) {
+ const modelRows = strataRowsFor(groundModel, rec.name);
+ if (modelRows) {
+ const fromModel = el('button', `지반 모델의 지층 ${modelRows.length}개 불러오기`, 'btn small');
+ fromModel.type = 'button';
+ fromModel.addEventListener('click', () => set({ strataRows: modelRows.map((r) => ({ ...r })) }));
+ box.append(fromModel);
+ } else {
+ box.append(el('p', `지반 모델에 ${rec.name}의 지층이 없습니다.`, 'muted small'));
+ }
+ }
+ const issues = abutGroundIssues(rec, members);
+ box.append(el('p', `검사 · ${issues.length ? issues.map((i) => `${i.code} ${i.message}`).join(' · ') : '경고 없음'}`, 'muted small abut-checks'));
+ box.append(
+ el(
+ 'p',
+ '시안 메모 · 교각과 같은 카드입니다. 위치 · 수위 · 지층은 표시와 검사에만 쓰고 형상 계산에는 쓰지 않습니다(3D는 지반면만). 연암 행이 있으면 말뚝 선단 근입(연암 상면 − 1.0 D)을 검사합니다(G04). 지반고가 저판 하면보다 낮으면 흙덮개가 없다는 정보(AI01)를 표시합니다. 다른 구조물에 적용하기는 교대 속성 복사 범위 밖이라 두지 않았습니다.',
+ 'muted small abut-memo',
+ ),
+ );
+}
+
/** 확대기초 · 말뚝 카드의 자동 값, 열 위치, 열별 본수 표(− / +), 메모 (시안, 2026-11-04). 말뚝 길이는 ①에서 고친다. */
function fillAbutFootingExtras(
box: HTMLDivElement,
@@ -1436,6 +1554,7 @@ function applyAbutMemberCard(rec: AbutRecord): void {
$('abut-panel-plan-footing').hidden = member !== 'footing';
$('abut-panel-plan-wing').hidden = member !== 'wing';
$('abut-panel-plan-slab').hidden = member !== 'slab';
+ $('abut-panel-plan-ground').hidden = member !== 'ground';
// The card title is the picked row; its 요약 follows the name (시안 머리줄).
const slabWidth = abutSummaryRows(rec).find(([label]) => label === '접속슬래브 폭')?.[1] ?? '—';
const row = abutMemberRows(rec, slabWidth).find((r) => r.id === member);
@@ -1521,6 +1640,11 @@ function fillAbutSlot(slot: (typeof ABUT_SLOTS)[keyof typeof ABUT_SLOTS], rec: A
slabBox.replaceChildren();
fillAbutSlabCard(slabBox, rec, apply, fail, idOf, numberInput);
}
+ if ('ground' in slot) {
+ const groundBox = $(slot.ground);
+ groundBox.replaceChildren();
+ fillAbutGroundCard(groundBox, rec, apply, fail, idOf, numberInput);
+ }
for (const side of planCard ? [] : (['left', 'right'] as const)) {
const id = idOf(`wing-${side}`);
const lab = el('label', side === 'left' ? '좌 날개벽 (① 쪽)' : '우 날개벽 (n 쪽)');
@@ -3430,6 +3554,75 @@ function setStrataRows(name: string, rows: StrataRow[]): void {
renderAll();
}
+/**
+ * 지층 행 표 (승인 2026-10-26; 2026-11-04 교각 · 교대 공용): one row per layer from the top down (지층 · 하단 EL · N값 · 삭제) and
+ * the 행 추가 button. `set` receives the new rows.
+ */
+function strataRowsEditor(rows: StrataRow[], groundEl: number, set: (rows: StrataRow[]) => void): HTMLElement[] {
+ const out: HTMLElement[] = [];
+ out.push(el('p', `지층 행 (위에서 아래 · 하단 EL m · 지반고 ${groundEl.toFixed(3)} 아래)`, 'lbl'));
+ const table = el('table', undefined, 'grid-table rebar-table');
+ const head = el('thead');
+ const hr = el('tr');
+ for (const h of ['지층', '하단 EL (m)', 'N값 (타수 · 타수/관입량 mm)', '']) hr.appendChild(el('th', h));
+ head.appendChild(hr);
+ table.appendChild(head);
+ const body = el('tbody');
+ rows.forEach((row, i) => {
+ const tr = el('tr');
+ const select = el('select');
+ for (const s of STRATA_NAMES) {
+ const o = el('option', s);
+ o.value = s;
+ select.appendChild(o);
+ }
+ if (!(STRATA_NAMES as readonly string[]).includes(row.name)) {
+ const o = el('option', row.name);
+ o.value = row.name;
+ select.appendChild(o);
+ }
+ select.value = row.name;
+ select.addEventListener('change', () => set(rows.map((r, j) => (j === i ? { ...r, name: select.value } : r))));
+ const bottomInput = el('input');
+ bottomInput.type = 'number';
+ bottomInput.step = '0.001';
+ bottomInput.value = String(row.bottomEl);
+ bottomInput.addEventListener('change', () => set(rows.map((r, j) => (j === i ? { ...r, bottomEl: Number(bottomInput.value) } : r))));
+ // N값 as text: a blow count or "타수/관입량 mm" (승인 2026-10-26). Empty means not known yet.
+ const nInput = el('input');
+ nInput.type = 'text';
+ nInput.placeholder = '예: 23 · 50/5';
+ nInput.value = row.nValue ?? '';
+ nInput.addEventListener('change', () => {
+ const v = nInput.value.trim();
+ set(rows.map((r, j) => (j === i ? { ...r, nValue: v === '' ? undefined : v } : r)));
+ });
+ if (parseNValue(row.nValue).kind === 'invalid') nInput.classList.add('warn-text');
+ const del = el('button', '삭제', 'btn small');
+ del.type = 'button';
+ del.addEventListener('click', () => set(rows.filter((_, j) => j !== i)));
+ tr.append(...[select, bottomInput, nInput, del].map((x) => {
+ const td = el('td');
+ td.appendChild(x);
+ return td;
+ }));
+ body.appendChild(tr);
+ });
+ table.appendChild(body);
+ const scroll = el('div', undefined, 'table-scroll');
+ scroll.appendChild(table);
+ out.push(scroll);
+ const add = el('button', '지층 행 추가', 'btn small');
+ add.type = 'button';
+ add.addEventListener('click', () => {
+ const last = rows[rows.length - 1];
+ const next = last ? (STRATA_NAMES as readonly string[])[Math.min((STRATA_NAMES as readonly string[]).indexOf(last.name) + 1, STRATA_NAMES.length - 1)] : STRATA_NAMES[0];
+ set([...rows, { name: next, bottomEl: Number(((last?.bottomEl ?? groundEl) - 2).toFixed(3)) }]);
+ });
+ out.push(add);
+ return out;
+}
+
/**
* ② 지반·지층 card (시안 2026-10-26; 승인 2026-10-26: 지반고는 계산에 쓰고, 지층은 행으로 입력하며, 지하수위는 직접 입력).
* The ground model import and the 지반고 apply stay at the bottom, as before.
@@ -3506,65 +3699,7 @@ function renderGroundModel(box: HTMLDivElement, pier: Pier): void {
}
// 지층 행 (승인 2026-10-26): one row per layer, from the top down; the EL is the layer's bottom EL.
- const rows = d.strataRows ?? [];
- wrap.appendChild(el('p', `지층 행 (위에서 아래 · 하단 EL m · 지반고 ${d.groundEl.toFixed(3)} 아래)`, 'lbl'));
- const table = el('table', undefined, 'grid-table rebar-table');
- const head = el('thead');
- const hr = el('tr');
- for (const h of ['지층', '하단 EL (m)', 'N값 (타수 · 타수/관입량 mm)', '']) hr.appendChild(el('th', h));
- head.appendChild(hr);
- table.appendChild(head);
- const body = el('tbody');
- rows.forEach((row, i) => {
- const tr = el('tr');
- const select = el('select');
- for (const s of STRATA_NAMES) {
- const o = el('option', s);
- o.value = s;
- select.appendChild(o);
- }
- if (!(STRATA_NAMES as readonly string[]).includes(row.name)) {
- const o = el('option', row.name);
- o.value = row.name;
- select.appendChild(o);
- }
- select.value = row.name;
- select.addEventListener('change', () => setStrataRows(name, rows.map((r, j) => (j === i ? { ...r, name: select.value } : r))));
- const bottomInput = el('input');
- bottomInput.type = 'number';
- bottomInput.step = '0.001';
- bottomInput.value = String(row.bottomEl);
- bottomInput.addEventListener('change', () => setStrataRows(name, rows.map((r, j) => (j === i ? { ...r, bottomEl: Number(bottomInput.value) } : r))));
- // N값 as text: a blow count or "타수/관입량 mm" (승인 2026-10-26). Empty means not known yet.
- const nInput = el('input');
- nInput.type = 'text';
- nInput.placeholder = '예: 23 · 50/5';
- nInput.value = row.nValue ?? '';
- nInput.addEventListener('change', () => {
- const v = nInput.value.trim();
- setStrataRows(name, rows.map((r, j) => (j === i ? { ...r, nValue: v === '' ? undefined : v } : r)));
- });
- if (parseNValue(row.nValue).kind === 'invalid') nInput.classList.add('warn-text');
- const del = el('button', '삭제', 'btn small');
- del.type = 'button';
- del.addEventListener('click', () => setStrataRows(name, rows.filter((_, j) => j !== i)));
- tr.append(...[select, bottomInput, nInput, del].map((x) => {
- const td = el('td');
- td.appendChild(x);
- return td;
- }));
- body.appendChild(tr);
- });
- table.appendChild(body);
- wrap.appendChild(el('div', undefined, 'table-scroll')).appendChild(table);
- const add = el('button', '지층 행 추가', 'btn small');
- add.type = 'button';
- add.addEventListener('click', () => {
- const last = rows[rows.length - 1];
- const next = last ? (STRATA_NAMES as readonly string[])[Math.min((STRATA_NAMES as readonly string[]).indexOf(last.name) + 1, STRATA_NAMES.length - 1)] : STRATA_NAMES[0];
- setStrataRows(name, [...rows, { name: next, bottomEl: Number(((last?.bottomEl ?? d.groundEl) - 2).toFixed(3)) }]);
- });
- wrap.appendChild(add);
+ wrap.append(...strataRowsEditor(d.strataRows ?? [], d.groundEl, (rows) => setStrataRows(name, rows)));
// 지반 모델의 지층 기둥 (Python, 시추 자료가 있을 때): one click fills the rows (승인 2026-10-26).
if (groundModel) {
const modelRows = strataRowsFor(groundModel, name);
diff --git a/src/style.css b/src/style.css
index f4e478c..1fff297 100644
--- a/src/style.css
+++ b/src/style.css
@@ -949,3 +949,5 @@ body { background: #eef1f4; font-family: 'Noto Sans KR', system-ui, sans-serif;
#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-slab > p, #abut-panel-plan-slab > .abut-wall-figs { grid-column: 1 / -1; }
+#abut-panel-plan-ground > p, #abut-panel-plan-ground > .table-scroll, #abut-panel-plan-ground > figure { grid-column: 1 / -1; }
+#abut-panel-plan-ground > figure { margin: 4px 0; max-width: 560px; }
diff --git a/src/view/ground-svg.ts b/src/view/ground-svg.ts
index c1eaea2..43b8eb4 100644
--- a/src/view/ground-svg.ts
+++ b/src/view/ground-svg.ts
@@ -14,73 +14,15 @@ const TOP_PAD = 24;
* Horizontal positions are schematic (no dimensions): piles are spread across the footing.
*/
export function groundSectionSvg(input: PierInput, model: PierModel): string {
- const rows = input.strataRows ?? [];
- const ground = input.groundEl;
const footTop = Math.max(...model.footings.map((f) => f.top));
const footBottom = Math.min(...model.footings.map((f) => f.bottom));
const pileTips = model.piles.map((p) => p.bottom);
- const lowestRow = rows.length ? rows[rows.length - 1].bottomEl : ground - 1;
- // 하상: the 설계홍수위 (solid) and the 세굴선 replace the groundwater (dashed, 승인 2026-10-26).
- const position = input.groundPosition ?? '육상';
- const river = position === '하상';
- const sea = position === '해상';
- const level = river
- ? (Number.isFinite(input.floodLevelEl) ? input.floodLevelEl : undefined)
- : sea
- ? (Number.isFinite(input.highWaterEl) ? input.highWaterEl : undefined)
- : (input.groundWaterOn && Number.isFinite(input.groundWaterEl) ? input.groundWaterEl : undefined);
- // 해상: the 저조위 is drawn too (dashed), on the MSL datum.
- const lowLevel = sea && Number.isFinite(input.lowWaterEl) ? input.lowWaterEl : undefined;
- const groundLabel = sea ? '해저면 EL' : '지반고';
- const scour = river && Number.isFinite(input.scourDepthM) && (input.scourDepthM as number) >= 0 ? (input.scourDepthM as number) : undefined;
- const scourEl = scour !== undefined ? ground - scour : undefined;
-
- const yMin = Math.min(footBottom, lowestRow, ...pileTips, level ?? ground, lowLevel ?? ground, scourEl ?? ground) - 1;
- const yMax = Math.max(ground, footTop, input.copingTopEl) + 0.5;
+ const { yMin } = groundRange(input, [footBottom, ...pileTips]);
+ const yMax = Math.max(input.groundEl, footTop, input.copingTopEl) + 0.5;
const scale = PLOT_H / (yMax - yMin);
const y = (el: number) => TOP_PAD + (yMax - el) * scale;
const H = TOP_PAD + PLOT_H + 16;
-
- const parts: string[] = [];
- // Layers: each band runs from the upper boundary (ground for the first) down to its 하단 EL.
- let upper = ground;
- rows.forEach((row, i) => {
- const top = y(upper);
- const bottom = y(row.bottomEl);
- parts.push(``);
- parts.push(`${escapeText(row.name)}`);
- if (row.name === SUPPORT_STRATUM) {
- parts.push(``);
- // Caption just under the line, so it does not collide with the label of the band above.
- parts.push(`연암층 상면 EL ${upper.toFixed(3)}`);
- }
- upper = row.bottomEl;
- });
- if (rows.length === 0 || upper > yMin) {
- parts.push(``);
- }
-
- // Ground line and label.
- parts.push(``);
- parts.push(`${groundLabel} ${ground.toFixed(3)}`);
-
- // Groundwater (육상, when on) as a dashed line; the 설계홍수위 (하상) as a solid line.
- if (level !== undefined) {
- const dash = river ? '' : ' stroke-dasharray="6 4"';
- const label = river ? '설계홍수위 DHWL' : sea ? '고조위 HWL' : '지하수위';
- parts.push(``);
- parts.push(`${label} ${level.toFixed(3)}`);
- }
- // 해상: 저조위 (dashed, MSL datum).
- if (lowLevel !== undefined) {
- parts.push(``);
- parts.push(`저조위 LWL ${lowLevel.toFixed(3)}`);
- }
- // 세굴선 (하상·해상): the bed after scour.
- if (scourEl !== undefined) {
- parts.push(``);
- parts.push(`세굴선 ${scourEl.toFixed(3)}`);
- }
+ const parts = groundBands(input, y, yMin, WIDTH, (v) => v.toFixed(3));
// Footing, column stub, and piles.
const cx = WIDTH / 2;
@@ -100,6 +42,135 @@ export function groundSectionSvg(input: PierInput, model: PierModel): string {
return ``;
}
+/** The 지반·지층 values the bands read (a pier input, or an abutment's 지반 values with its 지반고). */
+type GroundBandInput = Pick;
+
+/** The water line (지하수위 / 설계홍수위 / 고조위), the 저조위 and the 세굴선 of the input, as ELs. */
+function groundLevels(input: GroundBandInput) {
+ const position = input.groundPosition ?? '육상';
+ const river = position === '하상';
+ const sea = position === '해상';
+ const level = river
+ ? (Number.isFinite(input.floodLevelEl) ? input.floodLevelEl : undefined)
+ : sea
+ ? (Number.isFinite(input.highWaterEl) ? input.highWaterEl : undefined)
+ : (input.groundWaterOn && Number.isFinite(input.groundWaterEl) ? input.groundWaterEl : undefined);
+ const lowLevel = sea && Number.isFinite(input.lowWaterEl) ? input.lowWaterEl : undefined;
+ const scour = river && Number.isFinite(input.scourDepthM) && (input.scourDepthM as number) >= 0 ? (input.scourDepthM as number) : undefined;
+ const scourEl = scour !== undefined ? input.groundEl - scour : undefined;
+ return { river, sea, level, lowLevel, scourEl };
+}
+
+/** Lowest EL to draw: 1 m below the deepest of the layers, the structure ELs given, and the water and scour lines. */
+function groundRange(input: GroundBandInput, structureEls: number[]): { yMin: number } {
+ const rows = input.strataRows ?? [];
+ const g = input.groundEl;
+ const { level, lowLevel, scourEl } = groundLevels(input);
+ const lowestRow = rows.length ? rows[rows.length - 1].bottomEl : g - 1;
+ return { yMin: Math.min(lowestRow, ...structureEls, level ?? g, lowLevel ?? g, scourEl ?? g) - 1 };
+}
+
+/**
+ * 지층 띠, 지반선, 수위선 (지하수위 점선 · 설계홍수위 실선 · 고조위 / 저조위), 세굴선 (2026-11-04: shared by the pier and the
+ * abutment 삽도). `fmt` writes an EL (the pier writes "28.931", the abutment "EL.28.931").
+ */
+function groundBands(input: GroundBandInput, y: (el: number) => number, yMin: number, width: number, fmt: (el: number) => string): string[] {
+ const rows = input.strataRows ?? [];
+ const ground = input.groundEl;
+ const { river, sea, level, lowLevel, scourEl } = groundLevels(input);
+ const groundLabel = sea ? '해저면 EL' : '지반고';
+ const parts: string[] = [];
+ // Layers: each band runs from the upper boundary (ground for the first) down to its 하단 EL.
+ let upper = ground;
+ rows.forEach((row, i) => {
+ const top = y(upper);
+ const bottom = y(row.bottomEl);
+ parts.push(``);
+ parts.push(`${escapeText(row.name)}`);
+ if (row.name === SUPPORT_STRATUM) {
+ parts.push(``);
+ // Caption just under the line, so it does not collide with the label of the band above.
+ parts.push(`연암층 상면 ${fmt(upper).startsWith('EL') ? '' : 'EL '}${fmt(upper)}`);
+ }
+ upper = row.bottomEl;
+ });
+ if (rows.length === 0 || upper > yMin) {
+ parts.push(``);
+ }
+ // Ground line and label.
+ parts.push(``);
+ parts.push(`${groundLabel} ${fmt(ground)}`);
+ // Groundwater (육상, when on) as a dashed line; the 설계홍수위 (하상) as a solid line.
+ if (level !== undefined) {
+ const dash = river ? '' : ' stroke-dasharray="6 4"';
+ const label = river ? '설계홍수위 DHWL' : sea ? '고조위 HWL' : '지하수위';
+ parts.push(``);
+ parts.push(`${label} ${fmt(level)}`);
+ }
+ // 해상: 저조위 (dashed, MSL datum).
+ if (lowLevel !== undefined) {
+ parts.push(``);
+ parts.push(`저조위 LWL ${fmt(lowLevel)}`);
+ }
+ // 세굴선 (하상·해상): the bed after scour.
+ if (scourEl !== undefined) {
+ parts.push(``);
+ parts.push(`세굴선 ${fmt(scourEl)}`);
+ }
+ return parts;
+}
+
+/** 교대 지반 삽도 입력 (2026-11-04): the 지반 values and the generated 저판 · 벽체 · 흉벽 · 말뚝 (t in m, 전면 +). */
+export interface AbutGroundDrawing {
+ ground: GroundBandInput;
+ footingTopEl: number;
+ footingBottomEl: number;
+ seatEl: number;
+ parapetTopEl: number;
+ bracketHeightM: number;
+ wallFrontM: number;
+ wallBackM: number;
+ parapetBackM: number;
+ footingFrontM: number;
+ footingBackM: number;
+ pileRowsT: number[];
+ pileBottomEl?: number;
+}
+
+/**
+ * 교대 지반·지층 단면 (삽도, 2026-11-04): 교대축 직각 단면(저판, 벽체, 브라켓 단차, 흉벽, 말뚝 열)을 지층 · 수위 위에 그린다.
+ * 세로는 EL 한 축척, 가로는 단면 모양을 보이는 모식 축척이다. 왼쪽이 배면.
+ */
+export function abutGroundSectionSvg(d: AbutGroundDrawing): string {
+ const g = d.ground;
+ const { yMin } = groundRange(g, [d.footingBottomEl, ...(d.pileBottomEl === undefined ? [] : [d.pileBottomEl])]);
+ const yMax = Math.max(g.groundEl, d.parapetTopEl) + 0.8;
+ const scale = PLOT_H / (yMax - yMin);
+ const y = (el: number) => TOP_PAD + (yMax - el) * scale;
+ const H = TOP_PAD + PLOT_H + 16;
+ const fmt = (v: number) => `EL.${v.toFixed(3)}`;
+ const parts = groundBands(g, y, yMin, WIDTH, fmt);
+ const kx = 150 / (d.footingFrontM - d.footingBackM);
+ const cx = WIDTH / 2;
+ const X = (t: number) => cx + t * kx;
+ const rect = (t0: number, t1: number, e0: number, e1: number, style: string) =>
+ ``;
+ const bracketTop = d.parapetTopEl - d.bracketHeightM;
+ if (d.pileBottomEl !== undefined) {
+ for (const t of d.pileRowsT) parts.push(rect(t - 0.15, t + 0.15, d.pileBottomEl, d.footingBottomEl, 'fill="#7D8A97"'));
+ }
+ parts.push(rect(d.footingBackM, d.footingFrontM, d.footingBottomEl, d.footingTopEl, 'fill="#B7C7D3" stroke="#6E8496"'));
+ parts.push(rect(d.wallBackM, d.wallFrontM, d.footingTopEl, d.seatEl, 'fill="#D8C7AA" stroke="#9C8763"'));
+ parts.push(rect(d.wallBackM, d.parapetBackM, d.seatEl, bracketTop, 'fill="#D8C7AA" stroke="#9C8763"'));
+ parts.push(rect(d.parapetBackM, 0, d.seatEl, d.parapetTopEl, 'fill="#D8C7AA" stroke="#9C8763"'));
+ // Right-aligned at the drawing edge so the longer EL labels are not cut off.
+ parts.push(`기초 하단 ${fmt(d.footingBottomEl)}`);
+ if (d.pileBottomEl !== undefined) parts.push(`말뚝 선단 ${fmt(d.pileBottomEl)}`);
+ parts.push(`흉벽 상단 ${fmt(d.parapetTopEl)}`);
+ parts.push(`← 배면 · 전면 →`);
+ return ``;
+}
+
function escapeText(s: string): string {
return s.replace(/&/g, '&').replace(//g, '>');
}
diff --git a/tests/abut-ground.test.ts b/tests/abut-ground.test.ts
new file mode 100644
index 0000000..666e183
--- /dev/null
+++ b/tests/abut-ground.test.ts
@@ -0,0 +1,71 @@
+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, type AbutRecord } from '../src/core/abut/parse';
+import { buildMembers } from '../src/core/abut/members';
+import { setAbutGround } from '../src/core/abut/edit';
+import { abutGroundChanged, abutGroundIssues, supportTopEl } from '../src/core/abut/ground';
+import { abutMemberRows } from '../src/core/abut/member-list';
+import { checkStoredAbutments } from '../src/core/project';
+import { abutGroundSectionSvg } from '../src/view/ground-svg';
+
+const RAG = fileURLToPath(new URL('../../../RAG/운곡교P1입력.xlsx', import.meta.url));
+const rec = parseAbutments(XLSX.read(readFileSync(RAG), { type: 'buffer' })).abutments[0];
+const membersOf = (r: AbutRecord) => buildMembers(buildAbut(toAbutInput(r)), { bottomEl: r.foundationBottomEl, pileLengthMm: r.pileLengthMm }, r.shape);
+const codes = (r: AbutRecord) => abutGroundIssues(r, membersOf(r)).map((i) => i.code);
+// A1: 지반고 33.984, 기초 하단 40.895, 말뚝 선단 19.295 (spec_abut §12).
+const rows = [
+ { name: '퇴적토', bottomEl: 30 },
+ { name: '풍화암', bottomEl: 22 },
+ { name: '연암', bottomEl: 10 },
+];
+
+describe('교대 지반·지층 (2026-11-04, 교각 카드와 같은 입력)', () => {
+ it('defaults: 육상, no water, no rows; only AI01 (지반고 below the 저판 하면) is reported', () => {
+ expect(codes(rec)).toEqual(['AI01']);
+ expect(abutGroundIssues(rec, membersOf(rec))[0].message).toContain('6.911');
+ expect(abutGroundChanged(rec.ground)).toBe(false);
+ });
+
+ it('the pier checks apply: G01 order, G02 water, G07 DHWL, G13 sea, G04 pile embedment into the 연암', () => {
+ expect(codes(setAbutGround(rec, { strataRows: [{ name: '퇴적토', bottomEl: 35 }] }))).toContain('G01');
+ expect(codes(setAbutGround(rec, { groundWaterOn: true }))).toContain('G02');
+ expect(codes(setAbutGround(rec, { groundPosition: '하상' }))).toContain('G07');
+ expect(codes(setAbutGround(rec, { groundPosition: '해상' }))).toContain('G13');
+ // 연암 상면 22.000 − 1.0 × 0.6 = 21.4; the tip 19.295 is deep enough.
+ expect(codes(setAbutGround(rec, { strataRows: rows }))).toEqual(['AI01']);
+ const shallow = setAbutGround(rec, { strataRows: [{ name: '풍화암', bottomEl: 19.5 }, { name: '연암', bottomEl: 10 }] });
+ expect(codes(shallow)).toContain('G04');
+ expect(supportTopEl(rec.groundEl, rows)).toBe(22);
+ expect(supportTopEl(rec.groundEl, [{ name: '퇴적토', bottomEl: 30 }])).toBeUndefined();
+ });
+
+ it('set: merges values, removes undefined, refuses non-numbers; the member row follows', () => {
+ const a = setAbutGround(rec, { groundPosition: '하상', floodLevelEl: 36 });
+ expect(setAbutGround(a, { floodLevelEl: undefined }).ground).toEqual({ groundPosition: '하상' });
+ expect(() => setAbutGround(rec, { groundWaterEl: Number.NaN })).toThrow('숫자');
+ const row = abutMemberRows(setAbutGround(rec, { strataRows: rows }), '—').find((r) => r.id === 'ground')!;
+ expect(row.summary).toBe('육상 · 지층 3 · 지반고 EL.33.984');
+ expect(row.changed).toBe(true);
+ });
+
+ it('the project file keeps the 지반 values and refuses wrong ones', () => {
+ expect(() => checkStoredAbutments([setAbutGround(rec, { groundPosition: '해상', highWaterEl: 35, strataRows: rows })])).not.toThrow();
+ expect(() => checkStoredAbutments([{ ...rec, ground: { groundPosition: '산지' } as never }])).toThrow('지반');
+ expect(() => checkStoredAbutments([{ ...rec, ground: { strataRows: [{ name: '연암', bottomEl: 'x' }] } as never }])).toThrow('지반');
+ });
+
+ it('삽도: layers, water and the abutment section with EL. labels', () => {
+ const m = membersOf(rec);
+ const s = m.shape;
+ const svg = abutGroundSectionSvg({
+ ground: { groundEl: rec.groundEl, groundWaterOn: true, groundWaterEl: 31, strataRows: rows },
+ footingTopEl: m.footingTopEl, footingBottomEl: m.footingBottomEl!, seatEl: rec.seatEl, parapetTopEl: m.parapetTopStartEl,
+ bracketHeightM: s.bracketHeightM, wallFrontM: s.wallFrontM, wallBackM: s.wallBackM, parapetBackM: s.parapetBackM,
+ footingFrontM: s.footingFrontM, footingBackM: s.footingBackM, pileRowsT: m.piles.map((r) => r.t), pileBottomEl: m.pileBottomEl,
+ });
+ for (const t of ['지반고 EL.33.984', '지하수위 EL.31.000', '연암층 상면 EL.22.000', '기초 하단 EL.40.895', '말뚝 선단 EL.19.295', '퇴적토', '풍화암']) expect(svg).toContain(t);
+ });
+});
diff --git a/tests/abut-member-list.test.ts b/tests/abut-member-list.test.ts
index b0a85fe..a3c00b3 100644
--- a/tests/abut-member-list.test.ts
+++ b/tests/abut-member-list.test.ts
@@ -42,10 +42,10 @@ describe('교대 부재 목록 (시안 ② 부재 · 교대)', () => {
it('shows the slab width text and the ground EL as given', () => {
const rows = abutMemberRows(base, '11.253 m');
expect(rows.find((r) => r.id === 'slab')?.summary).toBe('매립형 · 하향 4 % · 폭 11.253 m');
- expect(rows.find((r) => r.id === 'ground')?.summary).toBe('지반고 33.984 m');
+ expect(rows.find((r) => r.id === 'ground')?.summary).toBe('육상 · 지층 0 · 지반고 EL.33.984');
});
- it('every card but the ground takes input in this step (접속슬래브 2026-11-04)', () => {
- expect(ABUT_MEMBER_EDITABLE).toEqual({ bearing: true, shape: true, footing: true, wing: true, slab: true, ground: false });
+ it('every card takes input in this step (접속슬래브 · 지반 2026-11-04)', () => {
+ expect(ABUT_MEMBER_EDITABLE).toEqual({ bearing: true, shape: true, footing: true, wing: true, slab: true, ground: true });
});
});