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:
1 parent
b1b2ad1ddb
commit
1d31d2f3a9
44 files changed
+2571
-120
No files matched your search
+45
-3
@@ -43,7 +43,7 @@
|
||||
|
||||
<main class="grid" id="view-shape">
|
||||
<aside class="panel piers" aria-labelledby="pier-title">
|
||||
<p id="pier-title" class="lbl">교각 목록</p>
|
||||
<p id="pier-title" class="lbl">교각·교대 목록</p>
|
||||
<div class="pier-tools">
|
||||
<label>형식
|
||||
<select id="type-filter"></select>
|
||||
@@ -54,7 +54,7 @@
|
||||
<p id="pier-empty" class="muted">불러온 교각이 없습니다.</p>
|
||||
<p class="pnote">시트 1장 = 교각 1기입니다. 오류가 난 교각만 건너뛰고 나머지는 생성합니다.</p>
|
||||
|
||||
<section class="pier-input" aria-labelledby="input-title">
|
||||
<section id="pier-info" class="pier-input" aria-labelledby="input-title">
|
||||
<p id="input-title" class="lbl">입력값</p>
|
||||
<div class="pier-table-wrap">
|
||||
<table id="inputs-all" class="grid-table compact">
|
||||
@@ -71,6 +71,27 @@
|
||||
<button id="edit-regen" class="btn primary" type="button" disabled>다시 생성 · 변경 0</button>
|
||||
</section>
|
||||
</section>
|
||||
<section id="abut-panel" class="pier-input" hidden aria-labelledby="abut-panel-title">
|
||||
<p id="abut-panel-title" class="lbl"><span id="abut-panel-name"></span> 입력값 <span class="badge">입력</span>
|
||||
<button id="abut-panel-edit-toggle" class="btn small" type="button" aria-expanded="false">편집</button></p>
|
||||
<dl id="abut-panel-input" class="kv wide"></dl>
|
||||
<div id="abut-panel-editor" hidden>
|
||||
<div id="abut-panel-basic" class="edit-row"></div>
|
||||
<details class="abut-group">
|
||||
<summary>받침 값</summary>
|
||||
<div id="abut-panel-bearings" class="edit-row"></div>
|
||||
</details>
|
||||
<details class="abut-group">
|
||||
<summary>형상 (벽체·흉벽, 저판, 말뚝)</summary>
|
||||
<div id="abut-panel-shape" class="edit-row"></div>
|
||||
</details>
|
||||
<p id="abut-panel-msg" class="muted" aria-live="polite"></p>
|
||||
</div>
|
||||
<details class="abut-group">
|
||||
<summary>상세 (계산값)</summary>
|
||||
<dl id="abut-panel-summary" class="kv wide"></dl>
|
||||
</details>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
<section class="panel viewer" id="viewer-block" aria-labelledby="viewer-title">
|
||||
@@ -159,9 +180,30 @@
|
||||
|
||||
<main class="grid plan" id="view-plan" hidden>
|
||||
<aside class="panel piers" aria-labelledby="plan-pier-title">
|
||||
<p id="plan-pier-title" class="lbl">교각 목록</p>
|
||||
<p id="plan-pier-title" class="lbl">교각·교대 목록</p>
|
||||
<div class="phead" aria-hidden="true"><span>교각</span><span>변경</span></div>
|
||||
<ul id="plan-pier-items" class="pier-items"></ul>
|
||||
<section id="abut-panel-plan" class="pier-input" hidden aria-labelledby="abut-panel-plan-title">
|
||||
<p id="abut-panel-plan-title" class="lbl"><span id="abut-panel-plan-name"></span> 입력값 <span class="badge">입력</span>
|
||||
<button id="abut-panel-plan-edit-toggle" class="btn small" type="button" aria-expanded="false">편집</button></p>
|
||||
<dl id="abut-panel-plan-input" class="kv wide"></dl>
|
||||
<div id="abut-panel-plan-editor" hidden>
|
||||
<div id="abut-panel-plan-basic" class="edit-row"></div>
|
||||
<details class="abut-group">
|
||||
<summary>받침 값</summary>
|
||||
<div id="abut-panel-plan-bearings" class="edit-row"></div>
|
||||
</details>
|
||||
<details class="abut-group">
|
||||
<summary>형상 (벽체·흉벽, 저판, 말뚝)</summary>
|
||||
<div id="abut-panel-plan-shape" class="edit-row"></div>
|
||||
</details>
|
||||
<p id="abut-panel-plan-msg" class="muted" aria-live="polite"></p>
|
||||
</div>
|
||||
<details class="abut-group">
|
||||
<summary>상세 (계산값)</summary>
|
||||
<dl id="abut-panel-plan-summary" class="kv wide"></dl>
|
||||
</details>
|
||||
</section>
|
||||
<p class="lbl members-title">부재 · 5개</p>
|
||||
<ul id="members" class="members"></ul>
|
||||
</aside>
|
||||
|
||||
Binary file not shown.
@@ -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)' },
|
||||
];
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 })),
|
||||
];
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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',
|
||||
'접속슬래브 폭',
|
||||
];
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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]),
|
||||
};
|
||||
}
|
||||
@@ -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)}의 값이 맞지 않습니다.`);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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), '교각');
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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%; }
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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>`;
|
||||
}
|
||||
@@ -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>`;
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
@@ -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>`;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import * as XLSX from 'xlsx';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { applyAbutEdit, applyBearingEdit, restoreAbutDefault, setWingForm } from '../src/core/abut/edit';
|
||||
import { parseAbutments } from '../src/core/abut/parse';
|
||||
|
||||
const RAG = fileURLToPath(new URL('../../../RAG/운곡교P1입력.xlsx', import.meta.url));
|
||||
const rec = parseAbutments(XLSX.read(readFileSync(RAG), { type: 'buffer' })).abutments[0];
|
||||
|
||||
describe('교대 입력 편집 (교대 편집 카드)', () => {
|
||||
it('a valid change returns a new record and leaves the old one unchanged', () => {
|
||||
const next = applyAbutEdit(rec, 'groundEl', 34.5);
|
||||
expect(next.groundEl).toBe(34.5);
|
||||
expect(rec.groundEl).toBeCloseTo(33.984, 3);
|
||||
});
|
||||
|
||||
it('a non-number, a pile length of 0 or a skew out of range are refused with the field name', () => {
|
||||
expect(() => applyAbutEdit(rec, 'seatEl', NaN)).toThrow('교좌면 EL');
|
||||
expect(() => applyAbutEdit(rec, 'pileLengthMm', 0)).toThrow('말뚝길이');
|
||||
expect(() => applyAbutEdit(rec, 'skewDeg', 200)).toThrow('skew');
|
||||
});
|
||||
|
||||
it('the 교좌면 EL must stay above the 기초 하단 EL', () => {
|
||||
expect(() => applyAbutEdit(rec, 'seatEl', rec.foundationBottomEl)).toThrow('기초 하단 EL보다 높아야');
|
||||
});
|
||||
|
||||
it('a wing form change sets one side and keeps the other', () => {
|
||||
const next = setWingForm(rec, 'left', 'short');
|
||||
expect(next.wings).toEqual({ left: 'short', right: 'general' });
|
||||
expect(rec.wings).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('교대 기본값 복원과 받침 편집', () => {
|
||||
it('restores the imported defaults after several changes (a copy, not the same object)', () => {
|
||||
const changed = setWingForm(applyAbutEdit(rec, 'groundEl', 34.5), 'left', 'none');
|
||||
const back = restoreAbutDefault(rec);
|
||||
expect(back).toEqual(rec);
|
||||
expect(back).not.toBe(rec);
|
||||
expect(changed.groundEl).toBe(34.5);
|
||||
});
|
||||
|
||||
it('a bearing z change keeps the others; a 교좌면 override sets that bearing only', () => {
|
||||
const z = applyBearingEdit(rec, 3, 'z', 44.7);
|
||||
expect(z.bearings.find((b) => b.no === 3)!.z).toBe(44.7);
|
||||
expect(z.bearings.find((b) => b.no === 1)!.z).toBe(rec.bearings[0].z);
|
||||
const seat = applyBearingEdit(rec, 2, 'seatEl', 44.5);
|
||||
expect(seat.bearings.find((b) => b.no === 2)!.seatEl).toBe(44.5);
|
||||
const auto = applyBearingEdit(seat, 2, 'seatEl', undefined);
|
||||
expect(auto.bearings.find((b) => b.no === 2)!.seatEl).toBeUndefined();
|
||||
});
|
||||
|
||||
it('refuses an unknown bearing, a non-number z, and a 교좌면 at or below the 기초 하단', () => {
|
||||
expect(() => applyBearingEdit(rec, 99, 'z', 44)).toThrow('받침 99번');
|
||||
expect(() => applyBearingEdit(rec, 1, 'z', undefined)).toThrow('z는 숫자');
|
||||
expect(() => applyBearingEdit(rec, 1, 'seatEl', rec.foundationBottomEl)).toThrow('기초 하단');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildAbut, type AbutInput } from '../src/core/abut/geometry';
|
||||
|
||||
// Abut 시트의 A1 (운곡교, 2026-10-30 6차). 시트 x = 북, y = 동 → 내부 (E, N) = (y, x).
|
||||
const A1: AbutInput = {
|
||||
name: 'A1',
|
||||
type: 'A1',
|
||||
seatEl: 44.367,
|
||||
ip1: { e: 179131.917023, n: 259815.91973 },
|
||||
ip2: { e: 179131.907126, n: 259815.91805 },
|
||||
ip3: { e: 179119.891533, n: 259817.959709 },
|
||||
bearings: [
|
||||
{ no: 1, pos: { e: 179121.1404, n: 259818.5035 }, z: 44.492 },
|
||||
{ no: 2, pos: { e: 179123.6148, n: 259818.0835 }, z: 44.563 },
|
||||
{ no: 3, pos: { e: 179126.0891, n: 259817.6635 }, z: 44.634 },
|
||||
{ no: 4, pos: { e: 179128.5634, n: 259817.2435 }, z: 44.705 },
|
||||
{ no: 5, pos: { e: 179131.0377, n: 259816.8236 }, z: 44.776 },
|
||||
],
|
||||
};
|
||||
|
||||
describe('교대 A1 축과 받침 (AA1, spec_abut §10.1)', () => {
|
||||
const m = buildAbut(A1);
|
||||
|
||||
it('axis IP1 → IP3 is 12.197 m', () => {
|
||||
expect(m.length).toBeCloseTo(12.197, 3);
|
||||
});
|
||||
|
||||
it('bearing s from IP1: 11.057 / 8.547 / 6.037 / 3.528 / 1.018 m (①–⑤)', () => {
|
||||
const expected = [11.057, 8.547, 6.037, 3.528, 1.018];
|
||||
m.bearings.forEach((b, i) => expect(b.s).toBeCloseTo(expected[i], 2));
|
||||
});
|
||||
|
||||
it('seat EL ①–⑤: 44.367 / 44.438 / 44.509 / 44.580 / 44.651 (block 125 mm, pad included)', () => {
|
||||
const expected = [44.367, 44.438, 44.509, 44.58, 44.651];
|
||||
m.bearings.forEach((b, i) => {
|
||||
expect(b.seatEl).toBeCloseTo(expected[i], 3);
|
||||
expect(b.blockHeightMm).toBe(125);
|
||||
});
|
||||
});
|
||||
|
||||
it('step boundaries from IP1: 2.273 / 4.783 / 7.292 / 9.802 m', () => {
|
||||
const expected = [2.273, 4.783, 7.292, 9.802];
|
||||
expect(m.stepBounds).toHaveLength(4);
|
||||
m.stepBounds.forEach((s, i) => expect(s).toBeCloseTo(expected[i], 2));
|
||||
});
|
||||
|
||||
it('the bearings are on the left of 1 → n for A1 (front), so the layout matches the type (AW03 clear)', () => {
|
||||
expect(m.frontLeftOfBearingDir).toBe(true);
|
||||
expect(m.layoutMatchesType).toBe(true);
|
||||
});
|
||||
|
||||
it('the bearing 1 lies 0.745 m from the axis (spec §10.1 전면 판정)', () => {
|
||||
expect(Math.abs(m.bearings[0].lateral)).toBeCloseTo(0.745, 2);
|
||||
});
|
||||
|
||||
it('an A2 label on the same layout fails the type check (AW03)', () => {
|
||||
expect(buildAbut({ ...A1, type: 'A2' }).layoutMatchesType).toBe(false);
|
||||
});
|
||||
|
||||
it('a bearing with its own 교좌면 EL uses it and its block height is z − seat', () => {
|
||||
const own = buildAbut({ ...A1, bearings: A1.bearings.map((b, i) => (i === 2 ? { ...b, seatEl: 44.5 } : b)) });
|
||||
expect(own.bearings[2].seatEl).toBeCloseTo(44.5, 6);
|
||||
expect(own.bearings[2].blockHeightMm).toBe(134);
|
||||
expect(own.bearings[0].blockHeightMm).toBe(125);
|
||||
});
|
||||
|
||||
it('refuses an abutment with fewer than two bearings or coincident IP1 and IP3', () => {
|
||||
expect(() => buildAbut({ ...A1, bearings: A1.bearings.slice(0, 1) })).toThrow('받침이 2개 이상');
|
||||
expect(() => buildAbut({ ...A1, ip3: A1.ip1 })).toThrow('같은 점');
|
||||
});
|
||||
});
|
||||
|
||||
// AA2 (spec_abut §4.7, §10.1, §10.2): 날개벽 선, 날개벽 경사, AW01, 접속슬래브 폭.
|
||||
const SKEW = 95 + 2 / 60 + 52 / 3600; // 95°02′52″
|
||||
const GIRDER = 75 + 56 / 60 + 55.9 / 3600; // 거더 방향 CAD 75°56′55.9″
|
||||
const A1_WING: AbutInput = { ...A1, skewDeg: SKEW, slopeI: -0.0191, girderAzimuthDeg: GIRDER };
|
||||
|
||||
// A2 합성 예제 (spec §10.2): A1을 중심 C 기준으로 180° 돌리고, 받침 번호를 거꾸로 매겨 1 → n 방향이 A1과 같게 한다.
|
||||
const A2_WING: AbutInput = {
|
||||
name: 'A2',
|
||||
type: 'A2',
|
||||
seatEl: 44.367,
|
||||
ip1: { e: 179161.0318, n: 259932.3259 },
|
||||
ip2: { e: 179161.0417, n: 259932.3276 },
|
||||
ip3: { e: 179173.0573, n: 259930.2859 },
|
||||
bearings: [
|
||||
{ no: 1, pos: { e: 179161.9111, n: 259931.422 }, z: 44.492 },
|
||||
{ no: 2, pos: { e: 179164.3854, n: 259931.0021 }, z: 44.563 },
|
||||
{ no: 3, pos: { e: 179166.8597, n: 259930.5821 }, z: 44.634 },
|
||||
{ no: 4, pos: { e: 179169.334, n: 259930.1621 }, z: 44.705 },
|
||||
{ no: 5, pos: { e: 179171.8084, n: 259929.7421 }, z: 44.776 },
|
||||
],
|
||||
skewDeg: SKEW,
|
||||
slopeI: -0.0191,
|
||||
girderAzimuthDeg: GIRDER,
|
||||
};
|
||||
|
||||
describe('교대 날개벽 · 접속슬래브 폭 (AA2, spec_abut §4.7–4.8, §10)', () => {
|
||||
const w = buildAbut(A1_WING).wing!;
|
||||
|
||||
it('A1 wing line is 255.32° CAD, on the 배면 side (not the bridge side)', () => {
|
||||
expect(w.lineDeg).toBeCloseTo(255.32, 1);
|
||||
});
|
||||
|
||||
it('AW01: the wing line is within 1° of the girder direction (0.63°)', () => {
|
||||
expect(w.girderDiffDeg).toBeCloseTo(0.63, 1);
|
||||
expect(w.aw01Ok).toBe(true);
|
||||
});
|
||||
|
||||
it('A1 wing slope is +1.91 % (A1 = −i), wing rising away from the wall', () => {
|
||||
expect(w.slopePct).toBeCloseTo(1.91, 2);
|
||||
});
|
||||
|
||||
it('slab width 11.253 m with both general wings; 11.725 with one; 12.197 with none', () => {
|
||||
expect(w.slabWidthMm).toBeCloseTo(11253, -1);
|
||||
expect(buildAbut({ ...A1_WING, wings: { left: 'general', right: 'none' } }).wing!.slabWidthMm).toBeCloseTo(11725, -1);
|
||||
expect(buildAbut({ ...A1_WING, wings: { left: 'none', right: 'none' } }).wing!.slabWidthMm).toBeCloseTo(12197, -1);
|
||||
});
|
||||
|
||||
it('A2 synthetic: the bearings are on the right of 1 → n, so the layout matches A2 (AW03 clear)', () => {
|
||||
const m2 = buildAbut(A2_WING);
|
||||
expect(m2.frontLeftOfBearingDir).toBe(false);
|
||||
expect(m2.layoutMatchesType).toBe(true);
|
||||
expect(m2.bearings[0].s).toBeCloseTo(1.018, 2);
|
||||
expect(m2.bearings[4].s).toBeCloseTo(11.057, 2);
|
||||
});
|
||||
|
||||
it('A2 synthetic: the wing line is 75.32° (배면 쪽, 교량 반대쪽), not 255.32° (the wrong 1→n rotation)', () => {
|
||||
const w2 = buildAbut(A2_WING).wing!;
|
||||
expect(w2.lineDeg).toBeCloseTo(75.32, 1);
|
||||
expect(w2.aw01Ok).toBe(true);
|
||||
expect(w2.slopePct).toBeCloseTo(-1.91, 2);
|
||||
expect(w2.slabWidthMm).toBeCloseTo(11253, -1);
|
||||
});
|
||||
|
||||
it('without skew there is no wing block', () => {
|
||||
expect(buildAbut(A1).wing).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import * as XLSX from 'xlsx';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildAbut } from '../src/core/abut/geometry';
|
||||
import { parseAbutments, toAbutInput } from '../src/core/abut/parse';
|
||||
import { buildMembers } from '../src/core/abut/members';
|
||||
|
||||
const RAG = fileURLToPath(new URL('../../../RAG/운곡교P1입력.xlsx', import.meta.url));
|
||||
|
||||
// 운곡교 A1, 기준 입력 파일에서 읽음 (spec_abut §10.1).
|
||||
const input = toAbutInput(parseAbutments(XLSX.read(readFileSync(RAG), { type: 'buffer' })).abutments[0]);
|
||||
const model = buildAbut(input);
|
||||
const m = buildMembers(model, { bottomEl: input.foundationBottomEl, pileLengthMm: input.pileLengthMm });
|
||||
|
||||
describe('교대 A1 벽체 · 흉벽 · 저판 · 말뚝 (AA3, spec_abut §10.1)', () => {
|
||||
it('저판 상면 41.995 (기초 하단 40.895 + 두께 1.1)', () => {
|
||||
expect(m.footingTopEl).toBeCloseTo(41.995, 3);
|
||||
});
|
||||
|
||||
it('wall height ① 구간 2.372 m, five segments with the seat EL of each bearing', () => {
|
||||
expect(m.wallSegments).toHaveLength(5);
|
||||
const first = m.wallSegments.find((w) => Math.abs(w.seatEl - 44.367) < 1e-6)!;
|
||||
expect(first.heightM).toBeCloseTo(2.372, 3);
|
||||
});
|
||||
|
||||
it('wall segments tile the axis from 0 to L with the step boundaries', () => {
|
||||
const w = m.wallSegments;
|
||||
expect(w[0].sFrom).toBeCloseTo(0, 9);
|
||||
expect(w[w.length - 1].sTo).toBeCloseTo(model.length, 9);
|
||||
expect(w[0].sTo).toBeCloseTo(model.stepBounds[0], 9);
|
||||
for (let i = 1; i < w.length; i++) expect(w[i].sFrom).toBeCloseTo(w[i - 1].sTo, 9);
|
||||
});
|
||||
|
||||
it('흉벽 상단 EL 47.180 at IP1 end and 46.835 at IP3 end; slope −2.829 %', () => {
|
||||
expect(m.parapetTopStartEl).toBeCloseTo(47.18, 2);
|
||||
expect(m.parapetTopEndEl).toBeCloseTo(46.835, 2);
|
||||
expect(m.parapetSlope * 100).toBeCloseTo(-2.829, 2);
|
||||
});
|
||||
|
||||
it('말뚝 3열 × 6본 = 18, rows at t −1.15 / +0.85 / +2.15, spacing ≈ 2.179 m', () => {
|
||||
expect(m.piles.map((r) => r.t)).toEqual([-1.15, 0.85, 2.15]);
|
||||
expect(m.piles.every((r) => r.s.length === 6)).toBe(true);
|
||||
const gap = m.piles[0].s[1] - m.piles[0].s[0];
|
||||
expect(gap).toBeCloseTo(2.179, 2);
|
||||
expect(m.piles[0].s[0]).toBeCloseTo(0.65, 6);
|
||||
});
|
||||
|
||||
it('말뚝 선단 EL 19.295 (40.895 − 21.6 m)', () => {
|
||||
expect(m.pileBottomEl).toBeCloseTo(19.295, 3);
|
||||
});
|
||||
|
||||
it('without a foundation the footing and piles have no EL (no crash)', () => {
|
||||
const bare = buildMembers(model);
|
||||
expect(bare.footingBottomEl).toBeUndefined();
|
||||
expect(bare.pileBottomEl).toBeUndefined();
|
||||
expect(bare.piles[0].s).toHaveLength(6);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { structureOrder } from '../src/core/abut/order';
|
||||
|
||||
describe('구조물 목록 순서 (A1 · P1 … · A2)', () => {
|
||||
it('A1 first, piers in order, A2 last', () => {
|
||||
const list = structureOrder([{ name: 'A2', type: 'A2' }, { name: 'A1', type: 'A1' }], ['P2', 'P1']);
|
||||
expect(list.map((s) => s.name)).toEqual(['A1', 'P2', 'P1', 'A2']);
|
||||
expect(list[0].kind).toBe('abut');
|
||||
expect(list[1].kind).toBe('pier');
|
||||
expect(list[3].type).toBe('A2');
|
||||
});
|
||||
|
||||
it('no abutments: only the piers', () => {
|
||||
expect(structureOrder([], ['P1']).map((s) => s.name)).toEqual(['P1']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import * as XLSX from 'xlsx';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { ABUT_DETAIL_LABELS, abutInputRows, abutSummaryRows } from '../src/core/abut/summary';
|
||||
import { parseAbutments } from '../src/core/abut/parse';
|
||||
|
||||
const RAG = fileURLToPath(new URL('../../../RAG/운곡교P1입력.xlsx', import.meta.url));
|
||||
const rec = parseAbutments(XLSX.read(readFileSync(RAG), { type: 'buffer' })).abutments[0];
|
||||
|
||||
describe('교대 패널 행 (시안 입력값 카드 · 상세 축약)', () => {
|
||||
it('the 입력값 card has the nine rows of the mockup', () => {
|
||||
const rows = abutInputRows(rec);
|
||||
expect(rows).toHaveLength(9);
|
||||
expect(rows[1]).toEqual(['교좌면 EL (구간 ①)', '44.367 m']);
|
||||
expect(rows[0][1]).toMatch(/^A1 \(시점\) · /);
|
||||
expect(rows[8][1]).toBe('5개 · z = 패드 상면');
|
||||
});
|
||||
|
||||
it('the 상세 shows 8 rows, all of them computed rows of the summary', () => {
|
||||
const all = new Map(abutSummaryRows(rec));
|
||||
expect(ABUT_DETAIL_LABELS).toHaveLength(8);
|
||||
for (const label of ABUT_DETAIL_LABELS) expect(all.has(label)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import * as XLSX from 'xlsx';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildAbut } from '../src/core/abut/geometry';
|
||||
import { parseAbutments, toAbutInput } from '../src/core/abut/parse';
|
||||
|
||||
const RAG = fileURLToPath(new URL('../../../RAG/운곡교P1입력.xlsx', import.meta.url));
|
||||
const load = (buf: Buffer) => XLSX.read(buf, { type: 'buffer' });
|
||||
|
||||
describe('교대 입력 읽기 (운곡교P1입력.xlsx, AA 입력)', () => {
|
||||
const { abutments, issues } = parseAbutments(load(readFileSync(RAG)));
|
||||
const a1 = abutments.find((a) => a.name === 'A1')!;
|
||||
|
||||
it('reads A1 without issues', () => {
|
||||
expect(issues).toEqual([]);
|
||||
expect(abutments.map((a) => a.name)).toEqual(['A1']);
|
||||
expect(a1.type).toBe('A1');
|
||||
expect(a1.seatEl).toBeCloseTo(44.367, 3);
|
||||
expect(a1.foundation).toBe('말뚝기초');
|
||||
expect(a1.pileLengthMm).toBe(21600);
|
||||
expect(a1.skewDeg).toBeCloseTo(95 + 2 / 60 + 52 / 3600, 6);
|
||||
expect(a1.slopeI).toBeCloseTo(-0.0191, 6);
|
||||
});
|
||||
|
||||
it('IP1·IP2·IP3 use E = y_m, N = x_m (2026-11-03 규약)', () => {
|
||||
expect(a1.ip1.e).toBeCloseTo(179131.917023, 6);
|
||||
expect(a1.ip1.n).toBeCloseTo(259815.91973, 6);
|
||||
expect(a1.ip3.e).toBeCloseTo(179119.891533, 6);
|
||||
});
|
||||
|
||||
it('reads the five bearings with their own 교좌면 EL and the 받침높이 as written (275 mm)', () => {
|
||||
expect(a1.bearings.map((b) => b.no)).toEqual([1, 2, 3, 4, 5]);
|
||||
expect(a1.bearings.map((b) => b.seatEl)).toEqual([44.367, 44.438, 44.509, 44.58, 44.651]);
|
||||
expect(a1.bearingHeightMm).toBe(275);
|
||||
});
|
||||
|
||||
it('the parsed A1 gives the AA1 and AA2 values (length 12.197 m, wing 255.32°, slope +1.91 %)', () => {
|
||||
const m = buildAbut(toAbutInput(a1));
|
||||
expect(m.length).toBeCloseTo(12.197, 3);
|
||||
expect(m.layoutMatchesType).toBe(true);
|
||||
expect(m.wing!.lineDeg).toBeCloseTo(255.32, 1);
|
||||
expect(m.wing!.slopePct).toBeCloseTo(1.91, 2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('교대 입력 오류 표시', () => {
|
||||
const rows = (type: string, skew: string) => [
|
||||
['교대명', '형식', '교좌면\nEL_m', '기초 하단\nEL_m', '지반고\nEL_m', '기초 형식', '말뚝길이\n_mm', 'skew', '종단경사'],
|
||||
['A1', type, 44.367, 40.895, 33.984, '말뚝기초', 21600, skew, -0.0191],
|
||||
[],
|
||||
['교대명', 'IP점', 'x_m', 'y_m'],
|
||||
['A1', 'IP1', 259815.91973, 179131.917023],
|
||||
['', 'IP2', 259815.91805, 179131.907126],
|
||||
['', 'IP3', 259817.959709, 179119.891533],
|
||||
[],
|
||||
['교대명', '받침번호', 'x_m', 'y_m', 'z_m_EL', '방위각', '위치', '교좌면_EL', '받침높이'],
|
||||
['A1', 1, 259818.5035, 179121.1404, 44.492, "75°56'55.9\"", '전열', 44.367, 275],
|
||||
['', 2, 259818.0835, 179123.6148, 44.563, "75°57'14.1\"", '전열', 44.438, 275],
|
||||
];
|
||||
const book = (r: unknown[][]) => {
|
||||
const wb = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(r), '교대');
|
||||
return wb;
|
||||
};
|
||||
|
||||
it('a wrong 형식 (A3) is an error and the abutment is left out', () => {
|
||||
const r = parseAbutments(book(rows('A3', "95°02'52\"")));
|
||||
expect(r.issues.map((i) => i.code)).toContain('AP02');
|
||||
expect(r.abutments).toEqual([]);
|
||||
});
|
||||
|
||||
it('an unreadable skew is an error', () => {
|
||||
const r = parseAbutments(book(rows('A1', 'abc')));
|
||||
expect(r.issues.map((i) => i.code)).toContain('AP03');
|
||||
});
|
||||
|
||||
it('a clean sheet with the same layout reads with no issues', () => {
|
||||
const r = parseAbutments(book(rows('A1', "95°02'52\"")));
|
||||
expect(r.issues).toEqual([]);
|
||||
expect(r.abutments[0].bearings).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import * as XLSX from 'xlsx';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { parseAbutments } from '../src/core/abut/parse';
|
||||
import { parseProject, serializeProject, SCHEMA_VERSION, type ProjectFile } from '../src/core/project';
|
||||
import { loadP1 } from './helpers';
|
||||
|
||||
const RAG = fileURLToPath(new URL('../../../RAG/운곡교P1입력.xlsx', import.meta.url));
|
||||
const abutments = parseAbutments(XLSX.read(readFileSync(RAG), { type: 'buffer' })).abutments;
|
||||
|
||||
function project(extra: Partial<ProjectFile> = {}): ProjectFile {
|
||||
return {
|
||||
schemaVersion: SCHEMA_VERSION,
|
||||
project: { name: '운곡교 교각', bridge: '운곡교', client: '', author: '', crs: 'KATEC', memo: '' },
|
||||
source: { fileName: '운곡교P1입력.xlsx', sheets: ['교각', 'IP', '받침'] },
|
||||
piers: [{ id: 'P1', input: loadP1() }],
|
||||
history: [],
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
describe('교대 기록의 저장과 불러오기 (AB)', () => {
|
||||
it('the A1 record survives a save and open unchanged', () => {
|
||||
const text = serializeProject(project({ abutments }));
|
||||
const back = parseProject(text);
|
||||
expect(back.abutments).toEqual(abutments);
|
||||
});
|
||||
|
||||
it('an old file without abutments opens with none', () => {
|
||||
const back = parseProject(serializeProject(project()));
|
||||
expect(back.abutments).toBeUndefined();
|
||||
});
|
||||
|
||||
it('a damaged abutment record is refused and the message names it', () => {
|
||||
const bad = JSON.parse(JSON.stringify(abutments)) as typeof abutments;
|
||||
bad[0].seatEl = NaN as unknown as number;
|
||||
expect(() => parseProject(serializeProject(project({ abutments: bad })))).toThrow('A1');
|
||||
});
|
||||
|
||||
it('a wrong 형식 is refused', () => {
|
||||
const bad = JSON.parse(JSON.stringify(abutments)) as typeof abutments;
|
||||
(bad[0] as unknown as { type: string }).type = 'A3';
|
||||
expect(() => parseProject(serializeProject(project({ abutments: bad })))).toThrow('A1 또는 A2');
|
||||
});
|
||||
|
||||
it('the imported defaults are saved and opened with the edited values', () => {
|
||||
const edited = abutments.map((a) => ({ ...a, groundEl: 34.5 }));
|
||||
const back = parseProject(serializeProject(project({ abutments: edited, abutDefaults: abutments })));
|
||||
expect(back.abutments![0].groundEl).toBe(34.5);
|
||||
expect(back.abutDefaults).toEqual(abutments);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import * as XLSX from 'xlsx';
|
||||
import * as THREE from 'three';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildAbut } from '../src/core/abut/geometry';
|
||||
import { parseAbutments, toAbutInput } from '../src/core/abut/parse';
|
||||
import { buildMembers } from '../src/core/abut/members';
|
||||
import { buildWings } from '../src/core/abut/wings';
|
||||
import { abutPlacement, buildAbutGroup, cameraForBox, covers } from '../src/view/abut-scene';
|
||||
import type { TerrainGrid } from '../src/core/ground-model';
|
||||
|
||||
const RAG = fileURLToPath(new URL('../../../RAG/운곡교P1입력.xlsx', import.meta.url));
|
||||
const input = toAbutInput(parseAbutments(XLSX.read(readFileSync(RAG), { type: 'buffer' })).abutments[0]);
|
||||
const GROUND_EL = 33.984; // 운곡교 A1 지반고 (입력 파일, spec_abut §10.1)
|
||||
const model = buildAbut(input);
|
||||
const members = buildMembers(model, { bottomEl: input.foundationBottomEl, pileLengthMm: input.pileLengthMm });
|
||||
const wings = buildWings(model, members);
|
||||
const group = buildAbutGroup(model, members, wings);
|
||||
|
||||
const count = (name: string) => (group.getObjectByName(name) as THREE.Group | undefined)?.children.length ?? 0;
|
||||
|
||||
describe('교대 A1 3D 장면 (AA3·AA3b·AA3c)', () => {
|
||||
it('has the members: 5 wall segments, 1 parapet, 1 footing, 18 piles, 2 wings, 4 haunches', () => {
|
||||
expect(count('wall')).toBe(5);
|
||||
expect(count('parapet')).toBe(1);
|
||||
expect(count('footing')).toBe(1);
|
||||
expect(count('piles')).toBe(18);
|
||||
expect(count('wings')).toBe(2);
|
||||
expect(count('haunches')).toBe(4);
|
||||
});
|
||||
|
||||
it('spans from the pile tip (19.295 m) up to the highest parapet end (47.18 m)', () => {
|
||||
const box = new THREE.Box3().setFromObject(group);
|
||||
expect(box.min.y).toBeCloseTo(19.295, 2);
|
||||
expect(box.max.y).toBeGreaterThan(47.0);
|
||||
expect(box.max.y).toBeLessThan(48.0);
|
||||
});
|
||||
|
||||
it('spans the axis from IP1 (s = 0) to IP3 (s = L) along x', () => {
|
||||
const box = new THREE.Box3().setFromObject(group);
|
||||
expect(box.min.x).toBeLessThanOrEqual(0.01);
|
||||
expect(box.max.x).toBeGreaterThanOrEqual(model.length - 0.01);
|
||||
});
|
||||
|
||||
it('a group without foundation (no footing or piles) still builds', () => {
|
||||
const bare = buildAbutGroup(model, buildMembers(model), buildWings(model, buildMembers(model)));
|
||||
expect((bare.getObjectByName('footing') as THREE.Group | undefined)).toBeUndefined();
|
||||
expect(bare.getObjectByName('wall')).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('교대 배치 (교각 장면과 같은 좌표계)', () => {
|
||||
const pivot = { e: 179138.0886505, n: 259865.6772855 };
|
||||
|
||||
it('the IP3 end maps to the pier-scene position of IP3 (E − pivot.E, EL, −(N − pivot.N))', () => {
|
||||
const v = new THREE.Vector3(model.length, 0, 0).applyMatrix4(abutPlacement(model, pivot));
|
||||
expect(v.x).toBeCloseTo(input.ip3.e - pivot.e, 4);
|
||||
expect(v.z).toBeCloseTo(-(input.ip3.n - pivot.n), 4);
|
||||
});
|
||||
|
||||
it('the IP1 end maps to the pier-scene position of IP1', () => {
|
||||
const v = new THREE.Vector3(0, 0, 0).applyMatrix4(abutPlacement(model, pivot));
|
||||
expect(v.x).toBeCloseTo(model.ip1.e - pivot.e, 4);
|
||||
expect(v.z).toBeCloseTo(-(model.ip1.n - pivot.n), 4);
|
||||
});
|
||||
|
||||
it('a placed group keeps the local parts: the parapet end at IP3 stays at 46.835 m', () => {
|
||||
const placed = buildAbutGroup(model, members, wings, pivot);
|
||||
const box = new THREE.Box3().setFromObject(placed);
|
||||
expect(box.max.y).toBeGreaterThan(47.0);
|
||||
expect(placed.matrixAutoUpdate).toBe(true);
|
||||
placed.updateMatrixWorld(true);
|
||||
// The placed group must sit at IP3 in the pier frame (x = E − pivot.E, z = −(N − pivot.N)).
|
||||
const ip3 = new THREE.Vector3(input.ip3.e - pivot.e, 0, -(input.ip3.n - pivot.n));
|
||||
const boxAtIp3 = new THREE.Box3().setFromObject(placed);
|
||||
expect(boxAtIp3.containsPoint(new THREE.Vector3(ip3.x, 40, ip3.z))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('교대 지반 (지반고 pad)', () => {
|
||||
const pivot = { e: 179138.0886505, n: 259865.6772855 };
|
||||
|
||||
it('the ground pad sits at 지반고 33.984 m and covers the axis and the wings', () => {
|
||||
const g = buildAbutGroup(model, members, wings, pivot, GROUND_EL);
|
||||
const ground = g.getObjectByName('ground') as THREE.Group;
|
||||
expect(ground).toBeDefined();
|
||||
const box = new THREE.Box3().setFromObject(ground);
|
||||
expect(box.min.y).toBeCloseTo(GROUND_EL - 0.025, 3);
|
||||
expect(box.max.y).toBeCloseTo(GROUND_EL + 0.025, 3);
|
||||
// Covers the axis length plus the margin on both ends (x is the axis direction).
|
||||
expect(box.max.x - box.min.x).toBeGreaterThan(model.length);
|
||||
});
|
||||
|
||||
it('without a 지반고 there is no ground pad', () => {
|
||||
const g = buildAbutGroup(model, members, wings, pivot);
|
||||
expect(g.getObjectByName('ground')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('교대 지형 모델', () => {
|
||||
const pivot = { e: 179138.0886505, n: 259865.6772855 };
|
||||
// A flat terrain grid at 33.984 m: N (origin[0]) 259800…259899, E (origin[1]) 179100…179199, 1 m step.
|
||||
const grid: TerrainGrid = { origin: [259800, 179100], step: 1, nx: 100, ny: 100, z: Array(100 * 100).fill(33.984) };
|
||||
const small: TerrainGrid = { origin: [259800, 179100], step: 1, nx: 10, ny: 10, z: Array(100).fill(33.984) };
|
||||
|
||||
it('covers() is true when the footprint lies inside the grid, false outside', () => {
|
||||
expect(covers(grid, [model.ip1, { e: model.ip1.e, n: model.ip1.n }], 2)).toBe(true);
|
||||
expect(covers(small, [model.ip1], 2)).toBe(false);
|
||||
});
|
||||
|
||||
it('a covering terrain replaces the flat pad', () => {
|
||||
const g = buildAbutGroup(model, members, wings, pivot, GROUND_EL, grid);
|
||||
const ground = g.getObjectByName('ground') as THREE.Group;
|
||||
const mesh = ground.children[0] as THREE.Mesh;
|
||||
expect(mesh.geometry.type).toBe('BufferGeometry');
|
||||
});
|
||||
|
||||
it('a terrain that does not cover the abutment falls back to the flat pad', () => {
|
||||
const g = buildAbutGroup(model, members, wings, pivot, 33.984, small);
|
||||
const mesh = (g.getObjectByName('ground') as THREE.Group).children[0] as THREE.Mesh;
|
||||
expect(mesh.geometry.type).toBe('BoxGeometry');
|
||||
});
|
||||
});
|
||||
|
||||
describe('교대 선택 시 카메라', () => {
|
||||
it('the camera target is the centre of the abutment box and the camera stands back from it', () => {
|
||||
const box = new THREE.Box3(new THREE.Vector3(0, 19, -2), new THREE.Vector3(12, 47, 2));
|
||||
const cam = cameraForBox(box);
|
||||
expect(cam.target.x).toBeCloseTo(6, 6);
|
||||
expect(cam.target.y).toBeCloseTo(33, 6);
|
||||
expect(cam.position.distanceTo(cam.target)).toBeGreaterThan(12);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { ABUT_SHAPE_DEFAULT, resolveShape, shapeIssues } from '../src/core/abut/shape';
|
||||
|
||||
describe('교대 형상 입력 (시안 ② 벽체·흉벽 · 확대기초·말뚝)', () => {
|
||||
it('the defaults equal the spec values (wall +1.6 … −0.8, footing +2.8 … −1.8, piles 650 / 2000)', () => {
|
||||
const r = resolveShape();
|
||||
expect(r.wallFrontM).toBeCloseTo(1.6, 9);
|
||||
expect(r.wallBackM).toBeCloseTo(-0.8, 9);
|
||||
expect(r.parapetBackM).toBeCloseTo(-0.5, 9);
|
||||
expect(r.footingFrontM).toBeCloseTo(2.8, 9);
|
||||
expect(r.footingBackM).toBeCloseTo(-1.8, 9);
|
||||
expect(r.pileEdgeM).toBeCloseTo(0.65, 9);
|
||||
expect(shapeIssues({})).toEqual([]);
|
||||
expect(ABUT_SHAPE_DEFAULT.wallThicknessMm).toBe(2400);
|
||||
});
|
||||
|
||||
it('a changed wall thickness moves the back face (front − thickness)', () => {
|
||||
expect(resolveShape({ wallThicknessMm: 2000 }).wallBackM).toBeCloseTo(-0.4, 9);
|
||||
});
|
||||
|
||||
it('refuses a footing that does not cover the wall, and non-positive sizes', () => {
|
||||
expect(shapeIssues({ footingBackMm: -500 }).join(' ')).toContain('뒷굽');
|
||||
expect(shapeIssues({ footingFrontMm: 1000 }).join(' ')).toContain('앞굽');
|
||||
expect(shapeIssues({ wallThicknessMm: 0 }).join(' ')).toContain('벽체 두께');
|
||||
expect(shapeIssues({ pileSpacingMm: 0 }).join(' ')).toContain('말뚝');
|
||||
});
|
||||
});
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import * as XLSX from 'xlsx';
|
||||
import { applyShapeEdit, SHAPE_EDIT_FIELDS } from '../src/core/abut/edit';
|
||||
import { buildAbut } from '../src/core/abut/geometry';
|
||||
import { buildMembers } from '../src/core/abut/members';
|
||||
import { parseAbutments } from '../src/core/abut/parse';
|
||||
|
||||
const RAG = fileURLToPath(new URL('../../../RAG/운곡교P1입력.xlsx', import.meta.url));
|
||||
const rec = parseAbutments(XLSX.read(readFileSync(RAG), { type: 'buffer' })).abutments[0];
|
||||
|
||||
describe('형상 입력이 계산에 반영됨 (시안 ② 카드)', () => {
|
||||
it('the card has one field per shape value, in the mockup order', () => {
|
||||
expect(SHAPE_EDIT_FIELDS.map((f) => f.key)[0]).toBe('wallFrontMm');
|
||||
expect(SHAPE_EDIT_FIELDS).toHaveLength(10);
|
||||
});
|
||||
|
||||
it('a footing that does not cover the wall is refused by the edit rule', () => {
|
||||
expect(() => applyShapeEdit(rec, 'footingBackMm', -500)).toThrow('뒷굽');
|
||||
expect(rec.shape).toBeUndefined();
|
||||
});
|
||||
|
||||
it('a changed footing thickness moves the footing top used by the walls', () => {
|
||||
const edited = applyShapeEdit(rec, 'footingThicknessMm', 1300);
|
||||
const model = buildAbut({ name: 'A1', type: 'A1', 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 })) });
|
||||
const base = buildMembers(model, { bottomEl: rec.foundationBottomEl, pileLengthMm: rec.pileLengthMm });
|
||||
const more = buildMembers(model, { bottomEl: rec.foundationBottomEl, pileLengthMm: rec.pileLengthMm }, edited.shape);
|
||||
expect(more.footingTopEl - base.footingTopEl).toBeCloseTo(0.2, 6);
|
||||
});
|
||||
|
||||
it('a changed pile spacing changes the number of piles per row', () => {
|
||||
const model = buildAbut({ name: 'A1', type: 'A1', 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 })) });
|
||||
const wider = buildMembers(model, {}, { pileSpacingMm: 3000 });
|
||||
expect(wider.piles[0].s.length).toBeLessThan(6);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import * as XLSX from 'xlsx';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { abutSummaryRows } from '../src/core/abut/summary';
|
||||
import { parseAbutments } from '../src/core/abut/parse';
|
||||
|
||||
const RAG = fileURLToPath(new URL('../../../RAG/운곡교P1입력.xlsx', import.meta.url));
|
||||
const rec = parseAbutments(XLSX.read(readFileSync(RAG), { type: 'buffer' })).abutments[0];
|
||||
const rows = new Map(abutSummaryRows(rec));
|
||||
|
||||
describe('교대 상세 (삽도 항목, spec_abut §10.1)', () => {
|
||||
it('the 삽도 values of A1: 저판 상면 41.995, 벽체 2.372 m, 브라켓 상면 46.017, 흉벽 상단 47.180 (IP1 끝) / 46.835 (IP3 끝)', () => {
|
||||
expect(rows.get('저판 상면 EL')).toBe('41.995 m');
|
||||
expect(rows.get('벽체 높이 (구간 ①)')).toBe('2.372 m');
|
||||
expect(rows.get('브라켓 상면 EL (구간 ①)')).toBe('46.017 m');
|
||||
expect(rows.get('흉벽 상단 EL (IP1 끝 / IP3 끝)')).toBe('47.180 / 46.835 m');
|
||||
});
|
||||
|
||||
it('the piles, joints, 접속슬래브 width and 받침블럭 height match the spec: 18 piles, tip 19.295, 1 joint, 11253 mm, 125 mm', () => {
|
||||
expect(rows.get('말뚝 (열 × 본수)')).toBe('3 × 6 = 18 본');
|
||||
expect(rows.get('말뚝 선단 EL')).toBe('19.295 m');
|
||||
expect(rows.get('수축줄눈 (ceil(L/10) − 1)')).toBe('1 개');
|
||||
// 11.2535 m exactly (450 / sin Δ + 20 per wing); rounding to mm gives 11253 or 11254.
|
||||
expect(Math.abs(parseInt(String(rows.get('접속슬래브 폭')), 10) - 11253)).toBeLessThanOrEqual(1);
|
||||
expect(rows.get('받침블럭 높이 (패드 포함)')).toBe('125 mm');
|
||||
});
|
||||
|
||||
it('the 계단 경계 from IP3 are 2.395 · 4.905 · 7.415 · 9.924 m', () => {
|
||||
expect(rows.get('계단 경계 (IP3 쪽에서)')).toBe('2.395 / 4.905 / 7.415 / 9.924 m');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import * as XLSX from 'xlsx';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildAbut, type AbutInput } from '../src/core/abut/geometry';
|
||||
import { parseAbutments, toAbutInput } from '../src/core/abut/parse';
|
||||
import { buildMembers } from '../src/core/abut/members';
|
||||
import { buildWings, WING_LENGTH_M } from '../src/core/abut/wings';
|
||||
|
||||
const RAG = fileURLToPath(new URL('../../../RAG/운곡교P1입력.xlsx', import.meta.url));
|
||||
const a1Input = toAbutInput(parseAbutments(XLSX.read(readFileSync(RAG), { type: 'buffer' })).abutments[0]);
|
||||
const SKEW = 95 + 2 / 60 + 52 / 3600;
|
||||
const GIRDER = 75 + 56 / 60 + 55.9 / 3600;
|
||||
|
||||
// A2 합성 예제 (spec §10.2): ① 쪽이 IP1 끝.
|
||||
const a2Input: AbutInput = {
|
||||
name: 'A2',
|
||||
type: 'A2',
|
||||
seatEl: 44.367,
|
||||
ip1: { e: 179161.0318, n: 259932.3259 },
|
||||
ip2: { e: 179161.0417, n: 259932.3276 },
|
||||
ip3: { e: 179173.0573, n: 259930.2859 },
|
||||
bearings: [
|
||||
{ no: 1, pos: { e: 179161.9111, n: 259931.422 }, z: 44.492 },
|
||||
{ no: 2, pos: { e: 179164.3854, n: 259931.0021 }, z: 44.563 },
|
||||
{ no: 3, pos: { e: 179166.8597, n: 259930.5821 }, z: 44.634 },
|
||||
{ no: 4, pos: { e: 179169.334, n: 259930.1621 }, z: 44.705 },
|
||||
{ no: 5, pos: { e: 179171.8084, n: 259929.7421 }, z: 44.776 },
|
||||
],
|
||||
skewDeg: SKEW,
|
||||
slopeI: -0.0191,
|
||||
girderAzimuthDeg: GIRDER,
|
||||
foundationBottomEl: 40.895,
|
||||
pileLengthMm: 21600,
|
||||
};
|
||||
|
||||
describe('교대 A2 합성 · 날개벽 (AA3b, spec_abut §10.2)', () => {
|
||||
const model = buildAbut(a2Input);
|
||||
const members = buildMembers(model, { bottomEl: 40.895, pileLengthMm: 21600 });
|
||||
const [left, right] = buildWings(model, members);
|
||||
|
||||
it('좌 날개벽 (① 쪽, IP1 끝): top 46.838 at the start, 46.714 at the end, bottom end 45.214', () => {
|
||||
expect(left.side).toBe('left');
|
||||
expect(left.sEnd).toBeCloseTo(0, 9);
|
||||
expect(left.topStartEl).toBeCloseTo(46.838, 2);
|
||||
expect(left.topEndEl).toBeCloseTo(46.714, 2);
|
||||
expect(left.bottomEndEl).toBeCloseTo(45.214, 2);
|
||||
});
|
||||
|
||||
it('우 날개벽 (⑤ 쪽, IP3 끝): top 47.183 → 47.059, bottom end 45.559', () => {
|
||||
expect(right.side).toBe('right');
|
||||
expect(right.sEnd).toBeCloseTo(model.length, 9);
|
||||
expect(right.topStartEl).toBeCloseTo(47.183, 2);
|
||||
expect(right.topEndEl).toBeCloseTo(47.059, 2);
|
||||
expect(right.bottomEndEl).toBeCloseTo(45.559, 2);
|
||||
});
|
||||
|
||||
it('the wing is 6.5 m long along the wing line and 0.45 m thick inward', () => {
|
||||
const [p0, p1] = left.corners;
|
||||
expect(Math.hypot(p1.e - p0.e, p1.n - p0.n)).toBeCloseTo(WING_LENGTH_M, 6);
|
||||
const [, , q2, q3] = left.corners;
|
||||
expect(Math.hypot(q2.e - p1.e, q2.n - p1.n)).toBeCloseTo(0.45, 6);
|
||||
expect(Math.hypot(q3.e - p0.e, q3.n - p0.n)).toBeCloseTo(0.45, 6);
|
||||
});
|
||||
|
||||
it('the wing starts at the axis end with the wing direction on the 배면 side (AA2 line 75.32°)', () => {
|
||||
const [p0, p1] = left.corners;
|
||||
const deg = (Math.atan2(p1.n - p0.n, p1.e - p0.e) * 180) / Math.PI;
|
||||
const cad = ((deg % 360) + 360) % 360;
|
||||
expect(cad).toBeCloseTo(75.32, 1);
|
||||
});
|
||||
|
||||
it('a 단축형 wing bottom sits at the footing top (41.995)', () => {
|
||||
const short = buildWings(model, members, { left: 'short', right: 'none' });
|
||||
expect(short).toHaveLength(1);
|
||||
expect(short[0].bottomEndEl).toBeCloseTo(41.995, 3);
|
||||
});
|
||||
|
||||
it('no wing when the side is 없음', () => {
|
||||
expect(buildWings(model, members, { left: 'none', right: 'none' })).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('교대 A1 날개벽 (운곡교P1입력.xlsx)', () => {
|
||||
const model = buildAbut(a1Input);
|
||||
const members = buildMembers(model, { bottomEl: a1Input.foundationBottomEl, pileLengthMm: a1Input.pileLengthMm });
|
||||
const wings = buildWings(model, members);
|
||||
|
||||
it('A1 has two general wings; ① 쪽 wing starts at the IP3 end (the bearing ① is near IP3)', () => {
|
||||
expect(wings.map((w) => w.side)).toEqual(['left', 'right']);
|
||||
const left = wings[0];
|
||||
expect(left.sEnd).toBeCloseTo(model.length, 9);
|
||||
expect(left.topStartEl).toBeCloseTo(46.835, 2);
|
||||
// A1 날개벽 경사는 +1.91 % (벽체에서 멀어질수록 높아짐): 끝이 0.124 m 높다.
|
||||
expect(left.topEndEl - left.topStartEl).toBeCloseTo(0.124, 2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('교대 날개벽 하단 선 · 헌치 (AA3c, spec_abut §4.7)', () => {
|
||||
const a1 = buildAbut(a1Input);
|
||||
const a1m = buildMembers(a1, { bottomEl: a1Input.foundationBottomEl, pileLengthMm: a1Input.pileLengthMm });
|
||||
const [a1Left] = buildWings(a1, a1m);
|
||||
const a2 = buildAbut(a2Input);
|
||||
const a2m = buildMembers(a2, { bottomEl: 40.895, pileLengthMm: 21600 });
|
||||
const [a2Left] = buildWings(a2, a2m);
|
||||
|
||||
it('뒷굽 끝까지의 날개벽 거리 1.807 m (단축형 표와 같다)', () => {
|
||||
expect(a1Left.heelDistanceM).toBeCloseTo(1.807, 2);
|
||||
expect(a2Left.heelDistanceM).toBeCloseTo(1.807, 2);
|
||||
});
|
||||
|
||||
it('하단선: 뒷굽 끝까지 저판 상면 41.995, 그 밖은 단부 하단까지 직선 (A2 좌: 45.214)', () => {
|
||||
const line = a2Left.bottomLine;
|
||||
expect(line[0].el).toBeCloseTo(41.995, 3);
|
||||
expect(line[1].dM).toBeCloseTo(1.807, 2);
|
||||
expect(line[1].el).toBeCloseTo(41.995, 3);
|
||||
expect(line[2].el).toBeCloseTo(45.214, 2);
|
||||
});
|
||||
|
||||
it('two 300 × 300 haunches: upper at the 흉벽 배면, lower at the 벽체 배면, legs 0.3 m', () => {
|
||||
expect(a2Left.haunches.map((h) => h.kind)).toEqual(['upper', 'lower']);
|
||||
const [c0, c1, c2] = a2Left.haunches[0].corners;
|
||||
expect(Math.hypot(c1.e - c0.e, c1.n - c0.n)).toBeCloseTo(0.3, 6);
|
||||
expect(Math.hypot(c2.e - c0.e, c2.n - c0.n)).toBeCloseTo(0.3, 6);
|
||||
});
|
||||
|
||||
it('upper haunch runs from the bracket top (흉벽 상단 − 0.85) to the wing top; lower from the footing top to the bracket top', () => {
|
||||
const [up, low] = a2Left.haunches;
|
||||
expect(up.zBottomEl).toBeCloseTo(46.838 - 0.85, 2);
|
||||
expect(low.zBottomEl).toBeCloseTo(41.995, 3);
|
||||
expect(low.zTopEl).toBeCloseTo(up.zBottomEl, 6);
|
||||
expect(up.zTopEl).toBeGreaterThan(up.zBottomEl);
|
||||
});
|
||||
});
|
||||
|
||||
describe('날개벽 수평 단 (QA-12 초안 적용, spec_abut §4.7)', () => {
|
||||
const a2 = buildAbut(a2Input);
|
||||
const a2m = buildMembers(a2, { bottomEl: 40.895, pileLengthMm: 21600 });
|
||||
const [a2Left] = buildWings(a2, a2m);
|
||||
|
||||
it('등단면 down to 1.5 m below the wing end, then 변화단면1 to the seat EL and 변화단면2 to the footing top', () => {
|
||||
const [equal, v1, v2] = a2Left.bands;
|
||||
expect(equal.name).toBe('등단면');
|
||||
expect(equal.zBottomEl).toBeCloseTo(45.214, 2);
|
||||
expect(v1.name).toBe('변화단면1');
|
||||
expect(v1.zBottomEl).toBeCloseTo(44.367, 3);
|
||||
expect(v2.name).toBe('변화단면2');
|
||||
expect(v2.zBottomEl).toBeCloseTo(41.995, 3);
|
||||
});
|
||||
|
||||
it('bands are contiguous from the wing top to the footing top', () => {
|
||||
const b = buildWings(a2, a2m)[0].bands;
|
||||
for (let i = 1; i < b.length; i++) expect(b[i].zTopEl).toBeCloseTo(b[i - 1].zBottomEl, 6);
|
||||
expect(b[b.length - 1].zBottomEl).toBeCloseTo(41.995, 3);
|
||||
});
|
||||
});
|
||||
+2
-2
@@ -21,8 +21,8 @@ describe('straight axis (regression against spec §12)', () => {
|
||||
expect(fs[fs.length - 1].s - bp1).toBeCloseTo(10.965, 3);
|
||||
expect(bs[0].s - bp1).toBeCloseTo(1.029, 3);
|
||||
expect(bs[bs.length - 1].s - bp1).toBeCloseTo(11.05, 3);
|
||||
expect(fs[0].n).toBeCloseTo(-0.698, 3);
|
||||
expect(bs[0].n).toBeCloseTo(0.6, 3);
|
||||
expect(fs[0].n).toBeCloseTo(0.698, 3);
|
||||
expect(bs[0].n).toBeCloseTo(-0.6, 3);
|
||||
});
|
||||
|
||||
it('straight axis: every point is on stretch 0 and round-trips', () => {
|
||||
|
||||
@@ -20,7 +20,7 @@ describe('bearing position / direction coordinate agreement', () => {
|
||||
});
|
||||
|
||||
it('keeps every real Sanwoe bearing approximately aligned with its coping', () => {
|
||||
const dir = fileURLToPath(new URL('../pierzainer_ground/', import.meta.url));
|
||||
const dir = fileURLToPath(new URL('../pierzainer_ground/data/input/sanwoe/', import.meta.url));
|
||||
const name = readdirSync(dir).find((name) => name.includes('산외교') && name.endsWith('.xlsx'))!;
|
||||
const { piers, issues } = parseWorkbook(XLSX.read(readFileSync(`${dir}/${name}`), { type: 'buffer' }));
|
||||
expect(issues).toEqual([]);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildPier } from '../src/core/geometry';
|
||||
import { bearingSectionSvg } from '../src/view/bearing-section-svg';
|
||||
import { solplateDetailSvg } from '../src/view/solplate-svg';
|
||||
import { loadP1 } from './helpers';
|
||||
|
||||
const model = buildPier(loadP1());
|
||||
@@ -22,14 +21,3 @@ describe('교량받침 상세 단면 (승인 2026-10-26, C안 2026-11-03)', () =
|
||||
expect(bearingSectionSvg(model, 99)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('솔플레이트 상세 (승인 2026-10-26, C안 2026-11-03)', () => {
|
||||
it('shows L, W, T1 and T2 of the pier plan', () => {
|
||||
const block = model.blocks.find((b) => b.no === 1)!;
|
||||
const svg = solplateDetailSvg(block);
|
||||
expect(svg).toContain('L 1000');
|
||||
expect(svg).toContain('W 1100');
|
||||
expect(svg).toContain('T1 27.5');
|
||||
expect(svg).toContain('T2 22.5');
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildPier } from '../src/core/geometry';
|
||||
import { elevationRows, solplateRows } from '../src/core/bearing-table';
|
||||
import { elevationRows, pairCopingSpans, solplateRows } from '../src/core/bearing-table';
|
||||
import { loadP1 } from './helpers';
|
||||
|
||||
const model = buildPier(loadP1());
|
||||
@@ -55,3 +55,19 @@ describe('솔플레이트 TABLE (모델값, 2026-11-03)', () => {
|
||||
expect(rows[0].checkRhs).toBeCloseTo(5, 9);
|
||||
});
|
||||
});
|
||||
|
||||
describe('S1·S2 코핑 치수 (2026-11-03)', () => {
|
||||
it('S1 + S2 + S1 = the coping width B (3100); S1 is bearing centre to the coping edge', () => {
|
||||
const s = pairCopingSpans(model, 0)!;
|
||||
expect(s.copingWidth).toBe(3100);
|
||||
expect(s.S1Low + s.S2 + s.S1High).toBe(3100);
|
||||
// Pair 1 · 6 (P1): the 후열 sits at n −0.600 (low side), the 전열 at n +0.698 (high side). S1 low 950 (후열), S2 1298, S1 high 852 (전열).
|
||||
expect(s.S1Low).toBe(950);
|
||||
expect(s.S2).toBe(1298);
|
||||
expect(s.S1High).toBe(852);
|
||||
});
|
||||
|
||||
it('an unknown pair gives null', () => {
|
||||
expect(pairCopingSpans(model, 99)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import * as THREE from 'three';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { fitCamera } from '../src/view/fit';
|
||||
|
||||
describe('카메라 맞춤 (전체 모델이 화면에 들어옴)', () => {
|
||||
const sphere = new THREE.Sphere(new THREE.Vector3(10, 30, 0), 50);
|
||||
|
||||
it('the target moves to the sphere centre and the sphere lies inside the view cone', () => {
|
||||
const cam = fitCamera(new THREE.Vector3(1, 1, 1).multiplyScalar(20), new THREE.Vector3(), sphere, 40, 1.6);
|
||||
expect(cam.target.distanceTo(sphere.center)).toBeLessThan(1e-9);
|
||||
// The distance from the camera to the centre must cover the radius at the vertical half-angle.
|
||||
const d = cam.position.distanceTo(sphere.center);
|
||||
expect(d * Math.sin(THREE.MathUtils.degToRad(20))).toBeGreaterThanOrEqual(sphere.radius - 1e-6);
|
||||
});
|
||||
|
||||
it('a narrow window (aspect < 1) backs off further than a wide one', () => {
|
||||
const wide = fitCamera(new THREE.Vector3(0, 0, 100), new THREE.Vector3(), sphere, 40, 2);
|
||||
const narrow = fitCamera(new THREE.Vector3(0, 0, 100), new THREE.Vector3(), sphere, 40, 0.5);
|
||||
expect(narrow.position.distanceTo(sphere.center)).toBeGreaterThan(wide.position.distanceTo(sphere.center));
|
||||
});
|
||||
|
||||
it('a camera already far enough keeps its distance', () => {
|
||||
const far = new THREE.Vector3(0, 0, 1000);
|
||||
const cam = fitCamera(far, new THREE.Vector3(), sphere, 40, 1.6);
|
||||
// The distance is kept from the old target (origin): 1000 m, measured from the new centre.
|
||||
expect(cam.position.distanceTo(sphere.center)).toBeCloseTo(1000, 6);
|
||||
});
|
||||
});
|
||||
@@ -33,8 +33,9 @@ describe('일체 블록 모델 (승인 2026-10-26)', () => {
|
||||
const f = model.blocks.find((b) => b.no === 1)!;
|
||||
const b = model.blocks.find((x) => x.no === 6)!;
|
||||
const half = INTEGRATED_BLOCK.length / 2000;
|
||||
expect(ib.n - half).toBeCloseTo(f.n - 0.6, 2);
|
||||
expect(ib.n + half).toBeCloseTo(b.n + 0.6, 2);
|
||||
// x = 북 convention (2026-11-03): the front row is on the high-n side, so the block spans min..max of the pair.
|
||||
expect(ib.n - half).toBeCloseTo(Math.min(f.n, b.n) - 0.6, 2);
|
||||
expect(ib.n + half).toBeCloseTo(Math.max(f.n, b.n) + 0.6, 2);
|
||||
});
|
||||
|
||||
it('분리 keeps no integrated blocks', () => {
|
||||
|
||||
+10
-10
@@ -12,14 +12,14 @@ const model = buildPier(loadP1());
|
||||
describe('P1 regression — spec.md §12', () => {
|
||||
it('axis length and azimuth', () => {
|
||||
expect(model.lengthIp).toBeCloseTo(12.166, 3);
|
||||
expect(model.azimuthDeg).toBeCloseTo(350.37, 2);
|
||||
expect(model.azimuthDeg).toBeCloseTo(99.63, 2);
|
||||
});
|
||||
|
||||
it('BP1, BP2 coordinates (국토좌표 E, N)', () => {
|
||||
expect(model.bp1.e).toBeCloseTo(259866.681, 2);
|
||||
expect(model.bp1.n).toBeCloseTo(179132.173, 2);
|
||||
expect(Math.abs(model.bp2.e - 259864.674)).toBeLessThan(TOL);
|
||||
expect(Math.abs(model.bp2.n - 179144.004)).toBeLessThan(TOL);
|
||||
expect(model.bp1.e).toBeCloseTo(179132.173, 2);
|
||||
expect(model.bp1.n).toBeCloseTo(259866.681, 2);
|
||||
expect(Math.abs(model.bp2.e - 179144.004)).toBeLessThan(TOL);
|
||||
expect(Math.abs(model.bp2.n - 259864.674)).toBeLessThan(TOL);
|
||||
});
|
||||
|
||||
it('IP2 reference recalculation and d_IP2', () => {
|
||||
@@ -27,15 +27,15 @@ describe('P1 regression — spec.md §12', () => {
|
||||
});
|
||||
|
||||
it('column centre coordinates', () => {
|
||||
expect(Math.abs(model.pivot.e - 259865.677)).toBeLessThan(TOL);
|
||||
expect(Math.abs(model.pivot.n - 179138.089)).toBeLessThan(TOL);
|
||||
expect(Math.abs(model.pivot.e - 179138.089)).toBeLessThan(TOL);
|
||||
expect(Math.abs(model.pivot.n - 259865.677)).toBeLessThan(TOL);
|
||||
});
|
||||
|
||||
it('bearing positions: front row across-axis −0.698, back row +0.600; front s from 0.943 to 10.965', () => {
|
||||
it('bearing positions: front row across-axis +0.698, back row −0.600; front s from 0.943 to 10.965', () => {
|
||||
const front = model.bearings.filter((b) => b.row === '전열');
|
||||
const back = model.bearings.filter((b) => b.row === '후열');
|
||||
expect(front[0].n).toBeCloseTo(-0.698, 3);
|
||||
expect(back[0].n).toBeCloseTo(0.6, 3);
|
||||
expect(front[0].n).toBeCloseTo(0.698, 3);
|
||||
expect(back[0].n).toBeCloseTo(-0.6, 3);
|
||||
expect(front[0].s).toBeCloseTo(0.943, 3);
|
||||
expect(front[4].s).toBeCloseTo(10.965, 3);
|
||||
expect(back[0].s).toBeCloseTo(1.029, 3);
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildPier } from '../src/core/geometry';
|
||||
import { validatePier } from '../src/core/validate';
|
||||
import type { Issue, PierInput } from '../src/core/types';
|
||||
import { loadP1 } from './helpers';
|
||||
|
||||
// 교좌면 EL of the P1 sheet (받침 1–10), 2026-11-03.
|
||||
const SEAT = [43.411, 43.483, 43.555, 43.626, 43.698, 43.411, 43.483, 43.555, 43.626, 43.698];
|
||||
|
||||
function withSeat(input: PierInput, seats: number[]): PierInput {
|
||||
return { ...input, bearings: input.bearings.map((b) => ({ ...b, seatEl: seats[b.no - 1] })) };
|
||||
}
|
||||
|
||||
describe('교좌면 EL 입력 (2026-11-03)', () => {
|
||||
it('the P1 교좌면 EL values reproduce the 두부보 상면 slope: the model does not change', () => {
|
||||
const base = buildPier(loadP1());
|
||||
const seated = buildPier(withSeat(loadP1(), SEAT));
|
||||
for (const b of base.blocks) {
|
||||
const s = seated.blocks.find((x) => x.no === b.no)!;
|
||||
expect(s.bottom).toBeCloseTo(b.bottom, 6);
|
||||
expect(s.top).toBeCloseTo(b.top, 6);
|
||||
}
|
||||
});
|
||||
|
||||
it('an input 교좌면 EL sets the block bottom and so h1 = (EL.X − 50 mm) − 교좌면 EL', () => {
|
||||
const m = buildPier(withSeat(loadP1(), [43.4, ...SEAT.slice(1)]));
|
||||
const b1 = m.blocks.find((x) => x.no === 1)!;
|
||||
expect(b1.bottom).toBeCloseTo(43.4, 6);
|
||||
expect((b1.top - b1.bottom) * 1000).toBeCloseTo(111, 6);
|
||||
});
|
||||
|
||||
it('without 교좌면 EL the seatCalc is the slope value (the check reference)', () => {
|
||||
const m = buildPier(loadP1());
|
||||
expect(m.blocks.every((b) => Math.abs(b.seatCalc - b.bottom) < 1e-9)).toBe(true);
|
||||
});
|
||||
|
||||
it('warns (W10) when the input 교좌면 EL differs from the slope by more than 5 mm, and keeps the input', () => {
|
||||
const input = withSeat(loadP1(), [43.5, ...SEAT.slice(1)]);
|
||||
const m = buildPier(input);
|
||||
const issues: Issue[] = [];
|
||||
validatePier(input, m, issues);
|
||||
const w = issues.filter((i) => i.code === 'W10');
|
||||
expect(w.length).toBe(1);
|
||||
expect(w[0].message).toContain('1번');
|
||||
expect(m.blocks.find((x) => x.no === 1)!.bottom).toBeCloseTo(43.5, 6);
|
||||
});
|
||||
|
||||
it('no W10 for the P1 values', () => {
|
||||
const input = withSeat(loadP1(), SEAT);
|
||||
const m = buildPier(input);
|
||||
const issues: Issue[] = [];
|
||||
validatePier(input, m, issues);
|
||||
expect(issues.filter((i) => i.code === 'W10')).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -210,6 +210,7 @@ describe('꺾인 코핑 IP4 (spec §4.1)', () => {
|
||||
it('a bend on a straight P1 sheet (IP1, IP2, IP3 collinear) is refused: IP4 would land on IP3', () => {
|
||||
const p1 = loadP1();
|
||||
const ips = p1.ips.map((p) => (p.name === 'IP4' ? { ...p, angleDeg: 5 } : p));
|
||||
expect(() => buildPier({ ...p1, ips })).toThrow('꺾임각과 IP 배치');
|
||||
// Both messages refuse the bend. Which check fires first depends on the axis orientation (x = 북, 2026-11-03).
|
||||
expect(() => buildPier({ ...p1, ips })).toThrow('꺾임');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user