fix(bearing): 짝 받침은 거더를 따라 처음 만나는 받침 (직선교 후열 오짝 방지)

검토(2026-11-06): 후열이 전열과 같은 거더 선 위에 같은 방위각으로 놓이면(직선교) 거더선 거리가
둘 다 0이라, A1 받침이 더 먼 P1 후열(다음 경간)과 짝을 짓고 모든 받침에 WS04가 떴다.
- 후보(진행 방향, 방위각 ≤ 0.1°, 거더선 ≤ 0.1 m) 중 거더를 따라 가장 가까운 받침을 짝으로 한다
- 그와 0.5 m 이내로 비슷한 거리의 후보가 더 있을 때만 WS04, 그중 거더선에 가까운 것 사용
- 테스트: A1 + A2만(교각 없음) 짝 성립 S −2 %, A1 + P1에서 전열 계산 · 후열 전열 값 가정(WS03),
  직선교 합성 입력에서 전열 ①–⑤와 짝 · WS04 없음

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ypshimandClaude Opus 5.5 committed 2026-11-06 15:22:34 +09:00
1 parent 364b329c54
commit 9d357de4d1
2 files changed
+48 -5

No files matched your search

+12 -5
View File
@@ -24,6 +24,8 @@ export interface PairStructure {
export const PAIR_AZIMUTH_TOL_DEG = 0.1;
export const PAIR_OFFSET_TOL_M = 0.1;
export const AZIMUTH_CHECK_SEC = 10;
/** Two candidates nearer than this along the girder are ambiguous (WS04). Less than the row spacing (block 1.2 m). */
export const PAIR_ALONG_TOL_M = 0.5;
export type PairWarning = 'WS01' | 'WS04';
@@ -51,8 +53,10 @@ const angleDiffDeg = (a: number, b: number) => Math.abs(((a - b + 540) % 360) -
/**
* Girder pairs between neighbouring structures (Q-SH1, Q-SH4). For each bearing b on structure k the candidates on k + 1 lie
* ahead along b's azimuth, within 0.1° of its azimuth and 0.1 m of its girder line; the closest to the line is the pair
* (WS04 when there were two or more). WS01 when either input azimuth is more than 10″ from the pair direction.
* ahead along b's azimuth, within 0.1° of its azimuth and 0.1 m of its girder line. The pair is the first one reached along
* the girder: on a straight bridge the next span's bearing (후열) lies on the same line further on, so "closest to the line"
* alone would be a tie (2026-11-06 review). WS04 when another candidate is within 0.5 m of it along the girder.
* WS01 when either input azimuth is more than 10″ from the pair direction.
*/
export function girderPairs(structures: PairStructure[]): BearingPair[] {
const pairs: BearingPair[] = [];
@@ -64,9 +68,12 @@ export function girderPairs(structures: PairStructure[]): BearingPair[] {
const d = { e: c.x - b.x, n: c.y - b.y };
return { c, along: d.e * u.e + d.n * u.n, offset: Math.abs(d.e * u.n - d.n * u.e), dAz: angleDiffDeg(c.azimuthDeg, b.azimuthDeg), d };
}).filter((t) => t.along > 0 && t.dAz <= PAIR_AZIMUTH_TOL_DEG && t.offset <= PAIR_OFFSET_TOL_M)
.sort((p, q) => p.offset - q.offset);
.sort((p, q) => p.along - q.along);
if (!candidates.length) continue;
const best = candidates[0];
// The nearest along the girder, and among those about as near (ambiguous, WS04) the one closest to the line.
const near = candidates.filter((t) => t.along - candidates[0].along < PAIR_ALONG_TOL_M).sort((p, q) => p.offset - q.offset);
const best = near[0];
const ambiguous = near.length > 1;
const distanceM = Math.hypot(best.d.e, best.d.n);
const directionDeg = (Math.atan2(best.d.e, best.d.n) / DEG + 360) % 360;
const fromCheckSec = angleDiffDeg(directionDeg, b.azimuthDeg) * 3600;
@@ -74,7 +81,7 @@ export function girderPairs(structures: PairStructure[]): BearingPair[] {
const slopePct = ((best.c.z - b.z) / distanceM) * 100;
const warnings: PairWarning[] = [];
if (fromCheckSec > AZIMUTH_CHECK_SEC || toCheckSec > AZIMUTH_CHECK_SEC) warnings.push('WS01');
if (candidates.length > 1) warnings.push('WS04');
if (ambiguous) warnings.push('WS04');
pairs.push({
from: { structure: here.name, no: b.no }, to: { structure: next.name, no: best.c.no },
distanceM, offsetM: best.offset, azimuthDiffDeg: best.dAz, directionDeg, fromCheckSec, toCheckSec,
+36
View File
@@ -77,3 +77,39 @@ describe('교량 전체 짝 받침 (bridgePairs, 구조물 순서 A1 · P1 …
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);
});
});