From 1fda705b75470a7cab490200b3ab43dd4df4eddd Mon Sep 17 00:00:00 2001 From: ypshim Date: Fri, 6 Nov 2026 16:03:57 +0900 Subject: [PATCH] =?UTF-8?q?feat(abut):=20=EA=B5=90=EB=8C=80=203D=EC=97=90?= =?UTF-8?q?=20=EB=B0=9B=EC=B9=A8=20=EB=B3=B8=EC=B2=B4=20=C2=B7=20=EC=86=94?= =?UTF-8?q?=ED=94=8C=EB=A0=88=EC=9D=B4=ED=8A=B8=20(=EA=B5=90=EA=B0=81?= =?UTF-8?q?=EA=B3=BC=20=EA=B0=99=EC=9D=80=20=EA=B0=9C=EB=85=90)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 교대 3D는 받침블럭 콘크리트와 몰탈패드만 그렸다. ② 받침 카드와 같은 솔플레이트(abutSoles)로 교각처럼 그린다. - 받침 본체: 솔플레이트 평면에서 사방 100 mm 뺀 상자, 높이 h3, 패드 상면에서 - 솔플레이트: L은 거더 방향(블럭 회전 = 방위각, 적용한 계산 방위각 포함), W는 그 직각, T1은 오르막 쪽 - 거더 하면 고정: T ≠ T_ref이면 블럭 상면 · 패드가 (T − T_ref)만큼 내려감 (받침 ELEVATION TABLE과 일치) - 부재 표시: '받침·받침블럭'에 교대 bearings, '솔플레이트'에 교대 solplates 연결 - 테스트: A1(짝 P1) 본체 높이 245 · 솔플 상면 z + 245 + 38, 두꺼운 끝이 오르막(A1) 쪽, S −3 % → 패드 −4 mm · 상면 유지, soles 없이 부를 때 기존 형상. 브라우저: 교대만 · 교각+교대 가져오기 예외 0, 받침 쌓임 확인 Co-Authored-By: Claude Opus 5.5 --- src/main.ts | 12 ++++-- src/view/abut-scene.ts | 39 ++++++++++++++++-- tests/abut-solplate-scene.test.ts | 68 +++++++++++++++++++++++++++++++ 3 files changed, 113 insertions(+), 6 deletions(-) create mode 100644 tests/abut-solplate-scene.test.ts diff --git a/src/main.ts b/src/main.ts index a57ba05..7606b0d 100644 --- a/src/main.ts +++ b/src/main.ts @@ -64,6 +64,7 @@ import { abutElevationRows, abutSolplateRows, resolveBearingRule, + abutSoles, } from './core/abut/bearing-table'; import { abutMemberRows, ABUT_MEMBER_EDITABLE, type AbutMemberId } from './core/abut/member-list'; import type { AbutModel, WingForm } from './core/abut/geometry'; @@ -147,8 +148,8 @@ type Stage = 'shape' | 'plan' | 'rebar' | 'quantity'; /** 부재 표시 toggles (2026-11-03). `pier` parts follow the pier scenes; `abut` names follow the abutment groups (abut-scene.ts). */ const PART_GROUPS: { label: string; pier: PartName[]; abut: string[] }[] = [ - { label: '받침·받침블럭', pier: ['blocks', 'pads', 'bearings'], abut: ['blocks'] }, - { label: '솔플레이트', pier: ['solplates'], abut: [] }, + { label: '받침·받침블럭', pier: ['blocks', 'pads', 'bearings'], abut: ['blocks', 'bearings'] }, + { label: '솔플레이트', pier: ['solplates'], abut: ['solplates'] }, { label: '코핑', pier: ['coping'], abut: [] }, { label: '기둥', pier: ['columns'], abut: [] }, { label: '확대기초', pier: ['footings'], abut: ['footing'] }, @@ -659,7 +660,12 @@ function setupScene(): void { try { const model = buildAbut(toAbutInput(rec)); const members = buildMembers(model, { bottomEl: rec.foundationBottomEl, pileLengthMm: rec.pileLengthMm }, rec.shape); - const group = buildAbutGroup(model, members, buildWings(model, members, rec.wings, rec.wingDims), pivot, rec.groundEl, groundModel?.terrainGrid, rec.slab); + // 받침 본체 · 솔플레이트 (2026-11-06): the same solplates as the ② 받침 카드 (짝 받침 S, T, 거더 하면 고정). + const soles = abutSoles(model, resolveBearingRule(rec.bearingRule), rec.bearingHeightMm, { + 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); 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)) { diff --git a/src/view/abut-scene.ts b/src/view/abut-scene.ts index a5354c3..65e1109 100644 --- a/src/view/abut-scene.ts +++ b/src/view/abut-scene.ts @@ -5,6 +5,7 @@ * 날개벽 평면(E, N)은 축 기준 (s, t)로 바꿔 쓴다: s = (P − IP1)·u, t = (P − IP1)·(전면 법선). * 흉벽·벽체 단면은 모식도다. 흉벽은 저판 상면에서 상단까지 그린다(교좌면 아래 세부는 배근 단계에서). */ +import type { BearingSole } from '../core/sole-plate'; import * as THREE from 'three'; import type { AbutModel, AbutPoint } from '../core/abut/geometry'; import { buildMembers, type AbutMembers } from '../core/abut/members'; @@ -13,7 +14,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', 'slab'] as const; +export const ABUT_GROUP_NAMES = ['wall', 'parapet', 'footing', 'blinding', 'piles', 'wings', 'haunches', 'ground', 'blocks', 'bearings', 'solplates', 'slab'] as const; /** 말뚝 두부 매입 (m): the drawn pile head goes this far into the 저판 (drawing only). */ const PILE_HEAD_EMBED_M = 0.1; @@ -100,6 +101,20 @@ function prism(profile: [number, number][], depth: number, origin: THREE.Vector3 return geom; } +/** Solplate wedge in the block frame: W along x, L along z, bottom at y0; thickness T1 at z = side·L/2, T2 at the other end. */ +function solplateWedge(L: number, W: number, T1: number, T2: number, side: 1 | -1, y0: number): THREE.BufferGeometry { + const g = new THREE.BoxGeometry(W, 1, L); + const pos = g.getAttribute('position') as THREE.BufferAttribute; + for (let i = 0; i < pos.count; i++) { + const z = pos.getZ(i), top = pos.getY(i) > 0; + const thick = T1 + ((T2 - T1) * (L / 2 - side * z)) / L; + pos.setY(i, y0 + (top ? thick : 0)); + } + pos.needsUpdate = true; + g.computeVertexNormals(); + return g; +} + function material(color: number): THREE.MeshStandardMaterial { return new THREE.MeshStandardMaterial({ color, side: THREE.DoubleSide, roughness: 0.85 }); } @@ -228,6 +243,8 @@ export function buildAbutGroup( groundEl?: number, terrain?: TerrainGrid, slabInput?: Partial, + /** Solplate of each bearing (abutSoles, 2026-11-06): the bearing body and the solplate wedge, and the block-top drop. */ + soles?: BearingSole[], ): THREE.Group { const root = new THREE.Group(); root.name = model.name; @@ -328,13 +345,29 @@ export function buildAbutGroup( // 받침블럭 (spec §4.2, §6.6): concrete 1200 × 1200 from the 교좌면 to 50 below z, and the 몰탈패드 1000 × 1000 × 50 up to z. // The plan follows the 교대축 (받침 방위각 = 축 방향). t is the 전면 +, so the bearing's lateral offset is taken with the front side. // The block turns by the bearing azimuth (rotDeg, from the 방위각 input) about its own centre. + // 교량받침 본체 · 솔플레이트 (2026-11-06, 교각과 같은 개념): the body is the solplate plan less 100 mm each side, height h3, + // on the pad; the solplate tapers along the girder (L) with T1 on the 오르막 쪽. 거더 하면 고정: the block top and the pad + // drop by T − T_ref (as the 받침 ELEVATION TABLE). + const soleOf = new Map((soles ?? []).map((s) => [s.no, s])); for (const b of model.bearings) { const t = model.frontSide * b.lateral; - const blockTop = b.z - 0.05; + const sole = soleOf.get(b.no); + const padTop = b.z - (sole?.dropMm ?? 0) / 1000; + const blockTop = padTop - 0.05; const turn = (b.rotDeg * Math.PI) / 180; const placed = (g: THREE.BufferGeometry) => g.rotateY(turn).translate(b.s, 0, t); add('blocks', placed(prism([[-0.6, b.seatEl], [0.6, b.seatEl], [0.6, blockTop], [-0.6, blockTop]], 1.2, new THREE.Vector3(0, 0, -0.6), ex, ey, ez)), 0xa9b4bf); - add('blocks', placed(prism([[-0.5, blockTop], [0.5, blockTop], [0.5, b.z], [-0.5, b.z]], 1.0, new THREE.Vector3(0, 0, -0.5), ex, ey, ez)), 0x6e7a86); + add('blocks', placed(prism([[-0.5, blockTop], [0.5, blockTop], [0.5, padTop], [-0.5, padTop]], 1.0, new THREE.Vector3(0, 0, -0.5), ex, ey, ez)), 0x6e7a86); + if (!sole) continue; + const L = sole.L / 1000, W = sole.W / 1000, h3 = sole.h3 / 1000; + // Local block frame after rotateY(turn): +z (the girder, L) is (sin turn)·u + (cos turn)·front in (E, N). + const front = { e: model.nLeft.e * model.frontSide, n: model.nLeft.n * model.frontSide }; + const zDir = { e: Math.sin(turn) * model.u.e + Math.cos(turn) * front.e, n: Math.sin(turn) * model.u.n + Math.cos(turn) * front.n }; + const t1Side = sole.uphill && zDir.e * sole.uphill.e + zDir.n * sole.uphill.n > 0 ? 1 : -1; + const body = new THREE.BoxGeometry(W - 0.2, h3, L - 0.2); + body.translate(0, padTop + h3 / 2, 0); + add('bearings', placed(body), 0x3d4a57); + add('solplates', placed(solplateWedge(L, W, sole.T1 / 1000, sole.T2 / 1000, t1Side, padTop + h3)), 0xc08a3e); } // 접속슬래브 (spec §4.8, 2026-11-04 매립형 / 노출형): the section (core/abut/slab.ts) is measured from the 흉벽 상단 at each s. diff --git a/tests/abut-solplate-scene.test.ts b/tests/abut-solplate-scene.test.ts new file mode 100644 index 0000000..ac8ab02 --- /dev/null +++ b/tests/abut-solplate-scene.test.ts @@ -0,0 +1,68 @@ +import * as THREE from 'three'; +import { describe, expect, it } from 'vitest'; +import { buildAbut } from '../src/core/abut/geometry'; +import { parseAbutments, toAbutInput } from '../src/core/abut/parse'; +import { abutSoles, resolveBearingRule } from '../src/core/abut/bearing-table'; +import { buildAbutGroup } from '../src/view/abut-scene'; +import { loadInputWorkbook, p1Pairs } from './helpers'; + +// 교대 받침 본체 · 솔플레이트 3D (2026-11-06, 교각과 같은 개념). A1 with its P1 pair: S −1.908 %, T 30, h3 245. +// Vertex positions are 32-bit floats: at EL 44 m they carry ~1.5 µm, so heights are compared to 5 decimals. +const rec = parseAbutments(loadInputWorkbook()).abutments.find((a) => a.name === 'A1')!; +const model = buildAbut(toAbutInput(rec)); +const ctx = { pairs: p1Pairs(), slopeI: rec.slopeI }; +const soles = abutSoles(model, resolveBearingRule(), rec.bearingHeightMm, ctx); +const group = buildAbutGroup(model, undefined, undefined, undefined, undefined, undefined, undefined, soles); +const meshes = (name: string) => (group.getObjectByName(name) as THREE.Group).children as THREE.Mesh[]; +const box = (m: THREE.Object3D) => new THREE.Box3().setFromObject(m); + +describe('교대 받침 본체 · 솔플레이트 3D', () => { + it('one bearing body per bearing, height h3 (245), on the pad top (z)', () => { + expect(meshes('bearings')).toHaveLength(5); + model.bearings.forEach((b, i) => { + const bb = box(meshes('bearings')[i]); + expect(bb.max.y - bb.min.y).toBeCloseTo(0.245, 5); + expect(bb.min.y).toBeCloseTo(b.z, 5); + }); + }); + + it('one solplate wedge per bearing: from z + h3 up to T1 (38 mm) at its thick end', () => { + expect(meshes('solplates')).toHaveLength(5); + model.bearings.forEach((b, i) => { + const bb = box(meshes('solplates')[i]); + expect(bb.min.y).toBeCloseTo(b.z + 0.245, 5); + expect(bb.max.y).toBeCloseTo(b.z + 0.245 + 0.038, 5); + }); + }); + + it('T1 sits on the 오르막 쪽: A1 is higher than P1, so the thick end points away from P1 (against the azimuth)', () => { + model.bearings.forEach((b, i) => { + const pos = meshes('solplates')[i].geometry.getAttribute('position') as THREE.BufferAttribute; + const pts = Array.from({ length: pos.count }, (_, k) => new THREE.Vector3(pos.getX(k), pos.getY(k), pos.getZ(k))); + const topY = Math.max(...pts.map((p) => p.y)); + const thick = pts.filter((p) => topY - p.y < 1e-6), thin = pts.filter((p) => p.y > b.z + 0.245 + 1e-6 && topY - p.y > 1e-3); + const mid = (ps: THREE.Vector3[]) => ps.reduce((a, p) => a.add(p), new THREE.Vector3()).multiplyScalar(1 / ps.length); + const d = mid(thick).sub(mid(thin)); + // Group without a pivot: local x = s along u, local z = t along the front (nLeft · frontSide). + const front = { e: model.nLeft.e * model.frontSide, n: model.nLeft.n * model.frontSide }; + const dir = { e: d.x * model.u.e + d.z * front.e, n: d.x * model.u.n + d.z * front.n }; + const az = (b.azimuthDeg! * Math.PI) / 180; + expect(dir.e * Math.sin(az) + dir.n * Math.cos(az)).toBeLessThan(0); + }); + }); + + it('거더 하면 고정: S −3 % typed → T 34, the pad and block top drop 4 mm and the solplate top stays', () => { + const steep = abutSoles(model, resolveBearingRule({ slopePct: -3 }), rec.bearingHeightMm, ctx); + const g = buildAbutGroup(model, undefined, undefined, undefined, undefined, undefined, undefined, steep); + const body = box((g.getObjectByName('bearings') as THREE.Group).children[0]); + expect(body.min.y).toBeCloseTo(model.bearings[0].z - 0.004, 5); + // Centre thickness T: the top at the middle of the wedge is pad top + h3 + T, the same as with T 30. + expect(body.min.y + 0.245 + 0.034).toBeCloseTo(model.bearings[0].z + 0.245 + 0.03, 5); + }); + + it('without soles the group keeps only the block and pad (old callers)', () => { + const plain = buildAbutGroup(model); + expect(plain.getObjectByName('bearings')).toBeUndefined(); + expect(plain.getObjectByName('solplates')).toBeUndefined(); + }); +});