diff --git a/src/main.ts b/src/main.ts index 8873bff..5005068 100644 --- a/src/main.ts +++ b/src/main.ts @@ -79,7 +79,10 @@ const state = { /** ② 부재별 fck. A member without an entry uses the 공통 fck (③). */ fckByMember: {} as FckByMember, /** Bar highlighted on ③ after [보기] (시안 ③ 알림). */ - rebarFocus: null as string | null, + /** Bars selected on ③ (철근표 checkboxes, 3D click, [보기]). */ + rebarSelection: new Set(), + /** 선택만 보기: the bars not selected are hidden in 3D. */ + rebarIsolate: false, /** Colour basis of the 3D rebar on ③: role (default), diameter, or member. */ rebarColor: 'role' as RebarColorMode, /** Member tab on ③ (철근표). */ @@ -285,6 +288,7 @@ function renderPierRows(list: HTMLUListElement, mode: 'shape' | 'plan' | 'rebar' name.setAttribute('aria-pressed', String(pier.input.name === state.active)); name.addEventListener('click', () => { state.active = pier.input.name; + state.rebarSelection = new Set(); setupScene(); renderAll(); }); @@ -736,10 +740,10 @@ function wireRebarPicking(): void { 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]; + const hit = ray.intersectObjects(rebar3d.group.children, false).find((h) => h.object.visible); if (!hit) return; const name = hit.object.userData.name as string; - state.rebarFocus = name; + state.rebarSelection = new Set([name]); state.rebarMember = memberOfBar(name); renderAll(); }); @@ -818,6 +822,7 @@ function renderRebar(): void { btn.setAttribute('aria-pressed', String(m.id === state.rebarMember)); btn.addEventListener('click', () => { state.rebarMember = m.id; + state.rebarSelection = new Set(); renderAll(); }); li.appendChild(btn); @@ -835,23 +840,40 @@ function renderRebar(): void { const { rows, warnings } = rebarSummary(pier.model, state.material, state.fckByMember); $('rebar-note').textContent = `${pier.input.name} · 형상 기준 계산 · 값은 기본값 기준 문서 규칙`; const shown = rows.filter((r) => r.member === state.rebarMember); + box.appendChild(rebarSelectBar(shown.map((r) => r.name))); const table = el('table', undefined, 'grid-table rebar-table'); const head = el('thead'); const hr = el('tr'); - for (const h of ['구분', '번호', '규격', '강도', '간격', '실간격', '위치', '방향', '형상', '길이 (mm)', '개수', '근거', '비고']) hr.appendChild(el('th', h)); + for (const h of ['선택', '구분', '번호', '규격', '강도', '간격', '실간격', '위치', '방향', '형상', '길이 (mm)', '개수', '근거', '비고']) hr.appendChild(el('th', h)); head.appendChild(hr); table.appendChild(head); const body = el('tbody'); for (const r of shown) { const tr = el('tr'); const nameCell = el('td', r.name, 'bar-no'); - // Clicking a row (any cell) focuses that bar in the table and in 3D; clicking it again clears it (시안: 번호를 누르면 그 철근만 강조). + // Clicking a row selects that bar alone; clicking it again clears it (시안: 번호를 누르면 그 철근만 강조). tr.addEventListener('click', () => { - state.rebarFocus = state.rebarFocus === r.name ? null : r.name; + state.rebarSelection = state.rebarSelection.size === 1 && state.rebarSelection.has(r.name) ? new Set() : new Set([r.name]); renderAll(); }); - if (r.name === state.rebarFocus) tr.classList.add('hl'); + if (state.rebarSelection.has(r.name)) tr.classList.add('hl'); + const check = el('input'); + check.type = 'checkbox'; + check.checked = state.rebarSelection.has(r.name); + check.setAttribute('aria-label', `${r.name} 선택`); + // The checkbox changes the selection on its own; its click must not reach the row handler. + check.addEventListener('click', (ev) => ev.stopPropagation()); + check.addEventListener('change', () => { + const next = new Set(state.rebarSelection); + if (check.checked) next.add(r.name); + else next.delete(r.name); + state.rebarSelection = next; + renderAll(); + }); + const pick = el('td'); + pick.appendChild(check); tr.append( + pick, el('td', ROLE[r.name] ?? '—'), nameCell, el('td', r.size), @@ -883,7 +905,7 @@ function renderRebar(): void { } else { // [보기]: go to the member and highlight the bar the alert is about. state.rebarMember = w.target; - state.rebarFocus = w.bar ?? null; + state.rebarSelection = new Set(w.bar ? [w.bar] : []); renderAll(); } }); @@ -927,9 +949,31 @@ function renderRebar(): void { box.appendChild(st); } // Table and 3D model point at each other: the focused bar is highlighted in both. - rebar3d?.highlight(state.rebarFocus); + rebar3d?.show(state.rebarSelection.size ? state.rebarSelection : null, state.rebarIsolate); const focused = box.querySelector('tr.hl'); - if (focused && state.rebarFocus) focused.scrollIntoView({ block: 'center' }); + if (focused && state.rebarSelection.size) focused.scrollIntoView({ block: 'center' }); +} + +/** Above the 철근표: selection count, select-all / clear for the shown member, and 선택만 보기 (승인 2026-10-23). */ +function rebarSelectBar(names: string[]): HTMLDivElement { + const bar = el('div', undefined, 'rebar-select'); + const picked = names.filter((n) => state.rebarSelection.has(n)).length; + bar.appendChild(el('span', `선택 ${picked} / ${names.length}`, 'muted small')); + const all = el('button', picked === names.length && names.length > 0 ? '전체 해제' : '전체 선택', 'btn small'); + all.type = 'button'; + all.addEventListener('click', () => { + state.rebarSelection = picked === names.length ? new Set() : new Set(names); + renderAll(); + }); + const iso = el('button', '선택만 보기', 'btn small'); + iso.type = 'button'; + iso.setAttribute('aria-pressed', String(state.rebarIsolate)); + iso.addEventListener('click', () => { + state.rebarIsolate = !state.rebarIsolate; + renderAll(); + }); + bar.append(all, iso); + return bar; } /** 구분 (role) of each bar, as in the 시안 ③ table. */ diff --git a/src/style.css b/src/style.css index 97791b3..4c5bef8 100644 --- a/src/style.css +++ b/src/style.css @@ -707,3 +707,5 @@ body { background: #eef1f4; font-family: 'Noto Sans KR', system-ui, sans-serif; .rebar-legend .swatch { display: inline-block; width: 14px; height: 14px; border-radius: 3px; } .rebar-color[hidden] { display: none; } .viewer-head .rebar-color { margin: 0 auto; } +.rebar-select { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin: 6px 0; } +.rebar-select .btn { min-height: 44px; } diff --git a/src/view/rebar3d.ts b/src/view/rebar3d.ts index 2bea896..2b9b0d8 100644 --- a/src/view/rebar3d.ts +++ b/src/view/rebar3d.ts @@ -18,7 +18,8 @@ export interface RebarScene { group: THREE.Group; byName: Map; /** Highlight one bar name (or none): the others are dimmed. */ - highlight(name: string | null): void; + /** Emphasise the bars in `names` (all when null). The others are dimmed, or hidden when `isolate` is on (선택만 보기). */ + show(names: ReadonlySet | null, isolate: boolean): void; /** Colour the bars by role (default, spec §6.7), by nominal diameter, or by member. */ setColorMode(mode: RebarColorMode): void; /** Legend entries of the mode, only for the items this model has. Role mode has none (the role colours are in §6.7). */ @@ -436,20 +437,17 @@ export function buildRebar3D(m: PierModel, common: Material = DEFAULT_MATERIAL, } } - const highlight = (name: string | null) => { + 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('커플러')); for (const [key, meshes] of byName) { + const on = isOn(key); for (const mesh of meshes) { const mat = mesh.material as THREE.MeshStandardMaterial; - const on = - name === null || - key === name || - (name === '커플러' && key.includes('커플러')) || - (name === '주철근 커플러' && key === '주철근 커플러') || - (name === '띠철근 커플러' && key === '띠철근 커플러'); mat.transparent = !on; mat.opacity = on ? 1 : 0.18; - const match = name !== null && (key === name || (name === '커플러' && key.includes('커플러'))); - mat.emissive.setHex(match ? 0x6b5a00 : 0x000000); + mat.emissive.setHex(names !== null && on ? 0x6b5a00 : 0x000000); + mesh.visible = !isolate || on; } } }; @@ -476,5 +474,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, highlight, setColorMode, legend }; + return { group, byName, show, setColorMode, legend }; } diff --git a/tests/rebar3d.test.ts b/tests/rebar3d.test.ts index 178970d..d7c6c2b 100644 --- a/tests/rebar3d.test.ts +++ b/tests/rebar3d.test.ts @@ -74,15 +74,25 @@ describe('3D 철근 모델 (③)', () => { expect(rb.byName.get('띠철근 커플러')!.length).toBe(35); }); - it('highlight dims the other bars and keeps the chosen one', () => { - rb.highlight('P3'); + it('show dims the other bars and keeps the chosen one', () => { + rb.show(new Set(['P3']), false); 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); + rb.show(null, false); expect((rb.byName.get('C1')![0].material as THREE.MeshStandardMaterial).opacity).toBe(1); }); + + it('선택만 보기: with isolate on, only the selected names stay visible; several names can be selected', () => { + rb.show(new Set(['P3', 'C1']), true); + expect(rb.byName.get('P3')!.every((m) => m.visible)).toBe(true); + expect(rb.byName.get('C1')!.every((m) => m.visible)).toBe(true); + expect(rb.byName.get('C9')!.every((m) => !m.visible)).toBe(true); + rb.show(null, true); + expect(rb.byName.get('C9')!.every((m) => m.visible)).toBe(true); + rb.show(null, false); + }); }); describe('받침블럭 S1 · S2 in 3D (받침블럭철근.md §3–5)', () => {