From 36b26209dfad9ccda93091c0dbbe7b661c3444df Mon Sep 17 00:00:00 2001 From: ypshim Date: Tue, 3 Nov 2026 11:07:51 +0900 Subject: [PATCH] =?UTF-8?q?feat(bearing):=20=EC=86=94=ED=94=8C=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8=20=EA=B7=9C=EC=B9=99=EC=9D=84=20=EA=B5=90?= =?UTF-8?q?=EA=B0=81=EB=B3=84=20=EC=9E=85=EB=A0=A5=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EB=B0=94=EA=BE=B8=EA=B3=A0=20=EB=8F=84=EB=A9=B4=20=EB=B6=88?= =?UTF-8?q?=EB=9F=AC=EC=98=A4=EA=B8=B0=20=EC=A0=9C=EA=B1=B0=20(C=EC=95=88)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 받침 기준 카드, 도면 불러오기(xlsx·csv·dxf), 도면 참조 표·오버레이 제거 - 교각마다 솔플레이트 규칙 입력: 받침높이 H(솔플레이트 포함) 211, 교직 L 1000, 교축 W 1100, 종단 기울기 S −0.5 % (기본값, 가정 표시) - 중심 두께 T = ⌈22 + L·|S|/200⌉ 로 계산해 얇은 쪽 T2가 22 mm 이상이 되게 함 (기본값 T 25, T2 22.5, h3 = H − T = 186) - geometry·scene·SVG는 교각 규칙에서 만든 같은 솔플레이트 계획을 모든 받침에 사용 Co-Authored-By: Claude Haiku 5.5 --- src/core/bearing-ref.ts | 148 ------------ src/core/drawing-import.ts | 61 ----- src/core/dxf-table.ts | 149 ------------ src/core/geometry.ts | 19 +- src/core/project.ts | 4 - src/core/solplate-rule.ts | 38 ++++ src/core/solplate.ts | 38 +--- src/core/types.ts | 31 ++- src/main.ts | 365 +++++------------------------- src/view/bearing-plan-svg.ts | 24 +- src/view/bearing-section-svg.ts | 55 +---- src/view/scene.ts | 10 +- src/view/solplate-svg.ts | 36 +-- tests/bearing-basis.test.ts | 35 --- tests/bearing-plan-svg.test.ts | 8 +- tests/bearing-ref.test.ts | 68 ------ tests/bearing-section-svg.test.ts | 25 +- tests/drawing-import.test.ts | 32 --- tests/dxf-table.test.ts | 70 ------ tests/scene.test.ts | 4 +- tests/solplate-rule.test.ts | 41 ++++ tests/solplate-scene.test.ts | 40 ++-- tests/solplate.test.ts | 37 +-- 23 files changed, 257 insertions(+), 1081 deletions(-) delete mode 100644 src/core/bearing-ref.ts delete mode 100644 src/core/drawing-import.ts delete mode 100644 src/core/dxf-table.ts create mode 100644 src/core/solplate-rule.ts delete mode 100644 tests/bearing-basis.test.ts delete mode 100644 tests/bearing-ref.test.ts delete mode 100644 tests/drawing-import.test.ts delete mode 100644 tests/dxf-table.test.ts create mode 100644 tests/solplate-rule.test.ts diff --git a/src/core/bearing-ref.ts b/src/core/bearing-ref.ts deleted file mode 100644 index 1850034..0000000 --- a/src/core/bearing-ref.ts +++ /dev/null @@ -1,148 +0,0 @@ -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. - */ -export interface BearingRefRow { - no: number; - row: '전열' | '후열'; - /** EL.X (m). */ - elx: number; - /** 블럭 높이 h1 (mm, 도면). */ - h1: number; - /** 모르타르 h2 (mm). */ - h2: number; - /** 받침 h3 (mm). */ - h3: number; - /** 솔플레이트 h4 (mm). */ - h4: number; - /** E (mm, 도면). */ - E: number; - A: number; - B: number; - C1: number; - C2: number | null; - C3: number | null; - C4: number | null; - C5: number | null; - C6: number | null; - S1: number; -} - -export interface SolplateRefRow { - no: number; - /** 기울기 S (%), negative as drawn. */ - s: number; - L: number; - W: number; - /** 중심 두께 T (mm). The solplate tapers along the bridge axis. */ - T: number; - /** 두께 at the 교각축 직각 (−) side (mm). */ - T1: number; - /** 두께 at the 교각축 직각 (+) side (mm). */ - T2: number; -} - -const FRONT = { h1: 164, h2: 50, h3: 181, h4: 30, E: 425 }; -const BACK = { h1: 139, h2: 50, h3: 181, h4: 30, E: 400 }; - -export const BEARING_ELEVATION_REF: BearingRefRow[] = [ - { no: 1, row: '전열', elx: 43.625, ...FRONT, A: 700, B: 640, C1: 600, C2: 1298, C3: 2500, C4: 600, C5: 600, C6: 1200, S1: 852 }, - { no: 2, row: '전열', elx: 43.697, ...FRONT, A: 700, B: 640, C1: 600, C2: 1298, C3: 2500, C4: 600, C5: 600, C6: 1200, S1: 852 }, - { no: 3, row: '전열', elx: 43.768, ...FRONT, A: 700, B: 790, C1: 600, C2: 1298, C3: 2500, C4: 600, C5: 600, C6: 1200, S1: 852 }, - { no: 4, row: '전열', elx: 43.840, ...FRONT, A: 700, B: 640, C1: 600, C2: 1298, C3: 2500, C4: 600, C5: 600, C6: 1200, S1: 852 }, - { no: 5, row: '전열', elx: 43.912, ...FRONT, A: 700, B: 640, C1: 600, C2: 1298, C3: 2500, C4: 600, C5: 600, C6: 1200, S1: 852 }, - { no: 6, row: '후열', elx: 43.600, ...BACK, A: 700, B: 540, C1: 602, C2: null, C3: null, C4: null, C5: null, C6: null, S1: 950 }, - { no: 7, row: '후열', elx: 43.672, ...BACK, A: 700, B: 540, C1: 602, C2: null, C3: null, C4: null, C5: null, C6: null, S1: 950 }, - { no: 8, row: '후열', elx: 43.744, ...BACK, A: 700, B: 650, C1: 602, C2: null, C3: null, C4: null, C5: null, C6: null, S1: 950 }, - { no: 9, row: '후열', elx: 43.816, ...BACK, A: 700, B: 540, C1: 602, C2: null, C3: null, C4: null, C5: null, C6: null, S1: 950 }, - { no: 10, row: '후열', elx: 43.887, ...BACK, A: 700, B: 540, C1: 602, C2: null, C3: null, C4: null, C5: null, C6: null, S1: 950 }, -]; - -export const SOLPLATE_REF: SolplateRefRow[] = [ - { no: 1, s: -1.910, L: 800, W: 740, T: 30, T1: 38, T2: 22 }, - { no: 2, s: -1.907, L: 800, W: 740, T: 30, T1: 38, T2: 22 }, - { no: 3, s: -1.905, L: 800, W: 890, T: 30, T1: 38, T2: 22 }, - { no: 4, s: -1.903, L: 800, W: 740, T: 30, T1: 38, T2: 22 }, - { no: 5, s: -1.901, L: 800, W: 740, T: 30, T1: 38, T2: 22 }, - { no: 6, s: -1.910, L: 800, W: 640, T: 30, T1: 38, T2: 22 }, - { no: 7, s: -1.907, L: 800, W: 640, T: 30, T1: 38, T2: 22 }, - { no: 8, s: -1.905, L: 800, W: 750, T: 30, T1: 38, T2: 22 }, - { no: 9, s: -1.903, L: 800, W: 640, T: 30, T1: 38, T2: 22 }, - { no: 10, s: -1.901, L: 800, W: 640, T: 30, T1: 38, T2: 22 }, -]; - -/** 중심 두께 T of the 공통 규칙 (30 mm). T1 = T + L·|S|/2 (−측), T2 = T − L·|S|/2 (+측), so T1 + T2 = 2T. */ -export const SOLPLATE_T_RULE = 30; - -/** T1 / T2 by the drawing's rule: T ± L·|S|/2, rounded to mm (S in %). */ -export function solplateTs(T: number, L: number, s: number): { T1: number; T2: number } { - const half = (L * Math.abs(s)) / 200; - return { T1: Math.round(T + half), T2: Math.round(T - half) }; -} - -/** 공통 규칙 (2단계, 승인 2026-10-23): inputs that drive E, L, W, T1, T2. Defaults are the drawing's values. */ -export interface BearingRules { - /** 모르타르 h2 (mm). */ - h2: number; - /** 받침 높이 h3 (mm). */ - h3: number; - /** 솔플레이트 중심 두께 T (mm). */ - T: number; - /** 솔플 여유 (mm): L = A + margin, W = B + margin. */ - margin: number; -} - -export const BEARING_RULES_DEFAULT: BearingRules = { h2: 50, h3: 181, T: SOLPLATE_T_RULE, margin: 100 }; - -/** Values of one bearing with the 공통 규칙 applied: E = h1 + h2 + h3 + h4, L = A + margin, W = B + margin, T1/T2 from T. */ -export function applyBearingRules(row: BearingRefRow, rules: BearingRules): { E: number; L: number; W: number; T1: number; T2: number } { - const sp = SOLPLATE_REF[row.no - 1]; - const L = row.A + rules.margin; - 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; - /** 받침 평면 A, B (mm) and solplate slope S (%), when read from the drawing (승인 2026-10-23). */ - A?: number; - B?: number; - S?: 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/drawing-import.ts b/src/core/drawing-import.ts deleted file mode 100644 index a37e129..0000000 --- a/src/core/drawing-import.ts +++ /dev/null @@ -1,61 +0,0 @@ -/** - * 도면 불러오기 (승인 2026-10-23): reads the bearing drawing values from a sheet (xlsx or csv rows). Pure: the caller - * passes the rows (arrays of cells). The header row names: No, EL.X (m), h1 (mm), h2 (mm). 열 and other columns are ignored. - */ -import type { DrawingBearing } from './bearing-ref'; - -export interface DrawingImportResult { - rows: DrawingBearing[]; - errors: string[]; -} - -// Header names ignore case, spaces, dots and a unit in parentheses: 'EL. X (m)' → 'elx'. -const norm = (v: unknown) => String(v ?? '').replace(/\(.*?\)/g, '').toLowerCase().replace(/[\s.]/g, ''); - -/** Column of each header name, or -1 when missing. */ -function headerIndex(header: unknown[]): { no: number; elx: number; h1: number; h2: number } { - const find = (...names: string[]) => header.findIndex((c) => names.includes(norm(c))); - return { no: find('no'), elx: find('elx', 'el'), h1: find('h1', '블럭높이h1'), h2: find('h2', '몰탈h2', '모르타르h2') }; -} - -export function parseDrawingRows(rows: unknown[][]): DrawingImportResult { - const errors: string[] = []; - const headerAt = rows.findIndex((r) => { - const idx = headerIndex(r); - return idx.no >= 0 && idx.elx >= 0 && idx.h1 >= 0 && idx.h2 >= 0; - }); - if (headerAt < 0) { - return { rows: [], errors: ['머리글을 찾지 못했습니다. No, EL.X, h1, h2 열이 있어야 합니다.'] }; - } - const idx = headerIndex(rows[headerAt]); - const out: DrawingBearing[] = []; - const seen = new Set(); - rows.slice(headerAt + 1).forEach((r, i) => { - const rowNo = headerAt + 2 + i; - if (r.every((c) => String(c ?? '').trim() === '')) return; - const no = Number(r[idx.no]); - const elx = Number(r[idx.elx]); - const h1 = Number(r[idx.h1]); - const h2 = Number(r[idx.h2]); - if (!Number.isInteger(no) || no < 1) { - errors.push(`${rowNo}행: No가 올바르지 않습니다.`); - return; - } - if (seen.has(no)) { - errors.push(`${rowNo}행: No ${no}가 중복됩니다.`); - return; - } - if (!Number.isFinite(elx) || elx < 30 || elx > 100) { - errors.push(`${rowNo}행: EL.X는 m 단위로 적어 주세요 (예: 43.625). 값: ${String(r[idx.elx])}`); - return; - } - if (!Number.isFinite(h1) || h1 <= 0 || h1 > 1000 || !Number.isFinite(h2) || h2 <= 0 || h2 > 1000) { - errors.push(`${rowNo}행: h1·h2는 mm 단위 양수로 적어 주세요.`); - return; - } - seen.add(no); - out.push({ no, elx, h1, h2 }); - }); - if (out.length === 0) errors.push('불러온 행이 없습니다.'); - return { rows: out.sort((a, b) => a.no - b.no), errors }; -} diff --git a/src/core/dxf-table.ts b/src/core/dxf-table.ts deleted file mode 100644 index 0bc4e80..0000000 --- a/src/core/dxf-table.ts +++ /dev/null @@ -1,149 +0,0 @@ -/** - * 도면에서 받침 ELEVATION TABLE 읽기 (승인 2026-10-23). Reads the TEXT entities of a DXF file and places each value - * by position: the header row names the columns (EL.X, h1 … h4, A, B, C1 …), the first column holds the bearing No. - * Pure: works on the text entities (x, y, text) of the drawing, not on the file itself. - */ -import type { DrawingBearing } from './bearing-ref'; - -export interface DxfText { - x: number; - y: number; - text: string; -} - -/** Reads TEXT and MTEXT entities (x, y, string) from DXF text. */ -export function readDxfTexts(dxf: string): DxfText[] { - const lines = dxf.split(/\r?\n/); - const pairs: [string, string][] = []; - for (let i = 0; i + 1 < lines.length; i += 2) pairs.push([lines[i].trim(), lines[i + 1].trim()]); - const out: DxfText[] = []; - for (let i = 0; i < pairs.length; i++) { - if (pairs[i][0] !== '0' || (pairs[i][1] !== 'TEXT' && pairs[i][1] !== 'MTEXT')) continue; - let text: string | null = null; - let x = NaN; - let y = NaN; - let j = i + 1; - for (; j < pairs.length && pairs[j][0] !== '0'; j++) { - const [c, v] = pairs[j]; - if (c === '1') text = v; - if (c === '10') x = Number(v); - if (c === '20') y = Number(v); - } - if (text !== null && Number.isFinite(x) && Number.isFinite(y)) out.push({ x, y, text }); - i = j - 1; - } - return out; -} - -const norm = (s: string) => s.replace(/[\s.'"]/g, '').toLowerCase(); -const NUMBER = /^-?\d+(\.\d+)?$/; - -export interface BearingTableRow { - no: number; - values: Record; -} - -export interface BearingTableResult { - /** Values per column as read from the drawing (keyed by header name). */ - rows: BearingTableRow[]; - /** The rows in the form the 받침 입력 uses (EL.X in m, h1 and h2 in mm). */ - drawing: DrawingBearing[]; - errors: string[]; -} - -/** - * Reads the bearing ELEVATION TABLE. The header is the text row with "EL.X" (as EL.'X', EL. "X"); the No column is the - * integer column just left of the 교량받침 column; each value sits under its header (x within 150) on the same row (y within 120). - */ -export function parseBearingElevationTable(texts: DxfText[]): BearingTableResult { - const errors: string[] = []; - // The drawing has other "EL.X" labels (leader lines). The table header is the one with h1 and h2 on the same row. - // The header is the EL.X label with the most table column names on its row, to its right (within 2500). - const TABLE_NAMES = ['h1', 'h2', 'h3', 'h4', 'a', 'b', 'c1', 'c2', 'c3', 'c4', 'c5', 'c6', 's1', 's2']; - const rowScore = (t: DxfText) => - texts.filter((u) => Math.abs(u.y - t.y) <= 60 && u.x > t.x && u.x - t.x <= 2500 && TABLE_NAMES.includes(norm(u.text))).length; - const candidates = texts.filter((t) => norm(t.text) === 'elx'); - const best = candidates.map((t) => ({ t, score: rowScore(t) })).sort((a, b) => b.score - a.score)[0]; - if (!best || best.score < 2) return { rows: [], drawing: [], errors: ['EL.X 머리글(표의 열 이름과 같은 행)을 찾지 못했습니다.'] }; - const elHeader = best.t; - const headerY = elHeader.y; - // Column headers: texts on the header row (within 60 of its y) with a short name (EL.X, h1 … h4, A, B, C1 …). - const headerRow = texts.filter((t) => Math.abs(t.y - headerY) <= 60 && /^(elx|h\d|[a-z]\d?)$/.test(norm(t.text))); - const cols = headerRow.map((t) => ({ key: norm(t.text), x: t.x })); - // The No header is the 'NO' label on the table's header row (the drawing has other 'NO' labels). - const noHeader = texts.find((t) => norm(t.text) === 'no' && Math.abs(t.y - headerY) <= 200); - // The No column: integers 1…10 below the header, within 150 of the No header (or the left-most integer column). - const numbers = texts.filter((t) => NUMBER.test(t.text) && Number.isInteger(Number(t.text)) && Number(t.text) >= 1 && Number(t.text) <= 99 && t.y < headerY - 60); - if (numbers.length === 0) return { rows: [], drawing: [], errors: ['받침 번호 열을 찾지 못했습니다.'] }; - const noX = noHeader ? noHeader.x : Math.min(...numbers.map((t) => t.x)); - const noCells = numbers.filter((t) => Math.abs(t.x - noX) <= 250).sort((a, b) => b.y - a.y); - - const rows: BearingTableRow[] = []; - for (const cell of noCells) { - const values: Record = {}; - for (const col of cols) { - const hit = texts.find((t) => Math.abs(t.x - col.x) <= 150 && Math.abs(t.y - cell.y) <= 120 && NUMBER.test(t.text)); - if (hit) values[col.key] = Number(hit.text); - } - rows.push({ no: Number(cell.text), values }); - } - const drawing: DrawingBearing[] = []; - for (const r of rows) { - const elx = r.values.elx; - const h1 = r.values.h1; - const h2 = r.values.h2; - if (elx === undefined || h1 === undefined || h2 === undefined) { - errors.push(`No ${r.no}: EL.X·h1·h2 중 값이 없습니다.`); - continue; - } - drawing.push({ no: r.no, elx, h1, h2, A: r.values.a, B: r.values.b }); - } - return { rows, drawing: drawing.sort((a, b) => a.no - b.no), errors }; -} - -export interface SolplateTableRow { - no: number; - /** 기울기 S (%), negative as drawn. */ - S: number; - L: number; - W: number; - /** 중심 두께 T, and the 교각축 직각 (−) and (+) side thicknesses T1, T2 (mm). */ - T: number; - T1: number; - T2: number; -} - -/** - * 솔플레이트 TABLE (승인 2026-10-23): the header row has NO, S(%), L, W, T, T1, T2; each bearing row gives the values under - * their headers, as in the bearing ELEVATION table. - */ -export function parseSolplateTable(texts: DxfText[]): { rows: SolplateTableRow[]; errors: string[] } { - // The drawing has more than one "S(%)" label: the header is the one with L, W, T, T1 and T2 on its row. - const SOL_NAMES = ['l', 'w', 't', 't1', 't2']; - const solScore = (t: DxfText) => texts.filter((u) => Math.abs(u.y - t.y) <= 60 && SOL_NAMES.includes(norm(u.text))).length; - const sCandidates = texts.filter((t) => norm(t.text) === 's(%)' || norm(t.text) === 's%'); - const sBest = sCandidates.map((t) => ({ t, score: solScore(t) })).sort((a, b) => b.score - a.score)[0]; - if (!sBest || sBest.score < 5) return { rows: [], errors: ['솔플레이트 TABLE의 머리글(NO, L, W, T, T1, T2)이 모두 있어야 합니다.'] }; - const sHeader = sBest.t; - const headerY = sHeader.y; - const headerRow = texts.filter((t) => Math.abs(t.y - headerY) <= 60); - const key = (name: string) => headerRow.find((t) => norm(t.text) === name); - const cols = { S: key('s(%)') ?? key('s%'), L: key('l'), W: key('w'), T: key('t'), T1: key('t1'), T2: key('t2') }; - const noHeader = headerRow.find((t) => norm(t.text) === 'no'); - if (!cols.L || !cols.W || !cols.T || !cols.T1 || !cols.T2 || !noHeader) { - return { rows: [], errors: ['솔플레이트 TABLE의 머리글(NO, L, W, T, T1, T2)이 모두 있어야 합니다.'] }; - } - const noCells = texts.filter((t) => NUMBER.test(t.text) && Number.isInteger(Number(t.text)) && Math.abs(t.x - noHeader.x) <= 250 && t.y < headerY - 60); - const at = (col: DxfText, y: number) => { - const hit = texts.find((t) => Math.abs(t.x - col.x) <= 150 && Math.abs(t.y - y) <= 120 && NUMBER.test(t.text)); - return hit ? Number(hit.text) : NaN; - }; - const rows: SolplateTableRow[] = []; - const errors: string[] = []; - for (const cell of noCells.sort((a, b) => b.y - a.y)) { - const r = { no: Number(cell.text), S: cols.S ? at(cols.S, cell.y) : NaN, L: at(cols.L, cell.y), W: at(cols.W, cell.y), T: at(cols.T, cell.y), T1: at(cols.T1, cell.y), T2: at(cols.T2, cell.y) }; - if (Object.values(r).some((v) => Number.isNaN(v))) errors.push(`No ${r.no}: 솔플레이트 값이 빠졌습니다.`); - else rows.push(r); - } - return { rows: rows.sort((a, b) => a.no - b.no), errors }; -} diff --git a/src/core/geometry.ts b/src/core/geometry.ts index b0e631c..73165d9 100644 --- a/src/core/geometry.ts +++ b/src/core/geometry.ts @@ -5,7 +5,8 @@ import { type CopingRebarSettings, DEFAULT_COPING_REBAR, DEFAULT_TOP_SPLICE, typ import { type ColumnRebarSettings, DEFAULT_COLUMN_REBAR } from './rebar/column-settings'; import { type FootingRebarSettings, DEFAULT_FOOTING_REBAR } from './rebar/footing-settings'; import { layoutText, resolveRowCounts, rowLayout, type PileGeometry } from './piles'; -import type { PierInput, Row } from './types'; +import type { PierInput, Row, SolplatePlan } from './types'; +import { SOLPLATE_RULE_DEFAULT, solplatePlan } from './solplate-rule'; import { type CopingGeom, P1_COPING_GEOM } from './coping-geom'; /** Dimensions in m. Sources: intent.PR sheet (P1 rules) and spec.md §3 defaults. */ @@ -74,8 +75,8 @@ 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 plan of this bearing (A, B, S, T, margin, h3), when the input gives it. */ - plan?: { A: number; B: number; S: number; T: number; margin: number; h3: number }; + /** Solplate of this bearing (C안, 2026-11-03): the pier's rule, the same for every bearing. */ + plan: SolplatePlan; } /** Skew of a bearing row (deg) from the bearing azimuth and the stretch axis direction u (see {@link BlockModel.rowSkewDeg}). */ @@ -432,19 +433,15 @@ 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 plans from the input (승인 2026-10-23); the 공통 규칙 default is T 30, margin 100, h3 181. - const rules = input.solplate ?? { T: 30, margin: 100, h3: 181 }; - const planOf = (no: number) => { - const p = input.bearingPlans?.find((q) => q.no === no); - return p ? { A: p.A, B: p.B, S: p.S, T: rules.T, margin: rules.margin, h3: rules.h3 } : undefined; - }; + // 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); frontPlaced.forEach((b) => { const bottom = segmentTop(segments, b.g); - 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: planOf(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, padTop: b.z, rowSkewDeg: skewOf(b.no, b.k), plan }); }); backPlaced.forEach((b) => { const bottom = b.z - h2; - 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: planOf(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, padTop: b.z, rowSkewDeg: skewOf(b.no, b.k), plan }); }); const blockHeightFront = blocks.find((b) => b.row === '전열')!.top - blocks.find((b) => b.row === '전열')!.bottom; diff --git a/src/core/project.ts b/src/core/project.ts index fd91531..3a31605 100644 --- a/src/core/project.ts +++ b/src/core/project.ts @@ -33,10 +33,6 @@ 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; /** 지반 모델 (ground_model.json)의 교각별 지반고. Absent = 불러오지 않음. Applied to a pier only by the user. */ diff --git a/src/core/solplate-rule.ts b/src/core/solplate-rule.ts new file mode 100644 index 0000000..b04a310 --- /dev/null +++ b/src/core/solplate-rule.ts @@ -0,0 +1,38 @@ +/** + * 솔플레이트 규칙 (C안, 2026-11-03). Pure: the solplate every bearing of a pier gets from its rule. The rule gives the + * 받침높이 H (solplate included), the plan size (교직 L across the 교각축 직각, 교축 W along the 교축) and the slope S (%). + * The centre thickness T is the smallest whole mm that keeps the thin end T2 at 22 mm or more: T = ⌈22 + L·|S|/200⌉. + * The bearing body is then h3 = H − T. + */ +import type { SolplatePlan, SolplateRule } from './types'; + +/** 공통 기본값 (2026-11-03): 받침높이 211 mm, 교직 1000 × 교축 1100 mm, 종단 기울기 −0.5 %. */ +export const SOLPLATE_RULE_DEFAULT: SolplateRule = { heightMm: 211, lengthMm: 1000, widthMm: 1100, slopePct: -0.5 }; + +/** Smallest allowed thickness at the thin end (mm). */ +export const SOLPLATE_MIN_T2_MM = 22; + +/** Centre thickness T (mm, whole mm): the thin end T2 = T − L·|S|/200 is at least 22 mm. */ +export function solplateCenterT(lengthMm: number, slopePct: number): number { + return Math.ceil(SOLPLATE_MIN_T2_MM + (lengthMm * Math.abs(slopePct)) / 200); +} + +/** The solplate plan of a rule: T, T1 = T + L·|S|/200, T2 = T − L·|S|/200, h3 = H − T. */ +export function solplatePlan(rule: SolplateRule): SolplatePlan { + const T = solplateCenterT(rule.lengthMm, rule.slopePct); + const drop = (rule.lengthMm * Math.abs(rule.slopePct)) / 200; + return { L: rule.lengthMm, W: rule.widthMm, slopePct: rule.slopePct, T, T1: T + drop, T2: T - drop, h3: rule.heightMm - T }; +} + +/** Input problems of a rule, in Korean; empty when the rule can build a solplate. */ +export function solplateRuleIssues(rule: SolplateRule): string[] { + const issues: string[] = []; + if (!(rule.lengthMm > 0) || !(rule.widthMm > 0)) issues.push('솔플레이트 교직·교축 길이는 0보다 커야 합니다.'); + if (!Number.isFinite(rule.slopePct)) issues.push('종단 기울기는 숫자여야 합니다.'); + if (!(rule.heightMm > 0)) issues.push('받침높이는 0보다 커야 합니다.'); + if (issues.length === 0) { + const plan = solplatePlan(rule); + if (!(rule.heightMm > plan.T)) issues.push(`받침높이는 솔플레이트 중심 두께 ${plan.T} mm보다 커야 합니다.`); + } + return issues; +} diff --git a/src/core/solplate.ts b/src/core/solplate.ts index fc1fc14..ded7d69 100644 --- a/src/core/solplate.ts +++ b/src/core/solplate.ts @@ -1,25 +1,14 @@ /** - * 솔플레이트 기하 (승인 2026-10-23). Pure: the solplate of one bearing, from its block (position, rowSkew) and the - * 공통 규칙 values. The plan is L = A + margin across the 교각축 직각 (n) and W = B + margin 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, 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). * The underside is the bearing top (block top EL.X + h3). Lengths in m, as in the model. */ import { blockFramePoint, type BlockModel } from './geometry'; - -export interface SolplateParams { - /** 받침 평면 A (mm, across the 교각축 직각) and B (mm, along the 교축). */ - A: number; - B: number; - /** 솔플 여유 (mm): L = A + margin, W = B + margin. */ - margin: number; - /** 중심 두께 T (mm). */ - T: number; - /** 기울기 S (%), negative as drawn: thickness changes by L·|S|/2 from the centre. */ - slopePct: number; -} +import type { SolplatePlan } from './types'; export interface SolplateGeometry { - /** Length across the 교각축 직각 (m, = A + margin) and width along the 교축 (m, = B + margin). */ + /** Length across the 교각축 직각 (m) and width along the 교축 (m). */ L: number; W: number; /** Thickness at the (−) side (T1) and the (+) side (T2) of the 교각축 직각 direction (m). */ @@ -33,16 +22,13 @@ export interface SolplateGeometry { volume: number; } -/** Solplate of the bearing at `block`, with the bearing height h3 (mm) and the 공통 규칙 parameters. */ -export function solplateGeometry(block: BlockModel, h3: number, p: SolplateParams): SolplateGeometry { - const L = (p.A + p.margin) / 1000; // across the 교각축 직각 (n) - const W = (p.B + p.margin) / 1000; // along the 교축 (s) - // Thickness change from the centre to each side: L·|S|/2, with S in % (mm). - const drop = ((p.A + p.margin) * Math.abs(p.slopePct)) / 200 / 1000; - const T = p.T / 1000; - const T1 = T + drop; - const T2 = T - drop; - const bottom = block.padTop + h3 / 1000; +/** Solplate of the bearing at `block`, from the pier's solplate plan (mm). */ +export function solplateGeometry(block: BlockModel, plan: SolplatePlan): SolplateGeometry { + const L = plan.L / 1000; // across the 교각축 직각 (n) + const W = plan.W / 1000; // along the 교축 (s) + 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; const corners: [number, number, number][] = []; for (const a of [-W / 2, W / 2]) { diff --git a/src/core/types.ts b/src/core/types.ts index 2bddbb4..4a2374d 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -48,8 +48,6 @@ export interface PierInput { bearings: Bearing[]; /** Angles normalized with the imported X/Y swap. Absent in legacy saved projects = workbook frame. */ bearingAzimuthFrame?: 'model-xy'; - /** 솔플레이트 plan per bearing (승인 2026-10-23): A, B (mm) and the slope S (%). Absent = no solplate. */ - bearingPlans?: { no: number; A: number; B: number; S: number }[]; /** 확대기초 L (축 방향), W, 높이 h1 (mm); 비림 여유 and 비림 두께 (mm); 말뚝 외경 D and 두께 t (mm). Absent = the 1단계 constants. */ footingLengthMm?: number; footingWidthMm?: number; @@ -60,8 +58,8 @@ export interface PierInput { pileThicknessMm?: number; /** 말뚝 종류 (규격 목록): 강관 (default) or PHC. */ pileKind?: '강관' | 'PHC'; - /** 공통 규칙 for the solplate: centre thickness T, 솔플 여유 margin (mm), bearing height h3 (mm). */ - solplate?: { T: number; margin: number; h3: number }; + /** 솔플레이트 규칙 of this pier (C안, 2026-11-03). Absent = the default (211 · 1000 × 1100 · −0.5 %). */ + solplate?: SolplateRule; /** 지반·지층 (승인 2026-10-26): 위치, 지하수위 고려와 EL, 지층별 행 (위에서 아래, 행의 EL = 그 층의 하단 EL). * Absent = 육상, 지하수위 없음, 지층 행 없음. */ groundPosition?: GroundPosition; @@ -91,6 +89,31 @@ export interface PierInput { strataRows?: StrataRow[]; } +/** 솔플레이트 규칙 of one pier (C안, 2026-11-03): 받침높이 (solplate included), plan size (mm) and the slope S (%). */ +export interface SolplateRule { + /** 받침높이 H (mm): from the mortar top (EL.X) to the solplate top, solplate included (H = h3 + T). */ + heightMm: number; + /** 교직 length L (mm, across the 교각축 직각 (n)). */ + lengthMm: number; + /** 교축 width W (mm, along the 교축 (s)). */ + widthMm: number; + /** 기울기 S (%), negative as drawn. */ + slopePct: number; +} + +/** The solplate every bearing of a pier gets from its rule (mm): T is the centre thickness, h3 = H − T. */ +export interface SolplatePlan { + L: number; + W: number; + slopePct: number; + T: number; + /** Thickness at the (−) side (T1) and the (+) side (T2) of the 교각축 직각 direction. */ + T1: number; + T2: number; + /** Bearing body height h3 (mm) between the mortar top and the solplate underside. */ + h3: number; +} + export type GroundPosition = '육상' | '하상' | '해상'; export interface StrataRow { diff --git a/src/main.ts b/src/main.ts index 109f0cd..0eed7a9 100644 --- a/src/main.ts +++ b/src/main.ts @@ -19,13 +19,12 @@ import { shearSegments } from './core/rebar/coping'; import { buildRebar3D, type RebarColorMode, type RebarScene } from './view/rebar3d'; import { DEFAULT_MATERIAL, FCK_MEMBERS, fckChanged, fyOf, GRADE_CHOICES, IMPORTED_FCK, 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 { parseDrawingRows } from './core/drawing-import'; import { groundElFor, parseGroundModel, strataRowsFor, type GroundMapLayer, type GroundModelFile } from './core/ground-model'; import { CORROSION_ALLOWANCE_MM, parseNValue, SEA_DATUM, STRATA_NAMES } from './core/ground'; import { bearingEdgeDistances, integratedCheck, PAIR_ECCENTRICITY_LIMIT_MM, pairEccentricities } from './core/bearing-layout'; -import { bearingPlanSvg, type BearingPlanOverlay, PAIR_CHIP_LABELS } from './view/bearing-plan-svg'; -import { bearingSectionSvg, type BearingDrawingRef } from './view/bearing-section-svg'; -import { solplateDetailSvg, type SolplateDrawingRef } from './view/solplate-svg'; +import { bearingPlanSvg, PAIR_CHIP_LABELS } from './view/bearing-plan-svg'; +import { bearingSectionSvg } from './view/bearing-section-svg'; +import { solplateDetailSvg } from './view/solplate-svg'; import { blockRebarSectionSvg } from './view/block-rebar-svg'; import { blockRebarPlanSvg } from './view/block-rebar-plan-svg'; import { copingEndStation, copingFrontSvg, copingSectionSvg } from './view/coping-rebar-svg'; @@ -43,19 +42,12 @@ import { footingLegRows } from './core/rebar/footing-legs'; import { columnElevationSvg } from './view/column-elevation-svg'; import { type ColumnSection, columnSectionSvg } from './view/column-section-svg'; import { groundSectionSvg } from './view/ground-svg'; -import { parseBearingElevationTable, parseSolplateTable, readDxfTexts } from './core/dxf-table'; 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 { 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 { SOLPLATE_RULE_DEFAULT, solplatePlan, solplateRuleIssues } from './core/solplate-rule'; +import type { SolplateRule } from './core/types'; /** 지반 모델 (ground_model.json) for the project. Its values reach a pier's 지반고 only when the user applies them. */ let groundModel: GroundModelFile | null = null; @@ -288,19 +280,14 @@ 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 baseInput = drawing?.length ? withDrawingBasis(input, drawing) : input; - // Solplate plans come from the drawing rows that have A, B and S, whatever the 받침 기준 (승인 2026-10-23). - const plans = (bearingDrawing[input.name] ?? []) - .filter((d) => d.A !== undefined && d.B !== undefined && d.S !== undefined) - .map((d) => ({ no: d.no, A: d.A as number, B: d.B as number, S: d.S as number })); - const modelInput: PierInput = { ...baseInput, bearingPlans: plans, solplate: { T: bearingRules.T, margin: bearingRules.margin, h3: bearingRules.h3 } }; - if (validateInput(input, issues)) { + // 솔플레이트 규칙 (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); + 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(modelInput); + model = buildPier(input); validatePier(input, model, issues); - for (const message of columnDiameterIssues(modelInput.columnDiameterMm ?? 2000, model)) { + for (const message of columnDiameterIssues(input.columnDiameterMm ?? 2000, model)) { issues.push({ code: 'D01', level: 'E', pier: input.name, message }); } } catch (err) { @@ -2108,188 +2095,15 @@ function setMaterial(key: 'fck' | GradeKey, raw: string): void { renderAll(); } -/** ② 교량받침 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); - - // 도면 불러오기 (승인 2026-10-23): a sheet with No, EL.X, h1, h2 (xlsx or csv) fills this pier's drawing values. - const importInput = el('input'); - importInput.type = 'file'; - importInput.accept = '.xlsx,.xls,.csv,.dxf'; - importInput.id = 'drawing-import'; - importInput.addEventListener('change', async () => { - const file = importInput.files?.[0]; - if (!file) return; - try { - // A DXF drawing: the 교량받침 ELEVATION TABLE is read by position. Otherwise a sheet with No, EL.X, h1, h2. - let drawingRows: DrawingBearing[]; - if (file.name.toLowerCase().endsWith('.dxf')) { - const texts = readDxfTexts(await file.text()); - const r = parseBearingElevationTable(texts); - if (r.errors.length) { - setError(`도면 불러오기 실패: ${r.errors.slice(0, 3).join(' ')}`); - return; - } - // The solplate table gives S per bearing (승인 2026-10-23); a missing S is reported, not guessed. - const sp = parseSolplateTable(texts); - const sByNo = new Map(sp.rows.map((x) => [x.no, x.S])); - drawingRows = r.drawing.map((d) => ({ ...d, S: sByNo.get(d.no) })); - if (sp.errors.length) setStatus(`솔플레이트 값 확인: ${sp.errors.slice(0, 2).join(' ')}`); - } else { - const wb = XLSX.read(await file.arrayBuffer(), { type: 'array' }); - const sheet = wb.Sheets[wb.SheetNames[0]]; - const rows = XLSX.utils.sheet_to_json(sheet, { header: 1, blankrows: false, defval: '' }); - const r = parseDrawingRows(rows); - if (r.errors.length) { - setError(`도면 불러오기 실패: ${r.errors.slice(0, 3).join(' ')}`); - return; - } - drawingRows = r.rows; - } - bearingDrawing = { ...bearingDrawing, [name]: drawingRows }; - regenerateAllPiers(); - renderAll(); - setStatus(`${file.name}에서 받침 ${drawingRows.length}개의 도면 값을 불러왔습니다.`); - } catch (err) { - setError(`도면 파일을 읽지 못했습니다: ${(err as Error).message}`); - } finally { - importInput.value = ''; - } - }); - const importLabel = el('label', '도면 파일 불러오기 (xlsx·csv)'); - importLabel.appendChild(importInput); - wrap.appendChild(importLabel); - const fill = el('button', '도면값 채우기 (참조 값)', 'btn small'); - fill.type = 'button'; - fill.addEventListener('click', () => { - // The reference values include A, B (plan) and S (solplate slope), so the solplate can be drawn (승인 2026-10-23). - bearingDrawing = { ...bearingDrawing, [name]: BEARING_ELEVATION_REF.map((r, i) => ({ no: r.no, elx: r.elx, h1: r.h1, h2: r.h2, A: r.A, B: r.B, S: SOLPLATE_REF[i].s })) }; - regenerateAllPiers(); - renderAll(); - }); - wrap.appendChild(fill); - const rowsNow = bearingDrawing[name] ?? []; - const missingPlan = pier.input.bearings.filter((b) => !rowsNow.some((d) => d.no === b.no && d.A !== undefined && d.B !== undefined && d.S !== undefined)).length; - if (missingPlan > 0) { - wrap.appendChild(el('p', `솔플레이트 값(A, B, S)이 없는 받침 ${missingPlan}개는 솔플레이트를 그리지 않습니다. 도면을 불러오거나 "도면값 채우기"를 누르세요.`, 'warn-text')); - } - if (bearingBasis === 'drawing' && !bearingDrawing[name]?.length) { - wrap.appendChild(el('p', '도면 값이 없어서 엑셀 z로 계산 중입니다.', 'warn-text')); - } -} - /** * 받침 배치 (승인 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). */ -/** 상세 보기 pair shown in the plan, and whether the drawing values are overlaid (screen state only, 승인 2026-10-26). */ +/** 상세 보기 pair shown in the plan (screen state only, 승인 2026-10-26). */ let bearingPairIndex = 0; -let bearingOverlayOn = true; /** ③ 받침블럭 view: the C–C section or the plan (승인 2026-10-26). */ let bearingRebarView: 'section' | 'plan' = 'section'; -/** - * Drawing values of one bearing for the overlays (승인 2026-10-26): the imported drawing row where it has the value - * (elx, h1, h2, A, B), otherwise the reference table (h3, h4, E). - */ -function drawingRefFor(pier: Pier, no: number): BearingDrawingRef | undefined { - const r = BEARING_ELEVATION_REF.find((x) => x.no === no); - if (!r) return undefined; - const d = (bearingDrawing[pier.input.name] ?? []).find((x) => x.no === no); - const A = d?.A ?? r.A; - const B = d?.B ?? r.B; - return { elx: d?.elx ?? r.elx, h1: d?.h1 ?? r.h1, h2: d?.h2 ?? r.h2, h3: r.h3, h4: r.h4, E: r.E, A, B }; -} - -/** Drawing A, B of the front and back bearings of a pair, for the plan overlay. */ -function overlayFor(pier: Pier, frontNo: number, backNo: number): BearingPlanOverlay | undefined { - const f = drawingRefFor(pier, frontNo); - const b = drawingRefFor(pier, backNo); - return f && b ? { front: { A: f.A, B: f.B }, back: { A: b.A, B: b.B } } : undefined; -} - -/** Drawing solplate values of one bearing (reference table), for the solplate detail overlay. */ -function solplateRefFor(no: number): SolplateDrawingRef | undefined { - const r = SOLPLATE_REF.find((x) => x.no === no); - return r ? { L: r.L, W: r.W, T1: r.T1, T2: r.T2, S: r.s } : undefined; -} - function renderBearingLayout(wrap: HTMLDivElement, pier: Pier): void { if (!pier.model) return; // 상세 보기 (승인 2026-10-26): pair chips (①·⑥ …) and the plan with the drawing values overlaid as dashed outlines. @@ -2308,39 +2122,26 @@ function renderBearingLayout(wrap: HTMLDivElement, pier: Pier): void { chips.appendChild(chip); }); wrap.appendChild(chips); - const overlayCheck = el('input'); - overlayCheck.type = 'checkbox'; - overlayCheck.checked = bearingOverlayOn; - overlayCheck.id = 'bearing-overlay'; - overlayCheck.addEventListener('change', () => { - bearingOverlayOn = overlayCheck.checked; - renderAll(); - }); - const overlayLabel = el('label', ' 도면값 겹쳐 보기 (점선)'); - overlayLabel.prepend(overlayCheck); - wrap.appendChild(overlayLabel); const current = pairList[bearingPairIndex]; if (current) { const fig = el('figure', undefined, 'ground-fig'); - const overlay = bearingOverlayOn ? overlayFor(pier, current.frontNo, current.backNo) : undefined; - fig.innerHTML = bearingPlanSvg(pier.model, bearingPairIndex, overlay); - fig.appendChild(el('figcaption', '평면 · 실선 = 모델(솔플레이트, 블록 윤곽) · 점선 = 도면값 · C4·C5는 교축 방향, 받침 중심에서 블록 바깥 연단까지', 'muted small')); + fig.innerHTML = bearingPlanSvg(pier.model, bearingPairIndex); + fig.appendChild(el('figcaption', '평면 · 실선 = 모델(솔플레이트, 블록 윤곽) · C4·C5는 교축 방향, 받침 중심에서 블록 바깥 연단까지', 'muted small')); wrap.appendChild(fig); - // 단면 (elevation): the blocks, mortar, bearing body and solplates; the drawing heights dashed. + // 단면 (elevation): the blocks, mortar, bearing body and solplates. const section = el('figure', undefined, 'ground-fig'); - const refs = bearingOverlayOn ? { front: drawingRefFor(pier, current.frontNo), back: drawingRefFor(pier, current.backNo) } : undefined; - section.innerHTML = bearingSectionSvg(pier.model, bearingPairIndex, refs); - section.appendChild(el('figcaption', '단면 · EL.X는 모르타르 상단, E = h1 + h2 + h3 + h4 · 점선 = 도면 높이', 'muted small')); + section.innerHTML = bearingSectionSvg(pier.model, bearingPairIndex); + section.appendChild(el('figcaption', '단면 · EL.X는 모르타르 상단 · h3 = 받침 본체 · 솔플레이트 T1(−측)·T2(+측)', 'muted small')); wrap.appendChild(section); - // 솔플레이트 상세 for the two bearings of the pair (plan and taper, drawing dashed). + // 솔플레이트 상세 for the two bearings of the pair (plan and taper). const solGrid = el('div', undefined, 'footing-grid'); for (const no of [current.frontNo, current.backNo]) { const block = pier.model.blocks.find((b) => b.no === no)!; const fig2 = el('figure', undefined, 'footing-fig'); - fig2.innerHTML = solplateDetailSvg(block, bearingOverlayOn ? solplateRefFor(no) : undefined); - fig2.appendChild(el('figcaption', `솔플레이트 상세 · ${no}번 · 두께 과장 · 점선 = 도면값`, 'muted small')); + fig2.innerHTML = solplateDetailSvg(block); + fig2.appendChild(el('figcaption', `솔플레이트 상세 · ${no}번 · 두께 과장`, 'muted small')); solGrid.appendChild(fig2); } wrap.appendChild(solGrid); @@ -2618,9 +2419,7 @@ function renderGroundModel(box: HTMLDivElement, pier: Pier): void { } function renderBearingRef(box: HTMLDivElement, pier: Pier): void { - const m = pier.model; const wrap = el('div', undefined, 'bearing-ref'); - renderBearingBasis(wrap, pier); // 받침 종류·용량 (승인 2026-10-26: 글자 입력, 기본 탄성받침). Saved in the project; no rule yet, so it does not change the model. wrap.appendChild(el('p', '받침 종류·용량 (글자 입력 · 기본 탄성받침)', 'lbl')); const typeInput = el('input'); @@ -2636,98 +2435,52 @@ function renderBearingRef(box: HTMLDivElement, pier: Pier): void { }); 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)']]; + renderSolplateRule(wrap, pier); + box.appendChild(wrap); +} + +/** Sets one pier's solplate rule (C안, 2026-11-03) and rebuilds that pier. */ +function setSolplateRule(name: string, rule: SolplateRule): void { + const p = state.piers.find((q) => q.input.name === name); + if (!p) return; + p.draft = { ...p.draft, solplate: rule }; + state.piers = state.piers.map((q) => (q === p ? regenerateEntry(q) : q)); + setupScene(); + renderAll(); +} + +/** 솔플레이트 규칙 (C안, 2026-11-03): per pier. 받침높이 H (solplate included), the plan L × W and the slope S; T and h3 follow. */ +function renderSolplateRule(wrap: HTMLDivElement, pier: Pier): void { + const rule = pier.draft.solplate ?? SOLPLATE_RULE_DEFAULT; + wrap.appendChild(el('p', '솔플레이트 규칙 (이 교각 · 기본 211 · 1000 × 1100 · −0.5 %)', 'lbl')); + const fields: [keyof SolplateRule, string][] = [ + ['heightMm', '받침높이 H (솔플레이트 포함, mm)'], + ['lengthMm', '솔플레이트 교직 L (mm)'], + ['widthMm', '솔플레이트 교축 W (mm)'], + ['slopePct', '종단 기울기 S (%)'], + ]; const grid = el('div', undefined, 'edit-row'); - for (const [key, label] of rules) { - const id = `bear-${key}`; + 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(bearingRules[key]); - const changed = bearingRules[key] !== BEARING_RULES_DEFAULT[key]; - if (changed) input.classList.add('changed-input'); - input.addEventListener('change', () => { - const v = Number(input.value); - const min = key === 'margin' ? 0 : 0.0001; - if (!Number.isFinite(v) || v < min) { - setError(`${label}은(는) ${key === 'margin' ? '0 이상' : '0보다 큰'} 숫자여야 합니다.`); - renderAll(); - return; - } - bearingRules = { ...bearingRules, [key]: v }; - regenerateAllPiers(); - renderAll(); - }); - grid.append(lab, input, el('span', `기본 ${BEARING_RULES_DEFAULT[key]}`, 'muted small')); + 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')); } wrap.appendChild(grid); - wrap.appendChild(el('p', '규칙 적용 값 (E = h1 + h2 + h3 + h4, L = A + 여유, W = B + 여유, T1·T2 = T ± L·|S|/2)', 'lbl')); - const ct = el('table', undefined, 'grid-table rebar-table'); - const ch = el('thead'); - const chr = el('tr'); - for (const h of ['No', '열', 'E', 'L', 'W', 'T1 (−측)', 'T2 (+측)', '도면 대비']) chr.appendChild(el('th', h)); - ch.appendChild(chr); - ct.appendChild(ch); - const cb = el('tbody'); - for (const r of BEARING_ELEVATION_REF) { - const v = applyBearingRules(r, bearingRules); - const d = applyBearingRules(r, BEARING_RULES_DEFAULT); - const same = v.E === d.E && v.L === d.L && v.W === d.W && v.T1 === d.T1 && v.T2 === d.T2; - cb.appendChild(el('tr')).append( - el('td', String(r.no)), el('td', r.row), el('td', String(v.E)), el('td', String(v.L)), el('td', String(v.W)), - el('td', String(v.T1)), el('td', String(v.T2)), el('td', same ? '같음' : '규칙 변경'), - ); + const issues = solplateRuleIssues(rule); + if (issues.length > 0) { + wrap.appendChild(el('p', issues.join(' '), 'warn-text')); + return; } - 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', '엑셀 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', '열', '엑셀 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) { - // 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', 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)), - ); - } - et.appendChild(eb); - wrap.appendChild(el('div', undefined, 'table-scroll')).appendChild(et); - - wrap.appendChild(el('p', '솔플레이트 TABLE (도면값, mm)', 'lbl')); - const st = el('table', undefined, 'grid-table rebar-table'); - const sh = el('thead'); - const shr = el('tr'); - for (const h of ['No', 'S (%)', 'L', 'W', 'T (중심)', 'T1 (−측)', 'T2 (+측)', '식 검산']) shr.appendChild(el('th', h)); - sh.appendChild(shr); - st.appendChild(sh); - const sb = el('tbody'); - for (const r of SOLPLATE_REF) { - const calc = solplateTs(SOLPLATE_T_RULE, r.L, r.s); - const ok = calc.T1 === r.T1 && calc.T2 === r.T2; - sb.appendChild(el('tr')).append( - el('td', String(r.no)), el('td', r.s.toFixed(3)), el('td', String(r.L)), el('td', String(r.W)), - el('td', String(r.T)), el('td', String(r.T1)), el('td', String(r.T2)), el('td', ok ? '일치' : `식 ${calc.T1} / ${calc.T2}`), - ); - } - st.appendChild(sb); - wrap.appendChild(el('div', undefined, 'table-scroll')).appendChild(st); - box.appendChild(wrap); + 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')); } /** ② 코핑 상세 (2단계, 읽기 전용): d_IP2 and the 계단 구간 표 from the model (승인 2026-10-23). */ @@ -2850,8 +2603,6 @@ 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, groundModel, }); } @@ -2869,8 +2620,6 @@ function currentProjectText(): string { history: state.history, common: state.material, fckByMember: state.fckByMember, - bearingBasis, - bearingDrawing, groundModel: groundModel ?? undefined, }); } @@ -3002,8 +2751,6 @@ 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]])); groundModel = parsed.groundModel ?? null; groundMapMode = groundModel?.mapLayers?.length ? 'satellite' : 'none'; setGroundMapMode(groundMapMode); diff --git a/src/view/bearing-plan-svg.ts b/src/view/bearing-plan-svg.ts index ad1f0bd..eb24470 100644 --- a/src/view/bearing-plan-svg.ts +++ b/src/view/bearing-plan-svg.ts @@ -1,19 +1,12 @@ import type { PierModel } from '../core/geometry'; import { bearingEdgeDistances, pairEccentricities } from '../core/bearing-layout'; -/** Drawing values (A, B of the solplate, mm) of the front and back bearings, to overlay on the model (승인 2026-10-26). */ -export interface BearingPlanOverlay { - front: { A: number; B: number }; - back: { A: number; B: number }; -} - /** Number of pairs the 상세 보기 chips can show (①–⑩ in the mockup). */ export const PAIR_CHIP_LABELS = ['①', '②', '③', '④', '⑤', '⑥', '⑦', '⑧', '⑨', '⑩']; /** * 교량받침 상세 · 평면 for one pair (승인 2026-10-26): the bearings keep their actual 교각축 positions (the offset - * between the two bearings is shown as it is), and the blocks and solplates follow them. The model's solplates are solid, the drawing values - * dashed. 일체 draws one 2500 × 1200 outline; 분리 draws two 1200 × 1200 outlines. Dimensions: C1 (bearing to block edge), + * between the two bearings is shown as it is), and the blocks and solplates follow them. 일체 draws one 2500 × 1200 outline; 분리 draws two 1200 × 1200 outlines. Dimensions: C1 (bearing to block edge), * C2 (between the bearings), C3 (block span). C4 and C5 are the outer margins along the 교축. */ /** @@ -24,7 +17,7 @@ function rect(cx: number, cy: number, w: number, h: number, skewDeg: number, att return ``; } -export function bearingPlanSvg(m: PierModel, pairIndex: number, overlay?: BearingPlanOverlay): string { +export function bearingPlanSvg(m: PierModel, pairIndex: number): string { const pair = pairEccentricities(m.blocks)[pairIndex]; if (!pair) return ''; const f = m.blocks.find((b) => b.no === pair.frontNo && b.row === '전열')!; @@ -55,18 +48,9 @@ export function bearingPlanSvg(m: PierModel, pairIndex: number, overlay?: Bearin } } - // Solplates: the model's plan (A across the 교축, B along the 교각축), centred on each bearing. + // Solplates: the pier's plan (L across the 교축, W along the 교각축), centred on each bearing. for (const b of [f, k]) { - if (b.plan) { - const { A, B } = b.plan; - parts.push(rect(X(b.n), Y(b.s), A, B, b.rowSkewDeg, `fill="#EEF3F7" stroke="#2F6DB5" stroke-width="10"`)); - } - } - // Drawing values overlaid (dashed) on the same bearings (#1 겹쳐 보이게). - if (overlay) { - for (const [b, v] of [[f, overlay.front], [k, overlay.back]] as const) { - parts.push(rect(X(b.n), Y(b.s), v.A, v.B, b.rowSkewDeg, `fill="none" stroke="#B4531F" stroke-width="10" stroke-dasharray="40 25"`)); - } + parts.push(rect(X(b.n), Y(b.s), b.plan.L, b.plan.W, b.rowSkewDeg, `fill="#EEF3F7" stroke="#2F6DB5" stroke-width="10"`)); } // Bearing centres and numbers. diff --git a/src/view/bearing-section-svg.ts b/src/view/bearing-section-svg.ts index d416608..e831457 100644 --- a/src/view/bearing-section-svg.ts +++ b/src/view/bearing-section-svg.ts @@ -1,27 +1,12 @@ import type { PierModel } from '../core/geometry'; import { pairEccentricities } from '../core/bearing-layout'; -/** - * Drawing values of one bearing (도면값), overlaid as dashed outlines (승인 2026-10-26). - * All lengths in mm; elx in m. - */ -export interface BearingDrawingRef { - elx?: number; - h1: number; - h2: number; - h3: number; - h4: number; - E: number; - A: number; - B: number; -} - /** * 교량받침 상세 · 단면 (elevation) for one pair (승인 2026-10-26): the model's blocks, mortar, bearing body, - * and solplate per bearing, with the drawing heights overlaid as dashed outlines. x is the 교축 (n), y the EL (up). - * Labels: EL.X (mortar top), h1 (block), h2 (mortar), h3 (bearing), h4 (solplate), E = h1 + h2 + h3 + h4. + * and solplate per bearing. x is the 교축 (n), y the EL (up). Labels: EL.X (mortar top), h1 (block), h3 (bearing body), + * T (solplate, taper T1 at the (−) side, T2 at the (+) side). */ -export function bearingSectionSvg(m: PierModel, pairIndex: number, refs?: { front?: BearingDrawingRef; back?: BearingDrawingRef }): string { +export function bearingSectionSvg(m: PierModel, pairIndex: number): string { const pair = pairEccentricities(m.blocks)[pairIndex]; if (!pair) return ''; const f = m.blocks.find((b) => b.no === pair.frontNo && b.row === '전열')!; @@ -46,37 +31,19 @@ export function bearingSectionSvg(m: PierModel, pairIndex: number, refs?: { fron } // Mortar, bearing body and solplate per bearing, from the model. - for (const [b, ref] of [[f, refs?.front], [k, refs?.back]] as const) { + for (const b of [f, k]) { const pad = 1000; parts.push(``); - if (b.plan) { - const { A, margin, h3, T } = b.plan; - const L = A + margin; - const bodyTop = b.padTop + h3 / 1000; - parts.push(``); - const drop = (L * Math.abs(b.plan.S)) / 200; - const t1 = T + drop; - const t2 = T - drop; - const x0 = X(b.n) - L / 2; - const x1 = X(b.n) + L / 2; - parts.push(``); - } - if (ref) { - // Drawing heights from the block bottom, dashed (overlay). - const base = b.bottom; - const dBlockTop = base + (ref.h1 ?? 0) / 1000; - const dPad = dBlockTop + ref.h2 / 1000; - const dBody = dPad + ref.h3 / 1000; - const dSol = dBody + ref.h4 / 1000; - parts.push(``); - parts.push(``); - parts.push(``); - } + const { L, h3, T1, T2 } = b.plan; + const bodyTop = b.padTop + h3 / 1000; + parts.push(``); + const x0 = X(b.n) - L / 2; + const x1 = X(b.n) + L / 2; + parts.push(``); // EL.X at the mortar top, bearing number. parts.push(``); parts.push(`${b.no} · EL.X ${b.padTop.toFixed(3)}`); parts.push(`h1 ${Math.round((b.top - b.bottom) * 1000)}`); } - return `${parts.join('')}`; + return `${parts.join('')}`; } - diff --git a/src/view/scene.ts b/src/view/scene.ts index 395a565..f468267 100644 --- a/src/view/scene.ts +++ b/src/view/scene.ts @@ -152,14 +152,14 @@ export function buildPierScene(m: PierModel, terrain?: TerrainGrid, groundEl: nu pad.rotateY(turn); pad.translate(b.s, b.padTop - 0.025, b.n); add('pads', pad, 1, b.k); - if (b.plan) { - // 교량받침 본체 (승인 2026-10-23): the solplate plan less 100 mm on each side, from the mortar top up to the solplate. - const body = new THREE.BoxGeometry((b.plan.B + b.plan.margin) / 1000 - 0.2, b.plan.h3 / 1000, (b.plan.A + b.plan.margin) / 1000 - 0.2); + { + // 교량받침 본체: the solplate plan less 100 mm on each side, from the mortar top up to the solplate. + const body = new THREE.BoxGeometry(b.plan.W / 1000 - 0.2, b.plan.h3 / 1000, b.plan.L / 1000 - 0.2); body.rotateY(turn); body.translate(b.s, b.padTop + b.plan.h3 / 2000, b.n); add('bearings', body, 1, b.k); - // Solplate: a wedge that tapers across the 교각축 직각 (T1 at the (−) side), on the bearing (승인 2026-10-23). - const sp = solplateGeometry(b, b.plan.h3, { A: b.plan.A, B: b.plan.B, margin: b.plan.margin, T: b.plan.T, slopePct: b.plan.S }); + // Solplate: a wedge that tapers across the 교각축 직각 (T1 at the (−) side), on the bearing. + const sp = solplateGeometry(b, b.plan); add('solplates', wedgeGeometry(sp.corners), 1, b.k); } } diff --git a/src/view/solplate-svg.ts b/src/view/solplate-svg.ts index bdd8823..4967fd3 100644 --- a/src/view/solplate-svg.ts +++ b/src/view/solplate-svg.ts @@ -1,46 +1,24 @@ import type { BlockModel } from '../core/geometry'; -/** Drawing values of one solplate (도면값), overlaid as dashed outlines. mm. */ -export interface SolplateDrawingRef { - L: number; - W: number; - T1: number; - T2: number; - S: number; -} - /** - * 솔플레이트 상세 (승인 2026-10-26) for one bearing: the plan (L across the 교축, W along the 교각축) and the - * taper in side view (T1 at the (−) side, T2 at the (+) side, S % · T). Model solid, drawing dashed. + * 솔플레이트 상세 (승인 2026-10-26, C안 2026-11-03) for one bearing: the plan (L across, W along the 교각축) and the + * taper in side view (T1 at the (−) side, T2 at the (+) side, S % · T). Thickness is exaggerated for legibility. */ -export function solplateDetailSvg(block: BlockModel, ref?: SolplateDrawingRef): string { +export function solplateDetailSvg(block: BlockModel): string { const plan = block.plan; - if (!plan) return ''; - const L = plan.A + plan.margin; - const W = plan.B + plan.margin; - const drop = (L * Math.abs(plan.S)) / 200; - const t1 = plan.T + drop; - const t2 = plan.T - drop; + const { L, W, T1: t1, T2: t2 } = plan; const parts: string[] = []; // Plan (left): L across, W along. parts.push(``); parts.push(`L ${L.toFixed(0)}`); parts.push(`W ${W.toFixed(0)}`); - if (ref) { - parts.push(``); - } // Side view (right): thickness T1 at x = −L/2 and T2 at x = +L/2, shown with the taper. const ox = L / 2 + 500; const sc = 4; // exaggerated thickness for legibility, labelled parts.push(``); - parts.push(`T1 ${t1.toFixed(0)}`); - parts.push(`T2 ${t2.toFixed(0)}`); - parts.push(`S ${plan.S} % · T ${plan.T} (두께 과장 ×${sc})`); - if (ref) { - const r1 = ref.T1; - const r2 = ref.T2; - parts.push(``); - } + parts.push(`T1 ${t1.toFixed(1)}`); + parts.push(`T2 ${t2.toFixed(1)}`); + parts.push(`S ${plan.slopePct} % · T ${plan.T} (두께 과장 ×${sc})`); const minX = -L / 2 - 200; const maxX = ox + L / 2 + 200; return `${parts.join('')}`; diff --git a/tests/bearing-basis.test.ts b/tests/bearing-basis.test.ts deleted file mode 100644 index 56c539c..0000000 --- a/tests/bearing-basis.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -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); - }); -}); diff --git a/tests/bearing-plan-svg.test.ts b/tests/bearing-plan-svg.test.ts index 90d4527..a2ee316 100644 --- a/tests/bearing-plan-svg.test.ts +++ b/tests/bearing-plan-svg.test.ts @@ -18,12 +18,8 @@ describe('교량받침 상세 평면 (승인 2026-10-26)', () => { expect(bearingPlanSvg(integrated, 0)).toContain('width="2500" height="1200"'); }); - it('the drawing values are overlaid as dashed outlines only when given', () => { - const plain = bearingPlanSvg(separate, 0); - expect(plain).not.toContain('stroke-dasharray="40 25"'); - const overlay = { front: { A: 700, B: 640 }, back: { A: 700, B: 540 } }; - const svg = bearingPlanSvg(separate, 0, overlay); - expect((svg.match(/stroke-dasharray="40 25"/g) ?? []).length).toBe(2); + 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('an unknown pair index gives an empty drawing', () => { diff --git a/tests/bearing-ref.test.ts b/tests/bearing-ref.test.ts deleted file mode 100644 index 8d400f8..0000000 --- a/tests/bearing-ref.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { buildPier } from '../src/core/geometry'; -import { BEARING_ELEVATION_REF, SOLPLATE_REF, SOLPLATE_T_RULE, solplateTs } from '../src/core/bearing-ref'; -import { loadP1 } from './helpers'; - -describe('교량받침 reference (도면값, 읽기 전용, 1단계)', () => { - it('E = h1 + h2 + h3 + h4 on every row', () => { - for (const r of BEARING_ELEVATION_REF) expect(r.h1 + r.h2 + r.h3 + r.h4, `No ${r.no}`).toBe(r.E); - }); - - it('solplate L = A + 100 and W = B + 100 against the elevation row', () => { - BEARING_ELEVATION_REF.forEach((e, i) => { - expect(SOLPLATE_REF[i].L, `No ${e.no}`).toBe(e.A + 100); - expect(SOLPLATE_REF[i].W, `No ${e.no}`).toBe(e.B + 100); - }); - }); - - it('T1 and T2 follow T ± L·|S|/2 on every row (the 검산 column)', () => { - for (const r of SOLPLATE_REF) { - const c = solplateTs(SOLPLATE_T_RULE, r.L, r.s); - expect(c.T1, `No ${r.no}`).toBe(r.T1); - expect(c.T2, `No ${r.no}`).toBe(r.T2); - } - }); - - it('the solplate tapers along the axis: T1 (−측) + T2 (+측) = 2·T, with T = 30 at the centre', () => { - for (const r of SOLPLATE_REF) { - expect(r.T, `No ${r.no}`).toBe(SOLPLATE_T_RULE); - expect(r.T1 + r.T2, `No ${r.no}`).toBe(2 * r.T); - expect(r.T1, `No ${r.no}`).toBeGreaterThan(r.T2); - } - }); - - it('Excel z is EL.X (option 1): the model keeps z; the drawing EL.X minus Excel z is +64 mm (전열) and −11 mm (후열)', () => { - const m = buildPier(loadP1()); - const back = m.blocks.filter((b) => b.row === '후열'); - const front = m.blocks.filter((b) => b.row === '전열'); - BEARING_ELEVATION_REF.forEach((r) => { - const z = r.row === '전열' ? front[r.no - 1].padTop : back[r.no - 6].padTop; - const diffMm = (r.elx - z) * 1000; // 도면 − 엑셀 - // About +64 mm on the front row and −11 mm on the back row (varies by a millimetre or two per bearing). - expect(Math.abs(diffMm - (r.row === '전열' ? 64 : -11)), `No ${r.no}`).toBeLessThan(2); - }); - }); -}); - -import { applyBearingRules, BEARING_RULES_DEFAULT } from '../src/core/bearing-ref'; - -describe('교량받침 공통 규칙 (2단계)', () => { - it('with the drawing values, the rules give back the drawing E, L, W, T1, T2 on every bearing', () => { - BEARING_ELEVATION_REF.forEach((r, i) => { - const v = applyBearingRules(r, BEARING_RULES_DEFAULT); - expect(v.E, `No ${r.no}`).toBe(r.E); - expect(v.L, `No ${r.no}`).toBe(SOLPLATE_REF[i].L); - expect(v.W, `No ${r.no}`).toBe(SOLPLATE_REF[i].W); - expect(v.T1, `No ${r.no}`).toBe(SOLPLATE_REF[i].T1); - expect(v.T2, `No ${r.no}`).toBe(SOLPLATE_REF[i].T2); - }); - }); - - it('a 10 mm larger mortar h2 raises E by 10 and leaves L, W and T1, T2 unchanged', () => { - const r = BEARING_ELEVATION_REF[0]; - const v = applyBearingRules(r, { ...BEARING_RULES_DEFAULT, h2: BEARING_RULES_DEFAULT.h2 + 10 }); - expect(v.E).toBe(r.E + 10); - expect(v.L).toBe(r.A + 100); - expect(v.T1).toBe(SOLPLATE_REF[0].T1); - }); -}); diff --git a/tests/bearing-section-svg.test.ts b/tests/bearing-section-svg.test.ts index 06e2dc0..96bcfed 100644 --- a/tests/bearing-section-svg.test.ts +++ b/tests/bearing-section-svg.test.ts @@ -6,21 +6,14 @@ import { loadP1 } from './helpers'; const model = buildPier(loadP1()); const integrated = buildPier({ ...loadP1(), bearingLayout: '일체' }); -const ref = { h1: 150, h2: 50, h3: 181, h4: 30, E: 425, A: 700, B: 640 }; -describe('교량받침 상세 단면 (승인 2026-10-26)', () => { +describe('교량받침 상세 단면 (승인 2026-10-26, C안 2026-11-03)', () => { it('labels EL.X at the mortar top and the block height h1 for each bearing', () => { const svg = bearingSectionSvg(model, 0); expect(svg).toContain('EL.X'); expect(svg).toContain('h1 150'); }); - it('draws the drawing heights dashed only when given', () => { - expect(bearingSectionSvg(model, 0)).not.toContain('stroke-dasharray="40 25"'); - const svg = bearingSectionSvg(model, 0, { front: ref, back: ref }); - expect((svg.match(/stroke-dasharray="40 25"/g) ?? []).length).toBeGreaterThan(0); - }); - it('일체 draws one stepped outline (a polygon) for the pair', () => { expect(bearingSectionSvg(integrated, 0)).toContain(' { }); }); -describe('솔플레이트 상세 (승인 2026-10-26)', () => { - it('shows L, W, T1 and T2 of the model plan, with the drawing dashed when given', () => { - const withPlan = buildPier({ ...loadP1(), bearingPlans: [{ no: 1, A: 700, B: 640, S: -1.9 }] }); - const block = withPlan.blocks.find((b) => b.no === 1)!; +describe('솔플레이트 상세 (승인 2026-10-26, C안 2026-11-03)', () => { + it('shows L, W, T1 and T2 of the pier plan', () => { + const block = model.blocks.find((b) => b.no === 1)!; const svg = solplateDetailSvg(block); - expect(svg).toContain('L '); - expect(svg).toContain('T1 '); - expect(svg).toContain('T2 '); - const withRef = solplateDetailSvg(block, { L: 800, W: 748, T1: 38, T2: 22, S: 1.9 }); - expect(withRef).toContain('stroke-dasharray="40 25"'); + expect(svg).toContain('L 1000'); + expect(svg).toContain('W 1100'); + expect(svg).toContain('T1 27.5'); + expect(svg).toContain('T2 22.5'); }); }); diff --git a/tests/drawing-import.test.ts b/tests/drawing-import.test.ts deleted file mode 100644 index d603d92..0000000 --- a/tests/drawing-import.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { parseDrawingRows } from '../src/core/drawing-import'; - -const header = ['No', '열', 'EL.X (m)', 'h1 (mm)', 'h2 (mm)']; - -describe('도면 불러오기 (승인 2026-10-23)', () => { - it('reads No, EL.X, h1, h2 from a sheet with a header and ignores other columns', () => { - const rows = [['도면 받침', '', '', '', ''], header, [1, '전열', 43.625, 164, 50], [2, '전열', 43.697, 164, 50]]; - const r = parseDrawingRows(rows); - expect(r.errors).toEqual([]); - expect(r.rows).toEqual([ - { no: 1, elx: 43.625, h1: 164, h2: 50 }, - { no: 2, elx: 43.697, h1: 164, h2: 50 }, - ]); - }); - - it('accepts the header spelled as "EL. X" and in a different order', () => { - const rows = [['h2', 'No', 'h1', 'EL. X'], [50, 1, 164, 43.625]]; - const r = parseDrawingRows(rows); - expect(r.errors).toEqual([]); - expect(r.rows[0]).toEqual({ no: 1, elx: 43.625, h1: 164, h2: 50 }); - }); - - it('reports a missing header, a duplicate No, and an EL.X given in mm', () => { - expect(parseDrawingRows([['x', 'y']]).errors[0]).toContain('머리글'); - const dup = parseDrawingRows([header, [1, '', 43.6, 164, 50], [1, '', 43.7, 164, 50]]); - expect(dup.errors.some((e) => e.includes('중복'))).toBe(true); - const mm = parseDrawingRows([header, [1, '', 43625, 164, 50]]); - expect(mm.errors.some((e) => e.includes('m 단위'))).toBe(true); - expect(mm.rows).toEqual([]); - }); -}); diff --git a/tests/dxf-table.test.ts b/tests/dxf-table.test.ts deleted file mode 100644 index 0314260..0000000 --- a/tests/dxf-table.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { readFileSync } from 'node:fs'; -import { BEARING_ELEVATION_REF } from '../src/core/bearing-ref'; -import { parseBearingElevationTable, readDxfTexts, type DxfText } from '../src/core/dxf-table'; - -const texts = JSON.parse(readFileSync(new URL('./fixtures/bearing-table-texts.json', import.meta.url), 'utf-8')) as DxfText[]; - -describe('도면 받침 ELEVATION TABLE 읽기 (DXF 텍스트, 승인 2026-10-23)', () => { - it('reads all ten bearings from the 운곡교 교각도(2) table', () => { - const r = parseBearingElevationTable(texts); - expect(r.errors).toEqual([]); - expect(r.drawing.map((d) => d.no)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); - }); - - it('the values equal the reference table used for the 도면 비교 (EL.X, h1, h2 on every bearing)', () => { - const r = parseBearingElevationTable(texts); - for (const ref of BEARING_ELEVATION_REF) { - const got = r.drawing.find((d) => d.no === ref.no)!; - expect(got.elx, `No ${ref.no} EL.X`).toBeCloseTo(ref.elx, 3); - expect(got.h1, `No ${ref.no} h1`).toBe(ref.h1); - expect(got.h2, `No ${ref.no} h2`).toBe(ref.h2); - } - }); - - it('a leader-line "EL. X" label placed before the table does not replace the table header', () => { - const distractor = { x: 11102.8, y: 11521.7, text: 'EL. "X"' }; - const r = parseBearingElevationTable([distractor, ...texts]); - expect(r.errors).toEqual([]); - expect(r.drawing.map((d) => d.no)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); - }); - - it('a stray "NO" label elsewhere on the drawing does not move the No column', () => { - const r = parseBearingElevationTable([{ x: 100, y: 100, text: 'NO.' }, ...texts]); - expect(r.errors).toEqual([]); - expect(r.drawing.map((d) => d.no)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); - }); - - it('reads TEXT entities from DXF text (x, y, string)', () => { - const dxf = ['0', 'SECTION', '2', 'ENTITIES', '0', 'TEXT', '10', '100.5', '20', '200.25', '1', 'EL. "X"', '0', 'ENDSEC'].join('\n'); - expect(readDxfTexts(dxf)).toEqual([{ x: 100.5, y: 200.25, text: 'EL. "X"' }]); - }); -}); - -import { parseSolplateTable } from '../src/core/dxf-table'; -import { SOLPLATE_REF } from '../src/core/bearing-ref'; - -describe('솔플레이트 TABLE 읽기 (승인 2026-10-23)', () => { - const sp = JSON.parse(readFileSync(new URL('./fixtures/solplate-table-texts.json', import.meta.url), 'utf-8')) as DxfText[]; - - it('a stray "S(%)" label elsewhere on the drawing does not replace the solplate header', () => { - const r = parseSolplateTable([{ x: 16000, y: 16200, text: 'S(%)' }, ...sp]); - expect(r.errors).toEqual([]); - expect(r.rows.length).toBe(10); - }); - - it('reads S, L, W, T, T1 and T2 for all ten bearings, and they equal the reference values', () => { - const r = parseSolplateTable(sp); - expect(r.errors).toEqual([]); - expect(r.rows.map((x) => x.no)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); - for (const ref of SOLPLATE_REF) { - const got = r.rows.find((x) => x.no === ref.no)!; - expect(got.S, `No ${ref.no} S`).toBeCloseTo(ref.s, 3); - expect(got.L, `No ${ref.no} L`).toBe(ref.L); - expect(got.W, `No ${ref.no} W`).toBe(ref.W); - expect(got.T, `No ${ref.no} T`).toBe(30); - expect(got.T1, `No ${ref.no} T1`).toBe(38); - expect(got.T2, `No ${ref.no} T2`).toBe(22); - } - }); -}); diff --git a/tests/scene.test.ts b/tests/scene.test.ts index b6821f0..cf28317 100644 --- a/tests/scene.test.ts +++ b/tests/scene.test.ts @@ -29,11 +29,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 pad top (back bearing 5, 43.898 m = sheet E41)', () => { + 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)', () => { 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, 2); + expect(box.max.y).toBeCloseTo(43.898 + 0.186 + 0.0275, 3); }); it('builds the expected part counts for P1', () => { diff --git a/tests/solplate-rule.test.ts b/tests/solplate-rule.test.ts new file mode 100644 index 0000000..18ad4b9 --- /dev/null +++ b/tests/solplate-rule.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest'; +import { SOLPLATE_RULE_DEFAULT, solplateCenterT, solplatePlan, solplateRuleIssues } from '../src/core/solplate-rule'; + +describe('솔플레이트 규칙 (C안, 2026-11-03)', () => { + it('default 211 · 1000 × 1100 · −0.5 %: T = 25, T1 = 27.5, T2 = 22.5, h3 = 211 − 25 = 186', () => { + const p = solplatePlan(SOLPLATE_RULE_DEFAULT); + expect(p.T).toBe(25); + expect(p.T1).toBeCloseTo(27.5, 9); + expect(p.T2).toBeCloseTo(22.5, 9); + expect(p.h3).toBe(186); + expect(p.L).toBe(1000); + expect(p.W).toBe(1100); + }); + + it('the centre thickness is the smallest whole mm that keeps T2 at 22 mm or more', () => { + for (const [L, S] of [[1000, -0.5], [1100, -0.5], [1000, -2], [800, -1.91], [1200, -3]] as const) { + const T = solplateCenterT(L, S); + expect(T - (L * Math.abs(S)) / 200).toBeGreaterThanOrEqual(22); + expect(solplateCenterT(L, S) - 1 - (L * Math.abs(S)) / 200).toBeLessThan(22); + } + }); + + it('T1 + T2 = 2T for any rule', () => { + const p = solplatePlan({ heightMm: 220, lengthMm: 900, widthMm: 800, slopePct: -1.2 }); + expect(p.T1 + p.T2).toBeCloseTo(2 * p.T, 9); + }); + + it('the default rule has no input issues', () => { + expect(solplateRuleIssues(SOLPLATE_RULE_DEFAULT)).toEqual([]); + }); + + it('reports a bearing height that does not exceed the solplate centre thickness', () => { + expect(solplateRuleIssues({ ...SOLPLATE_RULE_DEFAULT, heightMm: 25 })).toHaveLength(1); + }); + + it('reports non-positive plan sizes and a non-numeric slope', () => { + expect(solplateRuleIssues({ ...SOLPLATE_RULE_DEFAULT, lengthMm: 0 }).length).toBeGreaterThan(0); + expect(solplateRuleIssues({ ...SOLPLATE_RULE_DEFAULT, widthMm: -1 }).length).toBeGreaterThan(0); + expect(solplateRuleIssues({ ...SOLPLATE_RULE_DEFAULT, slopePct: NaN }).length).toBeGreaterThan(0); + }); +}); diff --git a/tests/solplate-scene.test.ts b/tests/solplate-scene.test.ts index beaa6eb..50bdd22 100644 --- a/tests/solplate-scene.test.ts +++ b/tests/solplate-scene.test.ts @@ -2,44 +2,38 @@ import { describe, expect, it } from 'vitest'; import * as THREE from 'three'; import { buildPier } from '../src/core/geometry'; import { solplateGeometry } from '../src/core/solplate'; -import { BEARING_ELEVATION_REF, SOLPLATE_REF } from '../src/core/bearing-ref'; import { buildPierScene } from '../src/view/scene'; import { loadP1 } from './helpers'; -const plans = SOLPLATE_REF.map((s, i) => ({ no: s.no, A: BEARING_ELEVATION_REF[i].A, B: BEARING_ELEVATION_REF[i].B, S: s.s })); - -describe('솔플레이트 모델과 3D (2단계, 승인 2026-10-23)', () => { - it('without plans there is no solplate; with plans every bearing has one', () => { - expect(buildPier(loadP1()).blocks.every((b) => b.plan === undefined)).toBe(true); - const m = buildPier({ ...loadP1(), bearingPlans: plans, solplate: { T: 30, margin: 100, h3: 181 } }); - expect(m.blocks.every((b) => b.plan !== undefined)).toBe(true); - expect(m.blocks[0].plan!.A).toBe(700); +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); }); - it('the 3D scene has one solplate wedge per bearing, and its height is the bearing top plus T', () => { - const m = buildPier({ ...loadP1(), bearingPlans: plans, solplate: { T: 30, margin: 100, h3: 181 } }); + 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('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 scene = buildPierScene(m); const wedges = scene.parts.solplates.children; expect(wedges.length).toBe(10); const box = new THREE.Box3().setFromObject(scene.parts.solplates); - // The highest point is the thick (시점) end of the tallest wedge: underside (block top + h3) + T1. - const top = Math.max(...m.blocks.map((b) => solplateGeometry(b, 181, { A: b.plan!.A, B: b.plan!.B, margin: 100, T: 30, slopePct: b.plan!.S }).bottom + solplateGeometry(b, 181, { A: b.plan!.A, B: b.plan!.B, margin: 100, T: 30, slopePct: b.plan!.S }).T1)); + const top = Math.max(...m.blocks.map((b) => solplateGeometry(b, b.plan).bottom + solplateGeometry(b, b.plan).T1)); expect(box.max.y).toBeCloseTo(top, 3); }); - it('without plans the scene has an empty solplate part', () => { + it('the 교량받침 본체 is one box per bearing: the plan less 100 mm on each side, height h3 (186 mm)', () => { const scene = buildPierScene(buildPier(loadP1())); - expect(scene.parts.solplates.children.length).toBe(0); - }); - - it('the 교량받침 본체 is one box per bearing: the solplate plan less 100 mm on each side, height h3 (181 mm)', () => { - const m = buildPier({ ...loadP1(), bearingPlans: plans, solplate: { T: 30, margin: 100, h3: 181 } }); - const scene = buildPierScene(m); 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.181, 3); - // Body 1: L = 800 − 200 = 600 across the 교각축 직각, W = 740 − 200 = 540 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.54); + 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); }); }); diff --git a/tests/solplate.test.ts b/tests/solplate.test.ts index 9d85cbb..118f4cc 100644 --- a/tests/solplate.test.ts +++ b/tests/solplate.test.ts @@ -1,41 +1,42 @@ import { describe, expect, it } from 'vitest'; import { buildPier } from '../src/core/geometry'; import { solplateGeometry } from '../src/core/solplate'; +import { SOLPLATE_RULE_DEFAULT, solplatePlan } from '../src/core/solplate-rule'; import { loadP1 } from './helpers'; const model = buildPier(loadP1()); const front1 = model.blocks.filter((b) => b.row === '전열')[0]; -const params = { A: 700, B: 640, margin: 100, T: 30, slopePct: -1.91 }; +const plan = solplatePlan(SOLPLATE_RULE_DEFAULT); -describe('솔플레이트 기하 (1단계, 승인 2026-10-23)', () => { - it('L = A + 100 and W = B + 100 along the 교축 and the 교각축 직각', () => { - const g = solplateGeometry(front1, 181, params); - expect(g.L).toBeCloseTo(0.8, 6); - expect(g.W).toBeCloseTo(0.74, 6); +describe('솔플레이트 기하 (C안, 2026-11-03)', () => { + it('L = 교직 1000 across the 교각축 직각 (n) and W = 교축 1100 along the 교축 (s)', () => { + const g = solplateGeometry(front1, plan); + expect(g.L).toBeCloseTo(1.0, 6); + expect(g.W).toBeCloseTo(1.1, 6); }); - it('the thick end T1 is on the 시점 side (−s): T1 ≈ 38, T2 ≈ 22 mm, and T1 + T2 = 2T', () => { - const g = solplateGeometry(front1, 181, params); - expect(g.T1 * 1000).toBeCloseTo(37.64, 1); - expect(g.T2 * 1000).toBeCloseTo(22.36, 1); - expect((g.T1 + g.T2) * 1000).toBeCloseTo(60, 6); - // The top at the 시점 corners is the thick side. + it('the thick end T1 is on the (−) side: T1 = 27.5 mm, T2 = 22.5 mm, and T1 + T2 = 2T', () => { + const g = solplateGeometry(front1, plan); + expect(g.T1 * 1000).toBeCloseTo(27.5, 6); + expect(g.T2 * 1000).toBeCloseTo(22.5, 6); + expect((g.T1 + g.T2) * 1000).toBeCloseTo(50, 6); + // The top at the (−) corners is the thick side. const topAtStart = g.corners.filter((c) => Math.abs(c[1] - (g.bottom + g.T1)) < 1e-9); expect(topAtStart.length).toBe(2); }); - it('the underside is the bearing top: block top (EL.X) + h3', () => { - const g = solplateGeometry(front1, 181, params); - expect(g.bottom).toBeCloseTo(front1.padTop + 0.181, 6); + it('the underside is the bearing top: block top (EL.X) + h3 (186 mm)', () => { + const g = solplateGeometry(front1, plan); + expect(g.bottom).toBeCloseTo(front1.padTop + 0.186, 6); }); it('volume = L·W·T (the taper averages to the centre thickness)', () => { - const g = solplateGeometry(front1, 181, params); - expect(g.volume).toBeCloseTo(g.L * g.W * 0.03, 9); + const g = solplateGeometry(front1, plan); + expect(g.volume).toBeCloseTo(g.L * g.W * 0.025, 9); }); it('the solplate is centred on the block and follows the row direction', () => { - const g = solplateGeometry(front1, 181, params); + const g = solplateGeometry(front1, plan); const c = g.corners.reduce((acc, p) => [acc[0] + p[0] / 8, acc[1] + p[2] / 8] as [number, number], [0, 0]); expect(c[0]).toBeCloseTo(front1.s, 6); expect(c[1]).toBeCloseTo(front1.n, 6);