feat(abut): 교대 지반·지층 카드 — 교각과 같은 위치 · 수위 · 지층 행 입력, 교대 단면 삽도, 검사

- 위치 (육상 · 하상 · 해상)와 위치별 수위 입력, 지층 행 표 (교각과 공용 strataRowsEditor), 지반 모델 지층 불러오기
- 검사: 교각과 같은 G01–G14, 말뚝 근입 G04, 지반고 < 저판 하면이면 정보 AI01
- 삽도: 지층 · 수위 띠를 교각과 공용으로 빼고, 교대축 직각 단면(저판 · 벽체 · 흉벽 · 말뚝 열)을 EL. 표기로 그림
- 자동 값: 지반고 (엑셀 입력), 연암층 상면 EL, 지층 수. 프로젝트 파일 저장 · 검증

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
ypshimandClaude Haiku 5.5 committed 2026-11-04 10:36:23 +09:00
1 parent 9835fb42b3
commit 59c2607418
12 files changed
+527 -135

No files matched your search

+197 -62
View File
@@ -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<AbutGround>) => 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 {
$<HTMLElement>('abut-panel-plan-footing').hidden = member !== 'footing';
$<HTMLElement>('abut-panel-plan-wing').hidden = member !== 'wing';
$<HTMLElement>('abut-panel-plan-slab').hidden = member !== 'slab';
$<HTMLElement>('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 = $<HTMLDivElement>(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);