diff --git a/src/main.ts b/src/main.ts index b05f019..a846204 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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): 보기 탭 · 삽도 → 범례 → 기본간격 · 배근 방향 · 입력 → 자동 값 → 철근표 → 근거. */ diff --git a/src/view/abut-rebar3d.ts b/src/view/abut-rebar3d.ts new file mode 100644 index 0000000..12db77c --- /dev/null +++ b/src/view/abut-rebar3d.ts @@ -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(); + const C = Object.fromEntries(Object.entries(ABUT_FOOTING_COLORS).map(([k, v]) => [k, parseInt(v.slice(1), 16)])) as Record; + 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) }; +} diff --git a/src/view/rebar3d.ts b/src/view/rebar3d.ts index ecc9e0f..8f2927d 100644 --- a/src/view/rebar3d.ts +++ b/src/view/rebar3d.ts @@ -49,7 +49,7 @@ const MEMBER_LABELS: Record = { 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): Pick { const show = (names: ReadonlySet | 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 }; } diff --git a/tests/abut-rebar3d.test.ts b/tests/abut-rebar3d.test.ts new file mode 100644 index 0000000..2702577 --- /dev/null +++ b/tests/abut-rebar3d.test.ts @@ -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 = { 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); + }); +});