From d7a61df7a91d0d35dcd0095fa7dbda26540e79b6 Mon Sep 17 00:00:00 2001 From: ypshim Date: Tue, 10 Nov 2026 15:11:25 +0900 Subject: [PATCH] =?UTF-8?q?feat(bearing):=20=EB=B0=9B=EC=B9=A8=EB=B3=84=20?= =?UTF-8?q?=EB=B0=9B=EC=B9=A8=EB=86=92=EC=9D=B4=EB=A1=9C=20h3=EB=A5=BC=20?= =?UTF-8?q?=EC=A0=95=ED=95=9C=EB=8B=A4=20(=EA=B5=90=EA=B0=81=C2=B7?= =?UTF-8?q?=EA=B5=90=EB=8C=80,=20AP07=20=ED=8F=90=EC=A7=80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 받침 시트의 받침높이_mm(9열)를 받침마다 읽어 h3 = H − T_ref로 쓴다. 거더 하면 = z + H가 받침마다 지켜진다. 솔플레이트 입력에 h3를 적으면 그 값이 모든 받침에 우선한다. 받침높이가 없으면 기존 기본값(교각 181, 교대 교대 값 − T_ref). 교대는 받침마다 달라도 받침별 값을 쓰므로 AP07(첫 값 사용) 경고를 없앤다. 교각 받침높이가 T_ref 이하이면 S01(E). 입력 양식 받침 표에 받침높이_mm 열을 추가한다. Co-Authored-By: Claude Haiku 5.5 --- src/core/abut/bearing-table.ts | 6 ++- src/core/abut/geometry.ts | 6 ++- src/core/abut/parse.ts | 9 ++-- src/core/geometry.ts | 7 ++- src/core/parseWorkbook.ts | 2 + src/core/sole-plate.ts | 6 ++- src/core/template.ts | 3 +- src/core/types.ts | 2 + src/core/validate.ts | 10 ++++ tests/bearing-height.test.ts | 83 ++++++++++++++++++++++++++++++++++ 10 files changed, 121 insertions(+), 13 deletions(-) create mode 100644 tests/bearing-height.test.ts diff --git a/src/core/abut/bearing-table.ts b/src/core/abut/bearing-table.ts index 22d42b2..a8662b4 100644 --- a/src/core/abut/bearing-table.ts +++ b/src/core/abut/bearing-table.ts @@ -4,7 +4,7 @@ * - 솔플레이트 (결정 2026-11-06, spec_abut §5·QA-15, src/core/sole-plate.ts): S = 같은 거더의 짝 받침(A1 ↔ P1 전열, * A2 ↔ Pn)으로 계산, 짝이 없으면 교대 종단경사 i(엑셀 Abut). L = A + 100, W = B + 100 (A × B 기본 700 × 640, 교각 P1 * 전열 값 가정). 받침이 1열이라 T는 교대 전체에 하나: max(22, ⌈22 + L·|S|/2⌉). 거더 하면 고정 → 블럭 상면 = z − (T − T_ref). - * - h3 기본값 = 받침 시트의 받침높이 − T_ref (A1 275 − 30 = 245): 엑셀 받침높이가 정한 거더 하면을 그대로 둔다 + * - h3 기본값 = 받침 시트의 받침높이 − T_ref (A1 275 − 30 = 245), 받침마다 그 받침의 값 (2026-11-10): 엑셀 받침높이가 정한 거더 하면을 그대로 둔다 * (시안은 교각 P1의 h3 181을 가정했으나 교대 시트에 받침높이가 있어 그 값을 지킨다). * - 높이: E = h1 (블럭 콘크리트) + h2 (몰탈패드 50) + h3 + h4 (= T). z = 패드 상면 = EL.X. */ @@ -62,7 +62,9 @@ export function abutSoles(model: AbutModel, rule: AbutBearingRule, heightMm: num model.name, model.bearings.map((b) => ({ no: b.no, row: '1열', azimuthDeg: b.azimuthDeg ?? 0, inputAzimuthDeg: ctx.inputAzimuthDeg?.[String(b.no)] })), { - h3Mm: rule.h3Mm ?? heightMm - rule.tRefMm, tRefMm: rule.tRefMm, tMinMm: rule.tMinMm, marginMm: rule.marginMm, plates, + // A typed h3 is for every bearing; otherwise h3 = 그 받침의 받침높이 (없으면 교대 값) − T_ref. + h3Mm: rule.h3Mm, tRefMm: rule.tRefMm, + h3ByNo: Object.fromEntries(model.bearings.map((b) => [String(b.no), (b.heightMm ?? heightMm) - rule.tRefMm])), tMinMm: rule.tMinMm, marginMm: rule.marginMm, plates, tByRow: rule.tMm === undefined ? undefined : { '1열': rule.tMm }, slopePct: rule.slopePct === undefined ? undefined : Object.fromEntries(model.bearings.map((b) => [String(b.no), rule.slopePct!])), }, diff --git a/src/core/abut/geometry.ts b/src/core/abut/geometry.ts index 3f99de5..88df2d3 100644 --- a/src/core/abut/geometry.ts +++ b/src/core/abut/geometry.ts @@ -30,6 +30,8 @@ export interface AbutBearing { seatEl?: number; /** 받침 방위각 (deg, model-xy: 북에서 시계방향, 교각과 같은 변환 swapXYAzimuth). 없으면 블럭은 교대축에 맞춘다. */ azimuthDeg?: number; + /** 이 받침의 받침높이 H (mm, 2026-11-10). 없으면 교대 받침높이를 쓴다. */ + heightMm?: number; } /** 날개벽 형식, 측별(spec_abut §4.7): 일반형 / 단축형 / 없음. */ @@ -87,6 +89,8 @@ export interface AbutBearingModel { blockHeightMm: number; /** 받침 방위각 (deg, model-xy). 입력이 없으면 undefined. */ azimuthDeg?: number; + /** 이 받침의 받침높이 H (mm). 입력이 없으면 undefined. */ + heightMm?: number; /** 블럭 평면 회전 (deg, −90 … 90): 받침 방위각과 교대축 직각(전면) 방향 사이의 각. 장면에서는 rotateY(이 값). 입력이 없으면 0. */ rotDeg: number; } @@ -164,7 +168,7 @@ export function buildAbut(input: AbutInput): AbutModel { const s = dot(sub(b.pos, input.ip1), u); const seatEl = b.seatEl ?? input.seatEl + (b.z - minZ); const blockHeightMm = Math.round((b.z - seatEl) * 1000); - return { no: b.no, s, lateral: lateral[i], z: b.z, seatEl, blockHeightMm, azimuthDeg: b.azimuthDeg, rotDeg: bearingRotDeg(b.azimuthDeg, u, frontVec) }; + return { no: b.no, s, lateral: lateral[i], z: b.z, seatEl, blockHeightMm, azimuthDeg: b.azimuthDeg, heightMm: b.heightMm, rotDeg: bearingRotDeg(b.azimuthDeg, u, frontVec) }; }); const sorted = [...bearings].sort((a, b) => a.s - b.s); diff --git a/src/core/abut/parse.ts b/src/core/abut/parse.ts index bdee227..133f6d2 100644 --- a/src/core/abut/parse.ts +++ b/src/core/abut/parse.ts @@ -58,7 +58,7 @@ export interface AbutRecord { ip2: AbutPoint; ip3: AbutPoint; bearings: AbutRecordBearing[]; - /** 받침 행에서 읽은 받침높이 (mm). 모든 받침이 같아야 하며, 다르면 AP07 경고. */ + /** 첫 받침의 받침높이 (mm): 받침별 값이 없을 때의 기본값. 받침마다 다르면 받침별 값을 쓴다 (2026-11-10, AP07 폐지). */ bearingHeightMm: number; /** 측별 날개벽 형식 (편집 카드에서 정함). Absent = 양측 일반형. */ wings?: { left: WingForm; right: WingForm }; @@ -220,9 +220,8 @@ export function parseAbutments(wb: XLSX.WorkBook, reservedNames: readonly string const missing = [ab.ip1, ab.ip2, ab.ip3].some((p) => !Number.isFinite(p.e) || !Number.isFinite(p.n)); if (missing) issues.push({ code: 'AP05', level: 'E', pier: ab.name, sheet: ab.sheet, message: `교대 ${ab.name}: IP1·IP2·IP3 중 빠진 점이 있습니다.` }); if (ab.bearings.length < 2) issues.push({ code: 'AP06', level: 'E', pier: ab.name, sheet: ab.sheet, message: `교대 ${ab.name}: 받침이 2개 이상 필요합니다.` }); - const heights = [...new Set(ab.bearings.map((b) => b.bearingHeightMm))]; - if (heights.length > 1) issues.push({ code: 'AP07', level: 'W', pier: ab.name, sheet: ab.sheet, message: `교대 ${ab.name}: 받침높이가 받침마다 다릅니다 (${heights.join(', ')} mm). 첫 값을 씁니다.` }); - ab.bearingHeightMm = heights[0] ?? NaN; + // 받침높이가 받침마다 달라도 받침별 값을 쓴다 (2026-11-10). 교대 값은 첫 받침의 값. + ab.bearingHeightMm = ab.bearings[0]?.bearingHeightMm ?? NaN; } return { abutments: abutments.filter((a) => Number.isFinite(a.ip1.e) && a.bearings.length >= 2), issues }; @@ -238,7 +237,7 @@ export function toAbutInput(rec: AbutRecord): AbutInput { ip2: rec.ip2, ip3: rec.ip3, // 계산 방위각을 적용한 받침 (2026-11-06) use that value; the record keeps the workbook value. - bearings: rec.bearings.map((b) => ({ no: b.no, pos: b.pos, z: b.z, seatEl: b.seatEl, azimuthDeg: rec.azimuthApplied?.[String(b.no)] ?? b.azimuthDeg })), + bearings: rec.bearings.map((b) => ({ no: b.no, pos: b.pos, z: b.z, seatEl: b.seatEl, azimuthDeg: rec.azimuthApplied?.[String(b.no)] ?? b.azimuthDeg, heightMm: Number.isFinite(b.bearingHeightMm) ? b.bearingHeightMm : undefined })), skewDeg: rec.skewDeg, slopeI: rec.slopeI, wings: rec.wings, diff --git a/src/core/geometry.ts b/src/core/geometry.ts index 2f9bb9a..cfa905b 100644 --- a/src/core/geometry.ts +++ b/src/core/geometry.ts @@ -7,7 +7,7 @@ import { type FootingRebarSettings, DEFAULT_FOOTING_REBAR } from './rebar/footin import { layoutText, resolveRowCounts, rowLayout, type PileGeometry } from './piles'; import type { PierInput, Row, SolplatePlan } from './types'; import type { BearingPair } from './bearing-pair'; -import { type BearingSole, structureSoles } from './sole-plate'; +import { type BearingSole, SOLE_DEFAULTS, structureSoles } from './sole-plate'; import { type CopingGeom, P1_COPING_GEOM } from './coping-geom'; import { castShape } from './cast-in-place-shape'; @@ -467,8 +467,11 @@ export function buildPier(input: PierInput, ctx: PierContext = {}): PierModel { const skewOf = (no: number, k: number) => rowSkewDeg(bearingAz.get(no) ?? NaN, axis.segments[k].u); // 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. + // 받침높이 per bearing (2026-11-10): h3 = H − T_ref keeps the girder underside at z + H; a typed h3 still wins. + const tRef = input.sole?.tRefMm ?? SOLE_DEFAULTS.tRefMm; + const h3ByNo = Object.fromEntries(input.bearings.filter((b) => b.heightMm !== undefined).map((b) => [String(b.no), b.heightMm! - tRef])); const soles = new Map(structureSoles(input.name, input.bearings.map((b) => ({ no: b.no, row: b.row, azimuthDeg: azimuthOf(b), inputAzimuthDeg: b.azimuthDeg })), - input.sole, ctx.pairs ?? [], input.longitudinalSlope === undefined ? undefined : input.longitudinalSlope * 100).map((s) => [s.no, s])); + { ...input.sole, h3ByNo }, 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. diff --git a/src/core/parseWorkbook.ts b/src/core/parseWorkbook.ts index 07ddbf4..f64b01e 100644 --- a/src/core/parseWorkbook.ts +++ b/src/core/parseWorkbook.ts @@ -188,5 +188,7 @@ function toBearing(c: Cell[]): Bearing { row: String(c[6]) as Row, // 교좌면_EL (m), optional: blank = not given, so the model keeps the 두부보 상면 slope. seatEl: c[7] === undefined || c[7] === null || c[7] === '' ? undefined : Number(c[7]), + // 받침높이_mm, optional (2026-11-10): blank = the pier h3 setting. + heightMm: c[8] === undefined || c[8] === null || c[8] === '' ? undefined : Number(c[8]), }; } diff --git a/src/core/sole-plate.ts b/src/core/sole-plate.ts index 71789ea..28a23cd 100644 --- a/src/core/sole-plate.ts +++ b/src/core/sole-plate.ts @@ -12,8 +12,10 @@ import { type BearingPair, pairOf } from './bearing-pair'; export interface SoleSettings { - /** 받침 본체 높이 h3 (mm). */ + /** 받침 본체 높이 h3 (mm), typed for the whole structure. It wins over h3ByNo. */ h3Mm?: number; + /** h3 per bearing number (mm), from that bearing's 받침높이 − T_ref (2026-11-10). Absent = h3Mm or the default. */ + h3ByNo?: Record; /** T_ref: the solplate thickness the workbook z assumed (mm). */ tRefMm?: number; /** T_min: the thin-end minimum (mm). */ @@ -130,7 +132,7 @@ export function structureSoles(structure: string, bearings: SoleBearing[], setti azimuthInputDeg: b.inputAzimuthDeg ?? b.azimuthDeg, azimuthCalcDeg: pair ? (pair.from.structure === structure && pair.from.no === b.no ? pair.fromCalcDeg : pair.toCalcDeg) : undefined, azimuthCheckSec: pair ? (pair.from.structure === structure && pair.from.no === b.no ? pair.fromCheckSec : pair.toCheckSec) : undefined, - slopePct, slopeSource: source, aMm, bMm, L, W, T, tTyped: typed, T1, T2, tReq: req[i], h3: s.h3Mm, + slopePct, slopeSource: source, aMm, bMm, L, W, T, tTyped: typed, T1, T2, tReq: req[i], h3: settings.h3Mm ?? s.h3ByNo?.[String(b.no)] ?? s.h3Mm, dropMm: T - s.tRefMm, uphill: sign ? { e: sign * dir.e, n: sign * dir.n } : null, warnings, }; }); diff --git a/src/core/template.ts b/src/core/template.ts index b382e4f..b3e6e2c 100644 --- a/src/core/template.ts +++ b/src/core/template.ts @@ -22,7 +22,8 @@ export function buildTemplateWorkbook(): XLSX.WorkBook { const bearing = [ note('받침 1행당 받침 1개. 전열·후열 수는 같아야 합니다. 위치는 전열 또는 후열.'), note('z_m은 받침 EL(m), 방위각은 거더 방향입니다.'), - ['교각명', '받침번호', 'x_m', 'y_m', 'z_m', '방위각', '위치', '교좌면_EL'], + note('받침높이_mm는 패드 상면(z)에서 솔플레이트 상면까지입니다. 비우면 교각의 받침 높이 h3 설정을 씁니다.'), + ['교각명', '받침번호', 'x_m', 'y_m', 'z_m', '방위각', '위치', '교좌면_EL', '받침높이_mm'], ]; XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(pier), '교각'); diff --git a/src/core/types.ts b/src/core/types.ts index cf6ed53..92e75a0 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -26,6 +26,8 @@ export interface Bearing { row: Row; /** 교좌면 EL (m): the block bottom under this bearing. Absent = the 두부보 상면 slope (the coping top). */ seatEl?: number; + /** 받침높이 H (mm) of this bearing: pad top (z) to solplate top, so h3 = H − T_ref (2026-11-10). Absent = the pier h3. */ + heightMm?: number; } export interface PierInput { diff --git a/src/core/validate.ts b/src/core/validate.ts index 68a6fd0..8026b06 100644 --- a/src/core/validate.ts +++ b/src/core/validate.ts @@ -5,6 +5,7 @@ import { integratedCheck } from './bearing-layout'; import { checkBlockRebar } from './rebar/block-check'; import { castSettingsIssues } from './rebar/cast-in-place'; import { castShape, castShapeIssues } from './cast-in-place-shape'; +import { SOLE_DEFAULTS } from './sole-plate'; const EXPECTED_COLUMNS: Record = { 'T형': 1, '2주형': 2, '3주형': 3 }; @@ -34,6 +35,15 @@ export function validateInput(input: PierInput, issues: Issue[]): boolean { }); ok = false; } + // 받침높이 per bearing (2026-11-10): h3 = H − T_ref must be positive unless the pier h3 is typed. + if (input.sole?.h3Mm === undefined) { + const tRef = input.sole?.tRefMm ?? SOLE_DEFAULTS.tRefMm; + for (const b of input.bearings) { + if (b.heightMm === undefined || (Number.isFinite(b.heightMm) && b.heightMm - tRef > 0)) continue; + issues.push({ code: 'S01', level: 'E', pier: input.name, message: `받침 ${b.no}번 받침높이 ${b.heightMm} mm가 T_ref ${tRef} mm보다 커야 받침 본체 h3를 만들 수 있습니다.` }); + ok = false; + } + } const front = input.bearings.filter((b) => b.row === '전열').length; const back = input.bearings.filter((b) => b.row === '후열').length; if (front === 0 || back === 0) { diff --git a/tests/bearing-height.test.ts b/tests/bearing-height.test.ts new file mode 100644 index 0000000..3cff0f8 --- /dev/null +++ b/tests/bearing-height.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from 'vitest'; +import { buildPier } from '../src/core/geometry'; +import { validateInput } from '../src/core/validate'; +import { parseAbutments, toAbutInput } from '../src/core/abut/parse'; +import { buildAbut } from '../src/core/abut/geometry'; +import { abutSoles, resolveBearingRule } from '../src/core/abut/bearing-table'; +import type { Issue, PierInput } from '../src/core/types'; +import { loadInputWorkbook, loadP1, p1Pairs } from './helpers'; + +// 받침별 받침높이 (2026-11-10): h3 = 그 받침의 받침높이 H − T_ref, 거더 하면 = z + H. +const CTX = { pairs: p1Pairs() }; +const withHeights = (input: PierInput, h: Record): PierInput => ({ + ...input, + bearings: input.bearings.map((b) => ({ ...b, heightMm: b.no in h ? h[b.no] : b.heightMm })), +}); + +describe('교각 받침별 받침높이', () => { + it('reads 받침높이 from the bearing sheet (P1: 211 for every bearing, so h3 stays 181)', () => { + const p1 = loadP1(); + expect(p1.bearings.map((b) => b.heightMm)).toEqual(Array(10).fill(211)); + expect(buildPier(p1, CTX).blocks.every((b) => b.plan.h3 === 181)).toBe(true); + }); + + it('each bearing gets h3 = its H − T_ref; the block (z − pad − drop) does not move', () => { + const base = buildPier(loadP1(), CTX); + const m = buildPier(withHeights(loadP1(), { 3: 174, 8: 188 }), CTX); + const h3 = (no: number) => m.blocks.find((b) => b.no === no)!.plan.h3; + expect(h3(3)).toBe(144); + expect(h3(8)).toBe(158); + expect(h3(1)).toBe(181); + for (const b of m.blocks) { + const o = base.blocks.find((x) => x.no === b.no)!; + expect(b.top).toBeCloseTo(o.top, 9); + expect(b.bottom).toBeCloseTo(o.bottom, 9); + } + }); + + it('girder underside = z + H: pad top + h3 + T − (T − T_ref) − … equals z + H for every bearing', () => { + const input = withHeights(loadP1(), { 2: 176, 7: 183 }); + const m = buildPier(input, CTX); + for (const b of m.blocks) { + const H = input.bearings.find((x) => x.no === b.no)!.heightMm!; + const z = input.bearings.find((x) => x.no === b.no)!.z; + expect(b.padTop + (b.plan.h3 + b.plan.T) / 1000).toBeCloseTo(z + H / 1000, 9); + } + }); + + it('a typed h3 (솔플레이트 입력) wins over the bearing heights', () => { + const input = { ...withHeights(loadP1(), { 3: 174 }), sole: { h3Mm: 200 } }; + expect(buildPier(input, CTX).blocks.every((b) => b.plan.h3 === 200)).toBe(true); + }); + + it('without 받침높이 the default h3 181 is used', () => { + const input = { ...loadP1(), bearings: loadP1().bearings.map(({ heightMm: _h, ...b }) => b) }; + expect(buildPier(input, CTX).blocks.every((b) => b.plan.h3 === 181)).toBe(true); + }); + + it('S01 (E) when a 받침높이 is not above T_ref, so no body could be built', () => { + const issues: Issue[] = []; + expect(validateInput(withHeights(loadP1(), { 4: 30 }), issues)).toBe(false); + expect(issues.filter((i) => i.code === 'S01').map((i) => i.message)).toEqual([expect.stringContaining('받침 4번')]); + }); +}); + +describe('교대 받침별 받침높이', () => { + const a1 = () => parseAbutments(loadInputWorkbook()).abutments.find((a) => a.name === 'A1')!; + + it('different heights per bearing give different h3 and no AP07 warning', () => { + const wb = loadInputWorkbook(); + wb.Sheets['받침']['I16'] = { t: 'n', v: 260 }; + const { abutments, issues } = parseAbutments(wb); + expect(issues.some((i) => i.code === 'AP07')).toBe(false); + const rec = abutments.find((a) => a.name === 'A1')!; + const soles = abutSoles(buildAbut(toAbutInput(rec)), resolveBearingRule(), rec.bearingHeightMm); + expect(soles.map((s) => s.h3)).toEqual([245, 245, 230, 245, 245]); + }); + + it('a typed h3 wins for every bearing', () => { + const rec = a1(); + const soles = abutSoles(buildAbut(toAbutInput(rec)), resolveBearingRule({ h3Mm: 200 }), rec.bearingHeightMm); + expect(soles.every((s) => s.h3 === 200)).toBe(true); + }); +});