/** * 교대 입력 편집 (2026-11-03, 교대 편집 카드): changes one value of an abutment record with checks. Pure: it returns a new * record and never changes the old one. Errors are in Korean and name the field. */ import type { AbutRecord } from './parse'; import type { WingForm } from './geometry'; import { WING_DIMS_DEFAULT, wingDimsIssues, type WingDims } from './wings'; import { ABUT_SHAPE_DEFAULT, normalizeShape, shapeIssues, type AbutShape } from './shape'; import { resolveSlab, slabIssues, type AbutSlab, type SlabForm } from './slab'; import type { AbutGround } from './ground'; import { footingRebarIssues, resolveFootingRebar, type AbutFootingRebarSettings } from './rebar/footing'; import { abutBearingRuleIssues, resolveBearingRule, type AbutBearingRule } from './bearing-table'; export type AbutEditKey = 'seatEl' | 'foundationBottomEl' | 'groundEl' | 'pileLengthMm' | 'skewDeg' | 'slopeI'; export const ABUT_EDIT_FIELDS: { key: AbutEditKey; label: string; unit: string }[] = [ { key: 'seatEl', label: '교좌면 EL (구간 ①)', unit: 'm' }, { key: 'foundationBottomEl', label: '기초 하단 EL', unit: 'm' }, { key: 'groundEl', label: '지반고 EL', unit: 'm' }, { key: 'pileLengthMm', label: '말뚝길이', unit: 'mm' }, { key: 'skewDeg', label: 'skew', unit: '°' }, { key: 'slopeI', label: '종단경사 i', unit: '' }, ]; /** The 저판 thickness (m) below the 기초 하단 is not part of this check; the 교좌면 must sit above the 기초 하단. */ export function applyAbutEdit(rec: AbutRecord, key: AbutEditKey, value: number): AbutRecord { const label = ABUT_EDIT_FIELDS.find((f) => f.key === key)?.label ?? key; if (!Number.isFinite(value)) throw new Error(`${label}은(는) 숫자여야 합니다.`); if (key === 'pileLengthMm' && !(value > 0)) throw new Error(`${label}은(는) 0보다 커야 합니다.`); if (key === 'skewDeg' && !(value > 0 && value < 180)) throw new Error(`${label}은(는) 0 초과 180 미만이어야 합니다.`); if (key === 'slopeI' && Math.abs(value) > 0.2) throw new Error(`${label}은(는) ±0.2 이내여야 합니다.`); const next: AbutRecord = { ...rec, [key]: value }; if (next.seatEl <= next.foundationBottomEl) { throw new Error('교좌면 EL은 기초 하단 EL보다 높아야 합니다.'); } return next; } /** 날개벽 형식은 측별로 정한다 (2026-11-04: "좌우 같게" 없음). */ export function setWingForm(rec: AbutRecord, side: 'left' | 'right', form: WingForm): AbutRecord { const current = rec.wings ?? { left: 'general' as WingForm, right: 'general' as WingForm }; return { ...rec, wings: { ...current, [side]: form } }; } /** * 반대쪽에도 적용 (2026-11-04): copies this side's wing dims to the other side once (no lasting link). * 두께 · 끝 높이 · 헌치는 늘 복사하고, 길이는 두 측의 형식이 같을 때만 복사한다 (단축형 길이 기본 = 뒷굽 끝). * The other side must have a wing. */ export function copyWingDims(rec: AbutRecord, side: 'left' | 'right'): AbutRecord { const other = side === 'left' ? 'right' : 'left'; const forms = rec.wings ?? { left: 'general' as WingForm, right: 'general' as WingForm }; if (forms[other] === 'none') throw new Error('반대쪽 날개벽이 없습니다.'); const from = rec.wingDims?.[side] ?? {}; const to: Partial = { ...(rec.wingDims?.[other] ?? {}) }; for (const key of ['thicknessMm', 'endHeightMm', 'haunchMm'] as const) { if (from[key] === undefined) delete to[key]; else to[key] = from[key]; } if (forms[side] === forms[other]) { if (from.lengthMm === undefined) delete to.lengthMm; else to.lengthMm = from.lengthMm; } return { ...rec, wingDims: { ...(rec.wingDims ?? {}), [other]: to } }; } /** 날개벽 치수 입력 칸 (② 날개벽 카드, 시안 순서). 단축형에는 끝 높이가 없다. */ export const WING_DIM_FIELDS: { key: keyof WingDims; label: string; general: boolean; short: boolean }[] = [ { key: 'lengthMm', label: '길이 (mm)', general: true, short: true }, { key: 'thicknessMm', label: '두께 (mm)', general: true, short: true }, { key: 'endHeightMm', label: '끝 높이 (mm)', general: true, short: false }, { key: 'haunchMm', label: '헌치 (mm, 300 × 300의 한 변)', general: true, short: true }, ]; /** Changes one wing dim of one side. Length, thickness and end height must be > 0, the haunch ≥ 0. */ export function applyWingDim(rec: AbutRecord, side: 'left' | 'right', key: keyof WingDims, value: number): AbutRecord { if (!Number.isFinite(value)) throw new Error('숫자를 입력하세요.'); const issues = wingDimsIssues({ ...WING_DIMS_DEFAULT, [key]: value }); if (issues.length > 0) throw new Error(issues[0]); return { ...rec, wingDims: { ...(rec.wingDims ?? {}), [side]: { ...(rec.wingDims?.[side] ?? {}), [key]: value } } }; } /** 받침 값 편집 (2026-11-03): z (패드 상면, m) or 교좌면 override (m, undefined = 자동 — 블럭 높이 규칙을 따른다). */ export function applyBearingEdit(rec: AbutRecord, no: number, key: 'z' | 'seatEl', value: number | undefined): AbutRecord { const target = rec.bearings.find((b) => b.no === no); if (!target) throw new Error(`받침 ${no}번이 없습니다.`); if (key === 'z' && (value === undefined || !Number.isFinite(value))) throw new Error('z는 숫자여야 합니다.'); if (key === 'seatEl' && value !== undefined && !Number.isFinite(value)) throw new Error('교좌면 EL은 숫자이거나 비워야 합니다.'); const next = rec.bearings.map((b) => { if (b.no !== no) return b; if (key === 'z') return { ...b, z: value as number }; return value === undefined ? { ...b, seatEl: undefined } : { ...b, seatEl: value }; }); const out: AbutRecord = { ...rec, bearings: next }; // The 교좌면 of every bearing must still lie above the 기초 하단 (the 저판 thickness is checked in the geometry step). const lowest = Math.min(...out.bearings.map((b) => b.seatEl ?? out.seatEl + (b.z - Math.min(...out.bearings.map((x) => x.z))))); if (lowest <= out.foundationBottomEl) throw new Error(`받침 ${no}번 교좌면 EL이 기초 하단 EL보다 낮거나 같습니다.`); return out; } /** 기본값 복원: the record as imported from the workbook (the wing forms go back to 양측 일반형). */ export function restoreAbutDefault(defaults: AbutRecord): AbutRecord { return JSON.parse(JSON.stringify(defaults)) as AbutRecord; } /** 형상 입력 change (벽체·흉벽, 확대기초·말뚝, mm): refused with a Korean message when the shape would not build. */ /** Several shape values at once (e.g. the 열 위치 "650 · 2000 · 1300 · 650"), checked together. */ export function applyShapeEdits(rec: AbutRecord, values: Partial): AbutRecord { for (const v of Object.values(values)) if (!Number.isFinite(v)) throw new Error('숫자를 입력하세요.'); const shape = { ...(rec.shape ?? {}), ...values }; const issues = [...shapeIssues(shape), ...slabFitIssues(rec.slab, shape)]; if (issues.length > 0) throw new Error(issues[0]); return { ...rec, shape }; } export function applyShapeEdit(rec: AbutRecord, key: keyof AbutShape, value: number): AbutRecord { if (!Number.isFinite(value)) throw new Error('숫자를 입력하세요.'); const shape = { ...(rec.shape ?? {}), [key]: value }; const issues = [...shapeIssues(shape), ...slabFitIssues(rec.slab, shape)]; if (issues.length > 0) throw new Error(issues[0]); return { ...rec, shape }; } /** 브라켓 높이 (mm) of a shape input, default when absent. */ function bracketHeightMm(shape: Partial | undefined): number { return { ...ABUT_SHAPE_DEFAULT, ...normalizeShape(shape) }.bracketHeightMm; } /** A lower 브라켓 must still hold the 접속슬래브 under the 흉벽 상단. */ function slabFitIssues(slab: Partial | undefined, shape: Partial): string[] { const s = resolveSlab(slab); const h = bracketHeightMm(shape); return s.thicknessMm > h ? [`브라켓 높이(${h})는 접속슬래브 두께(${s.thicknessMm}) 이상이어야 합니다.`] : []; } /** 접속슬래브 형식 (2026-11-04); the form also sets the slope (매립형 하향 4 %, 노출형 종단경사). */ export function setSlabForm(rec: AbutRecord, form: SlabForm): AbutRecord { const { slope: _old, ...kept } = (rec.slab ?? {}) as Partial & { slope?: unknown }; const slab: Partial = { ...kept, form }; const issues = slabIssues(resolveSlab(slab), bracketHeightMm(rec.shape)); if (issues.length > 0) throw new Error(issues[0]); return { ...rec, slab }; } /** 접속슬래브 입력칸 (② 접속슬래브 카드, 시안 순서). 헌치 길이는 노출형만. */ export const SLAB_EDIT_FIELDS: { key: 'lengthMm' | 'thicknessMm' | 'parapetGapMm' | 'wingGapMm' | 'haunchLengthMm'; label: string; exposedOnly?: boolean; note?: string }[] = [ { key: 'lengthMm', label: '길이 (mm)' }, { key: 'thicknessMm', label: '두께 (mm)' }, { key: 'parapetGapMm', label: '흉벽과 간격 (mm)' }, { key: 'wingGapMm', label: '날개벽과 간격 (mm)' }, { 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 }; } /** * ③ 저판 배근 입력 (2026-11-04): merges the values; undefined removes one (a 철근별 간격 goes back to the 기본간격). * Refused with a Korean message when the settings would not lay out. */ export function applyFootingRebar(rec: AbutRecord, patch: Partial): AbutRecord { for (const v of Object.values(patch)) if (typeof v === 'number' && !Number.isFinite(v)) throw new Error('숫자를 입력하세요.'); const footing: Partial = { ...(rec.rebar?.footing ?? {}), ...patch }; for (const key of Object.keys(footing) as (keyof AbutFootingRebarSettings)[]) if (footing[key] === undefined) delete footing[key]; const issues = footingRebarIssues(resolveFootingRebar(footing)); if (issues.length > 0) throw new Error(issues[0]); return { ...rec, rebar: { ...(rec.rebar ?? {}), footing } }; } /** 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('숫자를 입력하세요.'); const slab = { ...(rec.slab ?? {}), [key]: value }; const issues = slabIssues(resolveSlab(slab), bracketHeightMm(rec.shape)); if (issues.length > 0) throw new Error(issues[0]); return { ...rec, slab }; } /** 형상 입력 fields on the 교대 카드 (시안 ② 벽체·흉벽, 확대기초·말뚝), in the order of the mockup. */ export const SHAPE_EDIT_FIELDS: { key: keyof AbutShape; label: string; group: 'wall' | 'footing'; note?: string }[] = [ // 벽체 · 흉벽 (시안 입력 격자 순서). 벽체 두께는 자동 = 브라켓 폭 + 흉벽 두께 + 교좌면 폭. { key: 'wallFrontMm', label: '교좌면 폭 (mm)', group: 'wall' }, { key: 'parapetHeightMm', label: '흉벽 높이 (mm)', group: 'wall' }, { key: 'parapetThicknessMm', label: '흉벽 두께 (mm)', group: 'wall' }, { key: 'bracketHeightMm', label: '브라켓 높이 (mm)', group: 'wall', note: '= 접속슬래브 카드 흉벽 높이' }, { key: 'bracketWidthMm', label: '브라켓 폭 (mm)', group: 'wall' }, { key: 'shrinkJointCount', label: '수축줄눈 개수', group: 'wall' }, { key: 'parapetLevelLeftMm', label: '흉벽 상단 Level 좌 (mm)', group: 'wall', note: '기준 확인 QA-4' }, { key: 'parapetLevelRightMm', label: '흉벽 상단 Level 우 (mm)', group: 'wall', note: '기준 확인 QA-4' }, { key: 'seatStepIgnoreMm', label: '인접 무시 기준 (mm)', group: 'wall' }, // 확대기초 · 말뚝 (분리 카드, 시안 순서). 뒷굽 · 길이 · 선단 EL은 자동, 열 위치와 열별 본수는 카드에서 따로 입력한다. { key: 'footingWidthMm', label: '폭 (mm)', group: 'footing' }, { key: 'footingThicknessMm', label: '두께 (mm)', group: 'footing' }, { key: 'footingToeMm', label: '앞굽 (mm)', group: 'footing' }, { key: 'blindingMarginMm', label: '버림 여유 한쪽 (mm)', group: 'footing' }, { key: 'blindingThicknessMm', label: '버림 두께 (mm)', group: 'footing' }, { key: 'pileDiameterMm', label: '말뚝 지름 (mm)', group: 'footing', note: 'QA-5' }, { key: 'pileEdgeMm', label: '연단 · 길이 방향 (mm)', group: 'footing' }, { key: 'pileSpacingMm', label: '본수 규칙 간격 (mm)', group: 'footing' }, ]; /** 받침 규칙 입력칸 (② 교량받침 카드): 교각 솔플레이트 규칙과 같은 칸. 받침높이 H는 교대 받침 시트 값으로 고정 (2026-11-03). */ export const BEARING_RULE_FIELDS: { key: 'lengthMm' | 'widthMm' | 'slopePct'; label: string }[] = [ { key: 'lengthMm', label: '솔플레이트 교직 L (mm)' }, { key: 'widthMm', label: '솔플레이트 교축 W (mm)' }, { key: 'slopePct', label: '종단 기울기 S (%)' }, ]; /** * Changes one 받침 규칙 value. L and W must be greater than 0, S any number, and the rule must still build a solplate with * the 교대 받침높이 H (the pier rule's checks). */ export function applyBearingRule(rec: AbutRecord, key: keyof AbutBearingRule, value: number): AbutRecord { const n = Number(value); if (!Number.isFinite(n)) throw new Error('솔플레이트 규칙 값은 숫자여야 합니다.'); const bearingRule = { ...(rec.bearingRule ?? {}), [key]: n }; const issues = abutBearingRuleIssues(resolveBearingRule(bearingRule), rec.bearingHeightMm); if (issues.length > 0) throw new Error(issues[0]); return { ...rec, bearingRule }; }