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({
+289 -44
View File
@@ -4,6 +4,17 @@ import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
import { swapXYAzimuth } from './core/coordinate-frame';
import * as XLSX from 'xlsx';
import { parseWorkbook, type SheetReport } from './core/parseWorkbook';
import { parseAbutments, toAbutInput, type AbutRecord } from './core/abut/parse';
import { buildAbut } from './core/abut/geometry';
import { structureOrder } from './core/abut/order';
import { ABUT_DETAIL_LABELS, abutInputRows, abutSummaryRows } from './core/abut/summary';
import { applyAbutEdit, applyBearingEdit, applyShapeEdit, ABUT_EDIT_FIELDS, SHAPE_EDIT_FIELDS, restoreAbutDefault, setWingForm } from './core/abut/edit';
import { ABUT_SHAPE_DEFAULT } from './core/abut/shape';
import type { WingForm } from './core/abut/geometry';
import { buildMembers } from './core/abut/members';
import { buildWings } from './core/abut/wings';
import { buildAbutGroup, cameraForBox } from './view/abut-scene';
import { fitCamera } from './view/fit';
import { buildPier, type PierModel } from './core/geometry';
import { NOT_IMPLEMENTED_RULES, validateInput, validatePier } from './core/validate';
import { buildTemplateWorkbook } from './core/template';
@@ -24,7 +35,6 @@ import { CORROSION_ALLOWANCE_MM, parseNValue, SEA_DATUM, STRATA_NAMES } from './
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';
import { solplateDetailSvg } from './view/solplate-svg';
import { blockRebarSectionSvg } from './view/block-rebar-svg';
import { blockRebarPlanSvg } from './view/block-rebar-plan-svg';
import { copingEndStation, copingFrontSvg, copingSectionSvg } from './view/coping-rebar-svg';
@@ -106,10 +116,21 @@ interface Pending {
fileName: string;
entries: Pier[];
sheets: SheetReport[];
/** 교대 기록 read from the same workbook (AB). */
abutments: AbutRecord[];
abutIssues: Issue[];
}
const state = {
piers: [] as Pier[],
/** 교대 기록 of the project (AB, 2026-11-03). Not drawn yet: the 3D placement is a later step. */
abutments: [] as AbutRecord[],
/** 교대 편집 카드에서 고른 교대 (2026-11-03). */
abutActive: null as string | null,
/** The editable part of the 교대 panel is open (2026-11-03). */
abutEditOpen: false,
/** 교대 기본값 (가져온 그대로), for 기본값 복원. */
abutDefaults: [] as AbutRecord[],
active: null as string | null,
view: 'iso' as ViewKind,
visible: Object.fromEntries(PART_GROUPS.flatMap((g) => g.parts).map((p) => [p, true])) as Record<PartName, boolean>,
@@ -151,6 +172,9 @@ let pendingBackup: Backup | null = null;
let rebar3d: RebarScene | null = null;
/** 전체 보기: every pier's concrete in one frame (null in single-pier view). */
let allPiers: AllPiers | null = null;
let abutGroups: THREE.Group[] = [];
/** The 3D group of each abutment by name, so that selecting it can move the camera (2026-11-03). */
const abutGroupByName = new Map<string, THREE.Group>();
let groundMapGroup: THREE.Group | null = null;
let groundMapMode: 'none' | 'ground' | 'satellite' = 'none';
let backupTimer: number | undefined;
@@ -406,12 +430,25 @@ function setView(kind: ViewKind): void {
if (!scene) return;
const pier = activePier();
if (!pier?.model) return;
const { position, target } = viewPosition(scene, kind, pier.model);
camera.position.copy(position);
controls.target.copy(target);
const framed = viewPosition(scene, kind, pier.model);
// Keep the chosen view direction but fit every model (piers and abutments) in the window (2026-11-03).
const fitted = fitToModels(framed.position, framed.target);
camera.position.copy(fitted.position);
controls.target.copy(fitted.target);
controls.update();
}
/** Bounding sphere of everything drawn (the piers or the all-piers view, plus the abutments) fitted to the camera. */
function fitToModels(position: THREE.Vector3, target: THREE.Vector3): { position: THREE.Vector3; target: THREE.Vector3 } {
const box = new THREE.Box3();
const piersRoot = allPiers ? allPiers.piers : state.scene?.root;
if (piersRoot) box.expandByObject(piersRoot);
for (const g of abutGroups) box.expandByObject(g);
if (box.isEmpty()) return { position, target };
const sphere = box.getBoundingSphere(new THREE.Sphere());
return fitCamera(position, target, sphere, camera.fov, camera.aspect);
}
/** 전체 보기 applies on ① 형상 생성 only; ③ and the others keep the single-pier scene. */
/** The piers the 3D view shows together: all generated piers in ① (전체 보기), the 기준 pier alone in ②. */
function shownPiers(): Pier[] {
@@ -446,6 +483,26 @@ function buildAllScene(): boolean {
}
function setupScene(): void {
// 교대 (AB, 2026-11-03): drawn in the frame of the first pier with a model, so they sit with the piers.
for (const g of abutGroups) world.remove(g);
abutGroups = [];
abutGroupByName.clear();
// The frame is the same one the piers use: the first shown pier in the all-piers view, otherwise the active pier.
const ref = multiSceneOn() ? shownPiers().find((q) => q.model)?.model : (activePier()?.model ?? state.piers.find((q) => q.model)?.model);
if (ref) {
for (const rec of state.abutments) {
try {
const model = buildAbut(toAbutInput(rec));
const members = buildMembers(model, { bottomEl: rec.foundationBottomEl, pileLengthMm: rec.pileLengthMm }, rec.shape);
const group = buildAbutGroup(model, members, buildWings(model, members, rec.wings), ref.pivot, rec.groundEl, groundModel?.terrainGrid);
abutGroups.push(group);
abutGroupByName.set(rec.name, group);
world.add(group);
} catch {
// 입력 오류는 가져오기 때 알림으로 표시한다. 여기서는 그리지 않는다.
}
}
}
if (groundMapGroup) {
world.remove(groundMapGroup);
disposeTerrainMapGroup(groundMapGroup);
@@ -499,27 +556,222 @@ function renderPierList(): void {
renderPierRows($<HTMLUListElement>('pier-items'), 'shape');
renderPierRows($<HTMLUListElement>('plan-pier-items'), 'plan');
renderAbutEditor();
const visible = state.piers.filter((p) => state.filter === 'all' || p.input.type === state.filter);
$<HTMLParagraphElement>('pier-empty').hidden = state.piers.length > 0;
}
/** One 교대 row: the name and form, and a click shows its key values in the status line (the 3D view already has it). */
function abutRow(rec: AbutRecord, mode: Stage = 'shape'): HTMLLIElement {
const li = el('li');
const name = el('button', `${rec.name} · ${rec.type} 교대`, 'pier-name');
name.type = 'button';
name.setAttribute('aria-pressed', String(state.abutActive === rec.name));
name.addEventListener('click', () => {
state.abutActive = rec.name;
state.abutEditOpen = false;
setStatus(abutSummary(rec));
focusAbut(rec.name);
renderAll();
});
li.append(name, el('span', '교대', 'badge'));
return li;
}
/** Moves the camera to the selected pier: its own scene, in the all-piers view or the single view (2026-11-03). */
function focusPier(name: string): void {
const root = allPiers ? allPiers.scenes.get(name)?.root : state.scene?.root;
if (!root) return;
root.updateWorldMatrix(true, true);
const { position, target } = cameraForBox(new THREE.Box3().setFromObject(root));
camera.position.copy(position);
controls.target.copy(target);
controls.update();
}
/** Moves the camera to the selected abutment (2026-11-03). */
function focusAbut(name: string): void {
const group = abutGroupByName.get(name);
if (!group) return;
group.updateWorldMatrix(true, true);
const { position, target } = cameraForBox(new THREE.Box3().setFromObject(group));
camera.position.copy(position);
controls.target.copy(target);
controls.update();
}
/** 교대 편집 카드: the values of the selected abutment. Each change is checked, rebuilds the abutment in the 3D view and marks the project as changed. */
/** The 교대 입력 panel slots of ① (replaces the pier info) and ② (under the list). */
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' },
plan: { root: 'abut-panel-plan', name: 'abut-panel-plan-name', input: 'abut-panel-plan-input', toggle: 'abut-panel-plan-edit-toggle', editor: 'abut-panel-plan-editor', summary: 'abut-panel-plan-summary', basic: 'abut-panel-plan-basic', 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). */
function renderAbutEditor(): void {
const rec = state.abutments.find((a) => a.name === state.abutActive);
$<HTMLElement>('pier-info').hidden = Boolean(rec);
for (const slot of [ABUT_SLOTS.shape, ABUT_SLOTS.plan]) {
const box = $<HTMLElement>(slot.root);
box.hidden = !rec;
if (rec) fillAbutSlot(slot, rec);
}
}
/** Fills one 교대 입력 panel: the basic items, the 받침 values and the 형상 values. Each change is checked and applied. */
function fillAbutSlot(slot: (typeof ABUT_SLOTS)[keyof typeof ABUT_SLOTS], rec: AbutRecord): void {
$<HTMLElement>(slot.name).textContent = rec.name;
// 입력값 (read only, the mockup's "입력값" card) and 상세 (계산값, 8 rows), both as lists of label and value (2026-11-03).
const fillList = (dl: HTMLDListElement, rows: [string, string][]) => {
dl.replaceChildren();
for (const [label, value] of rows) dl.append(el('dt', label), el('dd', value));
};
fillList($<HTMLDListElement>(slot.input), abutInputRows(rec));
fillList($<HTMLDListElement>(slot.summary), abutSummaryRows(rec).filter(([label]) => ABUT_DETAIL_LABELS.includes(label)));
// 편집 opens the editable fields; they stay closed until asked for (2026-11-03).
const toggle = $<HTMLButtonElement>(slot.toggle);
toggle.textContent = state.abutEditOpen ? '닫기' : '편집';
toggle.setAttribute('aria-expanded', String(state.abutEditOpen));
toggle.onclick = () => {
state.abutEditOpen = !state.abutEditOpen;
renderAll();
};
$<HTMLElement>(slot.editor).hidden = !state.abutEditOpen;
const basic = $<HTMLDivElement>(slot.basic);
const bearings = $<HTMLDivElement>(slot.bearings);
const shape = $<HTMLDivElement>(slot.shape);
const msg = $<HTMLParagraphElement>(slot.msg);
basic.replaceChildren();
bearings.replaceChildren();
shape.replaceChildren();
msg.textContent = '';
const apply = (next: AbutRecord) => {
state.abutments = state.abutments.map((a) => (a.name === next.name ? next : a));
setupScene();
renderAll();
};
const fail = (err: unknown, input: HTMLInputElement, back: string) => {
msg.textContent = (err as Error).message;
input.value = back;
};
const numberInput = (id: string, value: string) => {
const input = el('input');
input.id = id;
input.type = 'number';
input.step = 'any';
input.value = value;
return input;
};
const idOf = (suffix: string) => `${slot.prefix}-${rec.name}-${suffix}`;
for (const f of ABUT_EDIT_FIELDS) {
const id = idOf(f.key);
const lab = el('label', `${f.label}${f.unit ? ` (${f.unit})` : ''}`);
lab.htmlFor = id;
const input = numberInput(id, String(rec[f.key]));
input.addEventListener('change', () => {
try {
apply(applyAbutEdit(rec, f.key, Number(input.value)));
} catch (err) {
fail(err, input, String(rec[f.key]));
}
});
basic.append(lab, input);
}
for (const side of ['left', 'right'] as const) {
const id = idOf(`wing-${side}`);
const lab = el('label', side === 'left' ? '좌 날개벽 (① 쪽)' : '우 날개벽 (n 쪽)');
lab.htmlFor = id;
const sel = el('select');
sel.id = id;
for (const [value, text] of [['general', '일반형'], ['short', '단축형'], ['none', '없음']] as const) sel.appendChild(new Option(text, value));
sel.value = rec.wings?.[side] ?? 'general';
sel.addEventListener('change', () => apply(setWingForm(rec, side, sel.value as WingForm)));
basic.append(lab, sel);
}
const restore = el('button', '기본값으로 되돌리기', 'btn small');
restore.type = 'button';
restore.addEventListener('click', () => {
const def = state.abutDefaults.find((a) => a.name === rec.name);
if (def) apply(restoreAbutDefault(def));
});
basic.appendChild(restore);
for (const b of rec.bearings) {
const zId = idOf(`bz-${b.no}`);
const zLab = el('label', `받침 ${b.no} z (m)`);
zLab.htmlFor = zId;
const zIn = numberInput(zId, String(b.z));
zIn.addEventListener('change', () => {
try {
apply(applyBearingEdit(rec, b.no, 'z', Number(zIn.value)));
} catch (err) {
fail(err, zIn, String(b.z));
}
});
const sId = idOf(`bs-${b.no}`);
const sLab = el('label', `받침 ${b.no} 교좌면 (m, 비우면 자동)`);
sLab.htmlFor = sId;
const sIn = numberInput(sId, b.seatEl === undefined ? '' : String(b.seatEl));
sIn.addEventListener('change', () => {
try {
apply(applyBearingEdit(rec, b.no, 'seatEl', sIn.value === '' ? undefined : Number(sIn.value)));
} catch (err) {
fail(err, sIn, b.seatEl === undefined ? '' : String(b.seatEl));
}
});
bearings.append(zLab, zIn, sLab, sIn);
}
for (const f of SHAPE_EDIT_FIELDS) {
const current = rec.shape?.[f.key] ?? ABUT_SHAPE_DEFAULT[f.key];
const id = idOf(`shape-${f.key}`);
const lab = el('label', f.label);
lab.htmlFor = id;
const input = numberInput(id, String(current));
if (current !== ABUT_SHAPE_DEFAULT[f.key]) input.classList.add('changed-input');
input.addEventListener('change', () => {
try {
apply(applyShapeEdit(rec, f.key, Number(input.value)));
} catch (err) {
fail(err, input, String(current));
}
});
shape.append(lab, input, el('span', `기본 ${ABUT_SHAPE_DEFAULT[f.key]}`, 'muted small'));
}
}
function abutSummary(rec: AbutRecord): string {
return `${rec.name} (${rec.type}): 교좌면 EL ${rec.seatEl.toFixed(3)} m · 기초 하단 ${rec.foundationBottomEl.toFixed(3)} m · 지반고 ${rec.groundEl.toFixed(3)} m · 받침 ${rec.bearings.length}개 · skew ${(rec.skewDeg).toFixed(2)}° · 종단경사 ${(rec.slopeI * 100).toFixed(2)} %`;
}
/** Rows of the pier list. ① shows the type and warnings; ② shows the count of changes from the defaults. */
function renderPierRows(list: HTMLUListElement, mode: Stage): void {
list.replaceChildren();
const visible = state.piers.filter((p) => state.filter === 'all' || p.input.type === state.filter);
// 교대 rows (2026-11-03, AB): A1 first, A2 last, shown in ① with the piers when the filter is 전체.
const abuts = (mode === 'shape' || mode === 'plan') && state.filter === 'all' ? structureOrder(state.abutments, visible.map((p) => p.input.name)) : [];
const abutRefs = abuts.filter((r) => r.kind === 'abut');
for (const ref of abutRefs.filter((r) => r.type === 'A1')) {
const rec = state.abutments.find((a) => a.name === ref.name);
if (rec) list.appendChild(abutRow(rec, mode));
}
for (const pier of visible) {
const li = el('li');
// The pier name and type are shown here, since the 3D title band is gone on wide screens (승인 2026-10-23).
const name = el('button', `${pier.input.name} · ${pier.input.type}`, 'pier-name');
name.type = 'button';
name.setAttribute('aria-pressed', String(pier.input.name === state.active));
// One highlighted row at a time: while an abutment is being edited, no pier row is pressed (2026-11-03).
name.setAttribute('aria-pressed', String(pier.input.name === state.active && state.abutActive === null));
name.addEventListener('click', () => {
state.active = pier.input.name;
state.abutActive = null;
state.quantityAll = false;
state.rebarSelection = new Set();
setupScene();
renderAll();
focusPier(pier.input.name);
});
const badge = mode === 'plan'
? el('span', `변경 ${overrideKeys(pier).length}`, 'badge')
@@ -531,6 +783,11 @@ function renderPierRows(list: HTMLUListElement, mode: Stage): void {
li.append(name, badge);
list.appendChild(li);
}
// 종점 교대 (A2) and any other form come after the piers.
for (const ref of abutRefs.filter((r) => r.type !== 'A1')) {
const rec = state.abutments.find((a) => a.name === ref.name);
if (rec) list.appendChild(abutRow(rec, mode));
}
}
function renderHeader(): void {
@@ -691,6 +948,7 @@ function renderAll(): void {
updatePartsCount();
const allToggle = $<HTMLButtonElement>('all-toggle');
allToggle.setAttribute('aria-pressed', String(state.allView));
allToggle.title = `${shownPiers().filter((p) => p.model).length}기를 한 화면에 (① 형상 생성)`;
allToggle.disabled = state.stage !== 'shape' || !state.piers.some((p) => p.model);
if (state.active !== lastScrolledPier) {
lastScrolledPier = state.active;
@@ -2107,7 +2365,7 @@ let bearingRebarView: 'section' | 'plan' = 'section';
function renderBearingLayout(wrap: HTMLDivElement, pier: Pier): void {
if (!pier.model) return;
// 상세 보기 (승인 2026-10-26): pair chips (①·⑥ …) and the plan with the drawing values overlaid as dashed outlines.
// 상세 보기 (승인 2026-10-26): pair chips (①·⑥ …), then the 삽도 of the pair (plan, section, solplate). Values are the model's (2026-11-03).
const pairList = pairEccentricities(pier.model.blocks);
if (bearingPairIndex >= pairList.length) bearingPairIndex = 0;
wrap.appendChild(el('p', '상세 보기 (전열 · 후열 쌍)', 'lbl'));
@@ -2125,47 +2383,23 @@ function renderBearingLayout(wrap: HTMLDivElement, pier: Pier): void {
wrap.appendChild(chips);
const current = pairList[bearingPairIndex];
if (current) {
const fig = el('figure', undefined, 'ground-fig');
// 삽도 set: the plan on the left and the section on its right (the solplate table carries its values).
const sketch = el('div', undefined, 'bearing-sketch');
const fig = el('figure', undefined, 'ground-fig plan-fig');
fig.innerHTML = bearingPlanSvg(pier.model, bearingPairIndex);
fig.appendChild(el('figcaption', '평면 · 실선 = 모델(솔플레이트, 블록 윤곽) · C4·C5는 교축 방향, 받침 중심에서 블록 바깥 연단까지', 'muted small'));
wrap.appendChild(fig);
fig.appendChild(el('figcaption', '교량받침 삽도 · 평면 (방위각 회전 생략) · C1–C3 교축, C4·C5 교각축, C6 블록 폭', 'muted small'));
sketch.appendChild(fig);
// 단면 (elevation): the blocks, mortar, bearing body and solplates.
const section = el('figure', undefined, 'ground-fig');
section.innerHTML = bearingSectionSvg(pier.model, bearingPairIndex);
section.appendChild(el('figcaption', '단면 · EL.X는 모르타르 상단 · h3 = 받침 본체 · 솔플레이트 T1(−측)·T2(+측)', 'muted small'));
wrap.appendChild(section);
section.appendChild(el('figcaption', '교량받침 삽도 · 단면 · E = h1 + h2 + h3 + h4 · S1 = 받침중심 ↔ 코핑 연단, S2 = 받침 사이', 'muted small'));
sketch.appendChild(section);
// 솔플레이트 상세 for the two bearings of the pair (plan and taper).
const solGrid = el('div', undefined, 'footing-grid');
for (const no of [current.frontNo, current.backNo]) {
const block = pier.model.blocks.find((b) => b.no === no)!;
const fig2 = el('figure', undefined, 'footing-fig');
fig2.innerHTML = solplateDetailSvg(block);
fig2.appendChild(el('figcaption', `솔플레이트 상세 · ${no}번 · 두께 과장`, 'muted small'));
solGrid.appendChild(fig2);
}
wrap.appendChild(solGrid);
wrap.appendChild(sketch);
}
wrap.appendChild(el('p', `배치 규칙 (모델값 · 편심 한계 ${PAIR_ECCENTRICITY_LIMIT_MM} mm)`, 'lbl'));
const edges = bearingEdgeDistances(pier.model);
const table = el('table', undefined, 'grid-table rebar-table');
const head = el('thead');
const hr = el('tr');
for (const h of ['받침', '열', 'C4 (교각축 · 받침 ↔ 블록 연단, mm)', 'C5 (교각축 · 받침 ↔ 블록 연단, mm)']) hr.appendChild(el('th', h));
head.appendChild(hr);
table.appendChild(head);
const body = el('tbody');
for (const e of edges) {
const tr = el('tr');
for (const v of [String(e.no), e.row, e.leftMm.toFixed(0), e.rightMm.toFixed(0)]) tr.appendChild(el('td', v));
body.appendChild(tr);
}
table.appendChild(body);
wrap.appendChild(el('div', undefined, 'table-scroll')).appendChild(table);
const pairs = pairEccentricities(pier.model.blocks);
const forced = pairs.filter((p) => !p.allowed).length;
wrap.appendChild(el('p', `쌍 편심 (전열 k · 후열 k, 교각축 방향): 최대 ${Math.max(...pairs.map((p) => p.eccentricityMm)).toFixed(0)} mm · ${forced === 0 ? '모든 쌍 일체 가능' : `${forced}쌍은 편심 초과 → 분리 우선`}`, 'muted small'));
wrap.appendChild(el('p', `쌍 편심 (전열 k · 후열 k, 교각축 방향, 한계 ${PAIR_ECCENTRICITY_LIMIT_MM} mm): 최대 ${Math.max(...pairs.map((p) => p.eccentricityMm)).toFixed(0)} mm · ${forced === 0 ? '모든 쌍 일체 가능' : `${forced}쌍은 편심 초과 → 분리 우선`}`, 'muted small'));
}
/** Text of one tracked value in the 변경 list. */
@@ -2449,7 +2683,7 @@ function renderElevationTable(wrap: HTMLDivElement, pier: Pier): void {
const table = el('table', undefined, 'grid-table rebar-table');
const head = el('thead');
const hr = el('tr');
for (const h of ['No', '열', 'EL.X', 'h1 블록', 'h2 몰탈', 'h3 받침', 'h4 솔플', 'E', 'C1', 'C2', 'C3', 'C4', 'C5', 'C6', 'S1 (본수·길이)', 'S2 (본수·길이)']) hr.appendChild(el('th', h));
for (const h of ['No', '열', 'EL.X', '교좌면 EL', 'h1 블록', 'h2 몰탈', 'h3 받침', 'h4 솔플', 'E', 'C1', 'C2', 'C3', 'C4', 'C5', 'C6', 'S1 (본수·길이)', 'S2 (본수·길이)']) hr.appendChild(el('th', h));
head.appendChild(hr);
table.appendChild(head);
const body = el('tbody');
@@ -2457,7 +2691,7 @@ function renderElevationTable(wrap: HTMLDivElement, pier: Pier): void {
const bars = (b: BarSummary | null) => (b === null ? '—' : `${b.count} · ${b.length}`);
for (const r of elevationRows(pier.model)) {
const tr = el('tr');
const cells = [String(r.no), r.row, r.elx.toFixed(3), String(r.h1), String(r.h2), String(r.h3), String(r.h4), String(r.E), String(r.C1), dash(r.C2), dash(r.C3), String(r.C4), String(r.C5), String(r.C6), bars(r.S1), bars(r.S2)];
const cells = [String(r.no), r.row, r.elx.toFixed(3), r.seatEl.toFixed(3), String(r.h1), String(r.h2), String(r.h3), String(r.h4), String(r.E), String(r.C1), dash(r.C2), dash(r.C3), String(r.C4), String(r.C5), String(r.C6), bars(r.S1), bars(r.S2)];
for (const c of cells) tr.appendChild(el('td', c));
body.appendChild(tr);
}
@@ -2650,6 +2884,8 @@ function snapshot(): string {
material: state.material,
fckByMember: state.fckByMember,
groundModel,
abutments: state.abutments,
abutDefaults: state.abutDefaults,
});
}
@@ -2667,6 +2903,8 @@ function currentProjectText(): string {
common: state.material,
fckByMember: state.fckByMember,
groundModel: groundModel ?? undefined,
abutments: state.abutments,
abutDefaults: state.abutDefaults,
});
}
@@ -2797,6 +3035,8 @@ function openProjectText(text: string, saved: boolean): void {
state.history = parsed.history;
state.material = materialFrom(parsed.common);
state.fckByMember = { ...parsed.fckByMember };
state.abutments = parsed.abutments ?? [];
state.abutDefaults = parsed.abutDefaults ?? JSON.parse(JSON.stringify(state.abutments)) as AbutRecord[];
groundModel = parsed.groundModel ?? null;
groundMapMode = groundModel?.mapLayers?.length ? 'satellite' : 'none';
setGroundMapMode(groundMapMode);
@@ -2965,7 +3205,9 @@ function updateApply(): void {
}
function loadBytes(bytes: Uint8Array, fileName: string): void {
const { piers, sheets } = parseWorkbook(XLSX.read(bytes, { type: 'array' }));
const wb = XLSX.read(bytes, { type: 'array' });
const { piers, sheets } = parseWorkbook(wb);
const abut = parseAbutments(wb);
const entries = piers.map((input) => buildEntry(input, false));
// A sheet is selectable only if every pier in it builds. Show the reason next to the sheet.
for (const sheet of sheets) {
@@ -2973,7 +3215,7 @@ function loadBytes(bytes: Uint8Array, fileName: string): void {
for (const e of own) sheet.issues.push(...e.issues.filter((i) => i.level === 'E'));
sheet.ok = sheet.ok && own.every((e) => e.model !== null);
}
const pending: Pending = { fileName, entries, sheets };
const pending: Pending = { fileName, entries, sheets, abutments: abut.abutments, abutIssues: abut.issues };
for (const e of entries) e.checked = selectable(pending, e);
state.pending = pending;
renderDialog();
@@ -2985,6 +3227,8 @@ function applyImport(): void {
if (!p || !name) return;
state.project = { ...state.project, name, bridge: $<HTMLInputElement>('bridge-name').value.trim() };
state.piers = p.entries.filter((e) => e.checked && selectable(p, e));
state.abutments = p.abutments;
state.abutDefaults = JSON.parse(JSON.stringify(p.abutments)) as AbutRecord[];
state.active = state.piers[0]?.input.name ?? null;
state.filter = 'all';
state.pending = null;
@@ -3004,7 +3248,8 @@ function applyImport(): void {
document.querySelector<HTMLElement>('.landing-overlay')?.setAttribute('hidden', 'true');
setupScene();
renderAll();
setStatus(`${state.piers.length}기 형상을 생성했습니다. 정보·경고는 오른쪽 알림에서 확인하세요.`);
const abutNote = state.abutments.length > 0 ? ` 교대 ${state.abutments.length}기를 읽었습니다${p.abutIssues.some((i) => i.level === 'E') ? ' (입력 오류 있음)' : ''}.` : '';
setStatus(`${state.piers.length}기 형상을 생성했습니다.${abutNote} 정보·경고는 오른쪽 알림에서 확인하세요.`);
}
function openImport(): void {
+22 -7
View File
@@ -212,17 +212,26 @@ select {
list-style: none;
padding: 0;
margin: 0;
max-height: 70vh;
overflow: auto;
max-height: 300px;
overflow-y: auto;
/* The side panel is a flex column; without this the list shrinks to 0 height and shows no rows. */
flex-shrink: 0;
}
/* Rows: name + badge (2 columns). Rows with a checkbox get a 28 px first column. */
.pier-items li {
display: grid;
grid-template-columns: 28px 1fr auto;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 6px;
gap: 8px;
border-bottom: 1px solid var(--line);
padding: 2px 0;
border-radius: 6px;
padding: 2px 6px 2px 0;
}
.pier-items li:has(> input[type='checkbox']) {
grid-template-columns: 28px minmax(0, 1fr) auto;
gap: 6px;
}
.pier-items input[type='checkbox'] {
@@ -630,7 +639,6 @@ body { background: #eef1f4; font-family: 'Noto Sans KR', system-ui, sans-serif;
.pier-tools { display: flex; align-items: center; gap: 8px; padding: 0 4px 8px; }
.pier-tools select { border: 1px solid #c3cbd4; border-radius: 6px; background: #ffffff; min-height: 36px; }
.phead { display: grid; grid-template-columns: 1fr auto; gap: 8px; padding: 0 10px 6px; font-size: 12px; color: #55606e; border-bottom: 1px solid #d5dbe2; }
.pier-items li { display: grid; grid-template-columns: 1fr auto; align-items: center; gap: 8px; border-radius: 6px; padding-right: 6px; }
.pier-items li:has(.pier-name[aria-pressed='true']) { background: #e3edf8; }
.pier-name { width: 100%; border: 0; background: transparent; color: #18212b; text-align: left; font-size: 13px; min-height: 44px; padding: 0 6px; }
.pier-name[aria-pressed='true'] { color: #123e70; font-weight: 700; }
@@ -673,7 +681,6 @@ body { background: #eef1f4; font-family: 'Noto Sans KR', system-ui, sans-serif;
.chip-opt { min-height: 36px; padding: 0 10px; border: 1px solid #c3cbd4; border-radius: 16px; background: #ffffff; color: #18212b; font-size: 13px; cursor: pointer; }
.chip-opt[aria-pressed='true'] { background: #1f5fa8; border-color: #1f5fa8; color: #ffffff; font-weight: 700; }
.chip-opt:disabled { color: #8a95a3; background: #f2f4f7; border-style: dashed; cursor: default; }
.pier-items { max-height: 300px; overflow-y: auto; }
/* 열별 본수 (C4) */
.pile-rows { margin-top: 10px; display: grid; gap: 6px; }
.pile-row { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
@@ -825,6 +832,14 @@ body { background: #eef1f4; font-family: 'Noto Sans KR', system-ui, sans-serif;
.footing-grid { grid-template-columns: 1fr; }
}
/* 교량받침 삽도: the plan on the left, the section on its right. Narrow screens stack both. */
.bearing-sketch { display: grid; grid-template-columns: minmax(240px, 1fr) minmax(240px, 1fr); gap: 12px; align-items: start; margin-top: 12px; }
.bearing-sketch figure { margin: 0; }
.bearing-sketch svg { display: block; width: 100%; max-width: none !important; height: auto; background: #ffffff; border: 1px solid #d5dde6; border-radius: 6px; }
@media (max-width: 800px) {
.bearing-sketch { grid-template-columns: 1fr; }
}
/* 입력값: one row per pier; the table scrolls sideways in the narrow left panel. */
.pier-table-wrap { overflow-x: auto; margin-bottom: 8px; }
#inputs-all { font-size: 12px; width: 100%; }
+211
View File
@@ -0,0 +1,211 @@
/**
* 교대 3D 장면 (2026-11-03): 벽체, 흉벽, 저판, 말뚝, 날개벽, 헌치를 Three.js 그룹으로 만든다. 순수 함수 (렌더러 없음).
*
* 좌표 (장면 기준): x = 교대축 방향 s (IP1 → IP3), y = EL (위 +), z = 교대축 직각 t (전면 +).
* 날개벽 평면(E, N)은 축 기준 (s, t)로 바꿔 쓴다: s = (P − IP1)·u, t = (P − IP1)·(전면 법선).
* 흉벽·벽체 단면은 모식도다. 흉벽은 저판 상면에서 상단까지 그린다(교좌면 아래 세부는 배근 단계에서).
*/
import * as THREE from 'three';
import type { AbutModel, AbutPoint } from '../core/abut/geometry';
import { buildMembers, type AbutMembers } from '../core/abut/members';
import { buildWings, type WingSolid } from '../core/abut/wings';
import type { TerrainGrid } from '../core/ground-model';
import { terrainGeometry } from './terrain';
export const ABUT_GROUP_NAMES = ['wall', 'parapet', 'footing', 'piles', 'wings', 'haunches', 'ground'] as const;
/** 지반 pad margin (m) around the abutment plan, like the pier's GROUND_PAD. */
export const ABUT_GROUND_MARGIN_M = 2;
/** The third axis point is IP3; the model keeps IP1 only, so it is taken from the axis length and direction. */
function input3(model: AbutModel): AbutPoint {
return { e: model.ip1.e + model.u.e * model.length, n: model.ip1.n + model.u.n * model.length };
}
/** True when every point (with margin m) lies inside the terrain grid's E–N extent. */
export function covers(grid: TerrainGrid, points: AbutPoint[], margin: number): boolean {
const eMin = grid.origin[1];
const eMax = grid.origin[1] + (grid.ny - 1) * grid.step;
const nMin = grid.origin[0];
const nMax = grid.origin[0] + (grid.nx - 1) * grid.step;
return points.every((p) => p.e - margin >= eMin && p.e + margin <= eMax && p.n - margin >= nMin && p.n + margin <= nMax);
}
/** Extrudes a closed profile given in its own (x, y) plane, placed by the basis vectors ax, ay and the extrusion az. */
function prism(profile: [number, number][], depth: number, origin: THREE.Vector3, ax: THREE.Vector3, ay: THREE.Vector3, az: THREE.Vector3): THREE.BufferGeometry {
const shape = new THREE.Shape(profile.map(([x, y]) => new THREE.Vector2(x, y)));
const geom = new THREE.ExtrudeGeometry(shape, { depth, bevelEnabled: false, steps: 1 });
geom.applyMatrix4(new THREE.Matrix4().makeBasis(ax, ay, az).setPosition(origin));
return geom;
}
function material(color: number): THREE.MeshStandardMaterial {
return new THREE.MeshStandardMaterial({ color, side: THREE.DoubleSide, roughness: 0.85 });
}
/** Local (s, t) of a plan point, relative to the IP1 end of the axis. */
function toAxis(model: AbutModel, p: AbutPoint): { s: number; t: number } {
const d = { e: p.e - model.ip1.e, n: p.n - model.ip1.n };
const front = { e: model.nLeft.e * model.frontSide, n: model.nLeft.n * model.frontSide };
return { s: d.e * model.u.e + d.n * model.u.n, t: d.e * front.e + d.n * front.n };
}
function wingDirectionAxis(model: AbutModel): { ws: number; wt: number } {
const rad = ((model.wing?.lineDeg ?? 0) * Math.PI) / 180;
const w = { e: Math.cos(rad), n: Math.sin(rad) };
const front = { e: model.nLeft.e * model.frontSide, n: model.nLeft.n * model.frontSide };
return { ws: w.e * model.u.e + w.n * model.u.n, wt: w.e * front.e + w.n * front.n };
}
/**
* Placement of the abutment in the pier scene (2026-11-03). The pier scene maps (E, N, EL) to
* (E − pivot.E, EL, −(N − pivot.N)); the abutment's local (s, t, EL) → (E, N) with s along u and t along the front
* normal, then the same mapping. So the abutment and any pier in the same workbook share one frame.
*/
export function abutPlacement(model: AbutModel, pivot: { e: number; n: number }): THREE.Matrix4 {
const f = { e: model.nLeft.e * model.frontSide, n: model.nLeft.n * model.frontSide };
const xs = new THREE.Vector3(model.u.e, 0, -model.u.n);
const y = new THREE.Vector3(0, 1, 0);
const zt = new THREE.Vector3(f.e, 0, -f.n);
const origin = new THREE.Vector3(model.ip1.e - pivot.e, 0, -(model.ip1.n - pivot.n));
return new THREE.Matrix4().makeBasis(xs, y, zt).setPosition(origin);
}
export function buildAbutGroup(
model: AbutModel,
members: AbutMembers = buildMembers(model),
wings: WingSolid[] = buildWings(model, members),
pivot?: { e: number; n: number },
groundEl?: number,
terrain?: TerrainGrid,
): THREE.Group {
const root = new THREE.Group();
root.name = model.name;
if (pivot) {
// applyMatrix4 keeps auto-update on, so the world matrix is refreshed by the scene and by bounding-box calls.
root.applyMatrix4(abutPlacement(model, pivot));
}
const L = model.length;
const ex = new THREE.Vector3(1, 0, 0);
const ey = new THREE.Vector3(0, 1, 0);
const ez = new THREE.Vector3(0, 0, 1);
const add = (name: (typeof ABUT_GROUP_NAMES)[number], geom: THREE.BufferGeometry, color: number) => {
const mesh = new THREE.Mesh(geom, material(color));
mesh.name = name;
let group = root.getObjectByName(name) as THREE.Group | undefined;
if (!group) {
group = new THREE.Group();
group.name = name;
root.add(group);
}
group.add(mesh);
};
const footingTop = members.footingTopEl;
const footingBottom = members.footingBottomEl;
// 벽체: one segment per 교좌면 step, from the footing top to the 교좌면, t −0.8 … +1.6.
for (const seg of members.wallSegments) {
const profile: [number, number][] = [
[seg.sFrom, footingTop],
[seg.sTo, footingTop],
[seg.sTo, seg.seatEl],
[seg.sFrom, seg.seatEl],
];
add('wall', prism(profile, members.shape.wallFrontM - members.shape.wallBackM, new THREE.Vector3(0, 0, members.shape.wallBackM), ex, ey, ez), 0xb7c7d3);
}
// 흉벽: t −0.5 … 0, from the footing top up to the parapet top line.
const parapetProfile: [number, number][] = [
[0, footingTop],
[L, footingTop],
[L, members.parapetTopEndEl],
[0, members.parapetTopStartEl],
];
add('parapet', prism(parapetProfile, -members.shape.parapetBackM, new THREE.Vector3(0, 0, members.shape.parapetBackM), ex, ey, ez), 0x8898aa);
if (footingBottom !== undefined) {
// 저판: s 0 … L, t −1.8 … +2.8, from the 기초 하단 to the 저판 상면.
const footProfile: [number, number][] = [
[0, members.shape.footingBackM],
[L, members.shape.footingBackM],
[L, members.shape.footingFrontM],
[0, members.shape.footingFrontM],
];
add('footing', prism(footProfile, members.shape.footingThicknessM, new THREE.Vector3(0, footingBottom, 0), ex, ez, ey), 0x5a6a78);
}
// 말뚝: D 600 (r 0.3) at each row and bar, from the 기초 하단 down to the 선단.
if (members.pileBottomEl !== undefined && footingBottom !== undefined) {
const height = footingBottom - members.pileBottomEl;
for (const row of members.piles) {
for (const s of row.s) {
const geom = new THREE.CylinderGeometry(0.3, 0.3, height, 16);
geom.translate(s, members.pileBottomEl + height / 2, row.t);
add('piles', geom, 0x7d8b99);
}
}
}
// 날개벽: the side profile (d along the wing, EL) extruded across the 0.45 m thickness toward the inside of the axis.
const dir = wingDirectionAxis(model);
for (const wing of wings) {
const sEnd = wing.sEnd;
const a = sEnd === 0 ? 1 : -1;
const profile: [number, number][] = [
...wing.topLine.map((p) => [p.dM, p.el] as [number, number]),
...[...wing.bottomLine].reverse().map((p) => [p.dM, p.el] as [number, number]),
];
const ax = new THREE.Vector3(dir.ws, 0, dir.wt);
const geom = prism(profile, 0.45, new THREE.Vector3(sEnd, 0, 0), ax, ey, new THREE.Vector3(a, 0, 0));
add('wings', geom, 0xc9a77c);
for (const h of wing.haunches) {
const shape: [number, number][] = h.corners.map((c) => {
const p = toAxis(model, c);
return [p.s, p.t];
});
const zBottom = h.zBottomEl;
const height = h.zTopEl - h.zBottomEl;
if (height > 0) add('haunches', prism(shape, height, new THREE.Vector3(0, zBottom, 0), ex, ez, ey), 0xc9a77c);
}
}
// 지형: when the ground model's terrain grid covers the abutment footprint, the terrain is drawn (the same frame as the
// piers); otherwise a flat pad at 지반고 covers the footing and the wings.
const footprint = [model.ip1, input3(model), ...wings.flatMap((w) => w.corners)];
const grid = terrain && pivot && covers(terrain, footprint, ABUT_GROUND_MARGIN_M) ? terrain : undefined;
if (grid && pivot) {
const mesh = new THREE.Mesh(terrainGeometry(grid, pivot), new THREE.MeshStandardMaterial({ color: 0x6b8f4e, roughness: 0.9, transparent: true, opacity: 0.35, depthWrite: false, side: THREE.DoubleSide }));
const group = new THREE.Group();
group.name = 'ground';
group.add(mesh);
root.add(group);
} else if (groundEl !== undefined) {
const ts = [members.shape.footingBackM, members.shape.footingFrontM, ...wings.flatMap((w) => w.corners.map((c) => toAxis(model, c).t))];
const tMin = Math.min(...ts) - ABUT_GROUND_MARGIN_M;
const tMax = Math.max(...ts) + ABUT_GROUND_MARGIN_M;
const sMin = -ABUT_GROUND_MARGIN_M;
const sMax = L + ABUT_GROUND_MARGIN_M;
const pad = new THREE.BoxGeometry(sMax - sMin, 0.05, tMax - tMin);
pad.translate((sMin + sMax) / 2, groundEl, (tMin + tMax) / 2);
const mesh = new THREE.Mesh(pad, new THREE.MeshStandardMaterial({ color: 0x6b8f4e, roughness: 0.9, transparent: true, opacity: 0.35, depthWrite: false, side: THREE.DoubleSide }));
mesh.name = 'ground';
const group = new THREE.Group();
group.name = 'ground';
group.add(mesh);
root.add(group);
}
return root;
}
/**
* Camera for looking at one abutment (2026-11-03): the target is the centre of its bounding box, and the camera stands
* back along a diagonal from the bridge side so both the wall and the piles are in view.
*/
export function cameraForBox(box: THREE.Box3): { position: THREE.Vector3; target: THREE.Vector3 } {
const target = box.getCenter(new THREE.Vector3());
const size = box.getSize(new THREE.Vector3());
const span = Math.max(size.x, size.y, size.z, 1);
const dist = span * 1.6;
const position = new THREE.Vector3(target.x + dist * 0.6, target.y + dist * 0.5, target.z + dist * 0.8);
return { position, target };
}
+4 -1
View File
@@ -1,5 +1,6 @@
import type { PierModel } from '../core/geometry';
import { bearingEdgeDistances, pairEccentricities } from '../core/bearing-layout';
import { BLOCK_DEPTH_MM } from '../core/bearing-table';
/** Number of pairs the 상세 보기 chips can show (①–⑩ in the mockup). */
export const PAIR_CHIP_LABELS = ['①', '②', '③', '④', '⑤', '⑥', '⑦', '⑧', '⑨', '⑩'];
@@ -88,6 +89,8 @@ export function bearingPlanSvg(m: PierModel, pairIndex: number): string {
const highEdge = ib ? Y(ib.s) + 600 : Y(k.s) + 600;
parts.push(vdim(lowEdge, Y(f.s), `C4 ${Math.round(d(f.no).leftMm)}`));
parts.push(vdim(Y(k.s), highEdge, `C5 ${Math.round(d(k.no).rightMm)}`));
// C6: block depth along the 교각축 (1200 for 분리 and 일체 alike).
parts.push(`<text x="${xRight + 60}" y="${highEdge + 110}" font-size="90" fill="#18212B">C6 ${BLOCK_DEPTH_MM} (교각축 방향 블록 폭)</text>`);
return `<svg viewBox="-1900 -1300 3800 2600" width="100%" style="max-width: 560px; height: auto" role="img" aria-label="교량받침 상세 평면: 쌍 ${pair.frontNo}·${pair.backNo}, 블록 한 줄, 교축·교각축 방향, C1·C2·C3, C4·C5, 도면값 겹쳐 보기">${parts.join('')}</svg>`;
return `<svg viewBox="-1900 -1300 3800 2600" width="100%" style="max-width: 560px; height: auto" role="img" aria-label="교량받침 삽도 평면: 쌍 ${pair.frontNo}·${pair.backNo}, 교축·교각축 방향, C1–C6">${parts.join('')}</svg>`;
}
+26 -6
View File
@@ -1,10 +1,11 @@
import type { PierModel } from '../core/geometry';
import { pairEccentricities } from '../core/bearing-layout';
import { pairCopingSpans } from '../core/bearing-table';
/**
* 교량받침 상세 · 단면 (elevation) for one pair (승인 2026-10-26): the model's blocks, mortar, bearing body,
* and solplate per bearing. x is the 교축 (n), y the EL (up). Labels: EL.X (mortar top), h1 (block), h3 (bearing body),
* T (solplate, taper T1 at the (−) side, T2 at the (+) side).
* 교량받침 삽도 · 단면 (elevation) for one pair (승인 2026-10-26, 모델값 2026-11-03): the model's blocks, mortar, bearing
* body and solplate per bearing. x is the 교축 (n), y the EL (up). Labels per bearing: EL.X (mortar top), h2 · h3 · h4 · E,
* h1 (block). Below: S1 (bearing centre to the coping edge), S2 (between the bearings), S1, and the coping width B.
*/
export function bearingSectionSvg(m: PierModel, pairIndex: number): string {
const pair = pairEccentricities(m.blocks)[pairIndex];
@@ -34,16 +35,35 @@ export function bearingSectionSvg(m: PierModel, pairIndex: number): string {
for (const b of [f, k]) {
const pad = 1000;
parts.push(`<rect x="${X(b.n) - pad / 2}" y="${Y(b.padTop)}" width="${pad}" height="${50}" fill="#B7C7D3" stroke="#6E8496" stroke-width="6"></rect>`);
const { L, h3, T1, T2 } = b.plan;
const { L, h3, T, T1, T2 } = b.plan;
const bodyTop = b.padTop + h3 / 1000;
parts.push(`<rect x="${X(b.n) - (L - 200) / 2}" y="${Y(bodyTop)}" width="${L - 200}" height="${h3}" fill="#8898AA" stroke="#5B6676" stroke-width="6"></rect>`);
const x0 = X(b.n) - L / 2;
const x1 = X(b.n) + L / 2;
parts.push(`<polygon points="${x0},${Y(bodyTop)} ${x1},${Y(bodyTop)} ${x1},${Y(bodyTop + T2 / 1000)} ${x0},${Y(bodyTop + T1 / 1000)}" fill="#F3D9B8" stroke="#B4531F" stroke-width="6"></polygon>`);
// EL.X at the mortar top, bearing number.
// EL.X at the mortar top, bearing number, and the heights of this bearing.
const E = Math.round((b.top - b.bottom) * 1000) + Math.round((b.padTop - b.top) * 1000) + h3 + T;
parts.push(`<circle cx="${X(b.n)}" cy="${Y(b.padTop)}" r="30" fill="#18212B"></circle>`);
parts.push(`<text x="${X(b.n) + 60}" y="${Y(b.padTop) - 60}" font-size="90" fill="#18212B">${b.no} · EL.X ${b.padTop.toFixed(3)}</text>`);
parts.push(`<text x="${X(b.n) + 60}" y="${Y(b.padTop) - 260}" font-size="80" fill="#55606E">h2 ${Math.round((b.padTop - b.top) * 1000)} · h3 ${h3} · h4 ${T} · E ${E}</text>`);
parts.push(`<text x="${X(b.n)}" y="${Y(b.bottom) + 160}" text-anchor="middle" font-size="90" fill="#55606E">h1 ${Math.round((b.top - b.bottom) * 1000)}</text>`);
}
return `<svg viewBox="-2000 -1200 4000 2000" width="100%" style="max-width: 560px; height: auto" role="img" aria-label="교량받침 상세 단면: 블록, 모르타르, 받침, 솔플레이트, EL.X">${parts.join('')}</svg>`;
// S1 · S2 · S1 along 교축 below the blocks: bearing centre to the coping edge (S1), between the bearings (S2).
const spans = pairCopingSpans(m, pairIndex);
if (spans) {
const lo = Math.min(f.n, k.n);
const xLo = X(lo);
const xHi = xLo + spans.S2;
const xEdge0 = xLo - spans.S1Low;
const xEdge1 = xHi + spans.S1High;
const yDim = Math.max(Y(f.bottom), Y(k.bottom)) + 420;
const dim = (x1: number, x2: number, label: string) =>
`<line x1="${x1}" y1="${yDim}" x2="${x2}" y2="${yDim}" stroke="#55606E" stroke-width="6"></line><line x1="${x1}" y1="${yDim - 50}" x2="${x1}" y2="${yDim + 50}" stroke="#55606E" stroke-width="6"></line><line x1="${x2}" y1="${yDim - 50}" x2="${x2}" y2="${yDim + 50}" stroke="#55606E" stroke-width="6"></line><text x="${(x1 + x2) / 2}" y="${yDim + 110}" text-anchor="middle" font-size="90" fill="#18212B">${label}</text>`;
parts.push(dim(xEdge0, xLo, `S1 ${spans.S1Low}`));
parts.push(dim(xLo, xHi, `S2 ${spans.S2}`));
parts.push(dim(xHi, xEdge1, `S1 ${spans.S1High}`));
parts.push(`<text x="${(xEdge0 + xEdge1) / 2}" y="${yDim + 280}" text-anchor="middle" font-size="90" fill="#55606E">코핑 폭 B ${spans.copingWidth} (받침중심 ↔ 코핑 연단: S1 · S2)</text>`);
}
return `<svg viewBox="-2000 -1200 4000 2000" width="100%" style="max-width: 560px; height: auto" role="img" aria-label="교량받침 삽도 단면: 블록, 모르타르, 받침, 솔플레이트, EL.X, S1·S2 코핑 치수">${parts.join('')}</svg>`;
}
+28
View File
@@ -0,0 +1,28 @@
import * as THREE from 'three';
/**
* Camera that contains a sphere (2026-11-03): keeps the view direction of `position → target`, moves the target to the
* sphere centre, and backs off until the sphere fits both the vertical and the horizontal field of view.
*/
export function fitCamera(
position: THREE.Vector3,
target: THREE.Vector3,
sphere: THREE.Sphere,
fovDeg: number,
aspect: number,
margin = 1.1,
): { position: THREE.Vector3; target: THREE.Vector3 } {
const halfV = THREE.MathUtils.degToRad(fovDeg / 2);
// The narrower of the two half-angles decides how far back the camera must stand.
const halfH = Math.atan(Math.tan(halfV) * aspect);
const halfFov = Math.min(halfV, halfH);
const fit = (sphere.radius / Math.sin(halfFov)) * margin;
const dir = position.clone().sub(target);
const current = dir.length();
const dist = Math.max(current, fit);
dir.normalize();
return {
position: sphere.center.clone().add(dir.multiplyScalar(dist)),
target: sphere.center.clone(),
};
}
-25
View File
@@ -1,25 +0,0 @@
import type { BlockModel } from '../core/geometry';
/**
* 솔플레이트 상세 (승인 2026-10-26, C안 2026-11-03) for one bearing: the plan (L across, W along the 교각축) and the
* taper in side view (T1 at the (−) side, T2 at the (+) side, S % · T). Thickness is exaggerated for legibility.
*/
export function solplateDetailSvg(block: BlockModel): string {
const plan = block.plan;
const { L, W, T1: t1, T2: t2 } = plan;
const parts: string[] = [];
// Plan (left): L across, W along.
parts.push(`<rect x="${-L / 2}" y="${-W / 2}" width="${L}" height="${W}" fill="#F3D9B8" stroke="#B4531F" stroke-width="8"></rect>`);
parts.push(`<text x="0" y="${-W / 2 - 40}" text-anchor="middle" font-size="60" fill="#18212B">L ${L.toFixed(0)}</text>`);
parts.push(`<text x="${L / 2 + 40}" y="0" font-size="60" fill="#18212B">W ${W.toFixed(0)}</text>`);
// Side view (right): thickness T1 at x = −L/2 and T2 at x = +L/2, shown with the taper.
const ox = L / 2 + 500;
const sc = 4; // exaggerated thickness for legibility, labelled
parts.push(`<polygon points="${ox - L / 2},0 ${ox + L / 2},0 ${ox + L / 2},${-t2 * sc} ${ox - L / 2},${-t1 * sc}" fill="#F3D9B8" stroke="#B4531F" stroke-width="8"></polygon>`);
parts.push(`<text x="${ox - L / 2}" y="${-t1 * sc - 40}" font-size="60" fill="#18212B">T1 ${t1.toFixed(1)}</text>`);
parts.push(`<text x="${ox + L / 2}" y="${-t2 * sc - 40}" text-anchor="end" font-size="60" fill="#18212B">T2 ${t2.toFixed(1)}</text>`);
parts.push(`<text x="${ox}" y="120" text-anchor="middle" font-size="60" fill="#55606E">S ${plan.slopePct} % · T ${plan.T} (두께 과장 ×${sc})</text>`);
const minX = -L / 2 - 200;
const maxX = ox + L / 2 + 200;
return `<svg viewBox="${minX} -900 ${maxX - minX} 1300" width="100%" style="max-width: 560px; height: auto" role="img" aria-label="솔플레이트 상세: 평면 L × W, 두께 T1 · T2 (두께 과장)">${parts.join('')}</svg>`;
}