Files
Pier_v1/tests/bearing-pair.test.ts
ypshimandClaude Opus 5.5 c7acfccd51 feat(bearing): 방위각 입력 · 계산 비교와 계산값 적용 버튼
교각·교대 솔플레이트 표에 받침별 '방위각 입력 (CAD)' · '방위각 계산 (CAD)' · '차이 (″, 계산 − 입력)' ·
[적용] 열을 두고, 표 위에 '계산 방위각 모두 적용 / 모두 입력값으로' 버튼을 둔다 (사용자 요청 2026-11-06,
Q-SH4 "입력값 유지"를 사용자가 고르게 바꿈).
- 계산 방위각: 거더 짝이면 두 받침을 잇는 방향, 교대만의 현 짝이면 원곡선 접선(현 방향 ± 방위각 변화의 절반)
- 적용값은 교각 input.azimuthApplied / 교대 record.azimuthApplied에 받침번호별로 저장, 엑셀 값은 그대로.
  블럭 · 받침 · 솔플레이트 회전과 오르막 방향에 쓰고, 짝 찾기는 엑셀 입력값으로
- 차이 표시: 거더 짝 ≤ 10″ / WS01, 현 짝은 '현 · 참고'. 도·분·초 CAD 각(엑셀과 같은 표기)
- 테스트: P1 전열 계산값 ≤ 0.5″ · 후열 없음, 적용한 받침만 블럭 회전 변화, 원곡선 현 접선 = 입력, 교대 toAbutInput 반영
- 브라우저: 운곡교AA.xlsx A1-광주 ① 적용 → 블럭 회전 4.39° → 4.36°, 모두 적용 5개 · 모두 해제, 예외 0

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 15:55:24 +09:00

214 lines
13 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { girderPairs, pairOf, type PairStructure, soleRowThickness } from '../src/core/bearing-pair';
import { loadP1 } from './helpers';
// RAG/받침방위각_솔플레이트검토.md §2 · §6 (Q-SH1·4·8·9). A1 from the RAG workbook's Abut sheet, P1 from the same input.
const RAG = fileURLToPath(new URL('../../../RAG/운곡교P1입력.xlsx', import.meta.url));
const a1 = parseAbutments(XLSX.read(readFileSync(RAG), { type: 'buffer' })).abutments[0];
const A1: PairStructure = { name: 'A1', bearings: a1.bearings.map((b) => ({ no: b.no, x: b.pos.e, y: b.pos.n, z: b.z, azimuthDeg: b.azimuthDeg! })) };
const p1 = loadP1();
const P1: PairStructure = { name: 'P1', bearings: p1.bearings.map((b) => ({ no: b.no, x: b.x, y: b.y, z: b.z, azimuthDeg: b.azimuthDeg })) };
describe('거더 짝 받침 · 종단경사 S (Q-SH1·4·5)', () => {
const pairs = girderPairs([A1, P1]);
it('pairs A1 ①–⑤ with P1 ①–⑤ on the girder line (offset 0.000 m, azimuth difference ≤ 0.1″)', () => {
expect(pairs.map((p) => [p.from.no, p.to.no])).toEqual([[1, 1], [2, 2], [3, 3], [4, 4], [5, 5]]);
for (const p of pairs) {
expect(p.offsetM).toBeLessThan(0.0005);
expect(p.fromCheckSec).toBeLessThanOrEqual(0.5);
expect(p.distanceM).toBeCloseTo(48.791, 3);
expect(p.warnings).toEqual([]);
}
});
it('S = −1.908 / −1.906 / −1.904 / −1.904 / −1.902 % (상세도 −1.910 … −1.901, ≤ 0.002 %), A1 is the 오르막 쪽', () => {
expect(pairs.map((p) => p.slopePct.toFixed(3))).toEqual(['-1.908', '-1.906', '-1.904', '-1.904', '-1.902']);
const drawing = [-1.910, -1.907, -1.905, -1.903, -1.901];
pairs.forEach((p, i) => expect(Math.abs(p.slopePct - drawing[i])).toBeLessThanOrEqual(0.0025));
expect(pairs.every((p) => p.uphill === 'A1')).toBe(true);
});
it('P1 back row ⑥–⑩ (next span, P2 not in the workbook) has no pair', () => {
for (const no of [6, 7, 8, 9, 10]) expect(pairOf(pairs, 'P1', no)).toBeUndefined();
expect(pairOf(pairs, 'P1', 3)!.from).toEqual({ structure: 'A1', no: 3 });
});
it('WS01: P1 ③ azimuth typed as 75°59′ (CAD) is kept but flagged', () => {
const typo: PairStructure = { ...P1, bearings: P1.bearings.map((b) => (b.no === 3 ? { ...b, azimuthDeg: 90 - (75 + 59 / 60) } : b)) };
const p = girderPairs([A1, typo]).find((q) => q.to.no === 3)!;
expect(p.warnings).toContain('WS01');
expect(p.toCheckSec).toBeGreaterThan(10);
expect(girderPairs([A1, typo]).filter((q) => q.to.no !== 3).every((q) => !q.warnings.includes('WS01'))).toBe(true);
});
it('WS04: two candidates within tolerance → the closest to the girder line is used', () => {
const b1 = P1.bearings[0];
const extra = { ...b1, no: 99, x: b1.x + 0.05 * Math.cos(b1.azimuthDeg * Math.PI / 180), y: b1.y - 0.05 * Math.sin(b1.azimuthDeg * Math.PI / 180) };
const p = girderPairs([A1, { ...P1, bearings: [...P1.bearings, extra] }]).find((q) => q.from.no === 1)!;
expect(p.warnings).toContain('WS04');
expect(p.to.no).toBe(1);
});
});
describe('솔플레이트 열 두께 (Q-SH3·8·9, L 800)', () => {
it('S 1.908 % → T 30, T1 38 / T2 22 (P1 상세도)', () => {
expect(soleRowThickness([{ lengthMm: 800, slopePct: -1.908 }])).toEqual({ T: 30, ends: [{ T1: 38, T2: 22 }] });
});
it('S 3 % → T 34, T1 46 / T2 22; S 0 → T 22', () => {
expect(soleRowThickness([{ lengthMm: 800, slopePct: 3 }])).toEqual({ T: 34, ends: [{ T1: 46, T2: 22 }] });
expect(soleRowThickness([{ lengthMm: 800, slopePct: 0 }]).T).toBe(22);
});
it('one T per row: the largest requirement wins, the flatter bearing gets a thicker T2', () => {
const row = soleRowThickness([{ lengthMm: 800, slopePct: 3 }, { lengthMm: 800, slopePct: 1 }]);
expect(row.T).toBe(34);
expect(row.ends[1]).toEqual({ T1: 38, T2: 30 });
});
});
describe('교량 전체 짝 받침 (bridgePairs, 구조물 순서 A1 · P1 … · A2)', () => {
it('builds the same A1 ↔ P1 pairs from the loaded records', async () => {
const { bridgePairs } = await import('../src/core/bridge-pairs');
const pairs = bridgePairs([p1], [a1]);
expect(pairs.map((p) => `${p.from.structure}${p.from.no}-${p.to.structure}${p.to.no}`)).toEqual(['A11-P11', 'A12-P12', 'A13-P13', 'A14-P14', 'A15-P15']);
});
});
describe('짝 받침 성립 범위 (검토 2026-11-06)', () => {
// A2 made from A1: each bearing moved 30 m along its girder and 0.6 m lower (S −2 %).
const a2 = { ...a1, name: 'A2', type: 'A2' as const, bearings: a1.bearings.map((b) => {
const t = (b.azimuthDeg! * Math.PI) / 180;
return { ...b, pos: { e: b.pos.e + 30 * Math.sin(t), n: b.pos.n + 30 * Math.cos(t) }, z: b.z - 0.6 };
}) };
it('A1 and A2 only (one span, no pier): A1 ①–⑤ pair with A2 ①–⑤, S −2 %', async () => {
const { bridgePairs } = await import('../src/core/bridge-pairs');
const pairs = bridgePairs([], [a1, a2]);
expect(pairs.map((p) => `${p.from.structure}${p.from.no}-${p.to.structure}${p.to.no}`)).toEqual(['A11-A21', 'A12-A22', 'A13-A23', 'A14-A24', 'A15-A25']);
expect(pairs.every((p) => Math.abs(p.slopePct + 2) < 1e-6 && p.warnings.length === 0)).toBe(true);
});
it('A1 and P1 only: the front row ①–⑤ is computed from A1, the back row ⑥–⑩ takes the assumed front value (가정, WS03)', async () => {
const { bridgePairs } = await import('../src/core/bridge-pairs');
const { buildPier } = await import('../src/core/geometry');
const m = buildPier(p1, { pairs: bridgePairs([p1], [a1]) });
const front = m.soles.filter((s) => s.row === '전열'), back = m.soles.filter((s) => s.row === '후열');
expect(front.every((s) => s.slopeSource === '계산' && s.pair?.structure === 'A1')).toBe(true);
expect(back.every((s, i) => s.slopeSource === '가정' && !s.pair && s.slopePct === front[i].slopePct && s.warnings.includes('WS03'))).toBe(true);
});
it('straight bridge: a back row on the same girder lines is not taken for the A1 partner (the nearest along the girder is)', () => {
const straight: PairStructure = { name: 'P1', bearings: P1.bearings.map((b) => {
if (b.no <= 5) return b;
const f = P1.bearings.find((q) => q.no === b.no - 5)!;
const t = (f.azimuthDeg * Math.PI) / 180;
return { ...b, azimuthDeg: f.azimuthDeg, x: f.x + 1.2 * Math.sin(t), y: f.y + 1.2 * Math.cos(t) };
}) };
const pairs = girderPairs([A1, straight]);
expect(pairs.map((p) => p.to.no)).toEqual([1, 2, 3, 4, 5]);
expect(pairs.every((p) => p.warnings.length === 0)).toBe(true);
});
});
describe('교대만 있는 프로젝트 (가져오기 허용 2026-11-06)', () => {
it('a project file with no pier and A1 only round-trips', async () => {
const { parseProject, serializeProject, SCHEMA_VERSION } = await import('../src/core/project');
const file = { schemaVersion: SCHEMA_VERSION, project: { name: '교대만', bridge: '', client: '', author: '', crs: '', memo: '' },
source: { fileName: '운곡교P1입력.xlsx', sheets: [] }, history: [], piers: [], abutments: [a1] };
const back = parseProject(serializeProject(file as Parameters<typeof serializeProject>[0]));
expect(back.piers).toEqual([]);
expect(back.abutments?.map((a) => a.name)).toEqual(['A1']);
});
});
describe('두 노선 (광주 · 완도) — 목록 순서와 무관하게 거더 기하로 짝 (2026-11-06)', () => {
it('A1-광주, A1-완도, P1-완도, P1-광주 in that order still pair 광주 with 광주 and 완도 with 완도', () => {
const shift = (s: PairStructure, name: string, de: number): PairStructure => ({ name, bearings: s.bearings.map((b) => ({ ...b, x: b.x + de })) });
const list = [shift(A1, 'A1-광주', 13), shift(A1, 'A1-완도', 0), shift(P1, 'P1-완도', 0), shift(P1, 'P1-광주', 13)];
const pairs = girderPairs(list);
expect(pairs).toHaveLength(10);
expect(pairs.every((p) => p.from.structure.slice(3) === p.to.structure.slice(3) && p.from.structure.startsWith('A1') && p.from.no === p.to.no)).toBe(true);
});
});
describe('교대만 (교각 0기): A1 ↔ A2 현 짝 (검토 2026-11-06)', () => {
// Circular arc: A2_i lies at chord azimuth θ_i − δ, 350 m away, with azimuth θ_i − 2δ (the chord is the mean azimuth).
const curved = (rec: typeof a1, name: string, type: 'A1' | 'A2', de = 0) => ({ ...rec, name, type, bearings: rec.bearings.map((b) => {
const delta = 4, t = ((b.azimuthDeg! - delta) * Math.PI) / 180;
return type === 'A1' ? { ...b, pos: { e: b.pos.e + de, n: b.pos.n } }
: { ...b, azimuthDeg: b.azimuthDeg! - 2 * delta, pos: { e: b.pos.e + de + 350 * Math.sin(t), n: b.pos.n + 350 * Math.cos(t) }, z: b.z - 6.65 };
}) });
const abuts = [curved(a1, 'A1-광주', 'A1', 13), curved(a1, 'A1-완도', 'A1'), curved(a1, 'A2-광주', 'A2', 13), curved(a1, 'A2-완도', 'A2')];
it('two curved lines without piers: each A1 bearing pairs with the same number on its own line\'s A2 (현), S = Δz / chord', async () => {
const { bridgePairs } = await import('../src/core/bridge-pairs');
const pairs = bridgePairs([], abuts);
expect(pairs).toHaveLength(10);
expect(pairs.every((p) => p.kind === 'chord' && p.from.structure.slice(3) === p.to.structure.slice(3) && p.from.no === p.to.no && p.warnings.length === 0)).toBe(true);
expect(pairs.every((p) => p.fromCheckSec < 1 && Math.abs(p.slopePct + 1.9) < 1e-6)).toBe(true);
});
it('the 교대 card shows the 현 pair: A1-광주 ① ↔ A2-광주-① (현), S 계산', async () => {
const { bridgePairs } = await import('../src/core/bridge-pairs');
const { abutSolplateRows, resolveBearingRule } = await import('../src/core/abut/bearing-table');
const { buildAbut } = await import('../src/core/abut/geometry');
const { toAbutInput } = await import('../src/core/abut/parse');
const rec = abuts[0];
const rows = abutSolplateRows(buildAbut(toAbutInput(rec)), resolveBearingRule(), rec.bearingHeightMm, { pairs: bridgePairs([], abuts), slopeI: rec.slopeI });
expect(rows[0]).toMatchObject({ pair: 'A2-광주-① (현)', pairKind: 'chord', source: '계산' });
expect(rows[0].S).toBeCloseTo(-1.9, 6);
});
it('with a pier loaded the chord rule is not used', async () => {
const { bridgePairs } = await import('../src/core/bridge-pairs');
expect(bridgePairs([p1], [a1, abuts[3]]).some((p) => p.kind === 'chord')).toBe(false);
});
});
describe('방위각 입력 · 계산 · 적용 (2026-11-06)', () => {
it('P1: the 계산 방위각 is the A1 girder direction (≤ 0.5″ from the input); the back row has none', async () => {
const { bridgePairs } = await import('../src/core/bridge-pairs');
const { buildPier } = await import('../src/core/geometry');
const m = buildPier(p1, { pairs: bridgePairs([p1], [a1]) });
for (const s of m.soles.filter((x) => x.row === '전열')) expect(Math.abs(s.azimuthCalcDeg! - s.azimuthInputDeg) * 3600).toBeLessThanOrEqual(0.5);
expect(m.soles.filter((x) => x.row === '후열').every((x) => x.azimuthCalcDeg === undefined)).toBe(true);
});
it('applying a value turns that block and keeps the workbook value as the input', async () => {
const { bridgePairs } = await import('../src/core/bridge-pairs');
const { buildPier } = await import('../src/core/geometry');
const pairs = bridgePairs([p1], [a1]);
const base = buildPier(p1, { pairs });
const turned = buildPier({ ...p1, azimuthApplied: { 3: p1.bearings[2].azimuthDeg + 1 } }, { pairs });
const skew = (m: typeof base, no: number) => m.blocks.find((b) => b.no === no)!.rowSkewDeg;
expect(skew(turned, 3) - skew(base, 3)).toBeCloseTo(1, 6);
expect(skew(turned, 2)).toBe(skew(base, 2));
expect(turned.soles.find((s) => s.no === 3)!.azimuthInputDeg).toBe(p1.bearings[2].azimuthDeg);
});
it('현 짝 (교대만): the 계산 방위각 is the arc tangent — equal to the input on a circular arc', async () => {
const { chordPairs } = await import('../src/core/bearing-pair');
const a2: PairStructure = { name: 'A2', bearings: A1.bearings.map((b) => {
const t = ((b.azimuthDeg - 4) * Math.PI) / 180;
return { ...b, x: b.x + 350 * Math.sin(t), y: b.y + 350 * Math.cos(t), azimuthDeg: b.azimuthDeg - 8, z: b.z - 6.65 };
}) };
const pairs = chordPairs([A1], [a2]);
expect(pairs).toHaveLength(5);
pairs.forEach((p, i) => {
expect(Math.abs(p.fromCalcDeg - A1.bearings[i].azimuthDeg) * 3600).toBeLessThan(0.5);
expect(Math.abs(p.toCalcDeg - a2.bearings[i].azimuthDeg) * 3600).toBeLessThan(0.5);
});
});
it('교대: an applied 계산 방위각 reaches the abutment model through toAbutInput', async () => {
const { toAbutInput } = await import('../src/core/abut/parse');
const input = toAbutInput({ ...a1, azimuthApplied: { 1: 10 } });
expect(input.bearings.find((b) => b.no === 1)!.azimuthDeg).toBe(10);
expect(input.bearings.find((b) => b.no === 2)!.azimuthDeg).toBe(a1.bearings[1].azimuthDeg);
});
});