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:
1 parent
b3533b69df
commit
f398b4341f
4 files changed
+78
-24
No files matched your search
+54
-10
@@ -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. */
|
||||
|
||||
@@ -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
@@ -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
@@ -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)', () => {
|
||||
|
||||
Reference in new issue
Block a user