From d7714f7ef229cd7dbd0b083152bb76dd976982f3 Mon Sep 17 00:00:00 2001 From: ypshim Date: Wed, 4 Nov 2026 15:56:57 +0900 Subject: [PATCH] =?UTF-8?q?feat(abut-rebar):=20=E2=91=A2=20=EA=B5=90?= =?UTF-8?q?=EB=8C=80=20=EB=B6=80=EC=9E=AC=20=EB=AA=A9=EB=A1=9D=20=EB=8B=A8?= =?UTF-8?q?=EC=88=9C=ED=99=94=20=C2=B7=20=EC=B2=A0=EA=B7=BC=ED=91=9C=20?= =?UTF-8?q?=EC=84=A0=ED=83=9D=20/=20=EC=A0=84=EC=B2=B4=20=EC=84=A0?= =?UTF-8?q?=ED=83=9D=20/=20=EC=84=A0=ED=83=9D=EB=A7=8C=20=EB=B3=B4?= =?UTF-8?q?=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 부재 목록: 교각처럼 이름 + 경고 수 배지만. 배근 타입 · 번호 · 다음 단계는 툴팁과 카드 설명 줄로 - 철근표: 선택 칸, 행 클릭 단독 선택, 전체 선택/해제, 선택만 보기 (교각과 같은 rebarSelection · rebarIsolate) - 삽도: 여러 철근 강조, 선택만 보기면 나머지 숨김. B10 풍선은 B10-1 · B10-2를 함께 선택 Co-Authored-By: Claude Opus 5.5 --- src/main.ts | 76 ++++++++++++++++++------------ src/view/abut-footing-rebar-svg.ts | 25 ++++++---- tests/abut-rebar-footing.test.ts | 3 +- 3 files changed, 63 insertions(+), 41 deletions(-) diff --git a/src/main.ts b/src/main.ts index 6e79a7f..b05f019 100644 --- a/src/main.ts +++ b/src/main.ts @@ -31,7 +31,7 @@ import { import { ABUT_FOOTING_REBAR_DEFAULT, abutFootingGeom, abutFootingRebar, ABUT_FOOTING_SIZES, type AbutFootingGeom, type AbutFootingRebar, type AbutFootingRebarSettings } from './core/abut/rebar/footing'; import { skewAngle } from './core/abut/rebar/skew-layout'; import { lapLength } from './core/abut/rebar/splice'; -import { ABUT_FOOTING_COLORS, abutFootingRebarPlanSvg, abutFootingSectionLSvg, abutFootingSectionWSvg } from './view/abut-footing-rebar-svg'; +import { ABUT_FOOTING_COLORS, abutFootingRebarPlanSvg, abutFootingSectionLSvg, abutFootingSectionWSvg, type AbutRebarPick } from './view/abut-footing-rebar-svg'; import { abutGroundIssues, supportTopEl, type AbutGround } from './core/abut/ground'; import { ABUT_SLAB_DEFAULT, resolveSlab, SLAB_FORM_LABEL, slabGradePct, slabSection, slabSlopeLabel, type SlabForm } from './core/abut/slab'; import { abutSlabPlanSvg, abutSlabSectionSvg } from './view/abut-slab-svg'; @@ -210,9 +210,8 @@ const state = { rebarColor: 'role' as RebarColorMode, /** Member tab on ③ (철근표). */ rebarMember: 'coping' as RebarRow['member'], - /** ③ 교대 (2026-11-04): 부재, 강조한 철근 번호, 보기 탭, 비고 펼치기. */ + /** ③ 교대 (2026-11-04): 부재, 보기 탭, 비고 펼치기. 선택한 철근은 교각과 같은 rebarSelection · rebarIsolate. */ abutRebarMember: 'foot' as AbutRebarMemberId, - abutRebarPick: null as string | null, abutRebarView: 'w' as 'w' | 'l' | 'plan', abutRebarNotes: false, /** Member card selected on ② (시안 ② 부재 목록). */ @@ -709,6 +708,7 @@ function abutRow(rec: AbutRecord, mode: Stage = 'shape'): HTMLLIElement { name.addEventListener('click', () => { state.abutActive = rec.name; state.abutEditOpen = false; + state.rebarSelection = new Set(); setStatus(abutSummary(rec)); // Rebuilt first: the abutment and the camera frame are in the new scene (visibility.ts in ②). setupScene(); @@ -3113,15 +3113,15 @@ function renderAbutRebar(rec: AbutRecord): void { tabs.replaceChildren(); for (const m of ABUT_REBAR_MEMBERS) { const li = el('li'); - const btn = el('button', undefined, 'member'); + // 교각 부재 목록처럼 이름과 경고 수만 (2026-11-04). 배근 타입 · 번호와 "다음 단계"는 카드 제목 줄에 있다. + const btn = el('button', m.label, 'member'); btn.type = 'button'; + btn.title = `Type-1 · ${m.marks}${m.ready ? '' : ' · 다음 단계'}`; btn.setAttribute('aria-pressed', String(m.id === state.abutRebarMember)); - btn.append(el('span', m.label), el('span', ` Type-1 · ${m.marks}`, 'muted small')); - if (!m.ready) btn.append(el('span', '다음 단계', 'badge')); - else if (warn > 0) btn.append(el('span', String(warn), 'badge warn')); + if (m.ready && warn > 0) btn.append(el('span', String(warn), 'badge warn')); btn.addEventListener('click', () => { state.abutRebarMember = m.id; - state.abutRebarPick = null; + state.rebarSelection = new Set(); renderAll(); }); li.appendChild(btn); @@ -3134,7 +3134,7 @@ function renderAbutRebar(rec: AbutRecord): void { const member = ABUT_REBAR_MEMBERS.find((m) => m.id === state.abutRebarMember)!; $('rebar-title').textContent = `${member.label} 배근`; if (!member.ready) { - $('rebar-note').textContent = `${rec.name} · ${member.label} (${member.marks})`; + $('rebar-note').textContent = `${rec.name} · ${member.label} · Type-1 · ${member.marks} · 다음 단계`; box.append(el('p', `${member.label} 배근은 다음 단계에서 추가합니다 (철근 기획서 확정, plan_abut AD4–AD8).`, 'muted')); } else if (!built) { $('rebar-note').textContent = `${rec.name} · 교대 모델을 만들 수 없어 배근하지 않습니다.`; @@ -3157,7 +3157,9 @@ function renderAbutRebar(rec: AbutRecord): void { function fillAbutFootingRebarCard(box: HTMLDivElement, rec: AbutRecord, built: { geom: AbutFootingGeom; result: AbutFootingRebar; skewK: number }): void { const { geom, result: r, skewK } = built; const s = r.settings; - const pick = state.abutRebarPick; + const pick: AbutRebarPick = { marks: state.rebarSelection, only: state.rebarIsolate }; + // The B10 풍선 and 철근표 rows stand for B10-1 and B10-2 together. + const marksOf = (mark: string) => (mark === 'B10' ? r.bars.filter((b) => b.mark.startsWith('B10')).map((b) => b.mark) : [mark]); const set = (patch: Partial) => { try { applyAbutRecord(applyAbutFootingRebar(rec, patch)); @@ -3166,8 +3168,11 @@ function fillAbutFootingRebarCard(box: HTMLDivElement, rec: AbutRecord, built: { renderAll(); } }; + // A 번호 풍선 or row click selects that bar alone; clicking it again clears it (교각 철근표와 같음). const togglePick = (mark: string) => { - state.abutRebarPick = state.abutRebarPick === mark ? null : mark; + const marks = marksOf(mark); + const alone = state.rebarSelection.size === marks.length && marks.every((m) => state.rebarSelection.has(m)); + state.rebarSelection = alone ? new Set() : new Set(marks); renderAll(); }; // 보기 탭 + 전체 보기 + 강조 상태. @@ -3183,22 +3188,14 @@ function fillAbutFootingRebarCard(box: HTMLDivElement, rec: AbutRecord, built: { }); head.append(b); } - const all = el('button', '전체 보기', 'btn small'); - all.type = 'button'; - all.disabled = pick === null; - all.addEventListener('click', () => { - state.abutRebarPick = null; - renderAll(); - }); - head.append(all); box.append(head); - const picked = pick ? r.bars.filter((b) => b.mark === pick || (pick === 'B10' && b.mark.startsWith('B10'))) : []; + const picked = r.bars.filter((b) => state.rebarSelection.has(b.mark)); box.append( el( 'p', - pick === null - ? '전체 표시 · 표의 번호나 도면의 번호 풍선을 누르면 그 철근만 강조합니다' - : `강조 중 · ${picked.map((b) => b.mark).join(' · ')}${picked[0] ? ` — ${picked[0].position}` : ''} · 같은 번호를 다시 누르거나 [전체 보기]로 해제`, + picked.length === 0 + ? '전체 표시 · 표의 행이나 도면의 번호 풍선을 누르면 그 철근만 강조합니다' + : `강조 중 · ${picked.map((b) => b.mark).join(' · ')}${picked.length === 1 ? ` — ${picked[0].position}` : ''}${state.rebarIsolate ? ' · 선택만 보기' : ''} · 철근표의 [전체 해제]로 해제`, 'muted small', ), ); @@ -3312,9 +3309,10 @@ function fillAbutFootingRebarCard(box: HTMLDivElement, rec: AbutRecord, built: { }); bar.append(notes); box.append(bar); + box.append(rebarSelectBar(r.bars.map((b) => b.mark))); const table = el('table', undefined, 'grid-table rebar-table'); const hr = table.createTHead().insertRow(); - for (const h of ['구분', '번호', '규격', '간격', '실간격', '형상', '길이 (mm)', '개수', '위치']) hr.append(el('th', h)); + for (const h of ['선택', '구분', '번호', '규격', '간격', '실간격', '형상', '길이 (mm)', '개수', '위치']) hr.append(el('th', h)); const body = table.createTBody(); const roleColor: Record = { 주철근: C.main, 보조: C.dist, '전단·간격재': C.shear, 측면: C.side }; // 철근별 간격 수정 (RB-AF10): the first row of each linked group takes the 실간격; B9 goes by the 배수 above. @@ -3324,14 +3322,28 @@ function fillAbutFootingRebarCard(box: HTMLDivElement, rec: AbutRecord, built: { 'B10-1': { key: 'sSide', base: s.sV }, }; for (const b of r.bars) { - const on = pick !== null && (b.mark === pick || (pick === 'B10' && b.mark.startsWith('B10'))); + const on = state.rebarSelection.has(b.mark); const tr = body.insertRow(); if (on) tr.classList.add('hl'); + tr.addEventListener('click', () => togglePick(b.mark)); + const check = el('input'); + check.type = 'checkbox'; + check.checked = on; + check.setAttribute('aria-label', `${b.mark} 선택`); + // 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(b.mark); + else next.delete(b.mark); + state.rebarSelection = next; + renderAll(); + }); + const checkCell = el('td'); + checkCell.append(check); const role = el('td'); role.innerHTML = ` ${b.role}`; const markCell = el('td', b.mark, 'bar-no'); - markCell.style.cursor = 'pointer'; - markCell.addEventListener('click', () => togglePick(b.mark.startsWith('B10') ? 'B10' : b.mark)); const actual = el('td'); const ov = overrideOf[b.mark]; if (ov) { @@ -3341,23 +3353,27 @@ function fillAbutFootingRebarCard(box: HTMLDivElement, rec: AbutRecord, built: { input.value = b.actual; input.style.width = '70px'; input.setAttribute('aria-label', `${b.mark} 묶음 실간격`); + input.addEventListener('click', (ev) => ev.stopPropagation()); input.addEventListener('change', () => set({ [ov.key]: Number(input.value) === ov.base ? undefined : Number(input.value) })); actual.append(input); if (b.modified) { actual.append(el('span', ' 수정됨', 'muted small abut-note-warn')); const back = el('button', '되돌리기', 'btn small'); back.type = 'button'; - back.addEventListener('click', () => set({ [ov.key]: undefined })); + back.addEventListener('click', (ev) => { + ev.stopPropagation(); + set({ [ov.key]: undefined }); + }); actual.append(back); } } else { actual.textContent = b.actual + (b.modified && b.mark !== 'B9' ? ' · 수정됨' : ''); } - tr.append(role, markCell, el('td', b.size), el('td', b.spacing), actual, el('td', b.shape), el('td', String(b.length)), el('td', String(b.count)), el('td', b.position)); + tr.append(checkCell, role, markCell, el('td', b.size), el('td', b.spacing), actual, el('td', b.shape), el('td', String(b.length)), el('td', String(b.count)), el('td', b.position)); if (state.abutRebarNotes || on) { const nr = body.insertRow(); const cell = el('td', `${b.mark} 비고 · ${b.note}`, 'muted small'); - cell.colSpan = 9; + cell.colSpan = 10; nr.append(cell); } } diff --git a/src/view/abut-footing-rebar-svg.ts b/src/view/abut-footing-rebar-svg.ts index 7cb80d3..a31e699 100644 --- a/src/view/abut-footing-rebar-svg.ts +++ b/src/view/abut-footing-rebar-svg.ts @@ -1,6 +1,6 @@ /** * 교대 저판 배근 삽도 (2026-11-04, 시안 ③ 확대기초 카드, 교대확대기초철근.md §10): 교대축 직각 단면 (폭 W), 교대축 단면 (길이 L), - * 상면 평면. 순수 문자열 함수. 번호 풍선(data-pick)을 누르면 그 철근만 강조한다; 강조할 번호는 `pick`으로 받는다. + * 상면 평면. 순수 문자열 함수. 번호 풍선(data-pick)을 누르면 그 철근만 강조한다; 선택한 번호는 `pick`(철근표 번호 집합)으로, 선택만 보기는 `only`로 받는다. * 색은 역할 기준: 주철근 B1–B4 #0F5C66, 보조 B5–B8 #B4531F, 측면 B10 #2E7D32 점선, 전단 B9 #6B3FA0. */ import type { AbutFootingGeom, AbutFootingRebar } from '../core/abut/rebar/footing'; @@ -14,21 +14,26 @@ const WALL = '#d9dee4'; const text = (x: number, y: number, s: string, anchor = 'middle', fill = INK, size = 11) => `${s}`; -/** Opacity of one bar group: dimmed when another bar is picked. B10 picks cover B10-1 and B10-2. */ -function opacity(pick: string | null, marks: string[]): number { - if (!pick) return 1; - return marks.some((m) => m === pick || (pick === 'B10' && m.startsWith('B10'))) ? 1 : 0.15; +/** Selected bars (철근표 번호) and 선택만 보기. An empty selection shows every bar. */ +export type AbutRebarPick = { marks: ReadonlySet; only: boolean }; +const NO_PICK: AbutRebarPick = { marks: new Set(), only: false }; + +/** Opacity of one bar group: dimmed (or hidden with 선택만 보기) when other bars are selected. */ +function opacity(pick: AbutRebarPick, marks: string[]): number { + if (pick.marks.size === 0) return 1; + return marks.some((m) => pick.marks.has(m)) ? 1 : pick.only ? 0 : 0.15; } /** A clickable 번호 풍선. */ -function balloon(x: number, y: number, mark: string, color: string, pick: string | null): string { - const on = pick === mark; +function balloon(x: number, y: number, mark: string, color: string, pick: AbutRebarPick): string { + // The B10 풍선 stands for B10-1 and B10-2. + const on = [...pick.marks].some((m) => m === mark || (mark === 'B10' && m.startsWith('B10'))); const r = mark.length > 3 ? 15 : 12; return `${text(x, y + 4, mark, 'middle', on ? '#fff' : color, 10)}`; } /** 교대축 직각 단면 (폭 W): 왼쪽 배면(뒷굽) → 오른쪽 전면(앞굽). B1–B4 선, B5–B8 점, B10-1 점, B9 연직선. */ -export function abutFootingSectionWSvg(g: AbutFootingGeom, r: AbutFootingRebar, pick: string | null = null): string { +export function abutFootingSectionWSvg(g: AbutFootingGeom, r: AbutFootingRebar, pick: AbutRebarPick = NO_PICK): string { const k = 0.12; const x0 = 60; const y0 = 46; @@ -82,7 +87,7 @@ export function abutFootingSectionWSvg(g: AbutFootingGeom, r: AbutFootingRebar, } /** 교대축 단면 (길이 L, 가로 축척 0.4배): B5–B8 선(이음 위치), B1–B4 점, B10-2 점. 왼쪽 IP3 쪽. */ -export function abutFootingSectionLSvg(g: AbutFootingGeom, r: AbutFootingRebar, pick: string | null = null): string { +export function abutFootingSectionLSvg(g: AbutFootingGeom, r: AbutFootingRebar, pick: AbutRebarPick = NO_PICK): string { const kx = 0.045; const kz = 0.12; const x0 = 50; @@ -132,7 +137,7 @@ export function abutFootingSectionLSvg(g: AbutFootingGeom, r: AbutFootingRebar, * 상면 평면: skew 평행사변형, 상면 B1·B3 (옵션에 따라 skew 선과 나란히 / 교대축 직각, 옵션 ② B1-S), B5·B7, B9, 벽체 밑 띠. * `skewK` = 날개벽 방향의 s / t 비 (장면의 abutSkewK): s(x) = s + skewK · x. 위 = 전면, 왼쪽 = IP3 끝 (s = L). */ -export function abutFootingRebarPlanSvg(g: AbutFootingGeom, r: AbutFootingRebar, skewK: number, pick: string | null = null): string { +export function abutFootingRebarPlanSvg(g: AbutFootingGeom, r: AbutFootingRebar, skewK: number, pick: AbutRebarPick = NO_PICK): string { const k = 0.045; const shiftMax = Math.abs(skewK) * g.W; const sMin = Math.min(0, skewK * g.W); diff --git a/tests/abut-rebar-footing.test.ts b/tests/abut-rebar-footing.test.ts index ea35a39..805fb87 100644 --- a/tests/abut-rebar-footing.test.ts +++ b/tests/abut-rebar-footing.test.ts @@ -207,7 +207,8 @@ describe('③ 저판 배근 입력 저장 · 삽도', () => { for (const s of ['100 · 175 · 27@150=4.050 · 175 · 100', '100 · 175 · 5@150=750 · 2.400 · 6@150=900 · 175 · 100', '150 · 3@200=600']) expect(w).toContain(s); for (const m of ['B1', 'B2', 'B3', 'B4', 'B5', 'B6', 'B7', 'B8', 'B9', 'B10']) expect(w).toContain(`data-pick="${m}"`); expect(w).not.toContain('opacity="0.15"'); - expect(abutFootingSectionWSvg(geom, A1, 'B5')).toContain('opacity="0.15"'); + expect(abutFootingSectionWSvg(geom, A1, { marks: new Set(['B5']), only: false })).toContain('opacity="0.15"'); + expect(abutFootingSectionWSvg(geom, A1, { marks: new Set(['B5']), only: true })).toContain('opacity="0"'); expect(abutFootingSectionLSvg(geom, A1)).toContain('100.4 · 185.8 · 93@125=11.625 · 185.8 · 100.4'); expect(abutFootingSectionLSvg(geom, A1)).toContain('B5·B6 이음 l_s 955'); const opt2 = abutFootingRebar(geom, { dir: 'perp' });