feat(abut-rebar): ③ 교대 저판 3D 철근 (B1–B10)

- 배근 결과(abutFootingRebar)로 저판 철근을 교대 좌표에 그림: B1 ⊓ · B2 ⊔ · B3 · B4, B5 ⊓ · B6 ⊔ · B7 · B8, B9, B10-1 · B10-2, 옵션 ② B1-S · B2-S
- 교대 선택 시 ③ 철근(rebar3d)으로 사용: 철근표 · 삽도 선택, 선택만 보기, 색 기준, 3D 클릭 선택 공통
- 교각 rebar3d의 강조 · 색 · 범례를 rebarControls로 공용화
- 테스트: 철근표 개수 = 3D 개수, 모든 철근이 저판 안 (skew · 교대축 직각)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
ypshimandClaude Haiku 5.5 committed 2026-11-04 16:01:56 +09:00
1 parent d7714f7ef2
commit 8361487b6b
4 files changed
+188 -4

No files matched your search

+15 -2
View File
@@ -70,6 +70,7 @@ import { pileGeometry } from './core/geometry';
import { rebarSummary, type RebarRow } from './core/rebar/summary';
import { shearSegments } from './core/rebar/coping';
import { buildRebar3D, type RebarColorMode, type RebarScene } from './view/rebar3d';
import { buildAbutFootingRebar3D } from './view/abut-rebar3d';
import { DEFAULT_MATERIAL, FCK_MEMBERS, fckChanged, fyOf, GRADE_CHOICES, IMPORTED_FCK, isAssumedMaterial, materialFor, materialFrom, type BendFactors, type FckByMember, type FckMember, type GradeKey, type Material } from './core/rebar/common';
import { buildQuantity, quantityRows, type Quantity, type QuantityLine } from './core/rebar/quantity';
import { groundElFor, parseGroundModel, strataRowsFor, type GroundMapLayer, type GroundModelFile } from './core/ground-model';
@@ -676,6 +677,17 @@ function setupScene(): void {
state.scene = scene;
setView(state.view);
}
// ③ 교대 (2026-11-04): with the piers hidden, the selected abutment's 저판 rebar is the ③ rebar (표 · 3D 선택 공통).
const abutRec = piersShown(state.stage, state.abutActive) ? undefined : state.abutments.find((a) => a.name === state.abutActive);
const abutGroup = abutRec ? abutGroupByName.get(abutRec.name) : undefined;
const footing = abutRec ? abutFootingBuilt(abutRec) : null;
if (abutGroup && footing && footing.bottomEl !== undefined) {
rebar3d = buildAbutFootingRebar3D(footing.geom, footing.result, footing.skewK, footing.backM, footing.bottomEl);
rebar3d.group.applyMatrix4(abutGroup.matrix);
rebar3d.group.visible = state.stage === 'rebar';
rebar3d.setColorMode(state.rebarColor);
world.add(rebar3d.group);
}
}
// --- main list (시안 ① 교각 목록) ----------------------------------------------------
@@ -3093,13 +3105,13 @@ const ABUT_REBAR_MEMBERS: { id: AbutRebarMemberId; label: string; marks: string;
];
/** The 저판 rebar of an abutment (null when the abutment does not build). */
function abutFootingBuilt(rec: AbutRecord): { geom: AbutFootingGeom; result: AbutFootingRebar; skewK: number } | null {
function abutFootingBuilt(rec: AbutRecord): { geom: AbutFootingGeom; result: AbutFootingRebar; skewK: number; backM: number; bottomEl: number | undefined } | null {
try {
const model = buildAbut(toAbutInput(rec));
const members = buildMembers(model, { bottomEl: rec.foundationBottomEl, pileLengthMm: rec.pileLengthMm }, rec.shape);
const geom = abutFootingGeom(model.length, members.shape, skewAngle(rec.skewDeg));
const result = abutFootingRebar(geom, rec.rebar?.footing, materialFor(state.material, state.fckByMember, 'abutFooting'), rec.name);
return { geom, result, skewK: abutSkewK(model) };
return { geom, result, skewK: abutSkewK(model), backM: members.shape.footingBackM, bottomEl: members.footingBottomEl };
} catch {
return null;
}
@@ -3151,6 +3163,7 @@ function renderAbutRebar(rec: AbutRecord): void {
}
if (issues.childElementCount === 0) issues.appendChild(el('li', '알림이 없습니다.'));
$('step3-status').textContent = `${rec.name} 확대기초 계산됨 · 경고 ${warn}`;
rebar3d?.show(state.rebarSelection.size ? state.rebarSelection : null, state.rebarIsolate);
}
/** ③ 교대 확대기초 카드 (시안, 교대확대기초철근.md §10): 보기 탭 · 삽도 → 범례 → 기본간격 · 배근 방향 · 입력 → 자동 값 → 철근표 → 근거. */
+100
View File
@@ -0,0 +1,100 @@
/**
* 교대 저판 3D 철근 (③, 2026-11-04). Positions come from `abutFootingRebar` (no new rules here); the shapes follow the
* 배근 삽도 (abut-footing-rebar-svg.ts): B1 ⊓ / B2 ⊔ / B3 · B4 직선, B5 ⊓ / B6 ⊔ / B7 · B8 직선, B9 연직, B10 측면, B1-S · B2-S.
* Hooks and the bend radii are not drawn. Each bar is named by its 철근표 number, so the table, the 삽도 and the model
* share one selection. Local frame = the abutment group's (x = s along the axis, y = EL, z = t toward the front).
*/
import * as THREE from 'three';
import type { AbutFootingGeom, AbutFootingRebar } from '../core/abut/rebar/footing';
import { NOMINAL_DIAMETER } from '../core/rebar/common';
import { memberOfBar } from '../core/rebar/color';
import { ABUT_FOOTING_COLORS } from './abut-footing-rebar-svg';
import { rebarColor, rebarControls, type RebarScene } from './rebar3d';
type P3 = [number, number, number];
/**
* @param skewK plan skew (s per m of t, `abutSkewK`), @param backM 저판 배면 t (m), @param bottomEl 기초 하단 EL (m).
*/
export function buildAbutFootingRebar3D(g: AbutFootingGeom, r: AbutFootingRebar, skewK: number, backM: number, bottomEl: number): RebarScene {
const group = new THREE.Group();
group.name = 'abut-rebar';
const byName = new Map<string, THREE.Mesh[]>();
const C = Object.fromEntries(Object.entries(ABUT_FOOTING_COLORS).map(([k, v]) => [k, parseInt(v.slice(1), 16)])) as Record<keyof typeof ABUT_FOOTING_COLORS, number>;
const sizeOf = new Map(r.bars.map((b) => [b.mark, b.size]));
// (s, x, z) in mm: s along the skew grid (끝면과 나란히), x from the 배면, z from the 저판 하면.
const P = (s: number, x: number, z: number): P3 => [s / 1000 + skewK * (backM + x / 1000), bottomEl + z / 1000, backM + x / 1000];
// Option ②: square to the axis, s in the 삽도's frame (abutFootingRebarPlanSvg SQ), the same for every x.
const shiftMax = Math.abs(skewK) * g.W;
const Q = (s: number, x: number, z: number): P3 => {
const u = (skewK >= 0 ? s : s - shiftMax) / 1000 + skewK * backM;
return [u, bottomEl + z / 1000, backM + x / 1000];
};
const main = r.dir === 'skew' ? P : Q;
const add = (name: string, pts: P3[], color: number) => {
const size = sizeOf.get(name);
if (!size) return;
const dMm = NOMINAL_DIAMETER[size];
for (let i = 1; i < pts.length; i++) {
const a = new THREE.Vector3(...pts[i - 1]);
const b = new THREE.Vector3(...pts[i]);
const dir = b.clone().sub(a);
const len = dir.length();
if (len < 1e-6) continue;
const geo = new THREE.CylinderGeometry(dMm / 2000, dMm / 2000, len, 6);
geo.applyQuaternion(new THREE.Quaternion().setFromUnitVectors(new THREE.Vector3(0, 1, 0), dir.normalize()));
geo.translate((a.x + b.x) / 2, (a.y + b.y) / 2, (a.z + b.z) / 2);
const mesh = new THREE.Mesh(geo, new THREE.MeshStandardMaterial({ color: rebarColor(color), roughness: 0.45, metalness: 0.1 }));
mesh.userData = { name, roleColor: color, dMm, member: memberOfBar(name) };
group.add(mesh);
const list = byName.get(name) ?? [];
list.push(mesh);
byName.set(name, list);
}
};
const c = r.settings.cSide;
const { B13, B24, B57, B68 } = r.z;
// B1–B4: across the width. B1 sits beside B2 along the axis (옆붙임).
r.main.positions.forEach((s, i) => {
if (i % 2 === 0) {
const s1 = s + r.sideShift.B1;
add('B1', [main(s1, c, B24), main(s1, c, B13), main(s1, g.W - c, B13), main(s1, g.W - c, B24)], C.main);
add('B2', [main(s, c, B13), main(s, c, B24), main(s, g.W - c, B24), main(s, g.W - c, B13)], C.main);
} else {
add('B3', [main(s, c, B13), main(s, g.xf - c, B13)], C.main);
add('B4', [main(s, c, B24), main(s, g.W - c, B24)], C.main);
}
});
for (const off of r.skewEnd?.offsets ?? []) {
for (const s of [c + off, g.L - c - off]) {
add('B1-S', [P(s, c, B13), P(s, g.W - c, B13)], C.main);
add('B2-S', [P(s, c, B24), P(s, g.W - c, B24)], C.main);
}
}
// B5–B8: along the axis, cut at the end cover (c / sin θ). Top bars only where the 삽도 keeps them.
const e = c / g.angle.sin;
r.dist.positions.forEach((x, i) => {
const one = i % 2 === 0;
if (r.dist.topKept[i]) {
if (one) {
const x5 = x + r.sideShift.B5;
add('B5', [P(e, x5, B68), P(e, x5, B57), P(g.L - e, x5, B57), P(g.L - e, x5, B68)], C.dist);
} else add('B7', [P(e, x, B57), P(g.L - e, x, B57)], C.dist);
}
if (one) add('B6', [P(e, x, B57), P(e, x, B68), P(g.L - e, x, B68), P(g.L - e, x, B57)], C.dist);
else add('B8', [P(e, x, B68), P(g.L - e, x, B68)], C.dist);
});
// B9: vertical, B2 → B1 level.
for (const row of r.shear.rows) for (const s of row.s) add('B9', [main(s, row.x, B24), main(s, row.x, B13)], C.shear);
// B10-1 on the front and back faces (one bar above the level, as in the 단면), B10-2 on the two skew end faces.
const { depth1, depth2, levels } = r.side;
const e2 = depth2 / g.angle.sin;
for (const z of levels) {
const z1 = z + NOMINAL_DIAMETER[sizeOf.get('B10-1') ?? 'H19'];
for (const x of [depth1, g.W - depth1]) add('B10-1', [P(e2, x, z1), P(g.L - e2, x, z1)], C.side);
for (const s of [e2, g.L - e2]) add('B10-2', [P(s, depth1, z), P(s, g.W - depth1, z)], C.side);
}
return { group, byName, records: [], ...rebarControls(byName) };
}
+7 -2
View File
@@ -49,7 +49,7 @@ const MEMBER_LABELS: Record<RebarMemberKey, string> = { bearing: '받침블럭',
const hex = (v: number) => `#${v.toString(16).padStart(6, '0')}`;
/** Rebar is drawn a little deeper than the 6.7 colours so it reads through the half-transparent members (승인 2026-10-23). */
const REBAR_SHADE = 0.8;
const rebarColor = (v: number) => new THREE.Color(v).multiplyScalar(REBAR_SHADE);
export const rebarColor = (v: number) => new THREE.Color(v).multiplyScalar(REBAR_SHADE);
const COLOR = {
coping: 0xb4531f,
@@ -555,6 +555,11 @@ export function buildRebar3D(m: PierModel, common: Material = DEFAULT_MATERIAL,
}
}
return { group, byName, records, ...rebarControls(byName) };
}
/** Highlight, colour and legend over bars grouped by 철근표 number (교각 · 교대 3D 철근 공통). */
export function rebarControls(byName: Map<string, THREE.Mesh[]>): Pick<RebarScene, 'show' | 'setColorMode' | 'legend'> {
const show = (names: ReadonlySet<string> | null, isolate: boolean) => {
// 커플러 as a name selects every coupler bar.
const isOn = (key: string) => names === null || names.has(key) || (names.has('커플러') && key.includes('커플러'));
@@ -592,5 +597,5 @@ export function buildRebar3D(m: PierModel, common: Material = DEFAULT_MATERIAL,
const items = DIAMETER_GROUPS.map((g, i) => ({ label: g.label, color: hex(DIAMETER_COLORS[i]) })).filter((_, i) => present.has(i));
return present.has(-1) ? [...items, { label: '기타', color: hex(OTHER_COLOR) }] : items;
};
return { group, byName, records, show, setColorMode, legend };
return { show, setColorMode, legend };
}
+66
View File
@@ -0,0 +1,66 @@
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 { abutFootingGeom, abutFootingRebar } from '../src/core/abut/rebar/footing';
import { skewAngle } from '../src/core/abut/rebar/skew-layout';
import { abutSkewK } from '../src/view/abut-scene';
import { buildAbutFootingRebar3D } from '../src/view/abut-rebar3d';
// 예제 A1 (교대확대기초철근.md §13).
const RAG = fileURLToPath(new URL('../../../RAG/운곡교P1입력.xlsx', import.meta.url));
const rec = parseAbutments(XLSX.read(readFileSync(RAG), { type: 'buffer' })).abutments[0];
const model = buildAbut(toAbutInput(rec));
const members = buildMembers(model, { bottomEl: rec.foundationBottomEl, pileLengthMm: rec.pileLengthMm });
const geom = abutFootingGeom(model.length, members.shape, skewAngle(rec.skewDeg));
const k = abutSkewK(model);
const back = members.shape.footingBackM;
const bottom = members.footingBottomEl!;
const build = (dir: 'skew' | 'perp') => {
const r = abutFootingRebar(geom, { dir });
return { r, scene: buildAbutFootingRebar3D(geom, r, k, back, bottom) };
};
// Segments per bar: ⊓ / ⊔ have 3, the others 1.
const PIECES: Record<string, number> = { B1: 3, B2: 3, B5: 3, B6: 3 };
describe('교대 저판 3D 철근 (③, 2026-11-04)', () => {
for (const dir of ['skew', 'perp'] as const) {
it(`draws every 철근표 bar with its count (${dir})`, () => {
const { r, scene } = build(dir);
for (const b of r.bars) expect(scene.byName.get(b.mark)?.length, b.mark).toBe(b.count * (PIECES[b.mark] ?? 1));
});
it(`keeps every bar inside the 저판 (${dir})`, () => {
const { scene } = build(dir);
const top = bottom + geom.H / 1000;
const front = back + geom.W / 1000;
const box = new THREE.Box3();
for (const mesh of scene.group.children as THREE.Mesh[]) {
mesh.geometry.computeBoundingBox();
box.copy(mesh.geometry.boundingBox!);
const tMid = (box.min.z + box.max.z) / 2;
const s0 = k * tMid;
expect(box.min.y).toBeGreaterThan(bottom);
expect(box.max.y).toBeLessThan(top);
expect(box.min.z).toBeGreaterThan(back);
expect(box.max.z).toBeLessThan(front);
// Along the axis, within the skew ends at that t (a bar diameter of slack for the cylinder's sides).
expect(box.min.x).toBeGreaterThan(s0 + Math.min(0, k * (box.min.z - tMid), k * (box.max.z - tMid)) - 0.03);
expect(box.max.x).toBeLessThan(s0 + geom.L / 1000 + Math.max(0, k * (box.min.z - tMid), k * (box.max.z - tMid)) + 0.03);
}
});
}
it('dims or hides the bars not selected (선택만 보기)', () => {
const { scene } = build('skew');
scene.show(new Set(['B9']), true);
expect(scene.byName.get('B9')!.every((m) => m.visible)).toBe(true);
expect(scene.byName.get('B1')!.some((m) => m.visible)).toBe(false);
scene.show(null, false);
expect(scene.byName.get('B1')!.every((m) => m.visible)).toBe(true);
});
});