feat(abut): 교대 3D에 받침블럭·몰탈패드와 접속슬래브 추가

- 받침마다 블럭 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 <noreply@anthropic.com>
This commit is contained in:
ypshimandClaude Haiku 5.5 committed 2026-11-03 15:59:51 +09:00
1 parent 5c2b23211e
commit 8f75c6dc9f
3 files changed
+121 -1

No files matched your search

+28
View File
@@ -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<PartName, boolean>,
/** 접속슬래브 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 = $<HTMLDivElement>('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);
+54 -1
View File
@@ -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;