From a692f1f5077b95c83ad38c7cf449c72acedb4f9d Mon Sep 17 00:00:00 2001 From: ypshim Date: Thu, 22 Oct 2026 12:31:26 +0900 Subject: [PATCH] =?UTF-8?q?feat(view):=20=EC=B2=A0=EA=B7=BC=ED=91=9C=20and?= =?UTF-8?q?=20the=203D=20rebar=20model=20point=20at=20each=20other=20(?= =?UTF-8?q?=E2=91=A2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The 3D model of ③ draws each bar family from the rebar modules (coping top and bottom bars, shear loops, side bars, column bars and ties, footing B1–B8, bearing S1/S2) and names every bar as its 철근표 number. - A table row click highlights the bar in 3D (the others dim); a click on a 3D bar selects its row in the table and sets the member tab. Drags still rotate. - The rebar is shown only on ③. Not drawn yet: C14, C15/C16, B9–B12, the couplers, and the bars of a bent coping. - Tests: family names and counts (P1 46), highlight behaviour. - Not verified in a browser. Co-Authored-By: Claude Haiku 5.5 --- src/main.ts | 48 +++++++++- src/view/rebar3d.ts | 200 ++++++++++++++++++++++++++++++++++++++++++ tests/rebar3d.test.ts | 30 +++++++ 3 files changed, 277 insertions(+), 1 deletion(-) create mode 100644 src/view/rebar3d.ts create mode 100644 tests/rebar3d.test.ts diff --git a/src/main.ts b/src/main.ts index 7447909..78a99d7 100644 --- a/src/main.ts +++ b/src/main.ts @@ -12,6 +12,7 @@ import { applyCopy, DEFAULT_COPY_ITEMS, planCopy, type CopyItem } from './core/c import { pileGeometry } from './core/geometry'; import { rebarSummary, type RebarRow } from './core/rebar/summary'; import { shearSegments } from './core/rebar/coping'; +import { buildRebar3D, type RebarScene } from './view/rebar3d'; import { DEFAULT_MATERIAL, isAssumedMaterial, type Material } from './core/rebar/common'; import { allVisibleChecked, setVisibleChecked, type TypeFilter } from './core/selection'; import { choose, defaultSummary, isSelectable, SHAPE_GROUPS, UNDEFINED_REASON, type MemberId } from './core/shape-options'; @@ -92,6 +93,8 @@ const state = { }; let pendingBackup: Backup | null = null; +/** 3D rebar of the current pier (③); its bars are named as in the 철근표. */ +let rebar3d: RebarScene | null = null; let backupTimer: number | undefined; const $ = (id: string): T => document.getElementById(id) as T; @@ -218,8 +221,13 @@ function setView(kind: ViewKind): void { function setupScene(): void { if (state.scene) world.remove(state.scene.root); state.scene = null; + if (rebar3d) world.remove(rebar3d.group); + rebar3d = null; const pier = activePier(); if (pier?.model) { + rebar3d = buildRebar3D(pier.model); + rebar3d.group.visible = state.stage === 'rebar'; + world.add(rebar3d.group); const scene = buildPierScene(pier.model); for (const key of Object.keys(state.visible) as PartName[]) { scene.parts[key].visible = state.visible[key]; @@ -698,6 +706,37 @@ function placeViewer(stage: 'shape' | 'plan' | 'rebar'): void { resize(); } +/** The 철근표 group that a bar name belongs to. */ +function memberOfBar(name: string): RebarRow['member'] { + if (name === 'S1' || name === 'S2') return 'bearing'; + if (name.startsWith('B')) return 'footing'; + if (name.startsWith('P') || name === '커플러') return 'column'; + return 'coping'; +} + +/** Click on a 3D bar selects its 철근표 row (on ③ only). */ +function wireRebarPicking(): void { + const dom = renderer.domElement; + let down: { x: number; y: number } | null = null; + dom.addEventListener('pointerdown', (e) => { + down = { x: e.clientX, y: e.clientY }; + }); + dom.addEventListener('pointerup', (e) => { + if (!down || state.stage !== 'rebar' || !rebar3d) return; + if (Math.hypot(e.clientX - down.x, e.clientY - down.y) > 5) return; // a drag rotates the view + const rect = dom.getBoundingClientRect(); + const ndc = new THREE.Vector2(((e.clientX - rect.left) / rect.width) * 2 - 1, -((e.clientY - rect.top) / rect.height) * 2 + 1); + const ray = new THREE.Raycaster(); + ray.setFromCamera(ndc, camera); + const hit = ray.intersectObjects(rebar3d.group.children, false)[0]; + if (!hit) return; + const name = hit.object.userData.name as string; + state.rebarFocus = name; + state.rebarMember = memberOfBar(name); + renderAll(); + }); +} + function setStage(stage: 'shape' | 'plan' | 'rebar'): void { state.stage = stage; $('view-shape').hidden = stage !== 'shape'; @@ -705,6 +744,7 @@ function setStage(stage: 'shape' | 'plan' | 'rebar'): void { $('view-rebar').hidden = stage !== 'rebar'; // Move the viewer after its new parent is visible, so that resize() measures the real size. placeViewer(stage); + if (rebar3d) rebar3d.group.visible = stage === 'rebar'; // The highlight is the 'current' class (style.css); aria-current is set alongside it for assistive tech. const nav: [string, 'shape' | 'plan' | 'rebar'][] = [['nav-shape', 'shape'], ['nav-plan', 'plan'], ['nav-rebar', 'rebar']]; for (const [id, key] of nav) { @@ -768,7 +808,10 @@ function renderRebar(): void { const nameCell = el('td', r.name); nameCell.classList.add('bar-no'); // Clicking a 번호 highlights that row (시안: 번호를 누르면 그 철근만 강조). - nameCell.addEventListener('click', () => tr.classList.toggle('hl')); + nameCell.addEventListener('click', () => { + state.rebarFocus = state.rebarFocus === r.name ? null : r.name; + renderAll(); + }); if (r.name === state.rebarFocus) tr.classList.add('hl'); tr.append( el('td', ROLE[r.name] ?? '—'), @@ -844,6 +887,8 @@ function renderRebar(): void { box.appendChild(el('p', '전단철근 구간 (끝면 기준, 철근 위치의 처음과 끝)', 'lbl')); box.appendChild(st); } + // Table and 3D model point at each other: the focused bar is highlighted in both. + rebar3d?.highlight(state.rebarFocus); const focused = box.querySelector('tr.hl'); if (focused && state.rebarFocus) focused.scrollIntoView({ block: 'center' }); } @@ -1401,6 +1446,7 @@ $('discard-backup').addEventListener('click', () => { // --- start ------------------------------------------------------------------------------ captureViewerHome(); +wireRebarPicking(); buildPartToggles(); buildViewButtons(); renderDefaults(); diff --git a/src/view/rebar3d.ts b/src/view/rebar3d.ts new file mode 100644 index 0000000..e53a32b --- /dev/null +++ b/src/view/rebar3d.ts @@ -0,0 +1,200 @@ +import * as THREE from 'three'; +import type { PierModel } from '../core/geometry'; +import { grid, layers } from '../core/rebar/footing'; +import { widthLayout } from '../core/rebar/coping-main'; +import { shearBars, shearBarsMulti, shearUHeight, sideRows } from '../core/rebar/coping'; +import { columnRebar, COLUMN_P1 } from '../core/rebar/column'; +import { NOMINAL_DIAMETER } from '../core/rebar/common'; +import { stretchMatrix } from './scene'; + +/** + * 3D rebar of the ③ screen. Positions come from the rebar modules (no new rules here). Each bar is a cylinder + * named by its 철근표 number (C1, P3, B5, S2 …), so the table and the model can point at each other. + * Local frame: x = along the axis s, y = absolute EL (m), z = across n. Stretch matrices map it to the scene. + */ +export interface RebarScene { + group: THREE.Group; + byName: Map; + /** Highlight one bar name (or none): the others are dimmed. */ + highlight(name: string | null): void; +} + +const COLOR = { + coping: 0xb4531f, + shear: 0x6b3fa0, + column: 0x0f5c66, + tie: 0x6b3fa0, + footing: 0x0f5c66, + footingBottom: 0x2e7d32, + block: 0xa07a10, +} as const; + +const mm = (v: number) => v / 1000; + +export function buildRebar3D(m: PierModel): RebarScene { + const group = new THREE.Group(); + const byName = new Map(); + const Ms = m.stretches.map((_, k) => stretchMatrix(m, k)); + const L = m.copingL; + const B = m.copingB; + const straight = m.kink === null; + + const addBar = (name: string, k: number, a: [number, number, number], b: [number, number, number], dMm: number, color: number) => { + const dir = new THREE.Vector3(b[0] - a[0], b[1] - a[1], b[2] - a[2]); + const len = dir.length(); + if (len < 1e-6) return; + const geo = new THREE.CylinderGeometry(mm(dMm) / 2, mm(dMm) / 2, len, 6); + geo.applyQuaternion(new THREE.Quaternion().setFromUnitVectors(new THREE.Vector3(0, 1, 0), dir.normalize())); + geo.translate((a[0] + b[0]) / 2, (a[1] + b[1]) / 2, (a[2] + b[2]) / 2); + geo.applyMatrix4(Ms[k]); + const mat = new THREE.MeshStandardMaterial({ color, roughness: 0.6, metalness: 0.1 }); + const mesh = new THREE.Mesh(geo, mat); + mesh.userData.name = name; + group.add(mesh); + const list = byName.get(name) ?? []; + list.push(mesh); + byName.set(name, list); + }; + + // Coping (straight coping only; a bent coping is not drawn in 3D yet). + if (straight) { + const w = widthLayout(B * 1000); + const xs = [w.yE, ...Array.from({ length: w.n + 1 }, (_, i) => w.yE + w.g + 125 * i), B * 1000 - w.yE].map((v) => mm(v) - B / 2); + const cover = 0.1; + // Top rows C1–C6: row k is 3 bars per cycle; the even positions are cycle 1 (C1–C3), the odd cycle 2 (C4–C6). + const topEl = m.copingRefTop; + for (let k = 1; k <= 3; k++) { + const z = topEl - 0.127 - (k - 1) * 0.057; + xs.forEach((n, j) => { + const name = `C${k + (j % 2 === 1 ? 3 : 0)}`; + addBar(name, 0, [cover, z, n], [L - cover, z, n], NOMINAL_DIAMETER.H29, COLOR.coping); + }); + } + // Bottom bars C7 / C8 along the Level height. + xs.forEach((n, j) => { + const z = m.levelBottom + 0.127; + addBar(j % 2 === 0 ? 'C7' : 'C8', 0, [cover, z, n], [L - cover, z, n], NOMINAL_DIAMETER.H29, COLOR.coping); + }); + // Shear loops C9 / C10 / C11 and the gaps between columns: a closed U at each position. + const colS = m.columns.map((c) => c.s * 1000); + const sh = colS.length > 1 ? shearBarsMulti(L * 1000, colS) : { ...shearBars(L * 1000), between: [] as number[] }; + const loops: [string, number[]][] = [['C9', sh.c9], ['C10', sh.c10], ['C11', sh.c11], ['C9·C11 사이', sh.between]]; + const ne = B / 2 - 0.1; + const top = topEl - 0.1; + for (const [name, list] of loops) { + for (const x of list) { + const xm = mm(x); + const bot = top - shearUHeight(x, L * 1000) / 1000; + const d = NOMINAL_DIAMETER.H25; + addBar(name, 0, [xm, top, -ne], [xm, top, ne], d, COLOR.shear); + addBar(name, 0, [xm, bot, -ne], [xm, bot, ne], d, COLOR.shear); + addBar(name, 0, [xm, top, -ne], [xm, bot, -ne], d, COLOR.shear); + addBar(name, 0, [xm, top, ne], [xm, bot, ne], d, COLOR.shear); + } + } + // Side horizontal bars C12 / C13 at both side faces. + const side = sideRows(41.288, 42.538); + const sideFace = B / 2 - 0.127; + for (const [name, rows] of [['C12', side.c12], ['C13', side.c13]] as const) { + for (const z of rows) { + addBar(name, 0, [cover, z, -sideFace], [L - cover, z, -sideFace], NOMINAL_DIAMETER.H25, COLOR.shear); + addBar(name, 0, [cover, z, sideFace], [L - cover, z, sideFace], NOMINAL_DIAMETER.H25, COLOR.shear); + } + } + } + + // Columns: P1 / P2 main bars on their radii, ties P3 / P5 / P4 as rings, P6 crossties across the column. + const col = columnRebar(COLUMN_P1); + const zBot = -1697.5; + const ring = (name: string, k: number, sc: number, y: number, r: number, d: number) => { + const N = 36; + for (let i = 0; i < N; i++) { + const a0 = (2 * Math.PI * i) / N; + const a1 = (2 * Math.PI * (i + 1)) / N; + addBar(name, k, [sc + r * Math.cos(a0), y, r * Math.sin(a0)], [sc + r * Math.cos(a1), y, r * Math.sin(a1)], d, COLOR.tie); + } + }; + for (const c of m.columns) { + const sc = c.s; + const base = c.bottom; + const rt = mm(col.tieRadius); + for (let i = 0; i < COLUMN_P1.nDiv; i++) { + const th = (2 * Math.PI * i) / COLUMN_P1.nDiv; + const r1 = mm(col.r1); + addBar('P1', c.k, [sc + r1 * Math.cos(th), base + mm(zBot), r1 * Math.sin(th)], [sc + r1 * Math.cos(th), base + mm(col.zTop1), r1 * Math.sin(th)], NOMINAL_DIAMETER.S32, COLOR.column); + const r2 = mm(col.r2); + addBar('P2', c.k, [sc + r2 * Math.cos(th), base + mm(zBot), r2 * Math.sin(th)], [sc + r2 * Math.cos(th), base + mm(col.zTop2), r2 * Math.sin(th)], NOMINAL_DIAMETER.S32, COLOR.column); + } + for (const z of col.p3) ring('P3', c.k, sc, base + mm(z), rt, NOMINAL_DIAMETER.H19); + for (const z of col.p5) ring('P5', c.k, sc, base + mm(z), rt, NOMINAL_DIAMETER.H19); + for (const z of col.p4) ring('P4', c.k, sc, base + mm(z), rt, NOMINAL_DIAMETER.H19); + for (const z of col.p6Levels) { + for (const deg of [0, 46.96, 90, 133.04]) { + const a = (deg * Math.PI) / 180; + addBar('P6', c.k, [sc - rt * Math.cos(a), base + mm(z), -rt * Math.sin(a)], [sc + rt * Math.cos(a), base + mm(z), rt * Math.sin(a)], NOMINAL_DIAMETER.H16, COLOR.tie); + } + } + } + + // Footing bars B1–B8 (교각축 · 교각축 직각), the grids from §3 and §6 of 확대기초철근 (1).md. + for (const f of m.footings) { + const Lf = f.length; + const Wf = f.width; + const Hf = (f.top - f.bottom) * 1000; + const z = layers(Hf); + const gW = grid(Wf * 1000); + const gL = grid(Lf * 1000); + const posW = (j: number) => -Wf / 2 + mm(100 + gW.e + j * 125); + const posL = (i: number) => -Lf / 2 + mm(100 + gL.e + i * 125); + const sA = -Lf / 2 + 0.1; + const sB = Lf / 2 - 0.1; + const nA = -Wf / 2 + 0.1; + const nB = Wf / 2 - 0.1; + const bottom = f.bottom; + const along = (name: string, n: number, y: number, dMm: number, color: number) => + addBar(name, 0, [f.s + sA, y, n], [f.s + sB, y, n], dMm, color); + const acrossN = (name: string, s: number, y: number, dMm: number, color: number) => + addBar(name, 0, [f.s + s, y, nA], [f.s + s, y, nB], dMm, color); + for (let j = 0; j <= gW.n; j++) { + const n = posW(j); + if (j % 2 === 1) { + along('B1', n, bottom + mm(z.B1), NOMINAL_DIAMETER.S25, COLOR.footing); + along('B3', n, bottom + mm(z.B3), NOMINAL_DIAMETER.S19, COLOR.footing); + } else { + along('B4', n, bottom + mm(z.B24), NOMINAL_DIAMETER.S25, COLOR.footing); + } + } + for (let i = 0; i <= gL.n; i++) { + const s = posL(i); + if (i % 2 === 1) { + acrossN('B5', s, bottom + mm(z.B5), NOMINAL_DIAMETER.S29, COLOR.footing); + acrossN('B6', s, bottom + mm(z.B68), NOMINAL_DIAMETER.S29, COLOR.footing); + acrossN('B7', s, bottom + mm(z.B7), NOMINAL_DIAMETER.S19, COLOR.footing); + } else { + acrossN('B8', s, bottom + mm(z.B68), NOMINAL_DIAMETER.S29, COLOR.footing); + } + } + } + + // Bearing blocks: S2 (upper, along s) and S1 (lower, along n), 8 bars each across the 1200 block. + for (const b of m.blocks) { + for (let j = 0; j < 8; j++) { + const off = (j - 3.5) * 0.125; + addBar('S2', b.k, [b.s - 0.5, b.top - 0.05, b.n + off], [b.s + 0.5, b.top - 0.05, b.n + off], NOMINAL_DIAMETER.H19, COLOR.block); + addBar('S1', b.k, [b.s + off, b.top - 0.069, b.n - 0.5], [b.s + off, b.top - 0.069, b.n + 0.5], NOMINAL_DIAMETER.H19, COLOR.block); + } + } + + const highlight = (name: string | null) => { + for (const [key, meshes] of byName) { + for (const mesh of meshes) { + const mat = mesh.material as THREE.MeshStandardMaterial; + const on = name === null || key === name; + mat.transparent = !on; + mat.opacity = on ? 1 : 0.18; + mat.emissive.setHex(name !== null && key === name ? 0x6b5a00 : 0x000000); + } + } + }; + return { group, byName, highlight }; +} diff --git a/tests/rebar3d.test.ts b/tests/rebar3d.test.ts new file mode 100644 index 0000000..dc51b1e --- /dev/null +++ b/tests/rebar3d.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; +import * as THREE from 'three'; +import { buildPier } from '../src/core/geometry'; +import { buildRebar3D } from '../src/view/rebar3d'; +import { loadP1 } from './helpers'; + +const rb = buildRebar3D(buildPier(loadP1())); + +describe('3D 철근 모델 (③)', () => { + it('every 철근표 family has bars, named as in the table', () => { + for (const n of ['C1', 'C4', 'C7', 'C9', 'C10', 'C11', 'C12', 'C13', 'P1', 'P2', 'P3', 'P4', 'P5', 'P6', 'B1', 'B4', 'B5', 'B6', 'B7', 'B8', 'S1', 'S2']) { + expect(rb.byName.get(n)?.length ?? 0, n).toBeGreaterThan(0); + } + }); + + it('P1 has one bar per 원주 등분 (46) and P2 likewise', () => { + expect(rb.byName.get('P1')!.length).toBe(46); + expect(rb.byName.get('P2')!.length).toBe(46); + }); + + it('highlight dims the other bars and keeps the chosen one', () => { + rb.highlight('P3'); + const p3 = (rb.byName.get('P3')![0].material as THREE.MeshStandardMaterial).opacity; + const c1 = (rb.byName.get('C1')![0].material as THREE.MeshStandardMaterial).opacity; + expect(p3).toBe(1); + expect(c1).toBeLessThan(1); + rb.highlight(null); + expect((rb.byName.get('C1')![0].material as THREE.MeshStandardMaterial).opacity).toBe(1); + }); +});