From 9d357de4d1a7ede6fd4a0fdcdf9d6af7aefe0fdd Mon Sep 17 00:00:00 2001 From: ypshim Date: Fri, 6 Nov 2026 15:22:34 +0900 Subject: [PATCH] =?UTF-8?q?fix(bearing):=20=EC=A7=9D=20=EB=B0=9B=EC=B9=A8?= =?UTF-8?q?=EC=9D=80=20=EA=B1=B0=EB=8D=94=EB=A5=BC=20=EB=94=B0=EB=9D=BC=20?= =?UTF-8?q?=EC=B2=98=EC=9D=8C=20=EB=A7=8C=EB=82=98=EB=8A=94=20=EB=B0=9B?= =?UTF-8?q?=EC=B9=A8=20(=EC=A7=81=EC=84=A0=EA=B5=90=20=ED=9B=84=EC=97=B4?= =?UTF-8?q?=20=EC=98=A4=EC=A7=9D=20=EB=B0=A9=EC=A7=80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 검토(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 --- src/core/bearing-pair.ts | 17 ++++++++++++----- tests/bearing-pair.test.ts | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 5 deletions(-) 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); + }); +});