From b06bf368b646c6a9b114362cfdc63acd25f98b42 Mon Sep 17 00:00:00 2001 From: ypshim Date: Wed, 4 Nov 2026 11:12:43 +0900 Subject: [PATCH] =?UTF-8?q?feat(abut-rebar):=20=E2=91=A2=20=EA=B5=90?= =?UTF-8?q?=EB=8C=80=20=ED=99=94=EB=A9=B4=20=ED=8B=80=EA=B3=BC=20=ED=99=95?= =?UTF-8?q?=EB=8C=80=EA=B8=B0=EC=B4=88=20=EB=B0=B0=EA=B7=BC=20=EC=B9=B4?= =?UTF-8?q?=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ③에서 교대를 고르면 부재 · 교대 목록(받침블럭 → 벽체 → 흉벽·브라켓 → 확대기초 → 날개벽 → 접속슬래브)과 카드를 보여 줌. 확대기초만 규칙이 있고 나머지는 '다음 단계'. 선택한 교대만 3D에 그림 - 확대기초 카드: 보기 탭 3개(교대축 직각 단면 · 교대축 단면 · 상면 평면)와 번호 풍선 강조, 범례, 기본간격 · 배근 방향 ① / ② · 피복 · 여유 · B9 배수 입력, 자동 값(cycle 간격 · B10 위 여유 · l_s · 옆붙임), 철근표(묶음별 실간격 수정 · 수정됨 · 되돌리기, 비고 펼치기), 근거 줄, RB-AF 알림 - 입력은 AbutRecord.rebar.footing에 바꾼 값만 저장 · 프로젝트 파일 검증. fck는 공통 설정의 기초부 값 Co-Authored-By: Claude Haiku 5.5 --- src/core/abut/edit.ts | 14 ++ src/core/abut/parse.ts | 3 + src/core/abut/visibility.ts | 4 +- src/core/project.ts | 7 + src/main.ts | 328 ++++++++++++++++++++++++++++- src/style.css | 13 ++ src/view/abut-footing-rebar-svg.ts | 192 +++++++++++++++++ tests/abut-rebar-footing.test.ts | 32 +++ tests/abut-visibility.test.ts | 8 +- 9 files changed, 591 insertions(+), 10 deletions(-) create mode 100644 src/view/abut-footing-rebar-svg.ts diff --git a/src/core/abut/edit.ts b/src/core/abut/edit.ts index e661427..342acfb 100644 --- a/src/core/abut/edit.ts +++ b/src/core/abut/edit.ts @@ -8,6 +8,7 @@ import { WING_DIMS_DEFAULT, wingDimsIssues, type WingDims } from './wings'; import { ABUT_SHAPE_DEFAULT, normalizeShape, shapeIssues, type AbutShape } from './shape'; import { resolveSlab, slabIssues, type AbutSlab, type SlabForm } from './slab'; import type { AbutGround } from './ground'; +import { footingRebarIssues, resolveFootingRebar, type AbutFootingRebarSettings } from './rebar/footing'; import { abutBearingRuleIssues, resolveBearingRule, type AbutBearingRule } from './bearing-table'; export type AbutEditKey = 'seatEl' | 'foundationBottomEl' | 'groundEl' | 'pileLengthMm' | 'skewDeg' | 'slopeI'; @@ -164,6 +165,19 @@ export function setAbutGround(rec: AbutRecord, patch: Partial): Abut return { ...rec, ground }; } +/** + * ③ 저판 배근 입력 (2026-11-04): merges the values; undefined removes one (a 철근별 간격 goes back to the 기본간격). + * Refused with a Korean message when the settings would not lay out. + */ +export function applyFootingRebar(rec: AbutRecord, patch: Partial): AbutRecord { + for (const v of Object.values(patch)) if (typeof v === 'number' && !Number.isFinite(v)) throw new Error('숫자를 입력하세요.'); + const footing: Partial = { ...(rec.rebar?.footing ?? {}), ...patch }; + for (const key of Object.keys(footing) as (keyof AbutFootingRebarSettings)[]) if (footing[key] === undefined) delete footing[key]; + const issues = footingRebarIssues(resolveFootingRebar(footing)); + if (issues.length > 0) throw new Error(issues[0]); + return { ...rec, rebar: { ...(rec.rebar ?? {}), footing } }; +} + /** Changes one 접속슬래브 dim, refused with a Korean message when the slab would not build. */ export function applySlabEdit(rec: AbutRecord, key: (typeof SLAB_EDIT_FIELDS)[number]['key'], value: number): AbutRecord { if (!Number.isFinite(value)) throw new Error('숫자를 입력하세요.'); diff --git a/src/core/abut/parse.ts b/src/core/abut/parse.ts index 093f595..dcc4862 100644 --- a/src/core/abut/parse.ts +++ b/src/core/abut/parse.ts @@ -15,6 +15,7 @@ import type { AbutBearingRule } from './bearing-table'; import type { WingDims } from './wings'; import type { AbutSlab } from './slab'; import type { AbutGround } from './ground'; +import type { AbutFootingRebarSettings } from './rebar/footing'; type Cell = string | number | null | undefined; type Grid = Cell[][]; @@ -65,6 +66,8 @@ export interface AbutRecord { slab?: Partial; /** 지반·지층 입력 (② 지반·지층 카드, 교각과 같은 필드). Absent = 육상, 지하수위 없음, 지층 행 없음. */ ground?: AbutGround; + /** ③ 배근 입력 (부재별, 바꾼 값만). Absent = 엑셀 Abut 시트 기본값. */ + rebar?: { footing?: Partial }; /** 예전 "좌우 같게" 값 (2026-11-04 이전 파일). 지금은 읽기만 하고 쓰지 않는다. */ wingsLinked?: boolean; } diff --git a/src/core/abut/visibility.ts b/src/core/abut/visibility.ts index b96c15f..3e8e0f7 100644 --- a/src/core/abut/visibility.ts +++ b/src/core/abut/visibility.ts @@ -2,15 +2,17 @@ * What the 3D view draws for the selection (2026-11-03, 형상계획 ②). * In ② only the selected object is drawn: a selected abutment hides the piers, and a selected pier hides the abutments. * In ① (형상 생성) the abutments are always drawn, as before; the piers follow 전체 보기 in ①, not this rule. + * In ③ (2026-11-04, 교대 배근) a selected abutment is drawn alone too; with a pier selected, ③ draws as before. */ /** True when the pier scene (and its rebar) should be built for this stage and selection. */ export function piersShown(stage: string, abutActive: string | null): boolean { - return !(stage === 'plan' && abutActive !== null); + return !((stage === 'plan' || stage === 'rebar') && abutActive !== null); } /** True when the abutment with this name should be drawn. */ export function abutShown(stage: string, abutActive: string | null, name: string): boolean { + if (stage === 'rebar') return abutActive === null || name === abutActive; if (stage !== 'plan') return true; return name === abutActive; } diff --git a/src/core/project.ts b/src/core/project.ts index 0be8dc5..450413e 100644 --- a/src/core/project.ts +++ b/src/core/project.ts @@ -162,6 +162,13 @@ export function checkStoredAbutments(list: unknown): void { if (!ok) throw new Error(`${who}: 지반 값 ${key}이(가) 맞지 않습니다.`); } } + if (a.rebar !== undefined) { + for (const [key, v] of Object.entries(a.rebar.footing ?? {})) { + const x: unknown = v; + const ok = key === 'dir' ? x === 'skew' || x === 'perp' : finite(x); + if (!ok) throw new Error(`${who}: 저판 배근 값 ${key}이(가) 맞지 않습니다.`); + } + } if (a.wingsLinked !== undefined && typeof a.wingsLinked !== 'boolean') throw new Error(`${who}: 좌우 같게 값이 맞지 않습니다.`); if (a.wings !== undefined) { const forms = ['general', 'short', 'none']; diff --git a/src/main.ts b/src/main.ts index a65296d..c300711 100644 --- a/src/main.ts +++ b/src/main.ts @@ -26,7 +26,12 @@ import { setSlabForm, SLAB_EDIT_FIELDS, setAbutGround, + applyFootingRebar as applyAbutFootingRebar, } from './core/abut/edit'; +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 { 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'; @@ -65,7 +70,7 @@ import { pileGeometry } from './core/geometry'; import { rebarSummary, type RebarRow } from './core/rebar/summary'; import { shearSegments } from './core/rebar/coping'; import { buildRebar3D, type RebarColorMode, type RebarScene } from './view/rebar3d'; -import { DEFAULT_MATERIAL, FCK_MEMBERS, fckChanged, fyOf, GRADE_CHOICES, IMPORTED_FCK, isAssumedMaterial, materialFrom, type BendFactors, type FckByMember, type FckMember, type GradeKey, type Material } from './core/rebar/common'; +import { DEFAULT_MATERIAL, FCK_MEMBERS, fckChanged, fyOf, GRADE_CHOICES, IMPORTED_FCK, isAssumedMaterial, materialFor, materialFrom, type BendFactors, type FckByMember, type FckMember, type GradeKey, type Material } from './core/rebar/common'; import { buildQuantity, quantityRows, type Quantity, type QuantityLine } from './core/rebar/quantity'; import { groundElFor, parseGroundModel, strataRowsFor, type GroundMapLayer, type GroundModelFile } from './core/ground-model'; import { CORROSION_ALLOWANCE_MM, GROUND_POSITIONS, parseNValue, SEA_DATUM, STRATA_NAMES } from './core/ground'; @@ -205,6 +210,11 @@ const state = { rebarColor: 'role' as RebarColorMode, /** Member tab on ③ (철근표). */ rebarMember: 'coping' as RebarRow['member'], + /** ③ 교대 (2026-11-04): 부재, 강조한 철근 번호, 보기 탭, 비고 펼치기. */ + abutRebarMember: 'foot' as AbutRebarMemberId, + abutRebarPick: null as string | null, + abutRebarView: 'w' as 'w' | 'l' | 'plan', + abutRebarNotes: false, /** Member card selected on ② (시안 ② 부재 목록). */ member: 'coping' as MemberId, /** True once the project has a file on disk (first save via 시안 ⑤, or an opened file). Later saves go straight to it. */ @@ -1563,6 +1573,13 @@ function applyAbutMemberCard(rec: AbutRecord): void { $('abut-panel-plan-msg').textContent = ABUT_MEMBER_EDITABLE[member] ? '' : `${rec.name}: 이 부재의 입력은 2단계에서 추가합니다. 현재 값만 보여 줍니다.`; } +/** Replaces one abutment record, rebuilds the 3D view and redraws (② cards and ③ 교대 배근). */ +function applyAbutRecord(next: AbutRecord): void { + state.abutments = state.abutments.map((a) => (a.name === next.name ? next : a)); + setupScene(); + renderAll(); +} + /** Fills one 교대 입력 panel: the basic items, the 받침 values and the 형상 values. Each change is checked and applied. */ function fillAbutSlot(slot: (typeof ABUT_SLOTS)[keyof typeof ABUT_SLOTS], rec: AbutRecord): void { $(slot.name).textContent = rec.name; @@ -1600,11 +1617,7 @@ function fillAbutSlot(slot: (typeof ABUT_SLOTS)[keyof typeof ABUT_SLOTS], rec: A bearings.replaceChildren(); shape.replaceChildren(); msg.textContent = ''; - const apply = (next: AbutRecord) => { - state.abutments = state.abutments.map((a) => (a.name === next.name ? next : a)); - setupScene(); - renderAll(); - }; + const apply = (next: AbutRecord) => applyAbutRecord(next); const fail = (err: unknown, input: HTMLInputElement, back: string) => { msg.textContent = (err as Error).message; input.value = back; @@ -1860,7 +1873,7 @@ function renderPierRows(list: HTMLUListElement, mode: Stage): void { list.replaceChildren(); const visible = state.piers.filter((p) => state.filter === 'all' || p.input.type === state.filter); // 교대 rows (2026-11-03, AB): A1 first, A2 last, shown in ① with the piers when the filter is 전체. - const abuts = (mode === 'shape' || mode === 'plan') && state.filter === 'all' ? structureOrder(state.abutments, visible.map((p) => p.input.name)) : []; + const abuts = (mode === 'shape' || mode === 'plan' || mode === 'rebar') && state.filter === 'all' ? structureOrder(state.abutments, visible.map((p) => p.input.name)) : []; const abutRefs = abuts.filter((r) => r.kind === 'abut'); for (const ref of abutRefs.filter((r) => r.type === 'A1')) { const rec = state.abutments.find((a) => a.name === ref.name); @@ -3053,10 +3066,311 @@ function renderCopingDrawings(box: HTMLDivElement, pier: Pier): void { box.appendChild(wrap); } +/** ③ 교대 부재 (시안 순서, plan_abut AD9). Only the 확대기초 has rules today (AD3); the rest come next. */ +type AbutRebarMemberId = 'blk' | 'wall' | 'para' | 'foot' | 'wing' | 'slab'; +const ABUT_REBAR_MEMBERS: { id: AbutRebarMemberId; label: string; marks: string; ready: boolean }[] = [ + { id: 'blk', label: '받침블럭', marks: 'S1·S2', ready: false }, + { id: 'wall', label: '벽체', marks: 'W1–W11', ready: false }, + { id: 'para', label: '흉벽·브라켓', marks: 'P1–P11', ready: false }, + { id: 'foot', label: '확대기초', marks: 'B1–B10', ready: true }, + { id: 'wing', label: '날개벽', marks: 'WR1–WR20', ready: false }, + { id: 'slab', label: '접속슬래브', marks: 'R1–R8', ready: false }, +]; + +/** The 저판 rebar of an abutment (null when the abutment does not build). */ +function abutFootingBuilt(rec: AbutRecord): { geom: AbutFootingGeom; result: AbutFootingRebar; skewK: number } | null { + try { + const model = buildAbut(toAbutInput(rec)); + const members = buildMembers(model, { bottomEl: rec.foundationBottomEl, pileLengthMm: rec.pileLengthMm }, rec.shape); + const geom = abutFootingGeom(model.length, members.shape, skewAngle(rec.skewDeg)); + const result = abutFootingRebar(geom, rec.rebar?.footing, materialFor(state.material, state.fckByMember, 'footing'), rec.name); + return { geom, result, skewK: abutSkewK(model) }; + } catch { + return null; + } +} + +/** ③ with an abutment selected (2026-11-04): 부재 · 교대 list, the member card, and the RB-AF alerts. */ +function renderAbutRebar(rec: AbutRecord): void { + const built = abutFootingBuilt(rec); + const warn = built ? built.result.issues.filter((i) => i.level !== 'I').length : 0; + const tabs = $('rebar-tabs'); + tabs.replaceChildren(); + for (const m of ABUT_REBAR_MEMBERS) { + const li = el('li'); + const btn = el('button', undefined, 'member'); + btn.type = 'button'; + 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')); + btn.addEventListener('click', () => { + state.abutRebarMember = m.id; + state.abutRebarPick = null; + renderAll(); + }); + li.appendChild(btn); + tabs.appendChild(li); + } + const box = $('rebar-table'); + box.replaceChildren(); + const issues = $('rebar-issues'); + issues.replaceChildren(); + 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})`; + box.append(el('p', `${member.label} 배근은 다음 단계에서 추가합니다 (철근 기획서 확정, plan_abut AD4–AD8).`, 'muted')); + } else if (!built) { + $('rebar-note').textContent = `${rec.name} · 교대 모델을 만들 수 없어 배근하지 않습니다.`; + } else { + const g = built.geom; + const toe = Math.round(g.W - g.xf); + $('rebar-note').textContent = `${rec.name} · Type-1 · ${Math.round(g.L)} × ${Math.round(g.W)} × ${Math.round(g.H)} · ${g.plan === 'skew' ? 'skew 평행사변형' : '직사각'} · 앞굽 ${toe} / 벽체 ${Math.round(g.xf - g.xb)} / 뒷굽 ${Math.round(g.xb)}`; + fillAbutFootingRebarCard(box, rec, built); + } + for (const i of built?.result.issues ?? []) { + const li = el('li', `${i.code} ${i.message}`); + if (i.level === 'W' || i.level === 'E') li.classList.add('warn'); + issues.appendChild(li); + } + if (issues.childElementCount === 0) issues.appendChild(el('li', '알림이 없습니다.')); + $('step3-status').textContent = `${rec.name} 확대기초 계산됨 · 경고 ${warn}`; +} + +/** ③ 교대 확대기초 카드 (시안, 교대확대기초철근.md §10): 보기 탭 · 삽도 → 범례 → 기본간격 · 배근 방향 · 입력 → 자동 값 → 철근표 → 근거. */ +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 set = (patch: Partial) => { + try { + applyAbutRecord(applyAbutFootingRebar(rec, patch)); + } catch (err) { + setError((err as Error).message); + renderAll(); + } + }; + const togglePick = (mark: string) => { + state.abutRebarPick = state.abutRebarPick === mark ? null : mark; + renderAll(); + }; + // 보기 탭 + 전체 보기 + 강조 상태. + const head = el('div', undefined, 'abut-rebar-head'); + const views: ['w' | 'l' | 'plan', string][] = [['w', '교대축 직각 단면 (폭 W)'], ['l', '교대축 단면 (길이 L)'], ['plan', '상면 평면']]; + for (const [id, label] of views) { + const b = el('button', label, `abut-optt${state.abutRebarView === id ? ' on' : ''}`); + b.type = 'button'; + b.setAttribute('aria-pressed', String(state.abutRebarView === id)); + b.addEventListener('click', () => { + state.abutRebarView = id; + renderAll(); + }); + 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'))) : []; + box.append( + el( + 'p', + pick === null + ? '전체 표시 · 표의 번호나 도면의 번호 풍선을 누르면 그 철근만 강조합니다' + : `강조 중 · ${picked.map((b) => b.mark).join(' · ')}${picked[0] ? ` — ${picked[0].position}` : ''} · 같은 번호를 다시 누르거나 [전체 보기]로 해제`, + 'muted small', + ), + ); + const fig = el('figure', undefined, 'ground-fig abut-rebar-fig'); + fig.innerHTML = + state.abutRebarView === 'w' ? abutFootingSectionWSvg(geom, r, pick) : state.abutRebarView === 'l' ? abutFootingSectionLSvg(geom, r, pick) : abutFootingRebarPlanSvg(geom, r, skewK, pick); + fig.querySelectorAll('[data-pick]').forEach((g) => { + const mark = g.getAttribute('data-pick')!; + g.addEventListener('click', () => togglePick(mark)); + g.addEventListener('keydown', (ev) => { + if (ev.key === 'Enter' || ev.key === ' ') { + ev.preventDefault(); + togglePick(mark); + } + }); + }); + const caption = + state.abutRebarView === 'w' + ? '교대축 직각 단면 · 폭 W (왼쪽 배면 · 뒷굽 → 오른쪽 전면 · 앞굽) · 선 = B1–B4, 점 = B5–B8 · B10' + : state.abutRebarView === 'l' + ? `교대축 단면 · 길이 L (가로 축척 0.4배) · B1·B3 / B2·B4 같은 높이에 번갈아 (실간격 ${s.sMain ?? s.sL}) · B1은 B2 옆 +${r.sideShift.B1.toFixed(2)}` + : `상면 평면 · 배근 방향 ${r.dir === 'skew' ? '① skew 방향 (날개벽 선과 나란히)' : '② 교대축 직각 (가운데 격자 + 끝 skew 철근 B1-S)'} · 위 = 전면, 왼쪽 IP3 → 오른쪽 IP1`; + fig.append(el('figcaption', caption, 'muted small')); + box.append(fig); + const C = ABUT_FOOTING_COLORS; + const legend = el('p', undefined, 'muted small abut-rebar-legend'); + legend.innerHTML = `━ 주철근 폭 방향 (B1–B4${r.skewEnd ? ' · B1-S·B2-S' : ''}) · ━ 배력 교대축 방향 (B5–B8) · ━ 전단 B9 · ┅ 측면 B10 · 색은 역할 기준 (교대는 폭 방향 B1–B4가 주철근)`; + box.append(legend); + + // 입력: 기본간격 · 배근 방향 · 피복 · 여유 · B9 배수. + const num = (into: HTMLElement, label: string, value: number, apply: (v: number) => Partial, def: number) => { + const id = `abr-${rec.name}-${label}`; + const lab = el('label', label); + lab.htmlFor = id; + const input = el('input'); + input.id = id; + input.type = 'number'; + input.step = 'any'; + input.value = String(value); + if (value !== def) input.classList.add('changed-input'); + input.addEventListener('change', () => set(apply(Number(input.value)))); + const cell = el('div'); + cell.append(lab, input); + into.append(cell); + }; + const grid = el('div', undefined, 'abut-rebar-inputs'); + const D = ABUT_FOOTING_REBAR_DEFAULT; + grid.append(el('p', '기본간격 · 확대기초 (부재별 입력)', 'lbl')); + num(grid, '교대축 (mm)', s.sL, (v) => ({ sL: v }), D.sL); + num(grid, '교대축 직각 (mm)', s.sW, (v) => ({ sW: v }), D.sW); + num(grid, '연직 (mm)', s.sV, (v) => ({ sV: v }), D.sV); + box.append(grid); + box.append( + optionTiles( + 'B1–B4 배근 방향', + [ + { label: '① skew 방향', on: r.dir === 'skew', isDefault: true, disabled: geom.plan === 'rect' ? '직사각 평면에서는 선택 불가' : undefined, pick: () => r.dir !== 'skew' && set({ dir: 'skew' }) }, + { label: '② 교대축 직각', on: r.dir === 'perp', pick: () => r.dir !== 'perp' && set({ dir: 'perp' }) }, + ], + '직사각 평면이면 ② 고정 (RB-AF04)', + ), + ); + const grid2 = el('div', undefined, 'abut-rebar-inputs'); + num(grid2, '상면·측면 피복 (mm)', s.cTop, (v) => ({ cTop: v, cSide: v }), D.cTop); + num(grid2, '하면 피복 (mm)', s.cBottom, (v) => ({ cBottom: v }), D.cBottom); + num(grid2, '첫 철근 최소 여유 (mm)', s.eMin, (v) => ({ eMin: v }), D.eMin); + { + const id = `abr-${rec.name}-k9`; + const lab = el('label', 'B9 배수 (교대축 × 폭)'); + lab.htmlFor = id; + const input = el('input'); + input.id = id; + input.type = 'text'; + input.value = `${s.k9L} × ${s.k9W}`; + if (s.k9L !== D.k9L || s.k9W !== D.k9W) input.classList.add('changed-input'); + input.addEventListener('change', () => { + const n = input.value.split(/[×x*,\s]+/).filter(Boolean).map(Number); + if (n.length !== 2) { + setError('B9 배수는 "2 × 2"처럼 두 정수로 입력하세요.'); + renderAll(); + return; + } + set({ k9L: n[0], k9W: n[1] }); + }); + const cell = el('div'); + cell.append(lab, input); + grid2.append(cell); + } + // 자동 값. + const fck = materialFor(state.material, state.fckByMember, 'footing').fck; + const fy = (size: string) => fyOf(state.material, size); + const auto = (label: string, value: string) => { + const cell = el('div', undefined, 'abut-rebar-auto'); + cell.append(el('span', label, 'muted small'), el('span', value, 'num')); + grid2.append(cell); + }; + auto('cycle 간격', `B1–B4 ${2 * (s.sMain ?? s.sL)} · B5–B8 ${2 * (s.sDist ?? s.sW)}`); + auto('B10 위 여유 (계산)', `${Math.round(r.side.topGap)} (≥ ${s.eTop})`); + auto(`이음 l_s = 1.3 l_d (fck ${fck})`, `H16 ${lapLength(15.9, fy('H16'), fck)} · H19 ${lapLength(19.1, fy('H19'), fck)}`); + auto('B1 옆붙임 · B5 옆붙임', `+${r.sideShift.B1.toFixed(2)} · +${r.sideShift.B5.toFixed(1)}`); + box.append(grid2); + + // 철근표. + const bar = el('div', undefined, 'abut-rebar-tablehead'); + bar.append(el('span', '가정값 없음 · 모든 철근이 규칙 계산', 'muted small')); + const notes = el('button', state.abutRebarNotes ? '비고 접기' : '비고 모두 펼치기', 'btn small'); + notes.type = 'button'; + notes.addEventListener('click', () => { + state.abutRebarNotes = !state.abutRebarNotes; + renderAll(); + }); + bar.append(notes); + box.append(bar); + const table = el('table', undefined, 'grid-table rebar-table'); + const hr = table.createTHead().insertRow(); + 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. + const overrideOf: Record = { + B1: { key: 'sMain', base: s.sL }, + B5: { key: 'sDist', base: s.sW }, + '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 tr = body.insertRow(); + if (on) tr.classList.add('hl'); + 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) { + const input = el('input'); + input.type = 'number'; + input.step = 'any'; + input.value = b.actual; + input.style.width = '70px'; + input.setAttribute('aria-label', `${b.mark} 묶음 실간격`); + 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 })); + 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)); + if (state.abutRebarNotes || on) { + const nr = body.insertRow(); + const cell = el('td', `${b.mark} 비고 · ${b.note}`, 'muted small'); + cell.colSpan = 9; + nr.append(cell); + } + } + const scroll = el('div', undefined, 'table-scroll'); + scroll.append(table); + box.append(scroll); + const sizes = ABUT_FOOTING_SIZES; + box.append( + el( + 'p', + `근거 · 교대확대기초철근.md · spec_abut.md §6.2 · 규격 B1·B3 ${sizes.top} / B2·B4 ${sizes.bottom} / B5–B8 ${sizes.dist} / B9 ${sizes.shear} / B10 ${sizes.side} · 하면 ${r.dist.bottomText} · 상면 ${r.dist.topText} · 측면 ${r.side.text} · 교대축 ${r.main.text}`, + 'muted small', + ), + ); +} + function renderRebar(): void { const pier = activePier(); // 교각 목록 with the 배근 status (시안 ③). The checks are the same as in ① and ②. renderPierRows($('rebar-pier-items'), 'rebar'); + // ③ 교대 (2026-11-04): a selected abutment shows its own member list and card. + const abutRec = state.abutments.find((a) => a.name === state.abutActive); + if (abutRec) { + renderAbutRebar(abutRec); + renderMaterial(); + return; + } + $('rebar-title').textContent = '철근표'; const tabs = $('rebar-tabs'); tabs.replaceChildren(); for (const m of REBAR_MEMBERS) { diff --git a/src/style.css b/src/style.css index 1fff297..56d3614 100644 --- a/src/style.css +++ b/src/style.css @@ -951,3 +951,16 @@ body { background: #eef1f4; font-family: 'Noto Sans KR', system-ui, sans-serif; #abut-panel-plan-slab > p, #abut-panel-plan-slab > .abut-wall-figs { grid-column: 1 / -1; } #abut-panel-plan-ground > p, #abut-panel-plan-ground > .table-scroll, #abut-panel-plan-ground > figure { grid-column: 1 / -1; } #abut-panel-plan-ground > figure { margin: 4px 0; max-width: 560px; } +/* ③ 교대 배근 (2026-11-04) */ +#rebar-table .abut-rebar-head { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin: 4px 0; } +#rebar-table .abut-rebar-head .abut-optt { border: 1px solid #c9d2dc; border-radius: 8px; background: #fff; padding: 4px 10px; font-size: 12px; cursor: pointer; } +#rebar-table .abut-rebar-head .abut-optt.on { border-color: #1f5fa8; background: #f0f6fd; font-weight: 600; } +#rebar-table .abut-rebar-fig { margin: 4px 0; } +#rebar-table .abut-rebar-inputs { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 8px 12px; margin: 8px 0; } +#rebar-table .abut-rebar-inputs > .lbl { grid-column: 1 / -1; margin: 0; } +#rebar-table .abut-rebar-inputs label { display: block; font-size: 12px; color: #55606e; } +#rebar-table .abut-rebar-inputs input { width: 100%; } +#rebar-table .abut-rebar-auto { display: flex; flex-direction: column; gap: 2px; padding: 4px 6px; background: #f5f7f9; border-radius: 6px; } +#rebar-table .abut-rebar-tablehead { display: flex; justify-content: space-between; align-items: center; margin: 8px 0 4px; } +#rebar-table .abut-grp { display: flex; flex-direction: column; gap: 6px; margin: 6px 0; } +#rebar-table .abut-opts { display: flex; flex-wrap: wrap; gap: 8px; } diff --git a/src/view/abut-footing-rebar-svg.ts b/src/view/abut-footing-rebar-svg.ts new file mode 100644 index 0000000..7cb80d3 --- /dev/null +++ b/src/view/abut-footing-rebar-svg.ts @@ -0,0 +1,192 @@ +/** + * 교대 저판 배근 삽도 (2026-11-04, 시안 ③ 확대기초 카드, 교대확대기초철근.md §10): 교대축 직각 단면 (폭 W), 교대축 단면 (길이 L), + * 상면 평면. 순수 문자열 함수. 번호 풍선(data-pick)을 누르면 그 철근만 강조한다; 강조할 번호는 `pick`으로 받는다. + * 색은 역할 기준: 주철근 B1–B4 #0F5C66, 보조 B5–B8 #B4531F, 측면 B10 #2E7D32 점선, 전단 B9 #6B3FA0. + */ +import type { AbutFootingGeom, AbutFootingRebar } from '../core/abut/rebar/footing'; + +export const ABUT_FOOTING_COLORS = { main: '#0F5C66', dist: '#B4531F', side: '#2E7D32', shear: '#6B3FA0' } as const; +const INK = '#18212b'; +const MUTED = '#55606e'; +const CONC = '#eef1f4'; +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; +} + +/** A clickable 번호 풍선. */ +function balloon(x: number, y: number, mark: string, color: string, pick: string | null): string { + const on = pick === mark; + 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 { + const k = 0.12; + const x0 = 60; + const y0 = 46; + const X = (x: number) => x0 + x * k; + const Y = (z: number) => y0 + (g.H - z) * k; + const c = r.settings.cSide; + const C = ABUT_FOOTING_COLORS; + const parts: string[] = []; + parts.push(``); + parts.push(text((X(g.xb) + X(g.xf)) / 2, y0 - 10, `벽체 ${Math.round(g.xf - g.xb)} (상면 B5·B7 없음)`, 'middle', MUTED, 10)); + parts.push(``); + const line = (pts: [number, number][], color: string, marks: string[], w = 1.8, dash = '') => + ``; + const { B13, B24, B57, B68 } = r.z; + // B9 (behind the others): vertical lines at the row positions. + for (const row of r.shear.rows) parts.push(line([[row.x, B24], [row.x, B13]], C.shear, ['B9'], 1.2)); + // B1 ⊓ (slightly inside so the legs show next to B2's), B2 ⊔, B3 to the 벽체 전면 − c, B4 straight. + const lx = c + 6; + parts.push(line([[lx, B24], [lx, B13], [g.W - lx, B13], [g.W - lx, B24]], C.main, ['B1'])); + parts.push(line([[c, B13], [c, B24], [g.W - c, B24], [g.W - c, B13]], C.main, ['B2'])); + parts.push(line([[c, B13 + 14], [g.xf - c, B13 + 14]], C.main, ['B3'], 1.6, '6 3')); + parts.push(line([[c, B24 - 14], [g.W - c, B24 - 14]], C.main, ['B4'], 1.6, '6 3')); + // B5–B8 dots. + const dot = (x: number, z: number, color: string, mark: string) => + ``; + r.dist.positions.forEach((x, i) => { + if (r.dist.topKept[i]) parts.push(dot(x + (i % 2 === 0 ? r.sideShift.B5 : 0), B57, C.dist, i % 2 === 0 ? 'B5' : 'B7')); + parts.push(dot(x, B68, C.dist, i % 2 === 0 ? 'B6' : 'B8')); + }); + // B10-1 dots on both faces. + for (const z of r.side.levels) { + const zz = z + 19.1; + parts.push(dot(r.side.depth1, zz, C.side, 'B10-1'), dot(g.W - r.side.depth1, zz, C.side, 'B10-1')); + } + // 치수 문자열. + parts.push(text(X(g.W / 2), Y(0) + 18, r.dist.bottomText + ' (하면)', 'middle', INK, 10)); + parts.push(text(X(g.W / 2), Y(g.H) - 32, r.dist.topText + ' (상면)', 'middle', INK, 10)); + parts.push(text(X(0) - 6, Y(g.H / 2), r.side.text, 'end', MUTED, 10)); + // 번호 풍선. + const by = Y(g.H) - 2; + parts.push(balloon(X(g.xb / 2), Y(B13) + 26, 'B1', C.main, pick), balloon(X(g.xb / 2) + 30, Y(B13) + 26, 'B3', C.main, pick)); + parts.push(balloon(X(g.xb / 2), Y(B24) - 26, 'B2', C.main, pick), balloon(X(g.xb / 2) + 30, Y(B24) - 26, 'B4', C.main, pick)); + parts.push(balloon(X(g.xf + (g.W - g.xf) / 2) - 15, Y(B57) + 30, 'B5', C.dist, pick), balloon(X(g.xf + (g.W - g.xf) / 2) + 15, Y(B57) + 30, 'B7', C.dist, pick)); + parts.push(balloon(X(g.xf + (g.W - g.xf) / 2) - 15, Y(B68) - 30, 'B6', C.dist, pick), balloon(X(g.xf + (g.W - g.xf) / 2) + 15, Y(B68) - 30, 'B8', C.dist, pick)); + if (r.shear.rows.length) parts.push(balloon(X(r.shear.rows[0].x) + 22, Y(g.H / 2), 'B9', C.shear, pick)); + parts.push(balloon(X(g.W) + 22, Y(r.side.levels[1] ?? g.H / 2), 'B10', C.side, pick)); + parts.push(text(X(0), by - 30, '← 배면 (뒷굽)', 'start', MUTED, 10), text(X(g.W), by - 30, '전면 (앞굽) →', 'end', MUTED, 10)); + const W = X(g.W) + 50; + const H = Y(0) + 30; + return `${parts.join('')}`; +} + +/** 교대축 단면 (길이 L, 가로 축척 0.4배): B5–B8 선(이음 위치), B1–B4 점, B10-2 점. 왼쪽 IP3 쪽. */ +export function abutFootingSectionLSvg(g: AbutFootingGeom, r: AbutFootingRebar, pick: string | null = null): string { + const kx = 0.045; + const kz = 0.12; + const x0 = 50; + const y0 = 30; + const X = (s: number) => x0 + s * kx; + const Y = (z: number) => y0 + (g.H - z) * kz; + const C = ABUT_FOOTING_COLORS; + const parts: string[] = []; + parts.push(``); + const e = r.settings.cSide / g.angle.sin; + const line = (pts: [number, number][], color: string, marks: string[], w = 1.8, dash = '') => + ``; + const { B13, B24, B57, B68 } = r.z; + parts.push(line([[e + 4, B68], [e + 4, B57], [g.L - e - 4, B57], [g.L - e - 4, B68]], C.dist, ['B5'])); + parts.push(line([[e, B57], [e, B68], [g.L - e, B68], [g.L - e, B57]], C.dist, ['B6'])); + parts.push(line([[e, B57 - 14], [g.L - e, B57 - 14]], C.dist, ['B7'], 1.4, '6 3')); + parts.push(line([[e, B68 + 14], [g.L - e, B68 + 14]], C.dist, ['B8'], 1.4, '6 3')); + // 이음 (B5 · B6): the two splice centres (± l_s around the middle) as ticks. + const lap = r.bars.find((b) => b.mark === 'B5')?.splice?.lap; + if (lap) { + for (const sc of [g.L / 2 - lap, g.L / 2 + lap]) { + parts.push(``); + } + parts.push(text(X(g.L / 2), Y(B57) - 10, `B5·B6 이음 l_s ${lap} (± l_s 엇갈림)`, 'middle', C.dist, 10)); + } + const dot = (s: number, z: number, color: string, mark: string) => + ``; + r.main.positions.forEach((s, i) => { + const one = i % 2 === 0; + parts.push(dot(one ? s + r.sideShift.B1 : s, B13, C.main, one ? 'B1' : 'B3')); + parts.push(dot(s, B24, C.main, one ? 'B2' : 'B4')); + }); + const d2 = r.side.depth2 / g.angle.sin; + for (const z of r.side.levels) parts.push(dot(d2, z, C.side, 'B10-2'), dot(g.L - d2, z, C.side, 'B10-2')); + parts.push(text(X(g.L / 2), Y(0) + 18, r.main.text, 'middle', INK, 10)); + parts.push(balloon(X(g.L * 0.12), Y(B13) - 16, 'B1', C.main, pick), balloon(X(g.L * 0.12) + 28, Y(B13) - 16, 'B3', C.main, pick)); + parts.push(balloon(X(g.L * 0.88), Y(B57) + 30, 'B5', C.dist, pick), balloon(X(g.L * 0.88) + 28, Y(B57) + 30, 'B7', C.dist, pick)); + parts.push(balloon(X(g.L * 0.3), Y(B68) - 30, 'B6', C.dist, pick), balloon(X(g.L * 0.3) + 28, Y(B68) - 30, 'B8', C.dist, pick)); + parts.push(balloon(X(g.L * 0.12), Y(B24) + 20, 'B2', C.main, pick), balloon(X(g.L * 0.12) + 28, Y(B24) + 20, 'B4', C.main, pick)); + parts.push(balloon(X(g.L) + 22, Y(g.H / 2), 'B10', C.side, pick)); + const W = X(g.L) + 50; + const H = Y(0) + 30; + return `${parts.join('')}`; +} + +/** + * 상면 평면: 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 { + const k = 0.045; + const shiftMax = Math.abs(skewK) * g.W; + const sMin = Math.min(0, skewK * g.W); + const x0 = 30; + const y0 = 24; + const X = (s: number) => x0 + (g.L + shiftMax + sMin - s) * k; // s = L (IP3) on the left + const Y = (x: number) => y0 + (g.W - x) * k; // 전면 (x = W) on top + const P = (s: number, x: number) => `${X(s + skewK * x).toFixed(1)},${Y(x).toFixed(1)}`; + // Option ② positions start after the end face that leans out (core: start = c + W·|cot θ|); with the faces leaning the + // other way (skewK < 0) that side is at x = W, so the square bars sit W·|cot θ| further toward s = 0 in this frame. + const SQ = (s: number) => (skewK >= 0 ? s : s - shiftMax); + const C = ABUT_FOOTING_COLORS; + const c = r.settings.cSide; + const parts: string[] = []; + parts.push(``); + parts.push(``); + const seg = (a: string, b: string, color: string, mark: string, w = 1, dash = '') => + ``; + // B5 · B7: along the axis at the kept top positions, cut square at the ends (끝 피복 c / sin θ). + const e = c / g.angle.sin; + r.dist.positions.forEach((x, i) => { + if (!r.dist.topKept[i]) return; + parts.push(seg(P(e, x), P(g.L - e, x), C.dist, i % 2 === 0 ? 'B5' : 'B7', 0.8)); + }); + // B1 · B3: option ① along the skew line from the end-face grid; option ② square to the axis. + r.main.positions.forEach((s, i) => { + const one = i % 2 === 0; + const xEnd = one ? g.W - c : g.xf - c; + if (r.dir === 'skew') parts.push(seg(P(s, c), P(s, xEnd), C.main, one ? 'B1' : 'B3', 0.8, one ? '' : '4 2')); + else parts.push(seg(`${X(SQ(s)).toFixed(1)},${Y(c).toFixed(1)}`, `${X(SQ(s)).toFixed(1)},${Y(xEnd).toFixed(1)}`, C.main, one ? 'B1' : 'B3', 0.8, one ? '' : '4 2')); + }); + if (r.skewEnd) { + for (const off of r.skewEnd.offsets) { + parts.push(seg(P(c + off, c), P(c + off, g.W - c), C.main, 'B1-S', 1.4)); + parts.push(seg(P(g.L - c - off, c), P(g.L - c - off, g.W - c), C.main, 'B1-S', 1.4)); + } + } + // B9 dots (rows). + for (const row of r.shear.rows) { + for (const s of row.s) { + const at = r.dir === 'skew' ? P(s, row.x) : `${X(SQ(s)).toFixed(1)},${Y(row.x).toFixed(1)}`; + const [cx, cy] = at.split(','); + parts.push(``); + } + } + parts.push(text(X(g.L / 2 + skewK * (g.xb + g.xf) / 2), Y((g.xb + g.xf) / 2) + 4, '벽체 밑 (상면 B5·B7 없음, B9 없음)', 'middle', MUTED, 10)); + parts.push(text(X(g.L / 2 + skewK * g.W), Y(g.W) - 6, '전면 (앞굽 끝)', 'middle', MUTED, 10)); + parts.push(text(X(g.L / 2), Y(0) + 14, '배면 (뒷굽 끝)', 'middle', MUTED, 10)); + parts.push(text(X(g.L) - 4, Y(g.W / 2), 'IP3', 'end', MUTED, 10), text(X(0) + 4, Y(g.W / 2), 'IP1', 'start', MUTED, 10)); + parts.push(balloon(X(g.L * 0.75 + skewK * g.W * 0.7), Y(g.W * 0.7), 'B1', C.main, pick), balloon(X(g.L * 0.75 + skewK * g.W * 0.7) + 28, Y(g.W * 0.7), 'B3', C.main, pick)); + parts.push(balloon(X(g.L * 0.4 + skewK * g.W * 0.9), Y(g.W * 0.92), 'B5', C.dist, pick), balloon(X(g.L * 0.4 + skewK * g.W * 0.9) + 28, Y(g.W * 0.92), 'B7', C.dist, pick)); + parts.push(balloon(X(g.L * 0.25 + skewK * g.W * 0.1), Y(g.W * 0.1), 'B9', C.shear, pick)); + if (r.skewEnd) parts.push(balloon(X(c + skewK * g.W * 0.5) - 26, Y(g.W * 0.5), 'B1-S', C.main, pick)); + const W = x0 * 2 + (g.L + shiftMax) * k; + const H = Y(0) + 26; + return `${parts.join('')}`; +} diff --git a/tests/abut-rebar-footing.test.ts b/tests/abut-rebar-footing.test.ts index 1a58ccc..ea35a39 100644 --- a/tests/abut-rebar-footing.test.ts +++ b/tests/abut-rebar-footing.test.ts @@ -183,3 +183,35 @@ describe('옵션 ② 교대축 직각 · 직사각 · 합성 (§13)', () => { expect(abutFootingRebar(geom, {}).bars).toEqual(A1.bars); }); }); + +import { applyFootingRebar } from '../src/core/abut/edit'; +import { checkStoredAbutments } from '../src/core/project'; +import { abutFootingRebarPlanSvg, abutFootingSectionLSvg, abutFootingSectionWSvg } from '../src/view/abut-footing-rebar-svg'; + +describe('③ 저판 배근 입력 저장 · 삽도', () => { + it('stores only the changed values; undefined links a bar group back; refuses bad values', () => { + const a = applyFootingRebar(rec, { sDist: 200, dir: 'perp' }); + expect(a.rebar?.footing).toEqual({ sDist: 200, dir: 'perp' }); + expect(applyFootingRebar(a, { sDist: undefined }).rebar?.footing).toEqual({ dir: 'perp' }); + expect(() => applyFootingRebar(rec, { k9L: 1.5 })).toThrow('B9 배수'); + expect(() => applyFootingRebar(rec, { sL: 0 })).toThrow(); + }); + + it('the project file keeps the 배근 input and refuses a wrong direction', () => { + expect(() => checkStoredAbutments([applyFootingRebar(rec, { sW: 200, dir: 'perp' })])).not.toThrow(); + expect(() => checkStoredAbutments([{ ...rec, rebar: { footing: { dir: 'diag' } } as never }])).toThrow('저판 배근'); + }); + + it('삽도: the dimension strings and a balloon per bar; a pick dims the others', () => { + const w = abutFootingSectionWSvg(geom, A1); + 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(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' }); + expect(abutFootingRebarPlanSvg(geom, opt2, -0.088)).toContain('data-pick="B1-S"'); + expect(abutFootingRebarPlanSvg(geom, A1, -0.088)).not.toContain('data-pick="B1-S"'); + }); +}); diff --git a/tests/abut-visibility.test.ts b/tests/abut-visibility.test.ts index 14b66ef..1954321 100644 --- a/tests/abut-visibility.test.ts +++ b/tests/abut-visibility.test.ts @@ -21,8 +21,12 @@ describe('형상계획 ②: 선택한 객체만 3D에 그림', () => { expect(abutShown('shape', null, 'A1')).toBe(true); }); - it('③ and ④ are not affected by the abutment selection', () => { - expect(piersShown('rebar', 'A1')).toBe(true); + it('③ draws a selected abutment alone (2026-11-04); with no abutment selected ③ and ④ draw as before', () => { + expect(piersShown('rebar', 'A1')).toBe(false); + expect(abutShown('rebar', 'A1', 'A1')).toBe(true); + expect(abutShown('rebar', 'A1', 'A2')).toBe(false); + expect(piersShown('rebar', null)).toBe(true); expect(abutShown('rebar', null, 'A1')).toBe(true); + expect(piersShown('quantity', 'A1')).toBe(true); }); });