diff --git a/src/core/bearing-pair.ts b/src/core/bearing-pair.ts index c003abf..8a0675b 100644 --- a/src/core/bearing-pair.ts +++ b/src/core/bearing-pair.ts @@ -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, diff --git a/tests/bearing-pair.test.ts b/tests/bearing-pair.test.ts index 4b7bff7..f2f92dc 100644 --- a/tests/bearing-pair.test.ts +++ b/tests/bearing-pair.test.ts @@ -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); + }); +});