diff --git a/src/core/bearing-ref.ts b/src/core/bearing-ref.ts index ace536b..ca1f92f 100644 --- a/src/core/bearing-ref.ts +++ b/src/core/bearing-ref.ts @@ -1,3 +1,6 @@ +import type { PierInput } from './types'; +import type { PierModel } from './geometry'; + /** * 교량받침 reference tables from the P1 상세도 (도면값, 읽기 전용, 2026-10-23). They are not used in the model: * the model keeps the imported values (Q16). EL.X is the drawing's bearing level; E = h1 + h2 + h3 + h4. @@ -101,3 +104,41 @@ export function applyBearingRules(row: BearingRefRow, rules: BearingRules): { E: const { T1, T2 } = solplateTs(rules.T, L, sp.s); return { E: row.h1 + rules.h2 + rules.h3 + row.h4, L, W: row.B + rules.margin, T1, T2 }; } + +/** 받침 기준 (Q16, 승인 2026-10-23): 'excel' = 엑셀 z (기본), 'drawing' = 도면 EL.X. Project-wide. */ +export type BearingBasis = 'excel' | 'drawing'; + +/** One bearing's drawing values entered on ② (mm for h1, h2; m for EL.X). */ +export interface DrawingBearing { + no: number; + elx: number; + h1: number; + h2: number; +} + +/** Tolerance for the coping top check against the drawing (승인 2026-10-23: 5 mm). */ +export const DRAWING_TOL_MM = 5; + +/** The input with each bearing's z replaced by the drawing EL.X, for bearings that have a drawing value. */ +export function withDrawingBasis(input: PierInput, drawing: DrawingBearing[]): PierInput { + const byNo = new Map(drawing.map((d) => [d.no, d])); + return { + ...input, + bearings: input.bearings.map((b) => { + const d = byNo.get(b.no); + return d ? { ...b, z: d.elx } : b; + }), + }; +} + +/** Coping top implied by each drawing row (EL.X − h1 − h2) against the model's coping top; differences in mm. */ +export function drawingCopingCheck(model: PierModel, drawing: DrawingBearing[]): { no: number; diffMm: number; ok: boolean }[] { + const front = model.blocks.filter((b) => b.row === '전열'); + const back = model.blocks.filter((b) => b.row === '후열'); + return drawing.map((d) => { + const implied = d.elx - (d.h1 + d.h2) / 1000; + const modelTop = d.no <= 5 ? model.segments[d.no - 1]?.top : back[d.no - 6]?.bottom; + const diffMm = modelTop === undefined ? NaN : Math.round((modelTop - implied) * 1000 * 10) / 10; + return { no: d.no, diffMm, ok: Number.isFinite(diffMm) && Math.abs(diffMm) <= DRAWING_TOL_MM }; + }); +} diff --git a/src/core/project.ts b/src/core/project.ts index 1441a7d..241a577 100644 --- a/src/core/project.ts +++ b/src/core/project.ts @@ -31,6 +31,10 @@ export interface ProjectFile { history: { version: string; note: string; at: string }[]; /** ③ 공통 설정 (fck · fy). Absent in older files: the defaults are used. */ common?: Material; + /** 받침 기준 (project-wide): 'excel' (default) or 'drawing'. Absent = 'excel'. */ + bearingBasis?: 'excel' | 'drawing'; + /** 도면 받침 값 per pier name (EL.X in m, h1 and h2 in mm). */ + bearingDrawing?: Record; /** ② 부재별 fck (MPa). Absent in older files: every member uses the 공통 fck. */ fckByMember?: FckByMember; } diff --git a/src/main.ts b/src/main.ts index 9be7d8b..5581a04 100644 --- a/src/main.ts +++ b/src/main.ts @@ -16,10 +16,14 @@ import { buildRebar3D, type RebarColorMode, type RebarScene } from './view/rebar import { DEFAULT_MATERIAL, FCK_MEMBERS, fyOf, GRADE_CHOICES, isAssumedMaterial, materialFrom, type BendFactors, type FckByMember, type FckMember, type GradeKey, type Material } from './core/rebar/common'; import { buildQuantity, quantityRows, type Quantity, type QuantityLine } from './core/rebar/quantity'; import { P1_COPING_GEOM } from './core/coping-geom'; -import { applyBearingRules, BEARING_ELEVATION_REF, BEARING_RULES_DEFAULT, type BearingRules, SOLPLATE_REF, SOLPLATE_T_RULE, solplateTs } from './core/bearing-ref'; +import { applyBearingRules, BEARING_ELEVATION_REF, BEARING_RULES_DEFAULT, type BearingBasis, type BearingRules, type DrawingBearing, DRAWING_TOL_MM, drawingCopingCheck, SOLPLATE_REF, SOLPLATE_T_RULE, solplateTs, withDrawingBasis } from './core/bearing-ref'; /** 공통 규칙 of the bearing card (2단계). Screen state for now; not yet saved to the project file. */ let bearingRules: BearingRules = { ...BEARING_RULES_DEFAULT }; + +/** 받침 기준 for the project (Q16, 승인 2026-10-23): 'excel' by default. Drawing values per pier name. */ +let bearingBasis: BearingBasis = 'excel'; +let bearingDrawing: Record = {}; import { allVisibleChecked, setVisibleChecked, type TypeFilter } from './core/selection'; import { choose, defaultSummary, isSelectable, SHAPE_GROUPS, UNDEFINED_REASON, type MemberId } from './core/shape-options'; import { parseProject, SCHEMA_VERSION, serializeProject, type ProjectFile, type ProjectInfo } from './core/project'; @@ -199,9 +203,12 @@ function buildEntry(rawInput: PierInput, checked: boolean): Pier { const input = withCopingDefaults(rawInput); const issues: Issue[] = []; let model: PierModel | null = null; + // The model uses the drawing EL.X when the project basis is 'drawing' and this pier has drawing values (Q16). + const drawing = bearingBasis === 'drawing' ? bearingDrawing[input.name] : undefined; + const modelInput = drawing?.length ? withDrawingBasis(input, drawing) : input; if (validateInput(input, issues)) { try { - model = buildPier(input); + model = buildPier(modelInput); validatePier(input, model, issues); } catch (err) { issues.push({ code: '미실시', level: 'E', pier: input.name, message: (err as Error).message }); @@ -1251,9 +1258,102 @@ function setMaterial(key: 'fck' | GradeKey, raw: string): void { } /** ② 교량받침 reference (1단계, 읽기 전용): the drawing's ELEVATION and 솔플레이트 tables, not used in the model (Q16). */ +/** Rebuilds every pier from its draft, so that a change of basis or drawing values reaches the model. */ +function regenerateAllPiers(): void { + state.piers = state.piers.map((p) => regenerateEntry(p)); + setupScene(); +} + +/** ② 받침 기준 (Q16, 승인 2026-10-23): project-wide basis, and the drawing EL.X / h1 / h2 for this pier. */ +function renderBearingBasis(wrap: HTMLDivElement, pier: Pier): void { + wrap.appendChild(el('p', '받침 기준 (프로젝트 전체 · 기본: 엑셀 z)', 'lbl')); + const sel = el('select'); + sel.id = 'bearing-basis'; + for (const [value, label] of [['excel', '엑셀 z (기본)'], ['drawing', '도면 EL.X']] as const) { + const o = el('option', label); + o.value = value; + sel.appendChild(o); + } + sel.value = bearingBasis; + sel.addEventListener('change', () => { + bearingBasis = sel.value as BearingBasis; + regenerateAllPiers(); + renderAll(); + }); + wrap.appendChild(sel); + + const name = pier.input.name; + const current = new Map((bearingDrawing[name] ?? []).map((d) => [d.no, d])); + const table = el('table', undefined, 'grid-table rebar-table'); + const head = el('thead'); + const hr = el('tr'); + for (const h of ['No', '열', 'EL.X (m)', 'h1 (mm)', 'h2 (mm)', '코핑 상면 차이 (mm)']) hr.appendChild(el('th', h)); + head.appendChild(hr); + table.appendChild(head); + const body = el('tbody'); + // The reference is the Excel-basis model: the drawing EL.X − h1 − h2 must reproduce its coping tops within 5 mm. + const checks = bearingBasis === 'drawing' && bearingDrawing[name]?.length ? drawingCopingCheck(buildPier(pier.input), bearingDrawing[name]) : []; + const checkOf = new Map(checks.map((c) => [c.no, c])); + const readTable = (): DrawingBearing[] => { + const rows: DrawingBearing[] = []; + body.querySelectorAll('tr').forEach((tr) => { + const inputs = tr.querySelectorAll('input'); + const [elx, h1, h2] = Array.from(inputs).map((i) => Number(i.value)); + const no = Number(tr.dataset.no); + if ([elx, h1, h2].every((v) => Number.isFinite(v) && v > 0)) rows.push({ no, elx, h1, h2 }); + }); + return rows; + }; + const commit = () => { + bearingDrawing = { ...bearingDrawing, [name]: readTable() }; + regenerateAllPiers(); + renderAll(); + }; + for (const b of [...pier.input.bearings].sort((x, y) => x.no - y.no)) { + const d = current.get(b.no); + const tr = el('tr'); + tr.dataset.no = String(b.no); + const cells: (string | HTMLElement)[] = [String(b.no), b.row]; + for (const [value, step] of [[d?.elx, '0.001'], [d?.h1, '1'], [d?.h2, '1']] as const) { + const input = el('input'); + input.type = 'number'; + input.step = step; + input.value = value === undefined ? '' : String(value); + input.addEventListener('change', commit); + cells.push(input); + } + const c = checkOf.get(b.no); + cells.push(c === undefined ? '—' : `${c.diffMm.toFixed(1)}${c.ok ? '' : ` (허용 ${DRAWING_TOL_MM} 초과)`}`); + tr.append( + ...cells.map((x) => { + const td = el('td'); + if (typeof x === 'string') td.textContent = x; + else td.appendChild(x); + return td; + }), + ); + body.appendChild(tr); + } + table.appendChild(body); + wrap.appendChild(el('div', undefined, 'table-scroll')).appendChild(table); + + const fill = el('button', '도면값 채우기 (참조 값)', 'btn small'); + fill.type = 'button'; + fill.addEventListener('click', () => { + bearingDrawing = { ...bearingDrawing, [name]: BEARING_ELEVATION_REF.map((r) => ({ no: r.no, elx: r.elx, h1: r.h1, h2: r.h2 })) }; + regenerateAllPiers(); + renderAll(); + }); + wrap.appendChild(fill); + if (bearingBasis === 'drawing' && !bearingDrawing[name]?.length) { + wrap.appendChild(el('p', '도면 값이 없어서 엑셀 z로 계산 중입니다.', 'warn-text')); + } +} + function renderBearingRef(box: HTMLDivElement, pier: Pier): void { const m = pier.model; const wrap = el('div', undefined, 'bearing-ref'); + renderBearingBasis(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'); @@ -1302,26 +1402,25 @@ function renderBearingRef(box: HTMLDivElement, pier: Pier): void { ct.appendChild(cb); wrap.appendChild(el('div', undefined, 'table-scroll')).appendChild(ct); wrap.appendChild(el('p', '교량받침 ELEVATION TABLE (도면값 · 읽기 전용, mm · EL m)', 'lbl')); - wrap.appendChild(el('p', 'EL.X = 몰탈 50 mm를 포함한 받침블럭 상면. 도면값은 모델에 쓰지 않으며, 차이는 도면과 모델의 코핑 상면 차이입니다.', 'muted small')); - const front = m?.segments ?? []; + wrap.appendChild(el('p', '엑셀 z를 EL.X로 씁니다(몰탈 50 mm 포함 받침블럭 상면, Q16 결정 2026-10-23). 도면 EL.X와의 차이는 "도면 ≠ 엑셀"로만 표시합니다.', 'muted small')); + const front = m?.blocks.filter((b) => b.row === '전열') ?? []; const backBlocks = m?.blocks.filter((b) => b.row === '후열') ?? []; const et = el('table', undefined, 'grid-table rebar-table'); const eh = el('thead'); const ehr = el('tr'); - for (const h of ['No', '열', 'EL.X', 'E', 'A', 'B', 'C1', 'S1', '코핑 상면 = EL.X − h1 − h2', '모델 코핑 상면', '차이 (m)']) ehr.appendChild(el('th', h)); + for (const h of ['No', '열', '엑셀 z (= EL.X, 모델)', '도면 EL.X', '도면 − 엑셀 (mm)', 'E', 'A', 'B', 'C1', 'S1']) ehr.appendChild(el('th', h)); eh.appendChild(ehr); et.appendChild(eh); const eb = el('tbody'); for (const r of BEARING_ELEVATION_REF) { - // EL.X is the block top including the 50 mm mortar: coping top = EL.X − h1 − h2 (승인 2026-10-23). - const drawingTop = r.elx - (r.h1 + r.h2) / 1000; - const modelTop = r.row === '전열' ? front[r.no - 1]?.top : backBlocks[r.no - 6]?.bottom; - const diff = modelTop === undefined ? NaN : modelTop - drawingTop; + // Excel z is EL.X (Q16 decision 2026-10-23, option 1); the drawing's EL.X is shown for comparison only. + const block = r.row === '전열' ? front[r.no - 1] : backBlocks[r.no - 6]; + const excelZ = block?.padTop; + const diffMm = excelZ === undefined ? NaN : Math.round((r.elx - excelZ) * 1000); eb.appendChild(el('tr')).append( - el('td', String(r.no)), el('td', r.row), el('td', r.elx.toFixed(3)), el('td', String(r.E)), + el('td', String(r.no)), el('td', r.row), el('td', excelZ === undefined ? '—' : excelZ.toFixed(3)), el('td', r.elx.toFixed(3)), + el('td', Number.isNaN(diffMm) ? '—' : String(diffMm)), el('td', String(r.E)), el('td', String(r.A)), el('td', String(r.B)), el('td', String(r.C1)), el('td', String(r.S1)), - el('td', drawingTop.toFixed(3)), el('td', modelTop === undefined ? '—' : modelTop.toFixed(3)), - el('td', Number.isNaN(diff) ? '—' : diff.toFixed(3)), ); } et.appendChild(eb); @@ -1457,6 +1556,8 @@ function snapshot(): string { piers: state.piers.map((p) => ({ input: p.input, defaults: p.defaults, draft: p.draft })), material: state.material, fckByMember: state.fckByMember, + bearingBasis, + bearingDrawing, }); } @@ -1473,6 +1574,8 @@ function currentProjectText(): string { history: state.history, common: state.material, fckByMember: state.fckByMember, + bearingBasis, + bearingDrawing, }); } @@ -1558,6 +1661,8 @@ function openProjectText(text: string, saved: boolean): void { state.history = parsed.history; state.material = materialFrom(parsed.common); state.fckByMember = { ...parsed.fckByMember }; + bearingBasis = parsed.bearingBasis ?? 'excel'; + bearingDrawing = Object.fromEntries(Object.entries(parsed.bearingDrawing ?? {}).map(([k, v]) => [k, [...v]])); state.piers = parsed.piers.map((p) => ({ ...buildEntry(p.input, false), defaults: p.defaults ?? p.input, diff --git a/tests/bearing-basis.test.ts b/tests/bearing-basis.test.ts new file mode 100644 index 0000000..56c539c --- /dev/null +++ b/tests/bearing-basis.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; +import { buildPier } from '../src/core/geometry'; +import { BEARING_ELEVATION_REF, DRAWING_TOL_MM, drawingCopingCheck, withDrawingBasis, type DrawingBearing } from '../src/core/bearing-ref'; +import { loadP1 } from './helpers'; + +const drawing: DrawingBearing[] = BEARING_ELEVATION_REF.map((r) => ({ no: r.no, elx: r.elx, h1: r.h1, h2: r.h2 })); + +describe('받침 기준: 도면 EL.X (Q16 option 2, 승인 2026-10-23)', () => { + it('the default basis (엑셀 z) keeps the model as before: the drawing check still reports the EL.X gap', () => { + const excel = buildPier(loadP1()); + expect(excel.blocks.filter((b) => b.row === '전열')[0].padTop).toBeCloseTo(43.561, 3); + }); + + it('with the drawing basis the pads sit at the drawing EL.X and the coping tops stay within the 5 mm tolerance', () => { + const m = buildPier(withDrawingBasis(loadP1(), drawing)); + expect(m.blocks.filter((b) => b.row === '전열')[0].padTop).toBeCloseTo(43.625, 3); + for (const c of drawingCopingCheck(m, drawing)) { + expect(Math.abs(c.diffMm), `No ${c.no}`).toBeLessThanOrEqual(DRAWING_TOL_MM); + } + }); + + it('with the drawing basis the front block (h1) is 164 mm and the back block 139 mm, excluding the 50 mm pad', () => { + const m = buildPier(withDrawingBasis(loadP1(), drawing)); + const front = m.blocks.filter((b) => b.row === '전열')[0]; + const back = m.blocks.filter((b) => b.row === '후열')[0]; + expect(Math.round((front.top - front.bottom) * 1000)).toBe(164); + expect(Math.round((back.top - back.bottom) * 1000)).toBe(139); + }); + + it('a drawing row that is off by more than 5 mm is flagged', () => { + const bad = drawing.map((d) => (d.no === 3 ? { ...d, elx: d.elx + 0.02 } : d)); + const c = drawingCopingCheck(buildPier(loadP1()), bad).find((x) => x.no === 3)!; + expect(c.ok).toBe(false); + }); +});