Files
Pier_v1/src/core/abut/edit.ts
T
ypshimandClaude Haiku 5.5 b06bf368b6 feat(abut-rebar): ③ 교대 화면 틀과 확대기초 배근 카드
- ③에서 교대를 고르면 부재 · 교대 목록(받침블럭 → 벽체 → 흉벽·브라켓 → 확대기초 → 날개벽 → 접속슬래브)과 카드를 보여 줌.
  확대기초만 규칙이 있고 나머지는 '다음 단계'. 선택한 교대만 3D에 그림
- 확대기초 카드: 보기 탭 3개(교대축 직각 단면 · 교대축 단면 · 상면 평면)와 번호 풍선 강조, 범례,
  기본간격 · 배근 방향 ① / ② · 피복 · 여유 · B9 배수 입력, 자동 값(cycle 간격 · B10 위 여유 · l_s · 옆붙임),
  철근표(묶음별 실간격 수정 · 수정됨 · 되돌리기, 비고 펼치기), 근거 줄, RB-AF 알림
- 입력은 AbutRecord.rebar.footing에 바꾼 값만 저장 · 프로젝트 파일 검증. fck는 공통 설정의 기초부 값

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-04 11:12:43 +09:00

233 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 교대 입력 편집 (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<WingDims> = { ...(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<AbutShape>): 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<AbutShape> | undefined): number {
return { ...ABUT_SHAPE_DEFAULT, ...normalizeShape(shape) }.bracketHeightMm;
}
/** A lower 브라켓 must still hold the 접속슬래브 under the 흉벽 상단. */
function slabFitIssues(slab: Partial<AbutSlab> | undefined, shape: Partial<AbutShape>): 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<AbutSlab> & { slope?: unknown };
const slab: Partial<AbutSlab> = { ...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<AbutGround>): 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<AbutFootingRebarSettings>): AbutRecord {
for (const v of Object.values(patch)) if (typeof v === 'number' && !Number.isFinite(v)) throw new Error('숫자를 입력하세요.');
const footing: Partial<AbutFootingRebarSettings> = { ...(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 };
}