feat(abut): 교대 3D에 받침 본체 · 솔플레이트 (교각과 같은 개념)

교대 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 <noreply@anthropic.com>
This commit is contained in:
ypshimandClaude Opus 5.5 committed 2026-11-06 16:03:57 +09:00
1 parent c7acfccd51
commit 1fda705b75
3 files changed
+113 -6

No files matched your search

+9 -3
View File
@@ -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)) {
+36 -3
View File
@@ -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<AbutSlab>,
/** 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.
+68
View File
@@ -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();
});
});