From 2af5cfb1440f2b859e343448e133bd444c0517f2 Mon Sep 17 00:00:00 2001 From: ypshim Date: Mon, 26 Oct 2026 11:56:26 +0900 Subject: [PATCH] =?UTF-8?q?feat(bearing):=20=EC=9D=BC=EC=B2=B4=20choice=20?= =?UTF-8?q?gated=20by=20the=20pair=20eccentricity=20rule=20(=EC=8A=B9?= =?UTF-8?q?=EC=9D=B8=202026-10-26)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PierInput.bearingLayout: 분리 (default) or 일체, tracked for changes. - 일체 is selectable only when there is at least one pair and every pair is within 100 mm; otherwise the chip is disabled with the reason. - Validation: B01 (info) when 일체 is chosen and allowed, the block shape is still the 분리 shape; B02 (warning) when it is not allowed and the model falls back to 분리. - The 일체 block shape (2500 × 1200) is not modelled yet. Co-Authored-By: Claude Haiku 5.5 --- src/core/bearing-layout.ts | 10 ++++++++++ src/core/changes.ts | 3 ++- src/core/shape-options.ts | 2 +- src/core/types.ts | 2 ++ src/core/validate.ts | 11 +++++++++++ src/main.ts | 23 +++++++++++++++++++---- tests/bearing-layout.test.ts | 31 ++++++++++++++++++++++++++++++- 7 files changed, 75 insertions(+), 7 deletions(-) diff --git a/src/core/bearing-layout.ts b/src/core/bearing-layout.ts index 8b46118..84fa9c7 100644 --- a/src/core/bearing-layout.ts +++ b/src/core/bearing-layout.ts @@ -53,3 +53,13 @@ export function pairEccentricities(blocks: BlockModel[]): PairEccentricity[] { } return out; } + +/** + * 일체 is possible when there is at least one pair and every pair is within the limit (승인 2026-10-26). + * Returns the pairs over the limit so the reason can be shown. + */ +export function integratedCheck(blocks: BlockModel[]): { allowed: boolean; overLimit: PairEccentricity[] } { + const pairs = pairEccentricities(blocks); + const overLimit = pairs.filter((p) => !p.allowed); + return { allowed: pairs.length > 0 && overLimit.length === 0, overLimit }; +} diff --git a/src/core/changes.ts b/src/core/changes.ts index 4defe9c..ed41efb 100644 --- a/src/core/changes.ts +++ b/src/core/changes.ts @@ -23,7 +23,7 @@ export const EDITABLE_FIELDS = [ ] as const; /** Inputs tracked for overrides and pending changes: the numeric cards plus 열별 본수 (an array, compared by value). */ -export const TRACKED_KEYS = [...EDITABLE_FIELDS.map((f) => f.key), 'pileRows', 'groundPosition', 'groundWaterOn', 'groundWaterEl', 'floodLevelEl', 'scourDepthM', 'highWaterEl', 'lowWaterEl', 'strataRows', 'bearingType'] as const; +export const TRACKED_KEYS = [...EDITABLE_FIELDS.map((f) => f.key), 'pileRows', 'groundPosition', 'groundWaterOn', 'groundWaterEl', 'floodLevelEl', 'scourDepthM', 'highWaterEl', 'lowWaterEl', 'strataRows', 'bearingType', 'bearingLayout'] as const; export type EditableKey = (typeof TRACKED_KEYS)[number]; @@ -52,6 +52,7 @@ export const FIELD_LABELS: Record = { highWaterEl: '고조위 HWL', lowWaterEl: '저조위 LWL', bearingType: '받침 종류·용량', + bearingLayout: '받침 배치', strataRows: '지층 행', }; diff --git a/src/core/shape-options.ts b/src/core/shape-options.ts index e6e8bfc..ab63b22 100644 --- a/src/core/shape-options.ts +++ b/src/core/shape-options.ts @@ -22,7 +22,7 @@ const x = (label: string): ShapeOption => ({ label, isDefault: false }); const ok = (label: string): ShapeOption => ({ label, isDefault: false, confirmed: true }); export const SHAPE_GROUPS: Record = { - bearing: [{ title: '배치', options: [d('분리'), x('일체')] }], + bearing: [{ title: '배치', options: [d('분리'), ok('일체')] }], coping: [ { title: '교좌면', options: [d('직각'), x('모따기'), x('라운드')] }, { title: '하면', options: [d('직선'), x('곡선 오목'), x('곡선 볼록')] }, diff --git a/src/core/types.ts b/src/core/types.ts index cf654b4..ad92d52 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -70,6 +70,8 @@ export interface PierInput { lowWaterEl?: number; /** 받침 종류·용량 as text (승인 2026-10-26). Absent = 탄성받침 (the default). */ bearingType?: string; + /** 받침 배치 (승인 2026-10-26): 분리 (default) or 일체. 일체 is allowed only when every front–back pair is within 100 mm. */ + bearingLayout?: '분리' | '일체'; strataRows?: StrataRow[]; } diff --git a/src/core/validate.ts b/src/core/validate.ts index d0e11c2..2522e4b 100644 --- a/src/core/validate.ts +++ b/src/core/validate.ts @@ -1,6 +1,7 @@ import { DIM, type PierModel } from './geometry'; import type { Issue, PierInput, PierType } from './types'; import { validateGround, validatePileEmbedment, validateStrataNValues } from './ground'; +import { integratedCheck } from './bearing-layout'; const EXPECTED_COLUMNS: Record = { 'T형': 1, '2주형': 2, '3주형': 3 }; @@ -93,4 +94,14 @@ export function validatePier(input: PierInput, model: PierModel, issues: Issue[] validateGround(input, issues); validatePileEmbedment(input, model, issues); validateStrataNValues(input, issues); + // 일체 (승인 2026-10-26): the block shape of 일체 is not modelled yet, so the model keeps 분리 and says so. + if (input.bearingLayout === '일체') { + const check = integratedCheck(model.blocks); + if (!check.allowed) { + const worst = check.overLimit.map((p) => `전열 ${p.frontNo}·후열 ${p.backNo} ${p.eccentricityMm.toFixed(0)} mm`).join(', '); + issues.push({ code: 'B02', level: 'W', pier: input.name, message: `일체를 골랐지만 편심이 100 mm를 넘는 쌍이 있어 분리로 계산합니다 (${worst || '쌍 없음'}).` }); + } else { + issues.push({ code: 'B01', level: 'I', pier: input.name, message: '일체 배치: 블록 형상은 아직 분리 기준으로 계산합니다 (일체 블록 모델은 다음 단계).' }); + } + } } diff --git a/src/main.ts b/src/main.ts index 14946f8..6645a42 100644 --- a/src/main.ts +++ b/src/main.ts @@ -18,7 +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 { bearingEdgeDistances, integratedCheck, 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'; @@ -77,7 +77,7 @@ const CRS = '국토좌표계 (x = 동, y = 북)'; /** 시안 ② 부재 목록 (spec §5.2). `keys` are the inputs that exist today; the rest show values only. */ const MEMBERS: { id: MemberId; label: string; keys: EditableKey[]; note: string }[] = [ - { id: 'bearing', label: '교량받침·받침블럭·솔플레이트', keys: ['bearingType'], note: '받침 상세(C3)는 Q16 결정 후 추가합니다. 지금은 표시만 합니다.' }, + { id: 'bearing', label: '교량받침·받침블럭·솔플레이트', keys: ['bearingType', 'bearingLayout'], note: '받침 상세(C3)는 Q16 결정 후 추가합니다. 지금은 표시만 합니다.' }, { id: 'coping', label: '코핑', keys: ['copingWidthMm', 'copingLengthMm', 'copingH1Mm', 'copingH2Mm', 'levelWidthMm'], note: '폭, 길이, 끝단 높이 h1, 테이퍼 높이 h2, Level 폭을 입력합니다. 계단 위치·단차선 입력은 다음 단계에서 추가합니다.' }, { id: 'column', label: '기둥', keys: ['columnDiameterMm'], note: '기둥 직경 D를 입력합니다 (1200–3000 mm, 50 mm 단위). 높이와 위치는 계산값입니다.' }, { id: 'footing', label: '확대기초 · 말뚝', keys: ['footingLengthMm', 'footingWidthMm', 'footingHeightMm', 'blindingMarginMm', 'blindingThicknessMm', 'pileLengthMm'], note: '확대기초 치수, 비림, 말뚝 길이와 규격을 입력합니다. 규칙 미정 항목(모따기, 경사, 복합 말뚝)은 다음 단계에서 추가합니다.' }, @@ -845,14 +845,21 @@ function renderShapeOptions(member: MemberId, pier?: Pier): void { const chip = el('button', option.label, 'chip-opt'); chip.type = 'button'; // The ground 위치 is stored in the pier's draft, so its pressed chip is the chosen one (승인 2026-10-26). - const chosen = member === 'ground' && pier ? (pier.draft.groundPosition ?? '육상') : undefined; + const chosen = member === 'ground' && pier ? (pier.draft.groundPosition ?? '육상') : member === 'bearing' && pier ? (pier.draft.bearingLayout ?? '분리') : undefined; chip.setAttribute('aria-pressed', String(chosen !== undefined ? option.label === chosen : option.isDefault)); chip.disabled = !isSelectable(option); if (chip.disabled) chip.title = UNDEFINED_REASON; + // 일체 needs every front–back pair within 100 mm (승인 2026-10-26); the reason is shown on the chip. + if (member === 'bearing' && option.label === '일체' && pier?.model) { + const check = integratedCheck(pier.model.blocks); + chip.disabled = !check.allowed; + chip.title = check.allowed ? '' : `편심 100 mm 초과 쌍 ${check.overLimit.length}개 → 분리 우선`; + } chip.addEventListener('click', () => { try { choose(member, gi, option.label); if (member === 'ground' && pier) editGround(pier.input.name, 'groundPosition', option.label); + if (member === 'bearing' && pier) editBearingLayout(pier.input.name, option.label as '분리' | '일체'); } catch (err) { setError((err as Error).message); } @@ -1542,6 +1549,14 @@ function valueText(key: EditableKey, v: unknown): string { return String(v); } +/** Sets 받침 배치 (분리 / 일체) in the draft (applied by 다시 생성). */ +function editBearingLayout(name: string, value: '분리' | '일체'): void { + const p = state.piers.find((q) => q.input.name === name); + if (!p) return; + p.draft = { ...p.draft, bearingLayout: value }; + renderAll(); +} + /** Sets one 지반·지층 input in the draft (applied to the model by 다시 생성). */ function editGround(name: string, key: 'groundPosition' | 'groundWaterOn' | 'groundWaterEl' | 'floodLevelEl' | 'scourDepthM' | 'highWaterEl' | 'lowWaterEl', value: unknown): void { const p = state.piers.find((q) => q.input.name === name); @@ -1956,7 +1971,7 @@ function renderPlan(): void { $('member-title').textContent = member.label; // Default and current selection are shown separately (spec §5.1). Only defaults are selectable today. // 선택 shows the pier's own 위치 for the ground; the other members still choose only their defaults. - const selected = member.id === 'ground' && pier ? (pier.draft.groundPosition ?? '육상') : defaultSummary(member.id); + const selected = member.id === 'ground' && pier ? (pier.draft.groundPosition ?? '육상') : member.id === 'bearing' && pier ? (pier.draft.bearingLayout ?? '분리') : defaultSummary(member.id); $('member-summary').textContent = `기본: ${defaultSummary(member.id)} · 선택: ${selected}`; renderShapeOptions(member.id, pier); $('member-note').textContent = member.note; diff --git a/tests/bearing-layout.test.ts b/tests/bearing-layout.test.ts index 617ebdf..916ab3d 100644 --- a/tests/bearing-layout.test.ts +++ b/tests/bearing-layout.test.ts @@ -1,6 +1,8 @@ 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 { bearingEdgeDistances, integratedCheck, PAIR_ECCENTRICITY_LIMIT_MM, pairEccentricities } from '../src/core/bearing-layout'; +import { validatePier } from '../src/core/validate'; +import type { Issue } from '../src/core/types'; import { loadP1 } from './helpers'; const model = buildPier(loadP1()); @@ -46,3 +48,30 @@ describe('받침 배치 규칙 (승인 2026-10-26)', () => { expect(pairEccentricities([block(1, '전열', 1), block(2, '전열', 3), block(6, '후열', 1.05)])).toHaveLength(1); }); }); + +describe('일체 배치 선택 (승인 2026-10-26)', () => { + it('P1 allows 일체 because every pair is within 100 mm', () => { + expect(integratedCheck(model.blocks)).toEqual({ allowed: true, overLimit: [] }); + }); + + it('one pair over the limit blocks 일체 and lists that pair', () => { + const r = integratedCheck([block(1, '전열', 1.0), block(1, '후열', 1.12)]); + expect(r.allowed).toBe(false); + expect(r.overLimit.map((p) => p.frontNo)).toEqual([1]); + }); + + it('no pairs at all does not allow 일체', () => { + expect(integratedCheck([]).allowed).toBe(false); + }); + + it('the model keeps 분리 for now and says so (B01); a 일체 that is not allowed is refused with B02', () => { + const input = loadP1(); + const ok: Issue[] = []; + validatePier({ ...input, bearingLayout: '일체' }, model, ok); + expect(ok.map((i) => i.code)).toContain('B01'); + const bad: Issue[] = []; + const fake = { ...model, blocks: [block(1, '전열', 1.0), block(1, '후열', 1.12)] } as unknown as typeof model; + validatePier({ ...input, bearingLayout: '일체' }, fake, bad); + expect(bad.map((i) => i.code)).toContain('B02'); + }); +});