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 `${parts.join('')}`; } +/** 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 `${parts.join('')}`; +} + 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 }); }); });