From bff1502ecf748680682f588639884454dc2f3c44 Mon Sep 17 00:00:00 2001 From: ypshim Date: Fri, 6 Nov 2026 16:23:04 +0900 Subject: [PATCH] =?UTF-8?q?fix(abut):=20=EC=88=98=EC=B6=95=EC=A4=84?= =?UTF-8?q?=EB=88=88=EC=9D=84=20=EB=B0=9B=EC=B9=A8=20=EC=82=AC=EC=9D=B4=20?= =?UTF-8?q?=EC=A4=91=EA=B0=84=EC=97=90=20=C2=B7=20=EA=B8=B0=EB=B3=B8=20?= =?UTF-8?q?=EA=B0=9C=EC=88=98=20ceil(L/10)=20=E2=88=92=201=20=C2=B7=203D?= =?UTF-8?q?=20=EC=84=A0=20=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit spec_abut §3 · §4.9 (QA-7 가정)와 맞지 않던 세 가지를 고친다. - 위치: 등분점(1개면 L/2)이 아니라 가장 가까운 '받침 사이 중간'(= 교좌면 계단 경계). 이전에는 운곡교 교대 5기 모두 줄눈이 가운데 받침 ③ 블럭을 지났다 (A1: 줄눈 6.099 · 받침 6.037). 이제 A1은 IP3에서 4.905 m - 개수: 입력이 없으면 ceil(L/10) − 1 (길이에 따라 자동, 이전에는 1 고정). 입력 칸은 비우면 자동 - 3D: 벽체 전면 · 흉벽 전면 · 벽체 배면 · 흉벽 배면에 선으로 표시(저판은 연속이라 없음), '벽체·흉벽' 토글을 따름 - 공통 계산 core/abut/joints.ts를 ② 정면·배면 삽도, 교대 상세 요약, 입력 칸 안내, 3D가 함께 씀 - 테스트: 개수 규칙, A1 위치 = ②·③ 중간 = 계단 경계, 여러 개 · 받침 부족 시 등분, 3D 선 4면 Co-Authored-By: Claude Opus 5.5 --- src/core/abut/joints.ts | 37 +++++++++++++++++++++++++++++++ src/core/abut/summary.ts | 3 ++- src/main.ts | 35 +++++++++++++++++++++++++++--- src/view/abut-scene.ts | 34 ++++++++++++++++++++++++++++- src/view/abut-wall-svg.ts | 11 +++++----- tests/abut-joints.test.ts | 43 +++++++++++++++++++++++++++++++++++++ tests/abut-summary.test.ts | 3 ++- tests/abut-wall-svg.test.ts | 5 +++-- 8 files changed, 158 insertions(+), 13 deletions(-) create mode 100644 src/core/abut/joints.ts create mode 100644 tests/abut-joints.test.ts diff --git a/src/core/abut/joints.ts b/src/core/abut/joints.ts new file mode 100644 index 0000000..62d162d --- /dev/null +++ b/src/core/abut/joints.ts @@ -0,0 +1,37 @@ +/** + * 교대 수축줄눈 (spec_abut §3 · §4.9, plan_abut AA13, QA-7 가정). Pure. + * + * - 개수: 입력값, 없으면 ceil(벽체 길이 / 10 m) − 1 (0 이상). + * - 위치: 등분점 L·j / (n + 1)에서 가장 가까운 '받침 사이 중간'으로 옮긴다 (같은 거리면 s가 작은 쪽). 받침 사이 중간은 + * 교좌면 계단 경계와 같은 자리라 줄눈이 받침블럭을 지나지 않는다. 받침이 2개 미만이거나 중간점이 모자라면 등분점. + * - 벽체 · 흉벽에만 두고 확대기초는 연속이다 (교대확대기초철근.md). 철근은 끊지 않는다 (spec_abut §6.1). + */ + +/** 기본 개수: ceil(L / 10) − 1, L in m. */ +export function defaultJointCount(lengthM: number): number { + return Math.max(0, Math.ceil(lengthM / 10 - 1e-9) - 1); +} + +/** The count in use: the typed value, else the default from the length. */ +export function jointCount(lengthM: number, typed?: number): number { + return typed ?? defaultJointCount(lengthM); +} + +/** Joint positions s (m from IP1, sorted). `bearingS` = the bearings' s along the 교대축. */ +export function jointPositions(lengthM: number, bearingS: number[], typed?: number): number[] { + const n = jointCount(lengthM, typed); + const sorted = [...bearingS].sort((a, b) => a - b); + const mids = sorted.slice(1).map((s, i) => (s + sorted[i]) / 2); + const used = new Set(); + const out: number[] = []; + for (let j = 1; j <= n; j++) { + const ideal = (lengthM * j) / (n + 1); + let best = -1; + mids.forEach((m, i) => { + if (used.has(i)) return; + if (best < 0 || Math.abs(m - ideal) < Math.abs(mids[best] - ideal) - 1e-9) best = i; + }); + if (best >= 0) { used.add(best); out.push(mids[best]); } else out.push(ideal); + } + return out.sort((a, b) => a - b); +} diff --git a/src/core/abut/summary.ts b/src/core/abut/summary.ts index e034586..5b2eba1 100644 --- a/src/core/abut/summary.ts +++ b/src/core/abut/summary.ts @@ -2,6 +2,7 @@ * 교대 상세 (2026-11-03, 교각 상세와 같은 개념): the values the 삽도 shows, computed from the model. Read only. * Each row is [label, value]. Lengths in mm, levels in m, as in the 삽도. */ +import { jointPositions } from './joints'; import type { AbutRecord } from './parse'; import { buildAbut, type AbutModel } from './geometry'; import { toAbutInput } from './parse'; @@ -38,7 +39,7 @@ export function abutSummaryRows(rec: AbutRecord): [string, string][] { ['저판 폭 (앞굽 끝 / 뒷굽 끝)', `${Math.round(shape.footingFrontM * 1000)} / ${Math.round(shape.footingBackM * 1000)} mm`], ['말뚝 (열 × 본수)', `${members.piles.length} × ${members.piles[0]?.s.length ?? 0} = ${members.piles.length * (members.piles[0]?.s.length ?? 0)} 본`], ['말뚝 선단 EL', members.pileBottomEl === undefined ? '—' : m3(members.pileBottomEl)], - ['수축줄눈 (ceil(L/10) − 1)', `${Math.max(0, Math.ceil(L / 10) - 1)} 개`], + ['수축줄눈 (기본 ceil(L/10) − 1, 받침 사이 중간)', (() => { const js = jointPositions(L, model.bearings.map((b) => b.s), rec.shape?.shrinkJointCount); return js.length ? `${js.length} 개 · IP3에서 ${js.map((s) => (L - s).toFixed(3)).reverse().join(' / ')} m` : '0 개'; })()], ]; if (wings.length > 0) { const w = wings[0]; diff --git a/src/main.ts b/src/main.ts index 7606b0d..0d63eb2 100644 --- a/src/main.ts +++ b/src/main.ts @@ -36,6 +36,7 @@ import { } from './core/abut/edit'; import { ABUT_FOOTING_REBAR_DEFAULT, abutFootingGeom, abutFootingRebar, ABUT_FOOTING_SIZES, type AbutFootingGeom, type AbutFootingRebar, type AbutFootingRebarSettings } from './core/abut/rebar/footing'; import { lenText, skewAngle } from './core/abut/rebar/skew-layout'; +import { defaultJointCount, jointPositions } from './core/abut/joints'; import { ABUT_BLOCK_PAD_MM, ABUT_BLOCK_REBAR_DEFAULT, abutBlockRebar, type AbutBlockRebar, type AbutBlockSettings } from './core/abut/rebar/block'; import { ABUT_BLOCK_COLORS, abutBlockPlanSvg as abutBlockRebarPlanSvg, abutBlockSectionSvg as abutBlockRebarSectionSvg } from './view/abut-block-rebar-svg'; import { ABUT_SLAB_REBAR_DEFAULT, ABUT_SLAB_SIZES, abutSlabGeom, abutSlabRebar, type AbutSlabGeom, type AbutSlabRebar, type AbutSlabRebarSettings } from './core/abut/rebar/slab'; @@ -156,7 +157,7 @@ const PART_GROUPS: { label: string; pier: PartName[]; abut: string[] }[] = [ { label: '말뚝', pier: ['piles'], abut: ['piles'] }, { label: '버림', pier: ['blinding'], abut: ['blinding'] }, { label: '지반면', pier: ['ground'], abut: ['ground'] }, - { label: '벽체·흉벽', pier: [], abut: ['wall', 'parapet'] }, + { label: '벽체·흉벽', pier: [], abut: ['wall', 'parapet', 'joints'] }, // 헌치 sits at the wing ends (wings.ts builds it per wing), so it follows the 날개벽 toggle (2026-11-03). { label: '날개벽', pier: [], abut: ['wings', 'haunches'] }, { label: '접속슬래브', pier: [], abut: ['slab'] }, @@ -665,7 +666,8 @@ function setupScene(): void { pairs: state.bridgePairs, slopeI: rec.slopeI, inputAzimuthDeg: Object.fromEntries(rec.bearings.filter((x) => x.azimuthDeg !== undefined).map((x) => [String(x.no), x.azimuthDeg!])), }); - const group = buildAbutGroup(model, members, buildWings(model, members, rec.wings, rec.wingDims), pivot, rec.groundEl, groundModel?.terrainGrid, rec.slab, soles); + const group = buildAbutGroup(model, members, buildWings(model, members, rec.wings, rec.wingDims), pivot, rec.groundEl, groundModel?.terrainGrid, rec.slab, soles, + jointPositions(model.length, model.bearings.map((b) => b.s), rec.shape?.shrinkJointCount)); group.visible = abutShown(state.stage, state.abutActive, rec.name); // The 부재 표시 toggles hold for a new abutment group too. for (const [name, on] of Object.entries(state.abutVisible)) { @@ -1831,7 +1833,7 @@ function fillAbutSlot(slot: (typeof ABUT_SLOTS)[keyof typeof ABUT_SLOTS], rec: A footingFrontM: s.footingFrontM, footingBackM: s.footingBackM, bracketHeightM: s.bracketHeightM, - shrinkJointCount: v.shrinkJointCount, + shrinkJointsS: jointPositions(built.model.length, built.model.bearings.map((b) => b.s), rec.shape?.shrinkJointCount), bracketWidthMm: v.bracketWidthMm, parapetThicknessMm: v.parapetThicknessMm, seatWidthMm: v.wallFrontMm, @@ -1891,6 +1893,33 @@ function fillAbutSlot(slot: (typeof ABUT_SLOTS)[keyof typeof ABUT_SLOTS], rec: A } } for (const f of SHAPE_EDIT_FIELDS) { + // 수축줄눈 개수 (spec_abut §3): 입력이 없으면 ceil(L/10) − 1 (교대 길이에 따름), 위치는 받침 사이 중간. + if (f.key === 'shrinkJointCount') { + // ① has no built model (② builds it above): build it here for the length and the bearings. + let jm: AbutModel | null = built?.model ?? null; + if (!jm) { try { jm = buildAbut(toAbutInput(rec)); } catch { jm = null; } } + const auto = jm ? defaultJointCount(jm.length) : ABUT_SHAPE_DEFAULT.shrinkJointCount; + const typed = rec.shape?.shrinkJointCount; + const id = idOf(`shape-${f.key}`); + const lab = el('label', f.label); + lab.htmlFor = id; + const input = numberInput(id, typed === undefined ? '' : String(typed)); + input.placeholder = `자동 ${auto}`; + if (typed !== undefined) input.classList.add('changed-input'); + input.addEventListener('change', () => { + try { + if (input.value.trim() === '') { + const { shrinkJointCount: _drop, ...rest } = rec.shape ?? {}; + apply({ ...rec, shape: Object.keys(rest).length ? rest : undefined }); + } else apply(applyShapeEdit(rec, f.key, Number(input.value))); + } catch (err) { + fail(err, input, typed === undefined ? '' : String(typed)); + } + }); + const where = jm ? jointPositions(jm.length, jm.bearings.map((b) => b.s), typed).map((s) => (jm!.length - s).toFixed(3)).reverse().join(' / ') : '—'; + shape.append(lab, input, el('span', `기본 자동 ceil(L/10) − 1 = ${auto} · 위치 받침 사이 중간 (IP3에서 ${where} m) · QA-7`, 'muted small')); + continue; + } const current = normalizeShape(rec.shape)[f.key] ?? ABUT_SHAPE_DEFAULT[f.key]; const id = idOf(`shape-${f.key}`); const lab = el('label', f.label); diff --git a/src/view/abut-scene.ts b/src/view/abut-scene.ts index 65e1109..8f9f12e 100644 --- a/src/view/abut-scene.ts +++ b/src/view/abut-scene.ts @@ -6,6 +6,7 @@ * 흉벽·벽체 단면은 모식도다. 흉벽은 저판 상면에서 상단까지 그린다(교좌면 아래 세부는 배근 단계에서). */ import type { BearingSole } from '../core/sole-plate'; +import { jointPositions } from '../core/abut/joints'; import * as THREE from 'three'; import type { AbutModel, AbutPoint } from '../core/abut/geometry'; import { buildMembers, type AbutMembers } from '../core/abut/members'; @@ -14,7 +15,7 @@ import { resolveSlab, slabGradePct, slabLevelAt, slabSection, type AbutSlab } fr import type { TerrainGrid } from '../core/ground-model'; import { terrainGeometry } from './terrain'; -export const ABUT_GROUP_NAMES = ['wall', 'parapet', 'footing', 'blinding', 'piles', 'wings', 'haunches', 'ground', 'blocks', 'bearings', 'solplates', 'slab'] as const; +export const ABUT_GROUP_NAMES = ['wall', 'parapet', 'footing', 'blinding', 'piles', 'wings', 'haunches', 'ground', 'blocks', 'bearings', 'solplates', 'slab', 'joints'] as const; /** 말뚝 두부 매입 (m): the drawn pile head goes this far into the 저판 (drawing only). */ const PILE_HEAD_EMBED_M = 0.1; @@ -245,6 +246,8 @@ export function buildAbutGroup( slabInput?: Partial, /** Solplate of each bearing (abutSoles, 2026-11-06): the bearing body and the solplate wedge, and the block-top drop. */ soles?: BearingSole[], + /** 수축줄눈 위치 s (m, core/abut/joints.ts). Absent = the default count between the bearings. */ + jointS?: number[], ): THREE.Group { const root = new THREE.Group(); root.name = model.name; @@ -316,6 +319,35 @@ export function buildAbutGroup( add('parapet', skewPrism(parapet, -members.shape.parapetBackM, members.shape.parapetBackM), 0x8898aa); } + // 수축줄눈 (spec_abut §4.9, 2026-11-06): lines only, on the 벽체 · 흉벽 faces (the 저판 is continuous). At each joint s: + // 벽체 전면 (t = 교좌면 끝) 저판 상면 → 교좌면, 흉벽 전면 (t = 0) 교좌면 → 흉벽 상단, 배면 (t = 벽체 배면) 저판 상면 → 브라켓 상면, + // 흉벽 배면 브라켓 상면 → 흉벽 상단. A joint between two steps takes the lower 교좌면. Lifted 3 mm off the faces. + { + const joints = jointS ?? jointPositions(L, model.bearings.map((b) => b.s)); + const seatAt = (s: number) => Math.min(...members.wallSegments.filter((g) => s >= g.sFrom - 1e-6 && s <= g.sTo + 1e-6).map((g) => g.seatEl)); + const pts: number[] = []; + const line = (s: number, t: number, y0: number, y1: number) => { if (y1 > y0) pts.push(s + skewK * t, y0, t, s + skewK * t, y1, t); }; + const lift = 0.003, sh = members.shape; + for (const s of joints) { + const seat = seatAt(s); + if (!Number.isFinite(seat)) continue; + line(s, sh.wallFrontM + lift, footingTop, seat); + line(s, 0 + lift, seat, parapetTopAt(s)); + line(s, sh.wallBackM - lift, footingTop, bracketAt(s)); + line(s, sh.parapetBackM - lift, bracketAt(s), parapetTopAt(s)); + } + if (pts.length) { + const g = new THREE.BufferGeometry(); + g.setAttribute('position', new THREE.Float32BufferAttribute(pts, 3)); + const lines = new THREE.LineSegments(g, new THREE.LineBasicMaterial({ color: 0x1b2733 })); + lines.name = 'joints'; + const group = new THREE.Group(); + group.name = 'joints'; + group.add(lines); + root.add(group); + } + } + if (footingBottom !== undefined) { // 저판: s 0 … L, t −1.8 … +2.8, from the 기초 하단 to the 저판 상면. // Skew (2026-11-03): the 저판 ends follow the wing outer lines (s = sEnd + (ws/wt)·t), so the plan is a parallelogram. diff --git a/src/view/abut-wall-svg.ts b/src/view/abut-wall-svg.ts index 0184860..2d566d2 100644 --- a/src/view/abut-wall-svg.ts +++ b/src/view/abut-wall-svg.ts @@ -22,7 +22,8 @@ export interface AbutWallDrawing { footingFrontM: number; footingBackM: number; bracketHeightM: number; - shrinkJointCount: number; + /** 수축줄눈 위치 s (m from IP1), from core/abut/joints.ts (받침 사이 중간). */ + shrinkJointsS: number[]; /** 시안 치수 줄 "브라켓 폭 + 흉벽 두께 + 교좌면 폭 = 벽체 두께" (mm). */ bracketWidthMm: number; parapetThicknessMm: number; @@ -113,11 +114,11 @@ export function abutWallElevationSvg(d: AbutWallDrawing): string { parts.push(poly([[X(b.s + 0.6), Y(b.seatEl)], [X(b.s - 0.6), Y(b.seatEl)], [X(b.s - 0.6), Y(b.z)], [X(b.s + 0.6), Y(b.z)]], `fill="#9aa7b5" stroke="${INK}"`)); parts.push(label(X(b.s), Y(b.z) - 6, ['①', '②', '③', '④', '⑤', '⑥', '⑦', '⑧'][b.no - 1] ?? String(b.no))); } - for (let j = 1; j <= d.shrinkJointCount; j++) { - const s = (d.lengthM * j) / (d.shrinkJointCount + 1); + d.shrinkJointsS.forEach((s, i) => { + const j = i + 1; parts.push(``); parts.push(label(X(s) + 4, Y(d.footingTopEl) - 8, `수축줄눈 ${j}`, 'start')); - } + }); // 벽체 높이 H_w (사용자 도면 2026-11-04): 받침 ① 연직 기준선, 받침 ① 교좌면 ~ 흉벽 상단. const b1 = d.bearings.find((b) => b.no === 1) ?? d.bearings[0]; if (b1) { @@ -139,7 +140,7 @@ export function abutWallElevationSvg(d: AbutWallDrawing): string { parts.push(label(X(0), H - 8, 'IP1', 'middle', MUTED)); parts.push(label((X(0) + X(d.lengthM)) / 2, H - 8, `${d.lengthM.toFixed(3)} m`)); return [ - ``, + ``, '', ...parts, '', diff --git a/tests/abut-joints.test.ts b/tests/abut-joints.test.ts new file mode 100644 index 0000000..792a206 --- /dev/null +++ b/tests/abut-joints.test.ts @@ -0,0 +1,43 @@ +import * as THREE from 'three'; +import { describe, expect, it } from 'vitest'; +import { defaultJointCount, jointCount, jointPositions } from '../src/core/abut/joints'; +import { buildAbut } from '../src/core/abut/geometry'; +import { parseAbutments, toAbutInput } from '../src/core/abut/parse'; +import { buildMembers } from '../src/core/abut/members'; +import { buildAbutGroup } from '../src/view/abut-scene'; +import { loadInputWorkbook } from './helpers'; + +// 교대 수축줄눈 (spec_abut §3 · §4.9, QA-7): count ceil(L/10) − 1, position = the middle between the nearest bearings. +const rec = parseAbutments(loadInputWorkbook()).abutments.find((a) => a.name === 'A1')!; +const model = buildAbut(toAbutInput(rec)); +const bearingS = model.bearings.map((b) => b.s); + +describe('교대 수축줄눈', () => { + it('count: ceil(L/10) − 1 by default (12.197 → 1, 20 → 1, 20.5 → 2, 9 → 0), a typed count wins', () => { + expect([12.197, 20, 20.5, 9].map(defaultJointCount)).toEqual([1, 1, 2, 0]); + expect(jointCount(12.197, 3)).toBe(3); + }); + + it('A1: the joint is not on bearing ③ (L/2) but in the middle between ② and ③, which is the 교좌면 step boundary', () => { + const [s] = jointPositions(model.length, bearingS); + const sorted = [...bearingS].sort((a, b) => a - b); + expect(s).toBeCloseTo((sorted[2] + sorted[3]) / 2, 9); + expect(Math.min(...bearingS.map((b) => Math.abs(b - s)))).toBeGreaterThan(0.6 + 0.6); + expect(model.stepBounds.some((x) => Math.abs(x - s) < 1e-3)).toBe(true); + }); + + it('several joints take distinct midpoints nearest to the even split; without enough bearings, the even split', () => { + const js = jointPositions(model.length, bearingS, 2); + expect(new Set(js.map((x) => x.toFixed(6))).size).toBe(2); + expect(jointPositions(30, [5], 2)).toEqual([10, 20]); + }); + + it('3D: one line on each of the four 벽체 · 흉벽 faces per joint, none in the 저판', () => { + const members = buildMembers(model, { bottomEl: rec.foundationBottomEl, pileLengthMm: rec.pileLengthMm }); + const group = buildAbutGroup(model, members); + const lines = group.getObjectByName('joints')!.children[0] as THREE.LineSegments; + const pos = lines.geometry.getAttribute('position') as THREE.BufferAttribute; + expect(pos.count).toBe(4 * 2); + for (let i = 0; i < pos.count; i++) expect(pos.getY(i)).toBeGreaterThanOrEqual(members.footingTopEl - 1e-5); + }); +}); diff --git a/tests/abut-summary.test.ts b/tests/abut-summary.test.ts index 74d374a..2bdfd63 100644 --- a/tests/abut-summary.test.ts +++ b/tests/abut-summary.test.ts @@ -20,7 +20,8 @@ describe('교대 상세 (삽도 항목, spec_abut §10.1)', () => { it('the piles, joints, 접속슬래브 width and 받침블럭 height match the spec: 18 piles, tip 19.295, 1 joint, 11253 mm, 125 mm', () => { expect(rows.get('말뚝 (열 × 본수)')).toBe('3 × 6 = 18 본'); expect(rows.get('말뚝 선단 EL')).toBe('19.295 m'); - expect(rows.get('수축줄눈 (ceil(L/10) − 1)')).toBe('1 개'); + // 1 joint (ceil(12.197/10) − 1), moved from L/2 (on bearing ③) to the middle between ② and ③ (2026-11-06). + expect(rows.get('수축줄눈 (기본 ceil(L/10) − 1, 받침 사이 중간)')).toMatch(/^1 개 · IP3에서 4\.90[45] m$/); // 11.2535 m exactly (450 / sin Δ + 20 per wing); rounding to mm gives 11253 or 11254. expect(Math.abs(parseInt(String(rows.get('접속슬래브 폭')), 10) - 11253)).toBeLessThanOrEqual(1); expect(rows.get('받침블럭 높이 (패드 포함)')).toBe('125 mm'); diff --git a/tests/abut-wall-svg.test.ts b/tests/abut-wall-svg.test.ts index cf9330c..34481a5 100644 --- a/tests/abut-wall-svg.test.ts +++ b/tests/abut-wall-svg.test.ts @@ -1,3 +1,4 @@ +import { jointPositions } from '../src/core/abut/joints'; import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import * as XLSX from 'xlsx'; @@ -26,7 +27,7 @@ const d: AbutWallDrawing = { footingFrontM: m.shape.footingFrontM, footingBackM: m.shape.footingBackM, bracketHeightM: m.shape.bracketHeightM, - shrinkJointCount: 1, + shrinkJointsS: jointPositions(model.length, model.bearings.map((b) => b.s)), bracketWidthMm: 300, parapetThicknessMm: 500, seatWidthMm: 1600, @@ -50,7 +51,7 @@ describe('교대 벽체·흉벽 삽도 (시안 ② 벽체·흉벽 카드)', () = }); it('the 수축줄눈 count follows the input', () => { - const svg = abutWallElevationSvg({ ...d, shrinkJointCount: 2 }); + const svg = abutWallElevationSvg({ ...d, shrinkJointsS: jointPositions(model.length, model.bearings.map((b) => b.s), 2) }); expect(svg).toContain('수축줄눈 2'); expect(svg.match(/stroke-dasharray="5 3"/g)).toHaveLength(2); });