feat(abut): 교대 형상·입력 패널과 교각 받침·솔플레이트 개편

- 교대 형상(src/core/abut, 3D 장면, 목록·카메라·맞춤)과 교대 입력 패널 추가
- 교각 받침 기준 카드 제거, 솔플레이트 규칙을 교각별 입력으로 변경
- 받침 ELEVATION·솔플레이트 TABLE을 모델값으로 연결
- 교대 입력값 카드: 형식 행에 시점·종점 표시, 제목을 "A1 입력값 [입력]"으로 변경
- 테스트 614건 통과, 타입 검사 통과

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
ypshimandClaude Haiku 5.5 committed 2026-11-03 14:51:34 +09:00
1 parent b1b2ad1ddb
commit 1d31d2f3a9
44 files changed
+2571 -120

No files matched your search

+84
View File
@@ -0,0 +1,84 @@
/**
* 교대 입력 편집 (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 { shapeIssues, type AbutShape } from './shape';
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;
}
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-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. */
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);
if (issues.length > 0) throw new Error(issues[0]);
return { ...rec, shape };
}
/** 형상 입력 fields on the 교대 카드 (시안 ② 벽체·흉벽, 확대기초·말뚝), in the order of the mockup. */
export const SHAPE_EDIT_FIELDS: { key: keyof AbutShape; label: string }[] = [
{ key: 'wallFrontMm', label: '벽체 전면 (교대축 기준, mm)' },
{ key: 'wallThicknessMm', label: '벽체 두께 (mm)' },
{ key: 'parapetHeightMm', label: '흉벽 높이 (mm)' },
{ key: 'parapetThicknessMm', label: '흉벽 두께 (mm)' },
{ key: 'bracketHeightMm', label: '브라켓 높이 (mm)' },
{ key: 'footingThicknessMm', label: '저판 두께 (mm)' },
{ key: 'footingFrontMm', label: '저판 앞굽 끝 (mm)' },
{ key: 'footingBackMm', label: '저판 뒷굽 끝 (mm)' },
{ key: 'pileEdgeMm', label: '말뚝 연단 (mm)' },
{ key: 'pileSpacingMm', label: '말뚝 간격 (mm)' },
];
+185
View File
@@ -0,0 +1,185 @@
/**
* 교대 형상 · AA1 (2026-11-03, spec_abut §1·§4.1–4.2): 교대축, 전면 쪽, 교좌면 위의 받침, 교좌면 EL과 블럭 높이를 계산한다.
* 순수 함수. 좌표는 (E, N) m 단위, EL은 m 단위, 길이는 별도 표기가 없으면 mm 단위다.
*
* - 축: IP1 → IP3 선(교대중심선), 길이 L. u = IP1 → IP3 단위벡터, nLeft = u를 반시계 90° 돌린 벡터.
* - 받침 s: IP1에서 u 방향으로 잰 거리(m). 축에서 벗어난 거리(lateral): nLeft 방향 거리(m).
* - z = 패드 상면(EL.X), 교각과 같다(2026-11-03 결정 1). 교좌면에서 패드 상면까지 블럭 높이는 125 mm
* (콘크리트 75 + 패드 50). 교좌면 EL은 콘크리트 하면이다.
* - 교좌면: 교대 전체에 교좌면 EL 하나를 입력받는다(첫 받침 ①). 블럭 높이 h = min(z) − 교좌면으로 모든 받침이 같고,
* 받침 i의 교좌면 = z_i − h. 받침이 자기 교좌면 EL(seatEl)을 직접 주면 그 값을 쓰고, 블럭 높이는 z_i − seatEl이 된다
* (2026-11-03 결정 3).
* - 전면: 받침이 있는 쪽. A1은 전면이 1 → n 방향의 왼쪽, 흉벽은 오른쪽에 있다. A2는 반대다(spec_abut §1).
* 맞지 않으면 AW03 경고 대상이다.
*/
export type AbutType = 'A1' | 'A2';
export interface AbutPoint {
e: number;
n: number;
}
export interface AbutBearing {
no: number;
/** 위치 (E, N), m. */
pos: AbutPoint;
/** 패드 상면 EL (z), m. */
z: number;
/** 이 받침의 교좌면 EL (m). 없으면 z에서 교대 블럭 높이를 뺀 값을 쓴다. */
seatEl?: number;
}
/** 날개벽 형식, 측별(spec_abut §4.7): 일반형 / 단축형 / 없음. */
export type WingForm = 'general' | 'short' | 'none';
export interface AbutInput {
name: string;
type: AbutType;
/** 첫 받침(구간 ①)의 교좌면 EL (m). 블럭 높이 h = min(z) − seatEl. */
seatEl: number;
ip1: AbutPoint;
ip2: AbutPoint;
ip3: AbutPoint;
bearings: AbutBearing[];
/** skew (deg): 받침축을 시계방향으로 돌린 선이 날개벽 선이다(spec_abut §1). 없으면 날개벽 검사를 하지 않는다. */
skewDeg?: number;
/** 종단경사 i (소수, 교량진행방향 기준, (−) = 낮아짐). 날개벽 경사는 A1이 −i, A2가 +i다. */
slopeI?: number;
/** AW01용 거더 방향 CAD 각 (deg). 없으면 AW01을 검사하지 않는다. */
girderAzimuthDeg?: number;
/** 측별 날개벽 형식: 'left' = 받침 ① 쪽 끝, 'right' = 받침 n 쪽 끝(spec §4.7). 기본은 양측 일반형. */
wings?: { left: WingForm; right: WingForm };
/** 기초 하단 EL (m), 말뚝길이 (mm), 기초 형식 (AA3, 저판·말뚝). 없으면 저판·말뚝을 만들지 않는다. */
foundationBottomEl?: number;
pileLengthMm?: number;
foundationType?: string;
}
export interface AbutWingModel {
/** 날개벽 선, CAD 각 (deg), 배면 쪽(예제: A1 ≈ 255.32, A2 ≈ 75.32). */
lineDeg: number;
/** 날개벽 선과 거더 방향의 사잇각, 0–90°로 접음(AW01 허용 1°). */
girderDiffDeg: number;
aw01Ok: boolean;
/** 날개벽 경사 (%), (+) = 벽체에서 멀어질수록 높아짐. */
slopePct: number;
/** 날개벽 사이 접속슬래브 폭 (mm) = L − Σ(450 / |sin Δ| + 20), 있는 날개벽마다. */
slabWidthMm: number;
wings: { left: WingForm; right: WingForm };
}
export interface AbutBearingModel {
no: number;
/** IP1에서 u 방향 거리 (m). */
s: number;
/** nLeft 방향으로 잰 축에서의 부호 있는 거리 (m). */
lateral: number;
z: number;
seatEl: number;
/** 교좌면에서 패드 상면까지 블럭 높이 (mm), 패드 포함. */
blockHeightMm: number;
}
export interface AbutModel {
name: string;
type: AbutType;
/** IP1 (축의 시작점, s = 0). */
ip1: AbutPoint;
/** 축 길이 IP1 → IP3 (m). */
length: number;
u: AbutPoint;
nLeft: AbutPoint;
/** 받침이 축의 nLeft 쪽이면 +1, 반대쪽이면 −1. */
frontSide: 1 | -1;
/** 받침이 1 → n 방향의 왼쪽이면 true (A1 배치). */
frontLeftOfBearingDir: boolean;
/** 입력 순서의 받침. */
bearings: AbutBearingModel[];
/** 축 위 계단 경계(IP1에서 m), 이웃 받침 사이의 중간. */
stepBounds: number[];
/** AW03: 배치가 형식과 맞으면 true. */
layoutMatchesType: boolean;
/** 날개벽 선, 경사, AW01, 접속슬래브 폭. 입력에 skew가 없으면 없다. */
wing?: AbutWingModel;
}
const WING_THICKNESS_MM = 450;
const SLAB_CLEARANCE_MM = 20;
const AW01_TOLERANCE_DEG = 1;
/** 공통 규칙의 패드 두께 (mm). 블럭 높이에 포함된다(패드 포함). */
export const ABUT_PAD_MM = 50;
const sub = (a: AbutPoint, b: AbutPoint): AbutPoint => ({ e: a.e - b.e, n: a.n - b.n });
const dot = (a: AbutPoint, b: AbutPoint) => a.e * b.e + a.n * b.n;
const cross = (a: AbutPoint, b: AbutPoint) => a.e * b.n - a.n * b.e;
export function buildAbut(input: AbutInput): AbutModel {
if (input.bearings.length < 2) throw new Error(`교대 ${input.name}: 받침이 2개 이상 필요합니다.`);
const axisVec = sub(input.ip3, input.ip1);
const length = Math.hypot(axisVec.e, axisVec.n);
if (!(length > 0)) throw new Error(`교대 ${input.name}: IP1과 IP3이 같은 점입니다.`);
const u = { e: axisVec.e / length, n: axisVec.n / length };
const nLeft = { e: -u.n, n: u.e };
const lateral = input.bearings.map((b) => dot(sub(b.pos, input.ip1), nLeft));
const meanLateral = lateral.reduce((a, x) => a + x, 0) / lateral.length;
const frontSide: 1 | -1 = meanLateral >= 0 ? 1 : -1;
// 1 → n 방향: 첫 받침(①)에서 마지막 받침까지. 전면 = 받침 쪽. 형식과 맞는지 확인한다.
const first = input.bearings[0];
const last = input.bearings[input.bearings.length - 1];
const dir = sub(last.pos, first.pos);
const frontVec = { e: nLeft.e * frontSide, n: nLeft.n * frontSide };
const frontLeftOfBearingDir = cross(dir, frontVec) > 0;
const layoutMatchesType = input.type === 'A1' ? frontLeftOfBearingDir : !frontLeftOfBearingDir;
const minZ = Math.min(...input.bearings.map((b) => b.z));
const bearings: AbutBearingModel[] = input.bearings.map((b, i) => {
const s = dot(sub(b.pos, input.ip1), u);
const seatEl = b.seatEl ?? input.seatEl + (b.z - minZ);
const blockHeightMm = Math.round((b.z - seatEl) * 1000);
return { no: b.no, s, lateral: lateral[i], z: b.z, seatEl, blockHeightMm };
});
const sorted = [...bearings].sort((a, b) => a.s - b.s);
const stepBounds = sorted.slice(1).map((b, i) => (sorted[i].s + b.s) / 2);
const wing = input.skewDeg === undefined ? undefined : buildWing(input, u, nLeft, frontSide, dir, length);
return { name: input.name, type: input.type, ip1: input.ip1, length, u, nLeft, frontSide, frontLeftOfBearingDir, bearings, stepBounds, layoutMatchesType, wing };
}
const deg = (rad: number) => (rad * 180) / Math.PI;
const norm360 = (x: number) => ((x % 360) + 360) % 360;
/** 두 선 방향의 차이(deg), 0–90°로 접는다. 선에는 방향이 없다. */
const lineDiff = (a: number, b: number) => {
const d = norm360(a - b) % 180;
return Math.min(d, 180 - d);
};
function buildWing(input: AbutInput, u: AbutPoint, nLeft: AbutPoint, frontSide: 1 | -1, dir: AbutPoint, length: number): AbutWingModel {
const skew = input.skewDeg!;
const bearingCad = norm360(deg(Math.atan2(dir.n, dir.e)));
// skew만큼 시계방향으로 돌린 선이 날개벽 선이다. 배면 쪽(받침 반대쪽)이 방향을 정한다.
const line = norm360(bearingCad - skew);
const back = { e: -nLeft.e * frontSide, n: -nLeft.n * frontSide };
const radLine = (line * Math.PI) / 180;
const pick = Math.cos(radLine) * back.e + Math.sin(radLine) * back.n > 0 ? line : norm360(line + 180);
const axisCad = norm360(deg(Math.atan2(u.n, u.e)));
const sinDelta = Math.abs(Math.sin(((pick - axisCad) * Math.PI) / 180));
const wingAxisWidth = WING_THICKNESS_MM / sinDelta;
const wings = input.wings ?? { left: 'general' as WingForm, right: 'general' as WingForm };
const present = (f: WingForm) => (f === 'none' ? 0 : wingAxisWidth + SLAB_CLEARANCE_MM);
const slabWidthMm = Math.round(length * 1000 - present(wings.left) - present(wings.right));
const girderDiffDeg = input.girderAzimuthDeg === undefined ? NaN : lineDiff(pick, input.girderAzimuthDeg);
const slopeI = input.slopeI ?? 0;
return {
lineDeg: pick,
girderDiffDeg,
aw01Ok: Number.isNaN(girderDiffDeg) ? true : girderDiffDeg <= AW01_TOLERANCE_DEG,
slopePct: (input.type === 'A1' ? -slopeI : slopeI) * 100,
slabWidthMm,
wings,
};
}
+91
View File
@@ -0,0 +1,91 @@
/**
* 교대 부재 · AA3 (2026-11-03, spec_abut §4.3–4.6): 벽체, 흉벽 상단, 저판, 말뚝의 치수와 위치. Pure.
*
* - 교대축 기준 t: 전면 쪽이 +. 벽체 전면 +1.6 m (교좌면 끝), 배면 −0.8 m (브라켓 배면). 두께 2.4 m.
* - 흉벽: t −0.5 ~ 0 m. 상단 EL(s) = 교좌면 EL(구간 ①) + 흉벽 높이 2.5 m + i_b × (s − s_1), i_b = 받침 z의 축 방향 경사.
* - 저판: 하면 = 기초 하단 EL, 두께 1.1 m. 폭은 t −1.8 ~ +2.8 m (앞굽 1.2, 뒷굽 1.0, 벽체 2.4). 길이 = 교좌면 길이 L.
* - 말뚝: 열 위치 t = −1.15 / +0.85 / +2.15 m. 열 안 본수 = int((L − 1.3)/2.0) + 1, 양끝 0.65 m 띄움, 균등 간격.
* 말뚝 선단 EL = 기초 하단 EL − 말뚝길이. (skew 평행사변형에서 열 끝이 밀리는 보정은 아직 없다 — QA-5 이후.)
* - 벽체 높이 = 구간 교좌면 EL − (기초 하단 EL + 저판 두께).
*/
import type { AbutModel } from './geometry';
import { resolveShape, type AbutShape, type ResolvedShape } from './shape';
export interface WallSegment {
/** 축 위 구간 범위 (IP1에서 m). */
sFrom: number;
sTo: number;
/** 구간의 교좌면 EL (구간 i). */
seatEl: number;
/** 벽체 높이 (m) = seat − (footing bottom + footing thickness). */
heightM: number;
}
export interface PileRow {
/** 횡방향 위치 t (m, 전면 +). */
t: number;
/** 축 위 말뚝 위치 (IP1에서 m). */
s: number[];
}
export interface AbutMembers {
/** The shape values in use (defaults merged with the user's changes), in m. */
shape: ResolvedShape;
wallSegments: WallSegment[];
/** 흉벽 상단 EL: s = 0 (IP1 끝)과 s = L (IP3 끝), 축 방향 경사 i_b (소수, + = IP3 쪽으로 올라감). */
parapetTopStartEl: number;
parapetTopEndEl: number;
parapetSlope: number;
footingTopEl: number;
footingBottomEl: number | undefined;
piles: PileRow[];
pileBottomEl: number | undefined;
}
/** 흉벽 상단 EL at axial position s (m). */
export function parapetTopEl(seatFirst: number, bearingSFirst: number, slope: number, s: number, parapetHeightM: number): number {
return seatFirst + parapetHeightM + slope * (s - bearingSFirst);
}
export function buildMembers(model: AbutModel, foundation: { bottomEl?: number; pileLengthMm?: number } = {}, shapeChanges?: Partial<AbutShape>): AbutMembers {
const L = model.length;
const shape = resolveShape(shapeChanges);
const sorted = [...model.bearings].sort((a, b) => a.s - b.s);
const bounds = [0, ...model.stepBounds, L];
const footingBottomEl = foundation.bottomEl;
const footingTopEl = footingBottomEl === undefined ? NaN : footingBottomEl + shape.footingThicknessM;
const wallSegments: WallSegment[] = sorted.map((b, k) => ({
sFrom: bounds[k],
sTo: bounds[k + 1],
seatEl: b.seatEl,
heightM: b.seatEl - footingTopEl,
}));
// 받침 ①(첫 입력)과 마지막 입력 받침이 흉벽 상단선을 정한다 (spec §4.4).
const first = model.bearings[0];
const last = model.bearings[model.bearings.length - 1];
const slope = (last.z - first.z) / (last.s - first.s);
const parapetTopStartEl = parapetTopEl(first.seatEl, first.s, slope, 0, shape.parapetHeightM);
const parapetTopEndEl = parapetTopEl(first.seatEl, first.s, slope, L, shape.parapetHeightM);
const count = Math.floor((L - 2 * shape.pileEdgeM) / shape.pileSpacingM) + 1;
const spacing = count > 1 ? (L - 2 * shape.pileEdgeM) / (count - 1) : 0;
const piles: PileRow[] = shape.pileRowsT.map((t) => ({
t,
s: Array.from({ length: count }, (_, k) => shape.pileEdgeM + k * spacing),
}));
const pileBottomEl = footingBottomEl === undefined || foundation.pileLengthMm === undefined ? undefined : footingBottomEl - foundation.pileLengthMm / 1000;
return {
shape,
wallSegments,
parapetTopStartEl,
parapetTopEndEl,
parapetSlope: slope,
footingTopEl,
footingBottomEl,
piles,
pileBottomEl,
};
}
+24
View File
@@ -0,0 +1,24 @@
/**
* 구조물 목록 순서 (2026-11-03, 교대 AB): 시점 교대(A1) → 교각 P1 … Pn → 종점 교대(A2), 교대가 여럿이면 번호 순.
* Pure. Used by the 구조물 목록 so the abutments sit at the two ends of the bridge.
*/
export interface StructureRef {
kind: 'pier' | 'abut';
name: string;
/** 교대 형식 (A1 · A2), only for abutments. */
type?: 'A1' | 'A2';
}
const numberOf = (name: string) => Number(name.replace(/\D/g, '')) || 0;
export function structureOrder(abutments: { name: string; type: 'A1' | 'A2' }[], piers: string[]): StructureRef[] {
const start = abutments.filter((a) => a.type === 'A1').sort((a, b) => numberOf(a.name) - numberOf(b.name));
const end = abutments.filter((a) => a.type === 'A2').sort((a, b) => numberOf(a.name) - numberOf(b.name));
const others = abutments.filter((a) => a.type !== 'A1' && a.type !== 'A2');
return [
...start.map((a) => ({ kind: 'abut' as const, name: a.name, type: a.type })),
...piers.map((name) => ({ kind: 'pier' as const, name })),
...end.map((a) => ({ kind: 'abut' as const, name: a.name, type: a.type })),
...others.map((a) => ({ kind: 'abut' as const, name: a.name, type: a.type })),
];
}
+201
View File
@@ -0,0 +1,201 @@
/**
* 교대 입력 읽기 (2026-11-03, spec_abut §2): 입력 엑셀의 교대 행을 읽는다. 교대 표는 교각 표와 같은 시트에 있으며,
* 첫 두 헤더 칸(교대명 + 형식 / IP점 / 받침번호)으로 구분한다. 워크북 객체에 대해 순수하다. 좌표: 시트 x_m = 북, y_m = 동 → 내부 (E, N) = (y_m, x_m).
*
* 받침높이 (2026-11-03 결정 1): 교각과 같은 정의 — 패드 상면(z)에서 솔플레이트 상면까지.
* 받침 행의 값을 그대로 읽는다(A1 시트 값 275). 여기서 계산하지 않는다.
*/
import * as XLSX from 'xlsx';
import { parseDms } from '../dms';
import type { Issue } from '../types';
import type { AbutInput, AbutPoint, AbutType, WingForm } from './geometry';
import type { AbutShape } from './shape';
type Cell = string | number | null | undefined;
type Grid = Cell[][];
export interface AbutRecordBearing {
no: number;
/** Position (E, N), m: E = y_m, N = x_m. */
pos: AbutPoint;
z: number;
/** 교좌면_EL of this bearing (m). Absent = the block rule (z − 블럭 높이). */
seatEl?: number;
/** 이 받침에 적힌 받침높이 (mm). */
bearingHeightMm: number;
row: string;
}
export interface AbutRecord {
name: string;
sheet: string;
type: AbutType;
/** 첫 받침(구간 ①)의 교좌면 EL (m). */
seatEl: number;
/** 기초 하단 EL (m), 지반고 EL (m), 기초 형식, 말뚝길이 (mm). */
foundationBottomEl: number;
groundEl: number;
foundation: string;
pileLengthMm: number;
/** skew (deg) from the DMS text, and 종단경사 i (decimal). */
skewDeg: number;
slopeI: number;
ip1: AbutPoint;
ip2: AbutPoint;
ip3: AbutPoint;
bearings: AbutRecordBearing[];
/** 받침 행에서 읽은 받침높이 (mm). 모든 받침이 같아야 하며, 다르면 AP07 경고. */
bearingHeightMm: number;
/** 측별 날개벽 형식 (편집 카드에서 정함). Absent = 양측 일반형. */
wings?: { left: WingForm; right: WingForm };
/** 형상 입력 (벽체·흉벽, 확대기초·말뚝, mm). Only the changed values; the rest are the defaults. Absent = all defaults. */
shape?: Partial<AbutShape>;
}
export interface AbutParseResult {
abutments: AbutRecord[];
issues: Issue[];
}
const num = (c: Cell) => (c === null || c === undefined || c === '' ? NaN : Number(c));
const isBlank = (c: Cell) => c === null || c === undefined || c === '';
/** 교대 표의 헤더 행 (첫 두 칸). */
const isAbutInfoHeader = (r: Cell[]) => r[0] === '교대명' && r[1] === '형식';
const isAbutIpHeader = (r: Cell[]) => r[0] === '교대명' && r[1] === 'IP점';
const isAbutBearingHeader = (r: Cell[]) => r[0] === '교대명' && r[1] === '받침번호';
/** 헤더 아래 데이터 행, 둘째 칸이 빌 때까지. 교대명 칸이 비면 위 행을 이어받는다. */
function readBlock(grid: Grid, header: number): { cells: Cell[]; row: number }[] {
const out: { cells: Cell[]; row: number }[] = [];
let last: Cell = null;
for (let i = header + 1; i < grid.length; i++) {
const r = grid[i] ?? [];
if (isBlank(r[1])) break;
const name: Cell = isBlank(r[0]) ? last : r[0];
last = name;
out.push({ cells: [name, ...r.slice(1)], row: i + 1 });
}
return out;
}
export function parseAbutments(wb: XLSX.WorkBook): AbutParseResult {
const issues: Issue[] = [];
const info: { cells: Cell[]; row: number; sheet: string }[] = [];
const ips: { cells: Cell[]; row: number; sheet: string }[] = [];
const bearings: { cells: Cell[]; row: number; sheet: string }[] = [];
for (const sheet of wb.SheetNames) {
const grid = XLSX.utils.sheet_to_json<Cell[]>(wb.Sheets[sheet], { header: 1, raw: true, defval: null }) as Grid;
grid.forEach((r, i) => {
const pick = (match: (r: Cell[]) => boolean, into: typeof info) => {
if (!match(r)) return;
for (const rec of readBlock(grid, i)) into.push({ ...rec, sheet });
};
pick(isAbutInfoHeader, info);
pick(isAbutIpHeader, ips);
pick(isAbutBearingHeader, bearings);
});
}
const abutments: AbutRecord[] = [];
for (const rec of info) {
const c = rec.cells;
const name = String(c[0] ?? '').trim();
const where = `${rec.sheet} ${rec.row}행`;
if (!/^A\d+$/.test(name)) {
issues.push({ code: 'AP01', level: 'E', sheet: rec.sheet, message: `${where}: 교대명 "${String(c[0] ?? '')}"이 올바르지 않습니다 (A1, A2 …).` });
continue;
}
const type = String(c[1] ?? '').trim();
if (type !== 'A1' && type !== 'A2') {
issues.push({ code: 'AP02', level: 'E', pier: name, sheet: rec.sheet, message: `${where}: 교대 ${name}의 형식 "${type}"은 A1 또는 A2여야 합니다.` });
continue;
}
const skewDeg = parseDms(String(c[7] ?? ''));
if (skewDeg === null) {
issues.push({ code: 'AP03', level: 'E', pier: name, sheet: rec.sheet, message: `${where}: 교대 ${name}의 skew "${String(c[7] ?? '')}"를 읽지 못했습니다.` });
continue;
}
abutments.push({
name,
sheet: rec.sheet,
type,
seatEl: num(c[2]),
foundationBottomEl: num(c[3]),
groundEl: num(c[4]),
foundation: String(c[5] ?? ''),
pileLengthMm: num(c[6]),
skewDeg,
slopeI: num(c[8]),
ip1: { e: NaN, n: NaN },
ip2: { e: NaN, n: NaN },
ip3: { e: NaN, n: NaN },
bearings: [],
bearingHeightMm: NaN,
});
}
for (const rec of ips) {
const name = String(rec.cells[0] ?? '').trim();
const ab = abutments.find((a) => a.name === name);
if (!ab) {
issues.push({ code: 'AP04', level: 'E', sheet: rec.sheet, message: `${rec.sheet} ${rec.row}행: 교대명 ${name}의 교대 정보 행이 없습니다.` });
continue;
}
const point = { e: num(rec.cells[3]), n: num(rec.cells[2]) };
const ipName = String(rec.cells[1] ?? '');
if (ipName === 'IP1') ab.ip1 = point;
else if (ipName === 'IP2') ab.ip2 = point;
else if (ipName === 'IP3') ab.ip3 = point;
}
for (const rec of bearings) {
const c = rec.cells;
const name = String(c[0] ?? '').trim();
const ab = abutments.find((a) => a.name === name);
if (!ab) {
issues.push({ code: 'AP04', level: 'E', sheet: rec.sheet, message: `${rec.sheet} ${rec.row}행: 교대명 ${name}의 교대 정보 행이 없습니다.` });
continue;
}
const h = num(c[8]);
ab.bearings.push({
no: num(c[1]),
pos: { e: num(c[3]), n: num(c[2]) },
z: num(c[4]),
seatEl: isBlank(c[7]) ? undefined : num(c[7]),
bearingHeightMm: h,
row: `${rec.sheet} ${rec.row}행`,
});
}
for (const ab of abutments) {
const missing = [ab.ip1, ab.ip2, ab.ip3].some((p) => !Number.isFinite(p.e) || !Number.isFinite(p.n));
if (missing) issues.push({ code: 'AP05', level: 'E', pier: ab.name, sheet: ab.sheet, message: `교대 ${ab.name}: IP1·IP2·IP3 중 빠진 점이 있습니다.` });
if (ab.bearings.length < 2) issues.push({ code: 'AP06', level: 'E', pier: ab.name, sheet: ab.sheet, message: `교대 ${ab.name}: 받침이 2개 이상 필요합니다.` });
const heights = [...new Set(ab.bearings.map((b) => b.bearingHeightMm))];
if (heights.length > 1) issues.push({ code: 'AP07', level: 'W', pier: ab.name, sheet: ab.sheet, message: `교대 ${ab.name}: 받침높이가 받침마다 다릅니다 (${heights.join(', ')} mm). 첫 값을 씁니다.` });
ab.bearingHeightMm = heights[0] ?? NaN;
}
return { abutments: abutments.filter((a) => Number.isFinite(a.ip1.e) && a.bearings.length >= 2), issues };
}
/** The layout input of AA1–AA2 from a parsed 교대 record. */
export function toAbutInput(rec: AbutRecord): AbutInput {
return {
name: rec.name,
type: rec.type,
seatEl: rec.seatEl,
ip1: rec.ip1,
ip2: rec.ip2,
ip3: rec.ip3,
bearings: rec.bearings.map((b) => ({ no: b.no, pos: b.pos, z: b.z, seatEl: b.seatEl })),
skewDeg: rec.skewDeg,
slopeI: rec.slopeI,
wings: rec.wings,
foundationBottomEl: rec.foundationBottomEl,
pileLengthMm: rec.pileLengthMm,
foundationType: rec.foundation,
};
}
+85
View File
@@ -0,0 +1,85 @@
/**
* 교대 형상 입력 (2026-11-03, 시안 ② 벽체·흉벽 · 확대기초·말뚝 카드): the values that used to be constants, in mm.
* The defaults are the spec_abut values. A record keeps only the values the user changed; the rest come from here.
*
* Axis frame: t (교대축 직각) is + toward the front. 벽체 전면 = wallFrontMm; 배면 = wallFrontMm − wallThicknessMm.
* 흉벽 sits behind the axis (t −parapetThicknessMm … 0). 저판: footingFrontMm (앞굽 끝) … footingBackMm (뒷굽 끝).
*/
export interface AbutShape {
wallFrontMm: number;
wallThicknessMm: number;
parapetHeightMm: number;
parapetThicknessMm: number;
bracketHeightMm: number;
footingThicknessMm: number;
footingFrontMm: number;
footingBackMm: number;
pileEdgeMm: number;
pileSpacingMm: number;
}
export const ABUT_SHAPE_DEFAULT: AbutShape = {
wallFrontMm: 1600,
wallThicknessMm: 2400,
parapetHeightMm: 2500,
parapetThicknessMm: 500,
bracketHeightMm: 850,
footingThicknessMm: 1100,
footingFrontMm: 2800,
footingBackMm: -1800,
pileEdgeMm: 650,
pileSpacingMm: 2000,
};
/** The same values in m, for the geometry (the model works in m). */
export interface ResolvedShape {
wallFrontM: number;
wallBackM: number;
parapetHeightM: number;
parapetBackM: number;
bracketHeightM: number;
footingThicknessM: number;
footingFrontM: number;
footingBackM: number;
pileEdgeM: number;
pileSpacingM: number;
pileRowsT: number[];
}
/** The pile rows (t, m) of the spec: 배면 열, 중간 열, 전면 열. Not an input on the card. */
export const PILE_ROWS_T = [-1.15, 0.85, 2.15];
/** Merges the user's changes over the defaults and converts to m. */
export function resolveShape(changes?: Partial<AbutShape>): ResolvedShape {
const v: AbutShape = { ...ABUT_SHAPE_DEFAULT, ...(changes ?? {}) };
const wallBackMm = v.wallFrontMm - v.wallThicknessMm;
return {
wallFrontM: v.wallFrontMm / 1000,
wallBackM: wallBackMm / 1000,
parapetHeightM: v.parapetHeightMm / 1000,
parapetBackM: -v.parapetThicknessMm / 1000,
bracketHeightM: v.bracketHeightMm / 1000,
footingThicknessM: v.footingThicknessMm / 1000,
footingFrontM: v.footingFrontMm / 1000,
footingBackM: v.footingBackMm / 1000,
pileEdgeM: v.pileEdgeMm / 1000,
pileSpacingM: v.pileSpacingMm / 1000,
pileRowsT: PILE_ROWS_T,
};
}
/** Input problems in Korean; empty when the shape can be built. */
export function shapeIssues(changes: Partial<AbutShape>): string[] {
const v: AbutShape = { ...ABUT_SHAPE_DEFAULT, ...changes };
const out: string[] = [];
if (!(v.wallThicknessMm > 0)) out.push('벽체 두께는 0보다 커야 합니다.');
if (!(v.parapetHeightMm > 0)) out.push('흉벽 높이는 0보다 커야 합니다.');
if (!(v.parapetThicknessMm > 0)) out.push('흉벽 두께는 0보다 커야 합니다.');
if (!(v.bracketHeightMm >= 0)) out.push('브라켓 높이는 0 이상이어야 합니다.');
if (!(v.footingThicknessMm > 0)) out.push('저판 두께는 0보다 커야 합니다.');
if (!(v.pileEdgeMm > 0) || !(v.pileSpacingMm > 0)) out.push('말뚝 연단과 간격은 0보다 커야 합니다.');
const wallBack = v.wallFrontMm - v.wallThicknessMm;
if (!(v.footingFrontMm > v.wallFrontMm)) out.push('저판 앞굽 끝은 벽체 전면보다 앞에 있어야 합니다.');
if (!(v.footingBackMm < wallBack)) out.push('저판 뒷굽 끝은 벽체 배면보다 뒤에 있어야 합니다.');
return out;
}
+78
View File
@@ -0,0 +1,78 @@
/**
* 교대 상세 (2026-11-03, 교각 상세와 같은 개념): the values the 삽도 shows, computed from the model. Read only.
* Each row is [label, value]. Lengths in mm, levels in m, as in the 삽도.
*/
import type { AbutRecord } from './parse';
import { buildAbut, type AbutModel } from './geometry';
import { toAbutInput } from './parse';
import { buildMembers } from './members';
import { buildWings } from './wings';
const m3 = (v: number) => `${v.toFixed(3)} m`;
const mm = (v: number) => `${Math.round(v)} mm`;
export function abutSummaryRows(rec: AbutRecord): [string, string][] {
const model: AbutModel = buildAbut(toAbutInput(rec));
const members = buildMembers(model, { bottomEl: rec.foundationBottomEl, pileLengthMm: rec.pileLengthMm }, rec.shape);
const wings = buildWings(model, members, rec.wings);
const shape = members.shape;
const first = model.bearings[0];
const L = model.length;
const rows: [string, string][] = [
['형식 / 기초 형식', `${rec.type} · ${rec.foundation}`],
['교좌면 EL (구간 ①)', m3(rec.seatEl)],
['기초 하단 EL', m3(rec.foundationBottomEl)],
['지반고 EL', m3(rec.groundEl)],
['저판 상면 EL', m3(members.footingTopEl)],
['교대축 길이 (IP1 → IP3)', m3(L)],
['skew', `${rec.skewDeg.toFixed(2)}°`],
['종단경사 i', `${(rec.slopeI * 100).toFixed(2)} %`],
['받침블럭 높이 (패드 포함)', mm(first.blockHeightMm)],
['벽체 높이 (구간 ①)', m3(members.wallSegments.find((w) => Math.abs(w.seatEl - first.seatEl) < 1e-9)?.heightM ?? NaN)],
['벽체 두께 · 전면', `${Math.round(shape.wallFrontM * 1000 - shape.wallBackM * 1000)} mm · ${Math.round(shape.wallFrontM * 1000)} mm`],
['흉벽 높이 · 두께', `${Math.round(shape.parapetHeightM * 1000)} mm · ${Math.round(-shape.parapetBackM * 1000)} mm`],
['흉벽 상단 EL (IP1 끝 / IP3 끝)', `${members.parapetTopStartEl.toFixed(3)} / ${members.parapetTopEndEl.toFixed(3)} m`],
['브라켓 상면 EL (구간 ①)', m3(first.seatEl + shape.parapetHeightM - shape.bracketHeightM)],
['흉벽 경사', `${(members.parapetSlope * 100).toFixed(3)} %`],
['계단 경계 (IP3 쪽에서)', model.stepBounds.map((s) => (L - s).toFixed(3)).reverse().join(' / ') + ' m'],
['저판 폭 (앞굽 끝 / 뒷굽 끝)', `${Math.round(shape.footingFrontM * 1000)} / ${Math.round(shape.footingBackM * 1000)} mm`],
['말뚝 (열 × 본수)', `${members.piles.length} × ${members.piles[0]?.s.length ?? 0} = ${members.piles.length * (members.piles[0]?.s.length ?? 0)} 본`],
['말뚝 선단 EL', members.pileBottomEl === undefined ? '—' : m3(members.pileBottomEl)],
['수축줄눈 (ceil(L/10) − 1)', `${Math.max(0, Math.ceil(L / 10) - 1)} 개`],
];
if (wings.length > 0) {
const w = wings[0];
rows.push(['날개벽 경사', `${model.wing ? (model.wing.slopePct).toFixed(2) : '—'} %`]);
rows.push(['날개벽 뒷굽 거리 (교대축 기준)', mm(w.heelDistanceM * 1000)]);
}
if (model.wing) rows.push(['접속슬래브 폭', mm(model.wing.slabWidthMm)]);
return rows;
}
/** 입력값 rows of the 교대 panel (시안 "A1 입력값" card): what the user typed, read only. */
export function abutInputRows(rec: AbutRecord): [string, string][] {
const rows: [string, string][] = [
['형식 / 기초 형식', `${rec.type} (${rec.type === 'A1' ? '시점' : '종점'}) · ${rec.foundation}`],
['교좌면 EL (구간 ①)', `${rec.seatEl.toFixed(3)} m`],
['기초 하단 EL', `${rec.foundationBottomEl.toFixed(3)} m`],
['지반고 EL', `${rec.groundEl.toFixed(3)} m`],
['skew', `${rec.skewDeg.toFixed(2)}°`],
['종단경사 i', `${(rec.slopeI * 100).toFixed(2)} %`],
['말뚝 길이', `${Math.round(rec.pileLengthMm)} mm`],
['IP', 'IP1 → IP3 직선'],
['받침', `${rec.bearings.length}개 · z = 패드 상면`],
];
return rows;
}
/** The 8 detail rows shown in the collapsed 상세 (the rest stay in the 삽도 view). */
export const ABUT_DETAIL_LABELS = [
'벽체 높이 (구간 ①)',
'흉벽 상단 EL (IP1 끝 / IP3 끝)',
'브라켓 상면 EL (구간 ①)',
'저판 상면 EL',
'계단 경계 (IP3 쪽에서)',
'말뚝 (열 × 본수)',
'말뚝 선단 EL',
'접속슬래브 폭',
];
+158
View File
@@ -0,0 +1,158 @@
/**
* 교대 날개벽 · AA3b (2026-11-03, spec_abut §4.7): 날개벽 평면, 상단선, 단부 하단. 순수 함수.
*
* - 평면: 교좌면 끝(IP1 끝 s = 0 또는 IP3 끝 s = L)에서 날개벽 선(AA2)을 따라 길이 6.5 m. 두께 0.45 m는 교대 안쪽으로 둔다.
* - 상단: 시작점 = 흉벽 상단 EL(그 끝의 s), 끝까지 날개벽 경사(i)로 직선.
* - 하단 (일반형): 단부 하단 = 끝점 상단 − 1.5 m. 단축형: 하단 = 저판 상면 높이(평평하게).
* - 좌(①쪽) 날개벽은 받침 ①에 가까운 끝, 우(n쪽)는 반대 끝 (spec_abut §4.7).
* - 하단 선 (AA3c): 저판 뒷굽 끝(배면 −1.8 m)까지는 저판 상면, 그 밖은 뒷굽 끝 상단 모서리에서 단부 하단까지 직선
* (변화단면). 뒷굽 끝까지의 날개벽 거리 = 1.8 / |sin Δ| (단축형 1.807 m와 같은 식).
* - 헌치 300 × 300 (AA3c): 흉벽 배면(−0.5) 모서리에 위 헌치, 벽체 배면(−0.8) 모서리에 아래 헌치. 수직 삼각기둥.
* 위 헌치는 브라켓 상면(흉벽 상단 − 0.85)부터 날개벽 상단까지, 아래 헌치는 저판 상면부터 브라켓 상면까지다.
* - 아직 없음: 등단면·변화단면 수평 단 구분 (QA-12), 저판 측면 윤곽의 정밀 모서리.
*
* 수치는 spec_abut §12 A2 합성 예제(좌 46.838 → 46.714, 하단 45.214 / 우 47.183 → 47.059, 하단 45.559)와 맞춘다.
*/
import type { AbutModel, AbutPoint, WingForm } from './geometry';
import type { AbutMembers } from './members';
export const WING_LENGTH_M = 6.5;
export const WING_THICKNESS_M = 0.45;
/** 단부 높이 (끝점 상단에서 단부 하단까지, m). */
export const WING_END_HEIGHT_M = 1.5;
export interface WingSolid {
side: 'left' | 'right';
form: Exclude<WingForm, 'none'>;
/** 날개벽이 시작하는 축 끝: 0 (IP1 끝) 또는 L (IP3 끝). */
sEnd: number;
/** 평면 네 꼭짓점 (E, N), 순서: 시작, 끝, 끝 안쪽, 시작 안쪽. */
corners: [AbutPoint, AbutPoint, AbutPoint, AbutPoint];
topStartEl: number;
topEndEl: number;
/** 단부 하단 EL (일반형: 끝 상단 − 1.5; 단축형: 저판 상면). */
bottomEndEl: number;
/** 축 끝에서 날개벽 선을 따라 잰 뒷굽 끝까지의 거리 (m). */
heelDistanceM: number;
/** 상단선 (d = 축 끝에서 날개벽 방향 거리 m, EL m). */
topLine: { dM: number; el: number }[];
/** 하단선: 저판 상면 → 뒷굽 끝 → 단부 하단 (일반형), 또는 저판 상면 평탄 (단축형). */
bottomLine: { dM: number; el: number }[];
/** 헌치 300 × 300 (위·아래), 평면 삼각형과 높이 범위. */
haunches: Haunch[];
/** 수평 단 (QA-12, spec_abut §4.7): 등단면 / 변화단면1 / 변화단면2. 경계는 단부 높이 1.5 m와 교좌면 EL. */
bands: WingBand[];
}
export interface WingBand {
name: '등단면' | '변화단면1' | '변화단면2' | '단축형 전구간';
/** 높이 범위 (EL m). */
zTopEl: number;
zBottomEl: number;
}
export interface Haunch {
kind: 'upper' | 'lower';
/** 평면 삼각형 꼭짓점 (E, N): 모서리, 날개벽 쪽 점, 벽체·흉벽 쪽 점. */
corners: [AbutPoint, AbutPoint, AbutPoint];
zBottomEl: number;
zTopEl: number;
}
export const HAUNCH_LEG_M = 0.3;
export function buildWings(model: AbutModel, members: AbutMembers, forms?: { left: WingForm; right: WingForm }): WingSolid[] {
if (!model.wing) return [];
const L = model.length;
const wings = forms ?? model.wing.wings;
// 좌 = 받침 ① 쪽 끝 (① 에 가까운 축 끝).
const firstS = model.bearings[0].s;
const leftS = firstS < L / 2 ? 0 : L;
const rightS = leftS === 0 ? L : 0;
const slope = model.wing.slopePct / 100;
const dirRad = (model.wing.lineDeg * Math.PI) / 180;
const w = { e: Math.cos(dirRad), n: Math.sin(dirRad) };
const ip1 = model.ip1;
// 축 끝에서 안쪽 방향: s = 0이면 +u, s = L이면 −u.
const at = (s: number) => ({ e: ip1.e + model.u.e * s, n: ip1.n + model.u.n * s });
// 배면 쪽으로 가는 날개벽 방향의 횡방향 성분 k = |w · (전면 방향)|: 날개벽 거리 d는 횡방향 t = −d·k.
const frontVec = { e: model.nLeft.e * model.frontSide, n: model.nLeft.n * model.frontSide };
const k = Math.abs(w.e * frontVec.e + w.n * frontVec.n);
const footingTop = members.footingTopEl;
const heelDistanceM = Math.abs(members.shape.footingBackM) / k;
const build = (side: 'left' | 'right', sEnd: number, topStart: number, form: Exclude<WingForm, 'none'>): WingSolid => {
const a = sEnd === 0 ? 1 : -1;
const p0 = at(sEnd);
const p1 = { e: p0.e + w.e * WING_LENGTH_M, n: p0.n + w.n * WING_LENGTH_M };
const inward = { e: model.u.e * a * WING_THICKNESS_M, n: model.u.n * a * WING_THICKNESS_M };
const topEnd = topStart + slope * WING_LENGTH_M;
const bottomEnd = form === 'general' ? topEnd - WING_END_HEIGHT_M : footingTop;
const topAt = (d: number) => topStart + slope * d;
const topLine = [
{ dM: 0, el: topStart },
{ dM: WING_LENGTH_M, el: topEnd },
];
const bottomLine =
form === 'general' && heelDistanceM < WING_LENGTH_M
? [
{ dM: 0, el: footingTop },
{ dM: heelDistanceM, el: footingTop },
{ dM: WING_LENGTH_M, el: bottomEnd },
]
: [
{ dM: 0, el: footingTop },
{ dM: WING_LENGTH_M, el: form === 'general' ? bottomEnd : footingTop },
];
// 헌치: 위 헌치는 흉벽 배면(−0.5), 아래 헌치는 벽체 배면(−0.8)의 모서리에 둔다.
const pointAt = (d: number) => ({ e: p0.e + w.e * d, n: p0.n + w.n * d });
const shift = (q: AbutPoint, v: AbutPoint, f: number) => ({ e: q.e + v.e * f, n: q.n + v.n * f });
const bracketTop = topStart - members.shape.bracketHeightM;
const haunchAt = (kind: 'upper' | 'lower', t: number): Haunch => {
const d = Math.abs(t) / k;
const c = pointAt(d);
const inwardUnit = { e: model.u.e * a, n: model.u.n * a };
return {
kind,
corners: [c, shift(c, w, HAUNCH_LEG_M), shift(c, inwardUnit, HAUNCH_LEG_M)],
zBottomEl: kind === 'upper' ? bracketTop : footingTop,
zTopEl: kind === 'upper' ? topAt(d) : bracketTop,
};
};
const haunches = [haunchAt('upper', members.shape.parapetBackM), haunchAt('lower', members.shape.wallBackM)];
// 수평 단 (QA-12): 등단면 = 끝 상단 − 1.5 m 위, 변화단면1 = 그 아래 ~ 교좌면 EL, 변화단면2 = 교좌면 EL ~ 저판 상면.
// 교좌면 EL은 날개벽 끝에 가장 가까운 받침의 교좌면이다.
const nearest = model.bearings.reduce((best, b) => (Math.abs(b.s - sEnd) < Math.abs(best.s - sEnd) ? b : best));
const bands: WingBand[] =
form === 'general'
? [
{ name: '등단면', zTopEl: topStart, zBottomEl: bottomEnd },
{ name: '변화단면1', zTopEl: bottomEnd, zBottomEl: nearest.seatEl },
{ name: '변화단면2', zTopEl: nearest.seatEl, zBottomEl: footingTop },
]
: [{ name: '단축형 전구간', zTopEl: topStart, zBottomEl: footingTop }];
return {
side,
form,
sEnd,
corners: [p0, p1, { e: p1.e + inward.e, n: p1.n + inward.n }, { e: p0.e + inward.e, n: p0.n + inward.n }],
topStartEl: topStart,
topEndEl: topEnd,
bottomEndEl: bottomEnd,
heelDistanceM,
topLine,
bottomLine,
haunches,
bands,
};
};
const out: WingSolid[] = [];
if (wings.left !== 'none') out.push(build('left', leftS, leftS === 0 ? members.parapetTopStartEl : members.parapetTopEndEl, wings.left));
if (wings.right !== 'none') out.push(build('right', rightS, rightS === 0 ? members.parapetTopStartEl : members.parapetTopEndEl, wings.right));
return out;
}
+30
View File
@@ -22,6 +22,8 @@ export interface ElevationRow {
no: number;
row: '전열' | '후열';
elx: number;
/** 교좌면 EL (m): the block bottom. */
seatEl: number;
h1: number;
h2: number;
h3: number;
@@ -87,6 +89,7 @@ export function elevationRows(m: PierModel): ElevationRow[] {
no: b.no,
row: b.row,
elx: b.padTop,
seatEl: b.bottom,
h1,
h2,
h3: plan.h3,
@@ -125,3 +128,30 @@ export function solplateRows(m: PierModel): SolplateRow[] {
};
});
}
/**
* S1·S2 of one pair along 교축 (mm, 2026-11-03): S1 = bearing centre to the coping edge on each outer side, S2 = between
* the two bearing centres. The coping is centred on the axis with width B (model copingB), so S1 + S2 + S1 = B.
*/
export interface PairCopingSpans {
S1Low: number;
S2: number;
S1High: number;
copingWidth: number;
}
export function pairCopingSpans(m: PierModel, pairIndex: number): PairCopingSpans | null {
const pair = pairEccentricities(m.blocks)[pairIndex];
if (!pair) return null;
const f = m.blocks.find((b) => b.no === pair.frontNo && b.row === '전열')!;
const k = m.blocks.find((b) => b.no === pair.backNo && b.row === '후열')!;
const lo = Math.min(f.n, k.n);
const hi = Math.max(f.n, k.n);
const half = m.copingB / 2;
return {
S1Low: Math.round((lo + half) * 1000),
S2: Math.round((hi - lo) * 1000),
S1High: Math.round((half - hi) * 1000),
copingWidth: Math.round(m.copingB * 1000),
};
}
+10 -4
View File
@@ -77,6 +77,8 @@ export interface BlockModel {
rowSkewDeg: number;
/** Solplate of this bearing (C안, 2026-11-03): the pier's rule, the same for every bearing. */
plan: SolplatePlan;
/** Block bottom from the 두부보 상면 slope (before any 교좌면 EL input), for the check against the input (2026-11-03). */
seatCalc: number;
}
/** Skew of a bearing row (deg) from the bearing azimuth and the stretch axis direction u (see {@link BlockModel.rowSkewDeg}). */
@@ -435,13 +437,17 @@ export function buildPier(input: PierInput): PierModel {
const skewOf = (no: number, k: number) => rowSkewDeg(bearingAz.get(no) ?? NaN, axis.segments[k].u);
// Solplate of every bearing from the pier's rule (C안, 2026-11-03): the same plan for each bearing.
const plan = solplatePlan(input.solplate ?? SOLPLATE_RULE_DEFAULT);
// 교좌면 EL (2026-11-03): when the input gives it, the block bottom is that EL; otherwise the 두부보 상면 slope.
const seatOf = new Map(input.bearings.map((r) => [r.no, r.seatEl]));
frontPlaced.forEach((b) => {
const bottom = segmentTop(segments, b.g);
blocks.push({ no: b.no, row: '전열', k: b.k, s: b.s, g: b.g, n: b.n, bottom, top: b.z - DIM.padThickness, padTop: b.z, rowSkewDeg: skewOf(b.no, b.k), plan });
const seatCalc = segmentTop(segments, b.g);
const bottom = seatOf.get(b.no) ?? seatCalc;
blocks.push({ no: b.no, row: '전열', k: b.k, s: b.s, g: b.g, n: b.n, bottom, top: b.z - DIM.padThickness, padTop: b.z, rowSkewDeg: skewOf(b.no, b.k), plan, seatCalc });
});
backPlaced.forEach((b) => {
const bottom = b.z - h2;
blocks.push({ no: b.no, row: '후열', k: b.k, s: b.s, g: b.g, n: b.n, bottom, top: b.z - DIM.padThickness, padTop: b.z, rowSkewDeg: skewOf(b.no, b.k), plan });
const seatCalc = b.z - h2;
const bottom = seatOf.get(b.no) ?? seatCalc;
blocks.push({ no: b.no, row: '후열', k: b.k, s: b.s, g: b.g, n: b.n, bottom, top: b.z - DIM.padThickness, padTop: b.z, rowSkewDeg: skewOf(b.no, b.k), plan, seatCalc });
});
const blockHeightFront = blocks.find((b) => b.row === '전열')!.top - blocks.find((b) => b.row === '전열')!.bottom;
+2
View File
@@ -186,5 +186,7 @@ function toBearing(c: Cell[]): Bearing {
z: Number(c[4]),
azimuthDeg: swapXYAzimuth(parseDms(String(c[5] ?? '')) ?? NaN),
row: String(c[6]) as Row,
// 교좌면_EL (m), optional: blank = not given, so the model keeps the 두부보 상면 slope.
seatEl: c[7] === undefined || c[7] === null || c[7] === '' ? undefined : Number(c[7]),
};
}
+42
View File
@@ -1,6 +1,7 @@
import type { FckByMember, Material } from './rebar/common';
import { checkStoredGroundModel, type GroundModelFile } from './ground-model';
import type { PierInput } from './types';
import type { AbutRecord } from './abut/parse';
import { normalizeBearingAzimuths } from './coordinate-frame';
/** Project file schema (spec §8). Bump only with a migration. */
@@ -37,6 +38,10 @@ export interface ProjectFile {
fckByMember?: FckByMember;
/** 지반 모델 (ground_model.json)의 교각별 지반고. Absent = 불러오지 않음. Applied to a pier only by the user. */
groundModel?: GroundModelFile;
/** 교대 (2026-11-03, AB): 교대 입력 기록. Absent in older files: no abutments. */
abutments?: AbutRecord[];
/** 교대 기본값 (가져온 그대로): 기본값 복원에 쓴다. Absent = 교대별 현재값이 기본값. */
abutDefaults?: AbutRecord[];
}
/** Serialises a project to the pretty-printed JSON stored in <교량명>_교각설계.pzr.json. */
@@ -69,6 +74,8 @@ export function parseProject(text: string): ProjectFile {
}
}
if (obj.groundModel !== undefined) checkStoredGroundModel(obj.groundModel);
if (obj.abutments !== undefined) checkStoredAbutments(obj.abutments);
if (obj.abutDefaults !== undefined) checkStoredAbutments(obj.abutDefaults);
obj.piers.forEach((p, i) => {
if (!p || typeof p.id !== 'string' || !p.input) throw new Error(`교각 ${i + 1}: id 또는 input이 없습니다.`);
const input = p.input;
@@ -99,3 +106,38 @@ export function parseProject(text: string): ProjectFile {
});
return obj as ProjectFile;
}
const finite = (v: unknown) => typeof v === 'number' && Number.isFinite(v);
const isPoint = (v: unknown) => !!v && typeof v === 'object' && finite((v as { e?: unknown }).e) && finite((v as { n?: unknown }).n);
/** 교대 기록 check (AB): refuses a damaged abutment record with a message that names it. */
export function checkStoredAbutments(list: unknown): void {
if (!Array.isArray(list)) throw new Error('교대 목록 형식이 맞지 않습니다.');
list.forEach((a: Partial<AbutRecord>, i) => {
const who = `교대 ${i + 1}(${String(a?.name)})`;
if (!a || typeof a.name !== 'string' || !/^A\d+$/.test(a.name)) throw new Error(`${who}: 교대명이 맞지 않습니다.`);
if (a.type !== 'A1' && a.type !== 'A2') throw new Error(`${who}: 형식은 A1 또는 A2여야 합니다.`);
for (const key of ['seatEl', 'foundationBottomEl', 'groundEl', 'pileLengthMm', 'skewDeg', 'slopeI'] as const) {
if (!finite(a[key])) throw new Error(`${who}: ${key} 값이 맞지 않습니다.`);
}
for (const key of ['ip1', 'ip2', 'ip3'] as const) {
if (!isPoint(a[key])) throw new Error(`${who}: ${key} 좌표가 맞지 않습니다.`);
}
if (!Array.isArray(a.bearings) || a.bearings.length < 2) throw new Error(`${who}: 받침이 2개 이상 필요합니다.`);
if (a.shape !== undefined) {
for (const [key, v] of Object.entries(a.shape)) {
if (!finite(v)) throw new Error(`${who}: 형상 값 ${key}이(가) 숫자가 아닙니다.`);
}
}
if (a.wings !== undefined) {
const forms = ['general', 'short', 'none'];
if (!forms.includes(a.wings.left) || !forms.includes(a.wings.right)) throw new Error(`${who}: 날개벽 형식이 맞지 않습니다.`);
}
for (const b of a.bearings) {
if (!b || !finite(b.no) || !isPoint(b.pos) || !finite(b.z) || (b.seatEl !== undefined && !finite(b.seatEl))) {
throw new Error(`${who}: 받침 ${String(b?.no)}의 값이 맞지 않습니다.`);
}
}
});
}
+1 -1
View File
@@ -22,7 +22,7 @@ export function buildTemplateWorkbook(): XLSX.WorkBook {
const bearing = [
note('받침 1행당 받침 1개. 전열·후열 수는 같아야 합니다. 위치는 전열 또는 후열.'),
note('z_m은 받침 EL(m), 방위각은 거더 방향입니다.'),
['교각명', '받침번호', 'x_m', 'y_m', 'z_m', '방위각', '위치'],
['교각명', '받침번호', 'x_m', 'y_m', 'z_m', '방위각', '위치', '교좌면_EL'],
];
XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(pier), '교각');
+2
View File
@@ -21,6 +21,8 @@ export interface Bearing {
z: number;
azimuthDeg: number;
row: Row;
/** 교좌면 EL (m): the block bottom under this bearing. Absent = the 두부보 상면 slope (the coping top). */
seatEl?: number;
}
export interface PierInput {
+14
View File
@@ -38,6 +38,20 @@ export function validateInput(input: PierInput, issues: Issue[]): boolean {
/** Geometry-level checks (spec §10 W01–W05, W07, I01) on a built model. */
export function validatePier(input: PierInput, model: PierModel, issues: Issue[]): void {
// W10 (2026-11-03): the input 교좌면 EL should match the 두부보 상면 slope within 5 mm; the input wins.
for (const b of model.blocks) {
const seat = input.bearings.find((r) => r.no === b.no)?.seatEl;
if (seat === undefined) continue;
const diffMm = (seat - b.seatCalc) * 1000;
if (Math.abs(diffMm) > 5) {
issues.push({
code: 'W10',
level: 'W',
pier: input.name,
message: `${b.row} 받침 ${b.no}번 교좌면 EL ${seat.toFixed(3)}은(는) 두부보 상면 계산값 ${b.seatCalc.toFixed(3)}과 ${diffMm.toFixed(1)} mm 차이가 납니다 (허용 5 mm). 입력값을 씁니다.`,
});
}
}
const ip2Gap = Math.hypot(model.ip2Input.e - model.ip2Calc.e, model.ip2Input.n - model.ip2Calc.n);
if (ip2Gap > 0.001) {
issues.push({