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:
1 parent
d7714f7ef2
commit
8361487b6b
4 files changed
+188
-4
No files matched your search
+15
-2
@@ -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): 보기 탭 · 삽도 → 범례 → 기본간격 · 배근 방향 · 입력 → 자동 값 → 철근표 → 근거. */
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user