diff --git a/src/core/bearing-layout.ts b/src/core/bearing-layout.ts new file mode 100644 index 0000000..8b46118 --- /dev/null +++ b/src/core/bearing-layout.ts @@ -0,0 +1,55 @@ +import type { BlockModel, PierModel } from './geometry'; + +/** + * 받침 배치 규칙 (승인 2026-10-26). + * - C4·C5: 코핑 가장자리와 받침 중심(받침좌표) 사이 거리, 교각축 방향 (s). 모델값을 쓰고 도면값과 비교한다. + * - 일체: 같은 순서끼리 (전열 1 · 후열 1, 전열 2 · 후열 2 …, 각 열의 번호 순) 두 받침의 편심거리가 100 mm를 넘으면 분리를 우선한다. + * (P1 은 전열 1–5, 후열 6–10 으로 번호가 매겨져 있어서 번호 자체가 아니라 열 안의 순서로 짝짓는다.) + * 편심거리 = 두 받침 중심의 교각축 방향 차이 |s(전열) − s(후열)|. + */ +export const PAIR_ECCENTRICITY_LIMIT_MM = 100; + +export interface EdgeDistance { + no: number; + row: BlockModel['row']; + /** 코핑 시작(s = 0)에서 받침 중심까지 (mm). */ + leftMm: number; + /** 받침 중심에서 코핑 끝(s = L)까지 (mm). */ + rightMm: number; +} + +export interface PairEccentricity { + /** 전열 번호 and 후열 번호 of the pair. */ + frontNo: number; + backNo: number; + eccentricityMm: number; + /** true: 편심이 한계 이하, 일체 배치 가능. */ + allowed: boolean; +} + +/** C4·C5 from the model: distances from each coping edge to each bearing centre, along the coping length (mm). */ +export function bearingEdgeDistances(m: PierModel): EdgeDistance[] { + const L = m.copingL * 1000; + return m.blocks.map((b) => ({ + no: b.no, + row: b.row, + leftMm: b.s * 1000, + rightMm: L - b.s * 1000, + })); +} + +/** + * Pairs by order within each row (전열 1 · 후열 1, 2 · 2 …) with their eccentricity. + * A bearing left without a partner (the rows have different counts) is not paired. + */ +export function pairEccentricities(blocks: BlockModel[]): PairEccentricity[] { + const byNo = (a: BlockModel, b: BlockModel) => a.no - b.no; + const front = blocks.filter((b) => b.row === '전열').sort(byNo); + const back = blocks.filter((b) => b.row === '후열').sort(byNo); + const out: PairEccentricity[] = []; + for (let i = 0; i < Math.min(front.length, back.length); i++) { + const e = Math.abs(front[i].s - back[i].s) * 1000; + out.push({ frontNo: front[i].no, backNo: back[i].no, eccentricityMm: e, allowed: e <= PAIR_ECCENTRICITY_LIMIT_MM }); + } + return out; +} diff --git a/src/main.ts b/src/main.ts index b5a3130..14946f8 100644 --- a/src/main.ts +++ b/src/main.ts @@ -18,6 +18,7 @@ import { buildQuantity, quantityRows, type Quantity, type QuantityLine } from '. import { parseDrawingRows } from './core/drawing-import'; import { groundElFor, parseGroundModel, strataRowsFor, type GroundModelFile } from './core/ground-model'; import { CORROSION_ALLOWANCE_MM, parseNValue, SEA_DATUM, STRATA_NAMES } from './core/ground'; +import { bearingEdgeDistances, PAIR_ECCENTRICITY_LIMIT_MM, pairEccentricities } from './core/bearing-layout'; import { groundSectionSvg } from './view/ground-svg'; import { parseBearingElevationTable, parseSolplateTable, readDxfTexts } from './core/dxf-table'; import { columnDiameterIssues } from './core/column-check'; @@ -1503,6 +1504,33 @@ function renderBearingBasis(wrap: HTMLDivElement, pier: Pier): void { } } +/** + * 받침 배치 (승인 2026-10-26, read-only): C4·C5 from the model (coping edge ↔ bearing centre, 교각축 방향) and the + * eccentricity of each front–back pair (일체 allowed up to 100 mm, otherwise 분리 first). + */ +function renderBearingLayout(wrap: HTMLDivElement, pier: Pier): void { + if (!pier.model) return; + wrap.appendChild(el('p', `배치 규칙 (모델값 · 편심 한계 ${PAIR_ECCENTRICITY_LIMIT_MM} mm)`, 'lbl')); + const edges = bearingEdgeDistances(pier.model); + const table = el('table', undefined, 'grid-table rebar-table'); + const head = el('thead'); + const hr = el('tr'); + for (const h of ['받침', '열', 'C4 (코핑 시작 ↔ 받침, mm)', 'C5 (받침 ↔ 코핑 끝, mm)']) hr.appendChild(el('th', h)); + head.appendChild(hr); + table.appendChild(head); + const body = el('tbody'); + for (const e of edges) { + const tr = el('tr'); + for (const v of [String(e.no), e.row, e.leftMm.toFixed(0), e.rightMm.toFixed(0)]) tr.appendChild(el('td', v)); + body.appendChild(tr); + } + table.appendChild(body); + wrap.appendChild(el('div', undefined, 'table-scroll')).appendChild(table); + const pairs = pairEccentricities(pier.model.blocks); + const forced = pairs.filter((p) => !p.allowed).length; + wrap.appendChild(el('p', `쌍 편심 (전열 k · 후열 k, 교각축 방향): 최대 ${Math.max(...pairs.map((p) => p.eccentricityMm)).toFixed(0)} mm · ${forced === 0 ? '모든 쌍 일체 가능' : `${forced}쌍은 편심 초과 → 분리 우선`}`, 'muted small')); +} + /** Text of one tracked value in the 변경 list. */ function valueText(key: EditableKey, v: unknown): string { if (v === undefined || v === null) return '없음'; @@ -1776,6 +1804,7 @@ function renderBearingRef(box: HTMLDivElement, pier: Pier): void { renderAll(); }); wrap.appendChild(typeInput); + renderBearingLayout(wrap, pier); wrap.appendChild(el('p', '공통 규칙 (입력 · 기본값 = 도면값)', 'lbl')); const rules: [keyof BearingRules, string][] = [['h2', '모르타르 h2 (mm)'], ['h3', '받침 높이 h3 (mm)'], ['T', '솔플레이트 중심 T (mm)'], ['margin', '솔플 여유 (mm)']]; const grid = el('div', undefined, 'edit-row'); diff --git a/tests/bearing-layout.test.ts b/tests/bearing-layout.test.ts new file mode 100644 index 0000000..617ebdf --- /dev/null +++ b/tests/bearing-layout.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest'; +import { buildPier, type BlockModel } from '../src/core/geometry'; +import { bearingEdgeDistances, PAIR_ECCENTRICITY_LIMIT_MM, pairEccentricities } from '../src/core/bearing-layout'; +import { loadP1 } from './helpers'; + +const model = buildPier(loadP1()); + +function block(no: number, row: '전열' | '후열', s: number): BlockModel { + return { no, row, s, n: 0, k: 0, g: 0, bottom: 0, top: 0, padTop: 0, rowSkewDeg: 0 } as BlockModel; +} + +describe('받침 배치 규칙 (승인 2026-10-26)', () => { + it('C4 and C5 from the model: distances from each coping edge to the bearing centre (P1 ≈ 943 / 950 mm)', () => { + const d = bearingEdgeDistances(model); + expect(d).toHaveLength(10); + const first = d.find((x) => x.no === 1 && x.row === '전열')!; + expect(first.leftMm).toBeCloseTo(943, 0); + expect(first.rightMm).toBeCloseTo(12000 - 943, 0); + // the two ends of one bearing sum to the coping length + for (const x of d) expect(x.leftMm + x.rightMm).toBeCloseTo(12000, 6); + }); + + it('the front and back bearings of one number are about 85 mm apart on P1, so 일체 is allowed', () => { + const pairs = pairEccentricities(model.blocks); + expect(pairs).toHaveLength(5); + for (const p of pairs) { + expect(p.eccentricityMm).toBeGreaterThan(80); + expect(p.eccentricityMm).toBeLessThan(90); + expect(p.allowed).toBe(true); + } + }); + + it('an eccentricity above 100 mm forbids 일체 for that pair (분리 우선)', () => { + expect(PAIR_ECCENTRICITY_LIMIT_MM).toBe(100); + const pairs = pairEccentricities([block(1, '전열', 1.0), block(1, '후열', 1.12), block(2, '전열', 3), block(2, '후열', 3.09)]); + expect(pairs.find((p) => p.frontNo === 1)!.allowed).toBe(false); + expect(pairs.find((p) => p.frontNo === 2)!.allowed).toBe(true); + }); + + it('pairs by order within each row, so 전열 1 pairs with 후열 6 on P1 (numbers run on across the rows)', () => { + const pairs = pairEccentricities(model.blocks); + expect(pairs[0]).toMatchObject({ frontNo: 1, backNo: 6 }); + }); + + it('a bearing left without a partner (the rows have different counts) is not paired', () => { + expect(pairEccentricities([block(1, '전열', 1), block(2, '전열', 3), block(6, '후열', 1.05)])).toHaveLength(1); + }); +});