From 8f75c6dc9fd7f5b59e9fda0df9a5604d2f89f49c Mon Sep 17 00:00:00 2001 From: ypshim Date: Tue, 3 Nov 2026 15:59:51 +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=EB=B8=94=EB=9F=AD=C2=B7=EB=AA=B0=ED=83=88?= =?UTF-8?q?=ED=8C=A8=EB=93=9C=EC=99=80=20=EC=A0=91=EC=86=8D=EC=8A=AC?= =?UTF-8?q?=EB=9E=98=EB=B8=8C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 받침마다 블럭 1200 × 1200 (교좌면 ~ z − 50)과 몰탈패드 1000 × 1000 × 50 (spec §4.2, §6.6). 평면은 교대축 방향 - 접속슬래브 450 두께, 브라켓 상면 위, 흉벽 배면에서 5 mm 떨어져 7 m 배면으로, 배면 쪽 4 % 하향 (spec §4.8). 폭은 날개벽 사이 slabWidthMm, 축 중심 - 부재 표시: 받침·받침블럭 토글이 교대 블럭에도 적용, 접속슬래브 토글 추가 - 테스트 추가 (tests/abut-blocks-slab.test.ts). 전체 634건 통과, 타입 검사·빌드 통과. 브라우저 시각 확인은 미실시 Co-Authored-By: Claude Haiku 5.5 --- src/main.ts | 28 +++++++++++++++++ src/view/abut-scene.ts | 55 +++++++++++++++++++++++++++++++++- tests/abut-blocks-slab.test.ts | 39 ++++++++++++++++++++++++ 3 files changed, 121 insertions(+), 1 deletion(-) create mode 100644 tests/abut-blocks-slab.test.ts diff --git a/src/main.ts b/src/main.ts index c5d8af4..509e0cc 100644 --- a/src/main.ts +++ b/src/main.ts @@ -139,6 +139,8 @@ const state = { active: null as string | null, view: 'iso' as ViewKind, visible: Object.fromEntries(PART_GROUPS.flatMap((g) => g.parts).map((p) => [p, true])) as Record, + /** 접속슬래브 toggle (abutments only). */ + abutSlabVisible: true, scene: null as PierScene | null, project: { name: '', bridge: '', client: '', author: '', crs: CRS, memo: '' } as ProjectInfo, /** Which stage screen is shown: ① 형상 생성 or ② 형상계획 (plan.md C1). */ @@ -358,9 +360,28 @@ function buildPartToggles(): void { label.append(input, document.createTextNode(group.label)); row.appendChild(label); } + // 접속슬래브 exists only on the abutments (2026-11-03): it has no pier part, so it is kept apart from state.visible. + const slabLabel = el('label'); + const slabInput = el('input'); + slabInput.type = 'checkbox'; + slabInput.checked = state.abutSlabVisible; + slabInput.addEventListener('change', () => { + state.abutSlabVisible = slabInput.checked; + setAbutPartVisible('slab', slabInput.checked); + }); + slabLabel.append(slabInput, document.createTextNode('접속슬래브')); + row.appendChild(slabLabel); box.appendChild(row); } +/** Shows or hides one named part of every abutment (the 받침블럭 and 접속슬래브 of abut-scene.ts). */ +function setAbutPartVisible(part: 'blocks' | 'slab', on: boolean): void { + for (const g of abutGroups) { + const member = g.getObjectByName(part); + if (member) member.visible = on; + } +} + function buildViewButtons(): void { const box = $('views'); for (const v of VIEWS) { @@ -385,6 +406,8 @@ function setPartVisible(part: PartName, on: boolean): void { else for (const s of allPiers.scenes.values()) s.parts.ground.visible = on; return; } + // 받침·받침블럭 also covers the abutments' blocks and pads (2026-11-03). + if (part === 'blocks') setAbutPartVisible('blocks', on); for (const s of partScenes()) s.parts[part].visible = on; } @@ -501,6 +524,11 @@ function setupScene(): void { const members = buildMembers(model, { bottomEl: rec.foundationBottomEl, pileLengthMm: rec.pileLengthMm }, rec.shape); const group = buildAbutGroup(model, members, buildWings(model, members, rec.wings), ref.pivot, rec.groundEl, groundModel?.terrainGrid); group.visible = abutShown(state.stage, state.abutActive, rec.name); + // The 부재 표시 toggles hold for a new abutment group too (blocks: the 받침·받침블럭 toggle; slab: 접속슬래브). + const blocks = group.getObjectByName('blocks'); + if (blocks) blocks.visible = state.visible.blocks; + const slab = group.getObjectByName('slab'); + if (slab) slab.visible = state.abutSlabVisible; abutGroups.push(group); abutGroupByName.set(rec.name, group); world.add(group); diff --git a/src/view/abut-scene.ts b/src/view/abut-scene.ts index 09d087d..5532a16 100644 --- a/src/view/abut-scene.ts +++ b/src/view/abut-scene.ts @@ -12,7 +12,36 @@ import { buildWings, type WingSolid } from '../core/abut/wings'; import type { TerrainGrid } from '../core/ground-model'; import { terrainGeometry } from './terrain'; -export const ABUT_GROUP_NAMES = ['wall', 'parapet', 'footing', 'piles', 'wings', 'haunches', 'ground'] as const; +export const ABUT_GROUP_NAMES = ['wall', 'parapet', 'footing', 'piles', 'wings', 'haunches', 'ground', 'blocks', 'slab'] as const; +/** 접속슬래브 두께 (m), spec_abut §4.8. */ +const SLAB_THICKNESS_M = 0.45; +/** 접속슬래브 길이 (배면 쪽, m), spec_abut §4.8 "7000 (날개벽 축 방향)". */ +const SLAB_LENGTH_M = 7; +/** 접속슬래브 하향 경사 (배면 쪽으로), spec_abut §4.8 "하향 4 %". */ +const SLAB_FALL = 0.04; + +/** Eight-corner slab with its top sloping away from the wall. Indexed by (s end, t end, bottom/top). Double-sided: the winding is not critical. */ +function slabGeometry(s0: number, s1: number, tNear: number, tFar: number, bottomEl: (s: number, t: number) => number): THREE.BufferGeometry { + const corners = [s0, s1].flatMap((s) => [tNear, tFar].flatMap((t) => [bottomEl(s, t), bottomEl(s, t) + SLAB_THICKNESS_M].map((y) => [s, y, t]))); + // index = is * 4 + it * 2 + ih (ih 0 = bottom, 1 = top) + const quads = [ + [0, 2, 3, 1], [4, 5, 7, 6], // s0 and s1 ends + [0, 4, 6, 2], [1, 3, 7, 5], // bottom and top + [0, 1, 5, 4], [2, 6, 7, 3], // near and far faces (t ends) + ]; + const positions: number[] = []; + const indices: number[] = []; + for (const q of quads) { + const base = positions.length / 3; + for (const i of q) positions.push(...corners[i]); + indices.push(base, base + 1, base + 2, base, base + 2, base + 3); + } + const geom = new THREE.BufferGeometry(); + geom.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)); + geom.setIndex(indices); + geom.computeVertexNormals(); + return geom; +} /** 지반 pad margin (m) around the abutment plan, like the pier's GROUND_PAD. */ export const ABUT_GROUND_MARGIN_M = 2; @@ -134,6 +163,30 @@ export function buildAbutGroup( add('footing', prism(footProfile, members.shape.footingThicknessM, new THREE.Vector3(0, footingBottom, 0), ex, ez, ey), 0x5a6a78); } + // 받침블럭 (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. + for (const b of model.bearings) { + const t = model.frontSide * b.lateral; + const blockTop = b.z - 0.05; + add('blocks', prism([[b.s - 0.6, b.seatEl], [b.s + 0.6, b.seatEl], [b.s + 0.6, blockTop], [b.s - 0.6, blockTop]], 1.2, new THREE.Vector3(0, 0, t - 0.6), ex, ey, ez), 0xa9b4bf); + add('blocks', prism([[b.s - 0.5, blockTop], [b.s + 0.5, blockTop], [b.s + 0.5, b.z], [b.s - 0.5, b.z]], 1.0, new THREE.Vector3(0, 0, t - 0.5), ex, ey, ez), 0x6e7a86); + } + + // 접속슬래브 (spec §4.8): bottom on the 브라켓 top (흉벽 상단 − 0.85), 5 mm behind the 흉벽 back face, 7 m back, falling 4 % away from the wall. + // Centred on the axis over the wing-to-wing width (slabWidthMm); the 흉벽 slope along s is followed. + const slabW = (model.wing?.slabWidthMm ?? L * 1000) / 1000; + const slabNear = members.shape.parapetBackM - 0.005; + const bracketTopEl = (s: number) => members.parapetTopStartEl + ((members.parapetTopEndEl - members.parapetTopStartEl) * s) / L - members.shape.bracketHeightM; + const slabMesh = new THREE.Mesh( + slabGeometry((L - slabW) / 2, (L + slabW) / 2, slabNear, slabNear - SLAB_LENGTH_M, (s, t) => bracketTopEl(s) + SLAB_FALL * (t - slabNear)), + new THREE.MeshStandardMaterial({ color: 0xd8c8a0, roughness: 0.9, side: THREE.DoubleSide }), + ); + slabMesh.name = 'slab'; + const slabGroup = new THREE.Group(); + slabGroup.name = 'slab'; + slabGroup.add(slabMesh); + root.add(slabGroup); + // 말뚝: D 600 (r 0.3) at each row and bar, from the 기초 하단 down to the 선단. if (members.pileBottomEl !== undefined && footingBottom !== undefined) { const height = footingBottom - members.pileBottomEl; diff --git a/tests/abut-blocks-slab.test.ts b/tests/abut-blocks-slab.test.ts new file mode 100644 index 0000000..4f40e9e --- /dev/null +++ b/tests/abut-blocks-slab.test.ts @@ -0,0 +1,39 @@ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import * as XLSX from 'xlsx'; +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 { buildMembers } from '../src/core/abut/members'; +import { buildWings } from '../src/core/abut/wings'; +import { buildAbutGroup } from '../src/view/abut-scene'; + +const RAG = fileURLToPath(new URL('../../../RAG/운곡교P1입력.xlsx', import.meta.url)); +const rec = parseAbutments(XLSX.read(readFileSync(RAG), { type: 'buffer' })).abutments[0]; +const input = toAbutInput(rec); +const model = buildAbut(input); +const members = buildMembers(model, { bottomEl: input.foundationBottomEl, pileLengthMm: input.pileLengthMm }); +const group = buildAbutGroup(model, members, buildWings(model, members)); + +const named = (name: string) => group.getObjectByName(name) as THREE.Group | undefined; + +describe('교대 3D: 받침블럭과 접속슬래브 (spec §4.2, §4.8)', () => { + it('has a block and a pad for each of the 5 bearings', () => { + expect(named('blocks')?.children).toHaveLength(2 * model.bearings.length); + }); + + it('draws the 접속슬래브 as one slab over the wing-to-wing width', () => { + const slab = named('slab'); + expect(slab?.children).toHaveLength(1); + const box = new THREE.Box3().setFromObject(slab as THREE.Group); + const width = box.max.x - box.min.x; + expect(width).toBeCloseTo((model.wing?.slabWidthMm ?? 0) / 1000, 2); + }); + + it('the slab reaches 7 m behind the 흉벽 back face and is 450 thick', () => { + const box = new THREE.Box3().setFromObject(named('slab') as THREE.Group); + expect(box.min.z).toBeCloseTo(members.shape.parapetBackM - 0.005 - 7, 2); + expect(box.max.y - box.min.y).toBeGreaterThan(0.45); + }); +});