From a0b0929e13760ef56ffbb0d56029496f534a960c Mon Sep 17 00:00:00 2001 From: ypshim Date: Fri, 6 Nov 2026 14:48:10 +0900 Subject: [PATCH] =?UTF-8?q?feat(bearing):=20=EA=B5=90=EA=B0=81=20=EC=86=94?= =?UTF-8?q?=ED=94=8C=EB=A0=88=EC=9D=B4=ED=8A=B8=EB=A5=BC=202026-11-06=20?= =?UTF-8?q?=EA=B2=B0=EC=A0=95=EC=9C=BC=EB=A1=9C=20(=EC=A7=9D=20=EB=B0=9B?= =?UTF-8?q?=EC=B9=A8=20S=20=C2=B7=20=EC=97=B4=20T=20=C2=B7=20=EA=B1=B0?= =?UTF-8?q?=EB=8D=94=20=ED=95=98=EB=A9=B4=20=EA=B3=A0=EC=A0=95)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 받침방위각_솔플레이트검토.md §3과 시안(Version 110–113)을 따른다 (사용자 결정 2026-11-06: 시안 적용). 11-03 C안(받침높이 H 고정, h3 = H − T, S 입력)을 대체한다. - S: 교량 전체(A1, P1 … Pn, A2) 거더 짝 받침의 z 차 / 수평거리. 없으면 교각 종단경사 입력 → 다른 열 같은 순번 값(가정, WS03) → 0 %(WS03). 받침별 직접 수정(수정됨) - 평면 L = A + 100, W = B + 100 (받침별 A × B, 기본 700 × 640 가정), h3 181 - T = 열마다 max(22, ⌈22 + L·|S|/2⌉), 열 T 직접 수정 가능(T2 < T_min → WS02). T1은 오르막 쪽(3D 쐐기의 두꺼운 끝도 그쪽), T2 반대쪽 - 거더 하면 고정: 블럭 상면 = z − (T − T_ref), T_ref 30 - 짝 받침은 불러오기·프로젝트 열기 때 교량 전체로 계산해 교각 생성에 넘김 - ② 카드: h3·T_ref·T_min·여유·열 T·종단경사 입력, 표에 짝 받침·S 근거·받침별 S/A/B 수정·검산 W08·검사 - P1 검증(A1과 함께): S −1.908 … −1.902 %, L 800 · W 740 · T 30 · T1 38 · T2 22, 블럭 높이 변화 없음 - 블럭·솔플레이트 테스트는 A1–P1 짝 문맥으로 실행 (짝이 없으면 S 0 · T 22 · 블럭 +8 mm) 교대(QA-15 C안)와 Pier 시트 종단경사 열 읽기는 아직 남음. Co-Authored-By: Claude Opus 5.5 --- src/core/bearing-pair.ts | 3 +- src/core/bearing-table.ts | 20 +++- src/core/bridge-pairs.ts | 22 +++++ src/core/geometry.ts | 35 +++++-- src/core/sole-plate.ts | 140 ++++++++++++++++++++++++++++ src/core/solplate.ts | 10 +- src/core/types.ts | 13 ++- src/main.ts | 150 ++++++++++++++++++++++-------- tests/bearing-pair.test.ts | 8 ++ tests/bearing-plan-svg.test.ts | 13 ++- tests/bearing-section-svg.test.ts | 9 +- tests/bearing-table.test.ts | 27 ++++-- tests/coping-steps.test.ts | 7 +- tests/helpers.ts | 22 +++++ tests/p1-regression.test.ts | 7 +- tests/rebar-bearing.test.ts | 11 ++- tests/scene.test.ts | 11 ++- tests/seat-el.test.ts | 17 ++-- tests/solplate-scene.test.ts | 70 +++++++++++--- 19 files changed, 486 insertions(+), 109 deletions(-) create mode 100644 src/core/bridge-pairs.ts create mode 100644 src/core/sole-plate.ts diff --git a/src/core/bearing-pair.ts b/src/core/bearing-pair.ts index fc692f6..c003abf 100644 --- a/src/core/bearing-pair.ts +++ b/src/core/bearing-pair.ts @@ -1,7 +1,6 @@ /** * 거더 짝 받침 · 종단경사 S · 솔플레이트 열 두께 (결정 2026-11-06, RAG/받침방위각_솔플레이트검토.md §3, Q-SH1·4·8·9). - * Pure. Not wired into the model yet: the height rule (Q-SH7, 블럭 상면 = z − (T − T_ref)) conflicts with the 2026-11-03 - * C안 already in use (받침높이 H 고정, h3 = H − T, src/core/solplate-rule.ts), so only the parts both rules need live here. + * Pure. Used by src/core/sole-plate.ts (pier solplates, 2026-11-06) through src/core/bridge-pairs.ts. * * Frame: (E, N) in m as in the model (x = E, y = N). Azimuth: the model's 진북 방위각 (deg, clockwise from north), i.e. the * workbook CAD angle θ_CAD converted by 90 − θ_CAD. A girder at azimuth θ runs along (sin θ, cos θ). diff --git a/src/core/bearing-table.ts b/src/core/bearing-table.ts index aab495b..cc96427 100644 --- a/src/core/bearing-table.ts +++ b/src/core/bearing-table.ts @@ -10,6 +10,7 @@ * along 교각축. * - S1·S2: block rebar bars from the model (bearing-block): count and bar length. For 일체 the pair's bars sit on the front row. */ +import type { SlopeSource } from './sole-plate'; import type { PierModel } from './geometry'; import { bearingEdgeDistances, pairEccentricities } from './bearing-layout'; import { INTEGRATED_BLOCK } from './integrated-block'; @@ -54,12 +55,24 @@ export interface SolplateRow { T: number; T1: number; T2: number; - /** Check: T1 − T2 against L·|S|/100 (mm). */ + /** Check W08: T1 − T2 against L·|S|/100 (mm), within 1 mm (T1 and T2 are rounded to whole mm). */ checkLhs: number; checkRhs: number; ok: boolean; + /** 짝 받침 (같은 거더), e.g. "A1-①"; absent when none (2026-11-06). */ + pair?: string; + /** Where S comes from: 계산 (짝) / 입력 (종단경사) / 가정 (다른 열) / 수정됨 / 없음. */ + source: SlopeSource; + /** T typed by the user (수정됨) rather than automatic. */ + tTyped: boolean; + /** 받침 평면 A × B (mm). */ + A: number; + B: number; + warnings: string[]; } +const CIRCLED_NO = ['①', '②', '③', '④', '⑤', '⑥', '⑦', '⑧', '⑨', '⑩', '⑪', '⑫', '⑬', '⑭', '⑮', '⑯', '⑰', '⑱', '⑲', '⑳']; + function barSummary(bars: BarRow[], name: 'S1' | 'S2'): BarSummary | null { const hit = bars.filter((x) => x.name === name); if (hit.length === 0) return null; @@ -112,9 +125,12 @@ export function solplateRows(m: PierModel): SolplateRow[] { .sort((a, b) => a.no - b.no) .map((b) => { const p = b.plan; + const s = m.soles.find((x) => x.no === b.no)!; const checkLhs = p.T1 - p.T2; const checkRhs = (p.L * Math.abs(p.slopePct)) / 100; return { + pair: s.pair ? `${s.pair.structure}-${CIRCLED_NO[s.pair.no - 1] ?? s.pair.no}` : undefined, + source: s.slopeSource, tTyped: s.tTyped, A: s.aMm, B: s.bMm, warnings: s.warnings, no: b.no, S: p.slopePct, L: p.L, @@ -124,7 +140,7 @@ export function solplateRows(m: PierModel): SolplateRow[] { T2: p.T2, checkLhs, checkRhs, - ok: Math.abs(checkLhs - checkRhs) < 1e-6, + ok: Math.abs(checkLhs - checkRhs) <= 1 + 1e-9, }; }); } diff --git a/src/core/bridge-pairs.ts b/src/core/bridge-pairs.ts new file mode 100644 index 0000000..dcc7e94 --- /dev/null +++ b/src/core/bridge-pairs.ts @@ -0,0 +1,22 @@ +/** + * Girder pairs over the whole bridge (A1, P1 … Pn, A2 — structureOrder) for the solplate slope S (결정 2026-11-06, Q-SH1). + * Pure. Bearing coordinates and z cannot be edited on the screens, so the pairs change only when structures are loaded. + */ +import type { AbutRecord } from './abut/parse'; +import { structureOrder } from './abut/order'; +import { type BearingPair, girderPairs, type PairStructure } from './bearing-pair'; +import type { PierInput } from './types'; + +export function bridgePairs(piers: Pick[], abuts: AbutRecord[]): BearingPair[] { + const byPier = new Map(piers.map((p) => [p.name, p])); + const byAbut = new Map(abuts.map((a) => [a.name, a])); + const structures: PairStructure[] = structureOrder(abuts, piers.map((p) => p.name)).map((ref) => { + if (ref.kind === 'pier') { + const p = byPier.get(ref.name)!; + return { name: p.name, bearings: p.bearings.filter((b) => Number.isFinite(b.azimuthDeg)).map((b) => ({ no: b.no, x: b.x, y: b.y, z: b.z, azimuthDeg: b.azimuthDeg })) }; + } + const a = byAbut.get(ref.name)!; + return { name: a.name, bearings: a.bearings.filter((b) => b.azimuthDeg !== undefined).map((b) => ({ no: b.no, x: b.pos.e, y: b.pos.n, z: b.z, azimuthDeg: b.azimuthDeg! })) }; + }); + return girderPairs(structures); +} diff --git a/src/core/geometry.ts b/src/core/geometry.ts index 71d6113..18f8e0d 100644 --- a/src/core/geometry.ts +++ b/src/core/geometry.ts @@ -6,7 +6,8 @@ import { type ColumnRebarSettings, DEFAULT_COLUMN_REBAR } from './rebar/column-s import { type FootingRebarSettings, DEFAULT_FOOTING_REBAR } from './rebar/footing-settings'; import { layoutText, resolveRowCounts, rowLayout, type PileGeometry } from './piles'; import type { PierInput, Row, SolplatePlan } from './types'; -import { SOLPLATE_RULE_DEFAULT, solplatePlan } from './solplate-rule'; +import type { BearingPair } from './bearing-pair'; +import { type BearingSole, structureSoles } from './sole-plate'; import { type CopingGeom, P1_COPING_GEOM } from './coping-geom'; import { castShape } from './cast-in-place-shape'; @@ -87,7 +88,7 @@ export interface BlockModel { padTop: number; /** Skew (deg, −90…90] of the bearing row against the perpendicular of the stretch axis; the block sides follow the row. */ rowSkewDeg: number; - /** Solplate of this bearing (C안, 2026-11-03): the pier's rule, the same for every bearing. */ + /** Solplate of this bearing (2026-11-06): S from the girder pair, T per row, T1 on the 오르막 쪽. */ plan: SolplatePlan; /** Block bottom from the 두부보 상면 slope (before any 교좌면 EL input), for the check against the input (2026-11-03). */ seatCalc: number; @@ -221,6 +222,8 @@ export interface PierModel { }; /** Dedicated rebar source; never substitute footing-anchored column inputs. */ castInput?: PierInput; + /** Solplate of every bearing with its S source, pair and warnings (2026-11-06), for the ② card and checks. */ + soles: BearingSole[]; } /** @@ -245,7 +248,10 @@ export function solveIp4(ip1: Vec2, ip2: Vec2, ip3: Vec2, angleDeg: number): Vec * Each object carries its stretch k and a local s measured from that stretch's base (k = 0: BP1, k = 1: IP4). * A bent coping (IP4 angle ≠ 0) puts each footing in its stretch's frame; footings that would cross the kink are not combined. */ -export function buildPier(input: PierInput): PierModel { +/** Bridge context for one pier: the girder pairs over A1, P1 … Pn, A2 (girderPairs), for the solplate slope S. */ +export interface PierContext { pairs?: BearingPair[] } + +export function buildPier(input: PierInput, ctx: PierContext = {}): PierModel { const ip = (name: string) => { const p = input.ips.find((q) => q.name === name); if (!p) throw new Error(`IP 누락: ${name}`); @@ -455,19 +461,33 @@ export function buildPier(input: PierInput): PierModel { const blocks: BlockModel[] = []; const bearingAz = new Map(input.bearings.map((r) => [r.no, r.azimuthDeg])); 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); + // Solplate of every bearing (결정 2026-11-06): S from the girder pair (ctx.pairs over the whole bridge), else the + // 종단경사 input, else the other row; T per row; the girder underside stays, so the block top drops by T − T_ref. + const soles = new Map(structureSoles(input.name, input.bearings.map((b) => ({ no: b.no, row: b.row, azimuthDeg: b.azimuthDeg })), + input.sole, ctx.pairs ?? [], input.longitudinalSlope === undefined ? undefined : input.longitudinalSlope * 100).map((s) => [s.no, s])); + const planOf = (no: number, k: number, skewDeg: number): SolplatePlan => { + const s = soles.get(no)!; + // The block's c axis in (E, N): (−sin d)·u + (cos d)·nrm; T1 goes to the end of c that points uphill. + const { u, nrm } = axis.segments[k]; + const d = (skewDeg * Math.PI) / 180; + const c = { e: -Math.sin(d) * u.e + Math.cos(d) * nrm.e, n: -Math.sin(d) * u.n + Math.cos(d) * nrm.n }; + const t1Side = s.uphill && c.e * s.uphill.e + c.n * s.uphill.n > 0 ? 1 : -1; + return { L: s.L, W: s.W, slopePct: s.slopePct, T: s.T, T1: s.T1, T2: s.T2, h3: s.h3, t1Side }; + }; + const dropOf = (no: number) => soles.get(no)!.dropMm / 1000; // 교좌면 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 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 }); + const skew = skewOf(b.no, b.k), drop = dropOf(b.no); + blocks.push({ no: b.no, row: '전열', k: b.k, s: b.s, g: b.g, n: b.n, bottom, top: b.z - DIM.padThickness - drop, padTop: b.z - drop, rowSkewDeg: skew, plan: planOf(b.no, b.k, skew), seatCalc }); }); backPlaced.forEach((b) => { 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 skew = skewOf(b.no, b.k), drop = dropOf(b.no); + blocks.push({ no: b.no, row: '후열', k: b.k, s: b.s, g: b.g, n: b.n, bottom, top: b.z - DIM.padThickness - drop, padTop: b.z - drop, rowSkewDeg: skew, plan: planOf(b.no, b.k, skew), seatCalc }); }); const blockHeightFront = blocks.find((b) => b.row === '전열')!.top - blocks.find((b) => b.row === '전열')!.bottom; @@ -526,6 +546,7 @@ export function buildPier(input: PierInput): PierModel { blockHeightBack, frontBackDiffSpread, }, + soles: [...soles.values()], ...(input.foundationType === '현장타설말뚝' ? { castInput: { ...input } } : {}), }; } diff --git a/src/core/sole-plate.ts b/src/core/sole-plate.ts new file mode 100644 index 0000000..abd7bf6 --- /dev/null +++ b/src/core/sole-plate.ts @@ -0,0 +1,140 @@ +/** + * 솔플레이트 (결정 2026-11-06, RAG/받침방위각_솔플레이트검토.md §3, spec.md §5.3, 시안 Version 110–113). Pure. + * Replaces the 2026-11-03 C안 (받침높이 H 고정, h3 = H − T, S 입력) by the user's decision to follow the 시안: + * + * - 평면: L = A + 여유 (거더 방향 = 방위각), W = B + 여유 (여유 100). A × B per bearing; absent = 700 × 640 (P1 전열 값, 가정). + * - S: 같은 거더 양끝 받침의 z 차 / 수평거리 (girderPairs). 없으면 교각 종단경사 입력 → 같은 구조물 다른 열의 같은 순번 값 + * ("가정", WS03) → 0 % (WS03). 받침별 직접 수정 = "수정됨". + * - T: 열마다 max(T_min, ⌈max(T_min + L·|S|/2)⌉). 직접 고치면 "수정됨", T2 < T_min이면 WS02. + * T1 = round(T + L·|S|/2) 오르막 쪽, T2 = round(T − L·|S|/2). + * - 높이: 거더 하면 고정 — 블럭 상면 = z − (T − T_ref); 받침 본체 h3 고정 (181, P1 상세도). + */ +import { type BearingPair, pairOf } from './bearing-pair'; + +export interface SoleSettings { + /** 받침 본체 높이 h3 (mm). */ + h3Mm?: number; + /** T_ref: the solplate thickness the workbook z assumed (mm). */ + tRefMm?: number; + /** T_min: the thin-end minimum (mm). */ + tMinMm?: number; + /** 솔플 여유 L − A = W − B (mm). */ + marginMm?: number; + /** 받침 평면 A (거더 방향) × B per bearing number (mm). */ + plates?: Record; + /** T per row, when typed (mm). Absent = 자동. */ + tByRow?: Partial>; + /** S per bearing number, when typed (%). */ + slopePct?: Record; +} + +export const SOLE_DEFAULTS = { h3Mm: 181, tRefMm: 30, tMinMm: 22, marginMm: 100, aMm: 700, bMm: 640 } as const; + +export type SlopeSource = '계산' | '입력' | '가정' | '수정됨' | '없음'; + +export interface BearingSole { + no: number; + row: string; + pair?: { structure: string; no: number }; + slopePct: number; + slopeSource: SlopeSource; + aMm: number; + bMm: number; + /** L along the girder, W across it (mm). */ + L: number; + W: number; + /** Row thickness T and whether it was typed; T1 on the 오르막 쪽, T2 on the other end (mm). */ + T: number; + tTyped: boolean; + T1: number; + T2: number; + /** This bearing's requirement T_min + L·|S|/2 (mm). */ + tReq: number; + h3: number; + /** Block top drop T − T_ref (mm, + = the block gets lower). */ + dropMm: number; + /** Unit vector (E, N) towards the 오르막 쪽, or null when S = 0. */ + uphill: { e: number; n: number } | null; + warnings: ('WS01' | 'WS02' | 'WS03' | 'WS04')[]; +} + +export interface SoleBearing { + no: number; + row: string; + azimuthDeg: number; +} + +const DEG = Math.PI / 180; + +/** + * Solplates of one structure. `pairs` are the girder pairs of the whole bridge (girderPairs over A1, P1 … Pn, A2); + * `fallbackSlopePct` is the structure's 종단경사 input (%), when given. + */ +export function structureSoles(structure: string, bearings: SoleBearing[], settings: SoleSettings = {}, pairs: BearingPair[] = [], fallbackSlopePct?: number): BearingSole[] { + const s = { ...SOLE_DEFAULTS, ...settings }; + const rows = [...new Set(bearings.map((b) => b.row))]; + const order = (b: SoleBearing) => bearings.filter((q) => q.row === b.row).indexOf(b); + const paired = new Map(bearings.map((b) => [b.no, pairOf(pairs, structure, b.no)])); + const pairSlope = (b: SoleBearing) => paired.get(b.no)?.slopePct; + + const slopes = bearings.map((b) => { + const typed = settings.slopePct?.[String(b.no)]; + if (typed !== undefined) return { slopePct: typed, source: '수정됨' as SlopeSource }; + const own = pairSlope(b); + if (own !== undefined) return { slopePct: own, source: '계산' as SlopeSource }; + if (fallbackSlopePct !== undefined) return { slopePct: fallbackSlopePct, source: '입력' as SlopeSource }; + // The same position in another row that has a pair (시안: 후열 ⑥ ← 전열 ①). + for (const other of rows.filter((r) => r !== b.row)) { + const twin = bearings.filter((q) => q.row === other)[order(b)]; + const v = twin && pairSlope(twin); + if (v !== undefined) return { slopePct: v, source: '가정' as SlopeSource }; + } + return { slopePct: 0, source: '없음' as SlopeSource }; + }); + + const plate = (b: SoleBearing) => s.plates?.[String(b.no)] ?? { aMm: s.aMm, bMm: s.bMm }; + const req = bearings.map((b, i) => s.tMinMm + ((plate(b).aMm + s.marginMm) * Math.abs(slopes[i].slopePct)) / 200); + const rowT = new Map(rows.map((r) => { + const typed = settings.tByRow?.[r]; + const auto = Math.max(s.tMinMm, Math.ceil(Math.max(...bearings.map((b, i) => (b.row === r ? req[i] : -Infinity))) - 1e-9)); + return [r, { T: typed ?? auto, typed: typed !== undefined }]; + })); + + return bearings.map((b, i) => { + const { aMm, bMm } = plate(b); + const L = aMm + s.marginMm, W = bMm + s.marginMm; + const { slopePct, source } = slopes[i]; + const { T, typed } = rowT.get(b.row)!; + const half = (L * Math.abs(slopePct)) / 200; + const T1 = Math.round(T + half), T2 = Math.round(T - half); + const pair = paired.get(b.no); + const warnings: BearingSole['warnings'] = [...(pair?.warnings ?? [])]; + if (T2 < s.tMinMm) warnings.push('WS02'); + if (source === '가정' || source === '없음') warnings.push('WS03'); + // S runs along the structure order, which is the azimuth direction (girderPairs looks ahead along it). + const dir = { e: Math.sin(b.azimuthDeg * DEG), n: Math.cos(b.azimuthDeg * DEG) }; + const sign = Math.sign(slopePct); + return { + no: b.no, row: b.row, pair: pair ? (pair.from.structure === structure ? pair.to : pair.from) : undefined, + slopePct, slopeSource: source, aMm, bMm, L, W, T, tTyped: typed, T1, T2, tReq: req[i], h3: s.h3Mm, + dropMm: T - s.tRefMm, uphill: sign ? { e: sign * dir.e, n: sign * dir.n } : null, warnings, + }; + }); +} + +/** Input problems of the settings, in Korean; empty when they can build solplates. */ +export function soleSettingsIssues(settings: SoleSettings | undefined): string[] { + if (!settings) return []; + const out: string[] = []; + const positive = (v: unknown, label: string) => { + if (v !== undefined && !(typeof v === 'number' && Number.isFinite(v) && v > 0)) out.push(`${label}은(는) 0보다 커야 합니다.`); + }; + positive(settings.h3Mm, '받침 높이 h3'); + positive(settings.tRefMm, 'T_ref'); + positive(settings.tMinMm, 'T_min'); + if (settings.marginMm !== undefined && !(Number.isFinite(settings.marginMm) && settings.marginMm >= 0)) out.push('솔플 여유는 0 이상이어야 합니다.'); + for (const [no, p] of Object.entries(settings.plates ?? {})) { positive(p.aMm, `받침 ${no} A`); positive(p.bMm, `받침 ${no} B`); } + for (const [row, t] of Object.entries(settings.tByRow ?? {})) positive(t, `${row} T`); + for (const [no, v] of Object.entries(settings.slopePct ?? {})) if (!Number.isFinite(v)) out.push(`받침 ${no} S는 숫자여야 합니다.`); + return out; +} diff --git a/src/core/solplate.ts b/src/core/solplate.ts index ded7d69..34cb999 100644 --- a/src/core/solplate.ts +++ b/src/core/solplate.ts @@ -1,7 +1,7 @@ /** - * 솔플레이트 기하 (승인 2026-10-23, C안 2026-11-03). Pure: the solplate of one bearing, from its block (position, rowSkew) - * and the pier's solplate plan. The plan is L across the 교각축 직각 (n) and W along the 교축 (s). The top tapers across L: - * the thick end T1 is at the (−) side of the 교각축 직각 direction (n = −L/2), the thin end T2 at (+) (n = +L/2). + * 솔플레이트 기하 (승인 2026-10-23, 2026-11-06 결정). Pure: the solplate of one bearing, from its block (position, rowSkew) + * and the bearing's solplate plan. L runs along the block's c axis (the girder, by the azimuth) and W along a. The top + * tapers across L: the thick end T1 is on the 오르막 쪽 (c = t1Side·L/2), the thin end T2 at the other end. * The underside is the bearing top (block top EL.X + h3). Lengths in m, as in the model. */ import { blockFramePoint, type BlockModel } from './geometry'; @@ -29,7 +29,9 @@ export function solplateGeometry(block: BlockModel, plan: SolplatePlan): Solplat const T1 = plan.T1 / 1000; const T2 = plan.T2 / 1000; const bottom = block.padTop + plan.h3 / 1000; - const thickAt = (c: number) => T1 + ((T2 - T1) * (c + L / 2)) / L; + // T1 at c = t1Side·L/2 (the 오르막 쪽), T2 at the other end. + const side = plan.t1Side ?? -1; + const thickAt = (c: number) => T1 + ((T2 - T1) * (side * -c + L / 2)) / L; const corners: [number, number, number][] = []; for (const a of [-W / 2, W / 2]) { for (const c of [-L / 2, L / 2]) { diff --git a/src/core/types.ts b/src/core/types.ts index 7746114..4c3d398 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -1,3 +1,4 @@ +import type { SoleSettings } from './sole-plate'; import type { CopingRebarSettings, TopSpliceSettings } from './rebar/coping-settings'; import type { ColumnRebarSettings } from './rebar/column-settings'; import type { FootingRebarSettings } from './rebar/footing-settings'; @@ -62,8 +63,12 @@ export interface PierInput { pileThicknessMm?: number; /** 말뚝 종류 (규격 목록): 강관 (default) or PHC. */ pileKind?: '강관' | 'PHC'; - /** 솔플레이트 규칙 of this pier (C안, 2026-11-03). Absent = the default (211 · 1000 × 1100 · −0.5 %). */ + /** Obsolete C안 (2026-11-03) rule: kept so that old project files still open; no longer used (replaced by `sole`, 2026-11-06). */ solplate?: SolplateRule; + /** 솔플레이트 입력 (결정 2026-11-06, 시안): h3, T_ref, T_min, 여유, 받침별 A × B, 열 T·받침 S 직접 수정. Absent = 기본값. */ + sole?: SoleSettings; + /** 교각 종단경사 (소수, (−) = 교량진행방향으로 낮아짐; Pier 시트 "종단경사" 열, Q-SH2). 짝 받침이 없을 때 S에 쓴다. */ + longitudinalSlope?: number; /** 지반·지층 (승인 2026-10-26): 위치, 지하수위 고려와 EL, 지층별 행 (위에서 아래, 행의 EL = 그 층의 하단 EL). * Absent = 육상, 지하수위 없음, 지층 행 없음. */ groundPosition?: GroundPosition; @@ -109,15 +114,17 @@ export interface SolplateRule { slopePct: number; } -/** The solplate every bearing of a pier gets from its rule (mm): T is the centre thickness, h3 = H − T. */ +/** The solplate of one bearing (mm): T is the row thickness, T1 the 오르막 쪽 end, h3 the bearing body (2026-11-06). */ export interface SolplatePlan { L: number; W: number; slopePct: number; T: number; - /** Thickness at the (−) side (T1) and the (+) side (T2) of the 교각축 직각 direction. */ + /** Thickness at the 오르막 쪽 (T1) and the other end (T2). */ T1: number; T2: number; + /** Side of the block's c axis (L direction) where T1 sits: −1 = c = −L/2, +1 = c = +L/2. Absent = −1. */ + t1Side?: -1 | 1; /** Bearing body height h3 (mm) between the mortar top and the solplate underside. */ h3: number; } diff --git a/src/main.ts b/src/main.ts index c2ced92..e8d4698 100644 --- a/src/main.ts +++ b/src/main.ts @@ -119,8 +119,10 @@ import { columnDiameterIssues } from './core/column-check'; import { findPileSpec, PHC_PILES, STEEL_PILES } from './core/pile-spec'; import { footingPlanSvg, footingSideSvg } from './view/footing-svg'; import { P1_COPING_GEOM } from './core/coping-geom'; -import { SOLPLATE_MIN_T2_MM, SOLPLATE_RULE_DEFAULT, solplatePlan, solplateRuleIssues } from './core/solplate-rule'; -import type { SolplateRule } from './core/types'; +import { SOLE_DEFAULTS, type SoleSettings, soleSettingsIssues } from './core/sole-plate'; +import { SOLPLATE_MIN_T2_MM } from './core/solplate-rule'; +import { bridgePairs } from './core/bridge-pairs'; +import type { BearingPair } from './core/bearing-pair'; import { type BarSummary, elevationRows, solplateRows } from './core/bearing-table'; /** 지반 모델 (ground_model.json) for the project. Its values reach a pier's 지반고 only when the user applies them. */ @@ -254,6 +256,8 @@ const state = { pending: null as Pending | null, source: { fileName: '', sheets: [] as string[] }, history: [] as ProjectFile['history'], + /** Girder pairs over the bridge (A1, P1 … Pn, A2) for the solplate S (2026-11-06); recomputed when structures load. */ + bridgePairs: [] as BearingPair[], /** Snapshot of the inputs at the last save or open. null until the project is first saved. */ savedSnapshot: null as string | null, }; @@ -396,12 +400,12 @@ function buildEntry(rawInput: PierInput, checked: boolean): Pier { const input = withCopingDefaults(rawInput); const issues: Issue[] = []; let model: PierModel | null = null; - // 솔플레이트 규칙 (C안, 2026-11-03): a rule that cannot make a solplate is reported, and no model is built from it. - const solIssues = solplateRuleIssues(input.solplate ?? SOLPLATE_RULE_DEFAULT); + // 솔플레이트 입력 (2026-11-06): settings that cannot make a solplate are reported, and no model is built from them. + const solIssues = soleSettingsIssues(input.sole); for (const message of solIssues) issues.push({ code: 'S01', level: 'E', pier: input.name, message }); if (validateInput(input, issues) && solIssues.length === 0) { try { - model = buildPier(input); + model = buildPier(input, { pairs: state.bridgePairs }); validatePier(input, model, issues); for (const message of columnDiameterIssues(input.columnDiameterMm ?? 2000, model)) { issues.push({ code: 'D01', level: 'E', pier: input.name, message }); @@ -5116,20 +5120,50 @@ function renderElevationTable(wrap: HTMLDivElement, pier: Pier): void { wrap.appendChild(el('div', undefined, 'table-scroll')).appendChild(table); } -/** 솔플레이트 TABLE (모델값, mm, 2026-11-03): S, L, W, T, T1, T2 and the check T1 − T2 ≈ L·|S|/100. */ +/** + * 솔플레이트 TABLE (모델값, mm, 2026-11-06): 짝 받침, S와 근거, A × B, L, W, T, T1 (오르막 쪽), T2, 검산 W08, 검사. + * S, A and B can be typed per bearing (수정됨); clearing the cell goes back to the computed or default value. + */ function renderSolplateTable(wrap: HTMLDivElement, pier: Pier): void { if (!pier.model) return; - wrap.appendChild(el('p', '솔플레이트 TABLE (모델값 · mm)', 'lbl')); + wrap.appendChild(el('p', '솔플레이트 TABLE (모델값 · mm) · S는 같은 거더 양끝 받침에서 계산', 'lbl')); const table = el('table', undefined, 'grid-table rebar-table'); const head = el('thead'); const hr = el('tr'); - for (const h of ['No', 'S (%)', 'L (교직)', 'W (교축)', 'T (중심)', 'T1 (−측)', 'T2 (+측)', '검산 T1 − T2 ≈ L·|S|/100']) hr.appendChild(el('th', h)); + for (const h of ['No', '짝 받침', 'S (%)', 'S 근거', 'A', 'B', 'L', 'W', 'T', 'T1 (오르막 쪽)', 'T2', '검산 T1 − T2 ≈ L·|S|/100', '검사']) hr.appendChild(el('th', h)); head.appendChild(hr); table.appendChild(head); const body = el('tbody'); + const settings = pier.draft.sole ?? {}; + const cellInput = (value: number, typed: boolean, label: string, apply: (v: number | undefined) => void) => { + const input = el('input'); + input.type = 'number'; input.step = 'any'; input.value = String(value); input.setAttribute('aria-label', label); + input.style.width = '6.5em'; + if (typed) input.classList.add('changed-input'); + input.addEventListener('change', () => apply(input.value.trim() === '' ? undefined : Number(input.value))); + const td = el('td'); td.appendChild(input); return td; + }; for (const r of solplateRows(pier.model)) { + const key = String(r.no); const tr = el('tr'); - const cells = [String(r.no), r.S.toFixed(2), String(r.L), String(r.W), String(r.T), r.T1.toFixed(1), r.T2.toFixed(1), `${r.checkLhs.toFixed(1)} ≈ ${r.checkRhs.toFixed(1)} ${r.ok ? '일치' : '불일치'}`]; + tr.append(el('td', String(r.no)), el('td', r.pair ?? '없음')); + tr.appendChild(cellInput(Math.round(r.S * 1000) / 1000, r.source === '수정됨', `받침 ${r.no} S (%)`, (v) => { + const next = { ...(settings.slopePct ?? {}) }; + if (v === undefined) delete next[key]; else next[key] = v; + setSole(pier.input.name, { ...settings, slopePct: next }); + })); + tr.appendChild(el('td', r.source)); + const plate = settings.plates?.[key]; + for (const dim of ['aMm', 'bMm'] as const) { + tr.appendChild(cellInput(dim === 'aMm' ? r.A : r.B, plate !== undefined, `받침 ${r.no} ${dim === 'aMm' ? 'A' : 'B'} (mm)`, (v) => { + const next = { ...(settings.plates ?? {}) }; + const cur = next[key] ?? { aMm: r.A, bMm: r.B }; + if (v === undefined) delete next[key]; else next[key] = { ...cur, [dim]: v }; + setSole(pier.input.name, { ...settings, plates: next }); + })); + } + const cells = [String(r.L), String(r.W), `${r.T}${r.tTyped ? ' (수정됨)' : ''}`, String(r.T1), String(r.T2), + `${r.checkLhs} ≈ ${r.checkRhs.toFixed(1)} ${r.ok ? '일치' : '불일치 (W08)'}`, r.warnings.length ? r.warnings.join(' · ') : '—']; for (const c of cells) tr.appendChild(el('td', c)); body.appendChild(tr); } @@ -5137,47 +5171,79 @@ function renderSolplateTable(wrap: HTMLDivElement, pier: Pier): void { wrap.appendChild(el('div', undefined, 'table-scroll')).appendChild(table); } -/** Sets one pier's solplate rule (C안, 2026-11-03) and rebuilds that pier. */ -function setSolplateRule(name: string, rule: SolplateRule): void { +/** Sets one pier's solplate settings (2026-11-06) and rebuilds that pier. */ +function setSole(name: string, sole: SoleSettings): void { const p = state.piers.find((q) => q.input.name === name); if (!p) return; - p.draft = { ...p.draft, solplate: rule }; + const issues = soleSettingsIssues(sole); + if (issues.length) { setError(issues.join(' · ')); return; } + p.draft = { ...p.draft, sole }; state.piers = state.piers.map((q) => (q === p ? regenerateEntry(q) : q)); setupScene(); renderAll(); } -/** 솔플레이트 규칙 (C안, 2026-11-03): per pier. The plan L × W and the slope S are inputs; 받침높이 H is fixed (211 mm, not on the UI). T and h3 follow. */ +/** + * 솔플레이트 입력 (결정 2026-11-06, 시안): h3, T_ref, T_min, 여유 (L − A = W − B), 열마다 T (비우면 자동), 교각 종단경사 + * (짝 받침이 없을 때). 거더 하면을 고정하므로 T가 T_ref와 다르면 블럭 상면 = z − (T − T_ref). + */ function renderSolplateRule(wrap: HTMLDivElement, pier: Pier): void { - const rule = pier.draft.solplate ?? SOLPLATE_RULE_DEFAULT; - wrap.appendChild(el('p', `솔플레이트 규칙 (이 교각 · 기본 1000 × 1100 · −0.5 %) · 받침높이 H ${rule.heightMm} mm 고정 (솔플레이트 포함)`, 'lbl')); - const fields: [keyof SolplateRule, string][] = [ - ['lengthMm', '솔플레이트 교직 L (mm)'], - ['widthMm', '솔플레이트 교축 W (mm)'], - ['slopePct', '종단 기울기 S (%)'], - ]; + const sole = pier.draft.sole ?? {}; + wrap.appendChild(el('p', `솔플레이트 (결정 2026-11-06) · T = 열마다 max(${sole.tMinMm ?? SOLE_DEFAULTS.tMinMm}, ⌈${sole.tMinMm ?? SOLE_DEFAULTS.tMinMm} + L·|S|/2⌉) · 거더 하면 고정`, 'lbl')); const grid = el('div', undefined, 'edit-row'); - for (const [key, label] of fields) { - const id = `sol-${key}`; - const lab = el('label', label); - lab.htmlFor = id; - const input = el('input'); - input.id = id; - input.type = 'number'; - input.step = 'any'; - input.value = String(rule[key]); - if (rule[key] !== SOLPLATE_RULE_DEFAULT[key]) input.classList.add('changed-input'); - input.addEventListener('change', () => setSolplateRule(pier.input.name, { ...rule, [key]: Number(input.value) })); - grid.append(lab, input, el('span', `기본 ${SOLPLATE_RULE_DEFAULT[key]}`, 'muted small')); + const field = (id: string, label: string, value: number | undefined, base: string, apply: (v: number | undefined) => void) => { + const lab = el('label', label); lab.htmlFor = id; + const input = el('input'); input.id = id; input.type = 'number'; input.step = 'any'; + input.value = value === undefined ? '' : String(value); + input.placeholder = base; + if (value !== undefined) input.classList.add('changed-input'); + input.addEventListener('change', () => apply(input.value.trim() === '' ? undefined : Number(input.value))); + grid.append(lab, input, el('span', `기본 ${base}`, 'muted small')); + }; + const set = (patch: Partial) => setSole(pier.input.name, { ...sole, ...patch }); + field('sole-h3', '받침 높이 h3 (mm)', sole.h3Mm, String(SOLE_DEFAULTS.h3Mm), (v) => set({ h3Mm: v })); + field('sole-tref', 'T_ref · 엑셀 z 산정 때 두께 (mm)', sole.tRefMm, String(SOLE_DEFAULTS.tRefMm), (v) => set({ tRefMm: v })); + field('sole-tmin', '솔플 최소두께 T_min (mm)', sole.tMinMm, String(SOLE_DEFAULTS.tMinMm), (v) => set({ tMinMm: v })); + field('sole-margin', '솔플 여유 L − A = W − B (mm)', sole.marginMm, String(SOLE_DEFAULTS.marginMm), (v) => set({ marginMm: v })); + const rows = [...new Set(pier.input.bearings.map((b) => b.row))]; + for (const row of rows) { + const auto = pier.model?.soles.find((s) => s.row === row); + field(`sole-t-${row}`, `솔플레이트 T · ${row} (mm, 비우면 자동)`, sole.tByRow?.[row], auto && !auto.tTyped ? `자동 ${auto.T}` : '자동', (v) => { + const next = { ...(sole.tByRow ?? {}) }; + if (v === undefined) delete next[row]; else next[row] = v; + set({ tByRow: next }); + }); } + const lsId = 'sole-longitudinal'; + const lsLab = el('label', '교각 종단경사 (소수, 짝 받침이 없을 때)'); lsLab.htmlFor = lsId; + const ls = el('input'); ls.id = lsId; ls.type = 'number'; ls.step = 'any'; + ls.value = pier.draft.longitudinalSlope === undefined ? '' : String(pier.draft.longitudinalSlope); + ls.placeholder = '입력 없음'; + ls.addEventListener('change', () => { + const p = state.piers.find((q) => q.input.name === pier.input.name); + if (!p) return; + const v = ls.value.trim() === '' ? undefined : Number(ls.value); + if (v !== undefined && !Number.isFinite(v)) { setError('종단경사는 숫자여야 합니다.'); return; } + p.draft = { ...p.draft, longitudinalSlope: v }; + state.piers = state.piers.map((q) => (q === p ? regenerateEntry(q) : q)); + setupScene(); + renderAll(); + }); + grid.append(lsLab, ls, el('span', '(−) = 교량진행방향으로 낮아짐 · Pier 시트 새 열(Q-SH2)', 'muted small')); wrap.appendChild(grid); - const issues = solplateRuleIssues(rule); - if (issues.length > 0) { - wrap.appendChild(el('p', issues.join(' '), 'warn-text')); - return; - } - const plan = solplatePlan(rule); - wrap.appendChild(el('p', `중심 두께 T = ${plan.T} mm (T2 ≥ 22 mm) · 받침 본체 h3 = H − T = ${plan.h3} mm · T1 = ${plan.T1.toFixed(1)} mm · T2 = ${plan.T2.toFixed(1)} mm`, 'muted small')); + const m = pier.model; + if (!m) return; + const unpaired = m.soles.filter((s) => s.warnings.includes('WS03')).map((s) => s.no); + const notes = [ + `받침 A × B 기본 ${SOLE_DEFAULTS.aMm} × ${SOLE_DEFAULTS.bMm} (P1 전열 값, 가정) · 표에서 받침별 수정`, + unpaired.length ? `WS03 짝 없음: ${unpaired.join(', ')}번 → ${m.soles.find((s) => s.no === unpaired[0])?.slopeSource === '입력' ? '종단경사 입력' : '다른 열 값 또는 0 %'} 사용` : '모든 받침이 짝 받침으로 S 계산', + ...['WS01', 'WS02', 'WS04'].flatMap((w) => { + const hit = m.soles.filter((s) => s.warnings.includes(w as 'WS01')).map((s) => s.no); + return hit.length ? [`${w}: ${hit.join(', ')}번`] : []; + }), + ...rows.map((row) => { const s = m.soles.find((x) => x.row === row)!; return `${row} 블럭 상면 보정 ${s.dropMm === 0 ? '0' : `${-s.dropMm > 0 ? '+' : ''}${-s.dropMm} mm`}`; }), + ]; + wrap.appendChild(el('p', `검사 · ${notes.join(' · ')}`, 'muted small')); } /** ② 코핑 상세 (2단계, 읽기 전용): d_IP2 and the 계단 구간 표 from the model (승인 2026-10-23). */ @@ -5512,6 +5578,7 @@ function openProjectText(text: string, saved: boolean): void { groundModel = parsed.groundModel ?? null; groundMapMode = groundModel?.mapLayers?.length ? 'satellite' : 'none'; setGroundMapMode(groundMapMode); + state.bridgePairs = bridgePairs(parsed.piers.map((p) => p.draft ?? p.input), state.abutments); state.piers = parsed.piers.map((p) => ({ ...buildEntry(p.input, false), defaults: p.defaults ?? p.input, @@ -5752,6 +5819,8 @@ function loadBytes(bytes: Uint8Array, fileName: string): void { const wb = XLSX.read(bytes, { type: 'array' }); const { piers, sheets } = parseWorkbook(wb); const abut = parseAbutments(wb, piers.map((pier) => pier.name)); + // Solplate S needs the girder pairs of the whole file (2026-11-06); applyImport recomputes them for the chosen ones. + state.bridgePairs = bridgePairs(piers, abut.abutments); 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) { @@ -5794,6 +5863,9 @@ function applyImport(): void { state.piers = p.entries.filter((e) => e.checked && selectable(p, e)); state.abutments = p.abutments.filter((rec) => p.selectedAbutments.has(rec.name) && selectableAbut(p, rec)); state.abutDefaults = JSON.parse(JSON.stringify(state.abutments)) as AbutRecord[]; + // The chosen structures may differ from the whole file: rebuild with their girder pairs (솔플레이트 S, 2026-11-06). + state.bridgePairs = bridgePairs(state.piers.map((e) => e.input), state.abutments); + state.piers = state.piers.map((e) => ({ ...buildEntry(e.input, e.checked), defaults: e.defaults, draft: e.draft })); state.active = state.piers[0]?.input.name ?? null; state.filter = 'all'; state.pending = null; diff --git a/tests/bearing-pair.test.ts b/tests/bearing-pair.test.ts index c400b0c..4b7bff7 100644 --- a/tests/bearing-pair.test.ts +++ b/tests/bearing-pair.test.ts @@ -69,3 +69,11 @@ describe('솔플레이트 열 두께 (Q-SH3·8·9, L 800)', () => { 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']); + }); +}); diff --git a/tests/bearing-plan-svg.test.ts b/tests/bearing-plan-svg.test.ts index a2ee316..cb49309 100644 --- a/tests/bearing-plan-svg.test.ts +++ b/tests/bearing-plan-svg.test.ts @@ -1,10 +1,13 @@ import { describe, expect, it } from 'vitest'; import { buildPier } from '../src/core/geometry'; import { bearingPlanSvg } from '../src/view/bearing-plan-svg'; -import { loadP1 } from './helpers'; +import { loadP1, p1Pairs } from './helpers'; -const separate = buildPier(loadP1()); -const integrated = buildPier({ ...loadP1(), bearingLayout: '일체' }); +// Bridge context (A1 ↔ P1 girder pairs) as in the app, so the solplate slope is computed (2026-11-06). +const P1CTX = { pairs: p1Pairs() }; + +const separate = buildPier(loadP1(), P1CTX); +const integrated = buildPier({ ...loadP1(), bearingLayout: '일체' }, P1CTX); describe('교량받침 상세 평면 (승인 2026-10-26)', () => { it('shows C4 and C5 from the bearing to the block edges along the 교축 (600 each)', () => { @@ -18,8 +21,8 @@ describe('교량받침 상세 평면 (승인 2026-10-26)', () => { expect(bearingPlanSvg(integrated, 0)).toContain('width="2500" height="1200"'); }); - it('draws the solplate of each bearing from the pier plan (L 1000 across, W 1100 along)', () => { - expect((bearingPlanSvg(separate, 0).match(/width="1000" height="1100"/g) ?? []).length).toBe(2); + it('draws the solplate of each bearing from its plan (L = A + 100 = 800, W = B + 100 = 740, 2026-11-06)', () => { + expect((bearingPlanSvg(separate, 0).match(/width="800" height="740"/g) ?? []).length).toBe(2); }); it('an unknown pair index gives an empty drawing', () => { diff --git a/tests/bearing-section-svg.test.ts b/tests/bearing-section-svg.test.ts index b539c8d..6c61335 100644 --- a/tests/bearing-section-svg.test.ts +++ b/tests/bearing-section-svg.test.ts @@ -1,10 +1,13 @@ import { describe, expect, it } from 'vitest'; import { buildPier } from '../src/core/geometry'; import { bearingSectionSvg } from '../src/view/bearing-section-svg'; -import { loadP1 } from './helpers'; +import { loadP1, p1Pairs } from './helpers'; -const model = buildPier(loadP1()); -const integrated = buildPier({ ...loadP1(), bearingLayout: '일체' }); +// Bridge context (A1 ↔ P1 girder pairs) as in the app, so the solplate slope is computed (2026-11-06). +const P1CTX = { pairs: p1Pairs() }; + +const model = buildPier(loadP1(), P1CTX); +const integrated = buildPier({ ...loadP1(), bearingLayout: '일체' }, P1CTX); describe('교량받침 상세 단면 (승인 2026-10-26, C안 2026-11-03)', () => { it('labels EL.X at the mortar top and the block height h1 for each bearing', () => { diff --git a/tests/bearing-table.test.ts b/tests/bearing-table.test.ts index c34530c..ca2a4a6 100644 --- a/tests/bearing-table.test.ts +++ b/tests/bearing-table.test.ts @@ -1,10 +1,13 @@ import { describe, expect, it } from 'vitest'; import { buildPier } from '../src/core/geometry'; import { elevationRows, pairCopingSpans, solplateRows } from '../src/core/bearing-table'; -import { loadP1 } from './helpers'; +import { loadP1, p1Pairs } from './helpers'; -const model = buildPier(loadP1()); -const integrated = buildPier({ ...loadP1(), bearingLayout: '일체' }); +// Bridge context (A1 ↔ P1 girder pairs) as in the app, so the solplate slope is computed (2026-11-06). +const P1CTX = { pairs: p1Pairs() }; + +const model = buildPier(loadP1(), P1CTX); +const integrated = buildPier({ ...loadP1(), bearingLayout: '일체' }, P1CTX); describe('교량받침 ELEVATION TABLE (모델값, 2026-11-03)', () => { const rows = elevationRows(model); @@ -15,9 +18,9 @@ describe('교량받침 ELEVATION TABLE (모델값, 2026-11-03)', () => { expect(rows[5].elx).toBeCloseTo(43.611, 3); }); - it('front block 100 mm, back block 150 mm; mortar 50; bearing 186; solplate 25; E = sum', () => { - expect(rows[0]).toMatchObject({ h1: 100, h2: 50, h3: 186, h4: 25, E: 361 }); - expect(rows[5]).toMatchObject({ h1: 150, h2: 50, h3: 186, h4: 25, E: 411 }); + it('front block 100 mm, back block 150 mm; mortar 50; bearing 181; solplate 30 (T = T_ref → no drop); E = sum', () => { + expect(rows[0]).toMatchObject({ h1: 100, h2: 50, h3: 181, h4: 30, E: 361 }); + expect(rows[5]).toMatchObject({ h1: 150, h2: 50, h3: 181, h4: 30, E: 411 }); }); it('C1 is 600 for a 분리 bearing; C6 is the 1200 block depth; C4 and C5 are 600 each', () => { @@ -47,12 +50,16 @@ describe('교량받침 ELEVATION TABLE (모델값, 2026-11-03)', () => { }); describe('솔플레이트 TABLE (모델값, 2026-11-03)', () => { - it('10 rows with S −0.5, L 1000, W 1100, T 25, T1 27.5, T2 22.5; the check holds', () => { + it('10 rows: S from the A1 pair (−1.908 %), L 800, W 740, T 30, T1 38, T2 22; W08 holds within 1 mm (2026-11-06)', () => { const rows = solplateRows(model); expect(rows.length).toBe(10); - expect(rows[0]).toMatchObject({ S: -0.5, L: 1000, W: 1100, T: 25, T1: 27.5, T2: 22.5, ok: true }); - expect(rows[0].checkLhs).toBeCloseTo(5, 9); - expect(rows[0].checkRhs).toBeCloseTo(5, 9); + expect(rows[0]).toMatchObject({ L: 800, W: 740, T: 30, T1: 38, T2: 22, ok: true, pair: 'A1-①', source: '계산' }); + expect(rows[0].S).toBeCloseTo(-1.908, 3); + expect(rows[0].checkLhs).toBe(16); + expect(rows[0].checkRhs).toBeCloseTo(15.265, 2); + // 후열 ⑥: no pair (P2 not loaded) → the front twin ① value, 가정 + WS03. + expect(rows[5]).toMatchObject({ source: '가정', pair: undefined, T: 30 }); + expect(rows[5].warnings).toContain('WS03'); }); }); diff --git a/tests/coping-steps.test.ts b/tests/coping-steps.test.ts index 3848b0f..a22a50a 100644 --- a/tests/coping-steps.test.ts +++ b/tests/coping-steps.test.ts @@ -1,8 +1,11 @@ import { describe, expect, it } from 'vitest'; import { buildPier } from '../src/core/geometry'; -import { loadP1 } from './helpers'; +import { loadP1, p1Pairs } from './helpers'; -const m = buildPier(loadP1()); +// Bridge context (A1 ↔ P1 girder pairs) as in the app, so the solplate slope is computed (2026-11-06). +const P1CTX = { pairs: p1Pairs() }; + +const m = buildPier(loadP1(), P1CTX); const front = m.blocks.filter((b) => b.row === '전열'); const back = m.blocks.filter((b) => b.row === '후열'); diff --git a/tests/helpers.ts b/tests/helpers.ts index 0f81f16..9e1ebb3 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -3,6 +3,9 @@ import { fileURLToPath } from 'node:url'; import * as XLSX from 'xlsx'; import { parseWorkbook } from '../src/core/parseWorkbook'; import type { PierInput } from '../src/core/types'; +import { parseAbutments } from '../src/core/abut/parse'; +import { type BearingPair, girderPairs } from '../src/core/bearing-pair'; +import { buildPier, type PierModel } from '../src/core/geometry'; /** Spec workbook (RAG/Pier ui_xxx.xlsx, sheet intent.PR, all tables on one sheet). Used by single-sheet tests. */ const SPEC_PATH = fileURLToPath(new URL('../public/samples/P1.xlsx', import.meta.url)); @@ -24,3 +27,22 @@ export function loadP1(): PierInput { if (!p1) throw new Error('P1 not found'); return p1; } + +/** + * P1 with its bridge context (2026-11-06): the girder pairs A1 ①–⑤ ↔ P1 ①–⑤ from the same input workbook, so the + * solplate slope S is computed (−1.908 … −1.902 %, T 30) as in the app where A1 is loaded with P1. + */ +export function p1Pairs(): BearingPair[] { + const a1 = parseAbutments(loadInputWorkbook()).abutments.find((a) => a.name === 'A1'); + if (!a1) throw new Error('A1 not found'); + const p1 = loadP1(); + return girderPairs([ + { name: 'A1', bearings: a1.bearings.map((b) => ({ no: b.no, x: b.pos.e, y: b.pos.n, z: b.z, azimuthDeg: b.azimuthDeg! })) }, + { name: p1.name, bearings: p1.bearings.map((b) => ({ no: b.no, x: b.x, y: b.y, z: b.z, azimuthDeg: b.azimuthDeg })) }, + ]); +} + +/** buildPier for P1 (with changes) in its bridge context. */ +export function buildP1(changes: Partial = {}): PierModel { + return buildPier({ ...loadP1(), ...changes }, { pairs: p1Pairs() }); +} diff --git a/tests/p1-regression.test.ts b/tests/p1-regression.test.ts index 24b6960..0e9c75a 100644 --- a/tests/p1-regression.test.ts +++ b/tests/p1-regression.test.ts @@ -1,13 +1,16 @@ import { describe, expect, it } from 'vitest'; import { buildPier } from '../src/core/geometry'; -import { loadP1 } from './helpers'; +import { loadP1, p1Pairs } from './helpers'; + +// Bridge context (A1 ↔ P1 girder pairs) as in the app, so the solplate slope is computed (2026-11-06). +const P1CTX = { pairs: p1Pairs() }; /** * Regression against spec.md §12 (P1, 운곡교, T형, 말뚝기초). * Coordinates and EL: ±1 mm (0.001 m). Angles: ±0.01°. */ const TOL = 0.001; -const model = buildPier(loadP1()); +const model = buildPier(loadP1(), P1CTX); describe('P1 regression — spec.md §12', () => { it('axis length and azimuth', () => { diff --git a/tests/rebar-bearing.test.ts b/tests/rebar-bearing.test.ts index 7493ea4..129f102 100644 --- a/tests/rebar-bearing.test.ts +++ b/tests/rebar-bearing.test.ts @@ -2,7 +2,10 @@ import { describe, expect, it } from 'vitest'; import { buildPier } from '../src/core/geometry'; import { barsAcross, bearingBlockRebar, blockBars, totals } from '../src/core/rebar/bearing-block'; import { rebarSummary } from '../src/core/rebar/summary'; -import { loadP1 } from './helpers'; +import { loadP1, p1Pairs } from './helpers'; + +// Bridge context (A1 ↔ P1 girder pairs) as in the app, so the solplate slope is computed (2026-11-06). +const P1CTX = { pairs: p1Pairs() }; describe('받침블럭 철근 (spec §6.3, plan C2)', () => { it('a 1200 block takes 8 bars at 125 mm: 7 gaps = 875, ends 162.5 each', () => { @@ -18,7 +21,7 @@ describe('받침블럭 철근 (spec §6.3, plan C2)', () => { }); it('coping embedment from the model block height (block top − coping top: front 100, back 150): S2 650 / 600, S1 669.1 / 619.1', () => { - const m = buildPier(loadP1()); + const m = buildPier(loadP1(), P1CTX); const rebar = bearingBlockRebar(m); const emb = (row: '전열' | '후열', name: 'S1' | 'S2') => rebar.find((b) => b.row === row)!.bars.find((r) => r.name === name)!.embedment; expect(emb('전열', 'S2')).toBeCloseTo(650, 6); @@ -28,7 +31,7 @@ describe('받침블럭 철근 (spec §6.3, plan C2)', () => { }); it('P1: 10 blocks, 80 bars each of S1 and S2 (spec table: 8 本 per block)', () => { - const m = buildPier(loadP1()); + const m = buildPier(loadP1(), P1CTX); const rebar = bearingBlockRebar(m); expect(rebar).toHaveLength(10); expect(totals(rebar)).toEqual({ S1: 80, S2: 80 }); @@ -52,7 +55,7 @@ describe('받침블럭 도면 재현 (spec §6.3)', () => { describe('받침블럭 묻힘 검토 (spec §9 RB-S04)', () => { it('P1: embedments 619–669 mm are shorter than the H19 development length 882, so RB-S04 is raised and the rows show the range', () => { - const { rows, warnings } = rebarSummary(buildPier(loadP1())); + const { rows, warnings } = rebarSummary(buildPier(loadP1(), P1CTX)); expect(warnings.some((w) => w.code === 'RB-S04')).toBe(true); expect(rows.find((r) => r.name === 'S1')!.note).toContain('묻힘 619–669'); expect(rows.find((r) => r.name === 'S2')!.note).toContain('묻힘 600–650'); diff --git a/tests/scene.test.ts b/tests/scene.test.ts index cf28317..7d53a7e 100644 --- a/tests/scene.test.ts +++ b/tests/scene.test.ts @@ -2,9 +2,12 @@ import * as THREE from 'three'; import { describe, expect, it } from 'vitest'; import { buildPier } from '../src/core/geometry'; import { buildPierScene, frameMatrix } from '../src/view/scene'; -import { loadP1 } from './helpers'; +import { loadP1, p1Pairs } from './helpers'; -const model = buildPier(loadP1()); +// Bridge context (A1 ↔ P1 girder pairs) as in the app, so the solplate slope is computed (2026-11-06). +const P1CTX = { pairs: p1Pairs() }; + +const model = buildPier(loadP1(), P1CTX); const M = frameMatrix(model); /** Scene coordinates of a national-grid point at EL 0 (x = E − pivot E, z = −(N − pivot N)). */ @@ -29,11 +32,11 @@ describe('3D frame (view/scene.ts)', () => { expect(mapped.distanceTo(expected)).toBeLessThan(1e-6); }); - it('bounding box spans pile tip (2.208 m) to the highest solplate top (back bearing 5: pad top 43.898 m + h3 0.186 m + T1 0.0275 m)', () => { + it('bounding box spans pile tip (2.208 m) to the highest solplate top (back bearing 5: pad top 43.898 m + h3 0.181 m + T1 0.038 m)', () => { const scene = buildPierScene(model); const box = new THREE.Box3().setFromObject(scene.root); expect(box.min.y).toBeCloseTo(2.208, 2); - expect(box.max.y).toBeCloseTo(43.898 + 0.186 + 0.0275, 3); + expect(box.max.y).toBeCloseTo(43.898 + 0.181 + 0.038, 3); }); it('builds the expected part counts for P1', () => { diff --git a/tests/seat-el.test.ts b/tests/seat-el.test.ts index 79f172e..0f299e9 100644 --- a/tests/seat-el.test.ts +++ b/tests/seat-el.test.ts @@ -2,7 +2,10 @@ 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'; +import { loadP1, p1Pairs } from './helpers'; + +// Bridge context (A1 ↔ P1 girder pairs) as in the app, so the solplate slope is computed (2026-11-06). +const P1CTX = { pairs: p1Pairs() }; // 교좌면 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]; @@ -13,8 +16,8 @@ function withSeat(input: PierInput, seats: number[]): PierInput { 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)); + const base = buildPier(loadP1(), P1CTX); + const seated = buildPier(withSeat(loadP1(), SEAT), P1CTX); for (const b of base.blocks) { const s = seated.blocks.find((x) => x.no === b.no)!; expect(s.bottom).toBeCloseTo(b.bottom, 6); @@ -23,20 +26,20 @@ describe('교좌면 EL 입력 (2026-11-03)', () => { }); 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 m = buildPier(withSeat(loadP1(), [43.4, ...SEAT.slice(1)]), P1CTX); 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()); + const m = buildPier(loadP1(), P1CTX); 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 m = buildPier(input, P1CTX); const issues: Issue[] = []; validatePier(input, m, issues); const w = issues.filter((i) => i.code === 'W10'); @@ -47,7 +50,7 @@ describe('교좌면 EL 입력 (2026-11-03)', () => { it('no W10 for the P1 values', () => { const input = withSeat(loadP1(), SEAT); - const m = buildPier(input); + const m = buildPier(input, P1CTX); const issues: Issue[] = []; validatePier(input, m, issues); expect(issues.filter((i) => i.code === 'W10')).toEqual([]); diff --git a/tests/solplate-scene.test.ts b/tests/solplate-scene.test.ts index 50bdd22..89255ab 100644 --- a/tests/solplate-scene.test.ts +++ b/tests/solplate-scene.test.ts @@ -3,22 +3,64 @@ import * as THREE from 'three'; import { buildPier } from '../src/core/geometry'; import { solplateGeometry } from '../src/core/solplate'; import { buildPierScene } from '../src/view/scene'; -import { loadP1 } from './helpers'; +import { loadP1, p1Pairs } from './helpers'; -describe('솔플레이트 모델과 3D (C안, 2026-11-03)', () => { - it('every pier has a solplate plan from its rule; the default gives T 25 and h3 186', () => { - const m = buildPier(loadP1()); - expect(m.blocks.every((b) => b.plan.T === 25 && b.plan.h3 === 186)).toBe(true); +// Bridge context (A1 ↔ P1 girder pairs) as in the app, so the solplate slope is computed (2026-11-06). +const P1CTX = { pairs: p1Pairs() }; +const FRONT = { 1: 3, 2: 3, 3: 3, 4: 3, 5: 3 }; + +describe('솔플레이트 모델과 3D (결정 2026-11-06, Q-SH1–9)', () => { + it('every bearing: h3 181, L 800 × W 740 (A 700 × B 640 + 100), T 30, T1 38 / T2 22', () => { + const m = buildPier(loadP1(), P1CTX); + expect(m.blocks.every((b) => b.plan.h3 === 181 && b.plan.L === 800 && b.plan.W === 740 && b.plan.T === 30 && b.plan.T1 === 38 && b.plan.T2 === 22)).toBe(true); }); - it('a pier rule changes its own plan: 받침높이 211 and slope −1 % give T 27 and h3 184', () => { - const m = buildPier({ ...loadP1(), solplate: { heightMm: 211, lengthMm: 1000, widthMm: 1100, slopePct: -1 } }); - expect(m.blocks[0].plan.T).toBe(27); - expect(m.blocks[0].plan.h3).toBe(184); + it('S 3 % typed on the front row → T 34, T1 46 / T2 22, and the front blocks drop 4 mm (girder underside kept)', () => { + const base = buildPier(loadP1(), P1CTX); + const m = buildPier({ ...loadP1(), sole: { slopePct: FRONT } }, P1CTX); + const b1 = m.blocks.find((b) => b.no === 1)!, b0 = base.blocks.find((b) => b.no === 1)!; + expect(b1.plan).toMatchObject({ T: 34, T1: 46, T2: 22 }); + expect(b1.top).toBeCloseTo(b0.top - 0.004, 9); + expect(b1.padTop + (b1.plan.h3 + b1.plan.T) / 1000).toBeCloseTo(b0.padTop + (b0.plan.h3 + b0.plan.T) / 1000, 9); + expect(m.soles.find((s) => s.no === 1)!.slopeSource).toBe('수정됨'); + // The back row keeps its own T (30): one T per row (Q-SH9). + expect(m.blocks.find((b) => b.no === 6)!.plan.T).toBe(30); + }); + + it('WS02: T typed 30 at S 3 % leaves T2 18 < 22', () => { + const m = buildPier({ ...loadP1(), sole: { slopePct: FRONT, tByRow: { 전열: 30 } } }, P1CTX); + const s = m.soles.find((x) => x.no === 1)!; + expect(s).toMatchObject({ T: 30, tTyped: true, T2: 18 }); + expect(s.warnings).toContain('WS02'); + }); + + it('without a pair or 종단경사 the slope is 0 (WS03): T 22, the block rises 8 mm', () => { + const alone = buildPier(loadP1()); + expect(alone.soles.every((s) => s.slopePct === 0 && s.warnings.includes('WS03') && s.T === 22)).toBe(true); + const withSlope = buildPier({ ...loadP1(), longitudinalSlope: -0.0191 }); + expect(withSlope.soles.every((s) => s.slopeSource === '입력' && Math.abs(s.slopePct + 1.91) < 1e-9 && s.T === 30)).toBe(true); + }); + + it('T1 sits on the 오르막 쪽: the thick end of P1 ① points towards A1 (the higher girder end)', () => { + const m = buildPier(loadP1(), P1CTX); + const b = m.blocks.find((x) => x.no === 1)!; + const g = solplateGeometry(b, b.plan); + // Top corners: index (ai·2 + ci)·2 + 1; the thick end has the larger top. + const tops = [0, 1, 2, 3].map((q) => g.corners[q * 2 + 1]); + const thick = tops.filter((p) => p[1] - g.bottom > (g.T1 + g.T2) / 2), thin = tops.filter((p) => p[1] - g.bottom < (g.T1 + g.T2) / 2); + const mid = (ps: number[][]) => [0, 2].map((i) => ps.reduce((s, p) => s + p[i], 0) / ps.length); + const [ds, dn] = [mid(thick)[0] - mid(thin)[0], mid(thick)[1] - mid(thin)[1]]; + const toThick = { e: ds * m.u.e + dn * m.nrm.e, n: ds * m.u.n + dn * m.nrm.n }; + const p1 = loadP1().bearings[0]; + const pair = P1CTX.pairs.find((p) => p.to.no === 1)!; + // A1 lies against the azimuth direction from P1 (pairs look ahead from A1 to P1). + const az = (p1.azimuthDeg * Math.PI) / 180; + expect(pair.uphill).toBe('A1'); + expect(toThick.e * -Math.sin(az) + toThick.n * -Math.cos(az)).toBeGreaterThan(0); }); it('the 3D scene has one solplate wedge per bearing, and its highest point is the thick end of the tallest one', () => { - const m = buildPier(loadP1()); + const m = buildPier(loadP1(), P1CTX); const scene = buildPierScene(m); const wedges = scene.parts.solplates.children; expect(wedges.length).toBe(10); @@ -27,13 +69,11 @@ describe('솔플레이트 모델과 3D (C안, 2026-11-03)', () => { expect(box.max.y).toBeCloseTo(top, 3); }); - it('the 교량받침 본체 is one box per bearing: the plan less 100 mm on each side, height h3 (186 mm)', () => { - const scene = buildPierScene(buildPier(loadP1())); + it('the 교량받침 본체 is one box per bearing, height h3 (181 mm)', () => { + const scene = buildPierScene(buildPier(loadP1(), P1CTX)); const bodies = scene.parts.bearings.children; expect(bodies.length).toBe(10); const one = new THREE.Box3().setFromObject(bodies[0]); - expect(one.max.y - one.min.y).toBeCloseTo(0.186, 3); - // Body: L − 200 = 800 across the 교각축 직각, W − 200 = 900 along the 교축; the row skew turns it a few degrees. - expect(Math.max(one.max.x - one.min.x, one.max.z - one.min.z)).toBeGreaterThan(0.85); + expect(one.max.y - one.min.y).toBeCloseTo(0.181, 3); }); });