feat(rebar): select several bars on ③ and show only the selected ones (선택만 보기)

The 철근표 has a selection checkbox per row. Select-all / clear and 선택만 보기 sit above the table. In 선택만 보기 the other
bars are hidden in 3D and the members stay at 50% for context. A 3D click or an alert [보기] selects one bar; the
selection clears when the member tab or the pier changes. The selection is screen state and is not saved to the project.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
ypshimandClaude Haiku 5.5 committed 2026-10-23 09:57:04 +09:00
1 parent b3533b69df
commit f398b4341f
4 files changed
+78 -24

No files matched your search

+54 -10
View File
@@ -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<string>(),
/** 선택만 보기: 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. */
+2
View File
@@ -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; }
+9 -11
View File
@@ -18,7 +18,8 @@ export interface RebarScene {
group: THREE.Group;
byName: Map<string, THREE.Mesh[]>;
/** 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<string> | 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<string> | 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 };
}
+13 -3
View File
@@ -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)', () => {