feat(abut-rebar): ③ 교대 화면 틀과 확대기초 배근 카드

- ③에서 교대를 고르면 부재 · 교대 목록(받침블럭 → 벽체 → 흉벽·브라켓 → 확대기초 → 날개벽 → 접속슬래브)과 카드를 보여 줌.
  확대기초만 규칙이 있고 나머지는 '다음 단계'. 선택한 교대만 3D에 그림
- 확대기초 카드: 보기 탭 3개(교대축 직각 단면 · 교대축 단면 · 상면 평면)와 번호 풍선 강조, 범례,
  기본간격 · 배근 방향 ① / ② · 피복 · 여유 · B9 배수 입력, 자동 값(cycle 간격 · B10 위 여유 · l_s · 옆붙임),
  철근표(묶음별 실간격 수정 · 수정됨 · 되돌리기, 비고 펼치기), 근거 줄, RB-AF 알림
- 입력은 AbutRecord.rebar.footing에 바꾼 값만 저장 · 프로젝트 파일 검증. fck는 공통 설정의 기초부 값

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
ypshimandClaude Haiku 5.5 committed 2026-11-04 11:12:43 +09:00
1 parent e00e43ca61
commit b06bf368b6
9 files changed
+591 -10

No files matched your search

+14
View File
@@ -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<AbutGround>): 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<AbutFootingRebarSettings>): AbutRecord {
for (const v of Object.values(patch)) if (typeof v === 'number' && !Number.isFinite(v)) throw new Error('숫자를 입력하세요.');
const footing: Partial<AbutFootingRebarSettings> = { ...(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('숫자를 입력하세요.');
+3
View File
@@ -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<AbutSlab>;
/** 지반·지층 입력 (② 지반·지층 카드, 교각과 같은 필드). Absent = 육상, 지하수위 없음, 지층 행 없음. */
ground?: AbutGround;
/** ③ 배근 입력 (부재별, 바꾼 값만). Absent = 엑셀 Abut 시트 기본값. */
rebar?: { footing?: Partial<AbutFootingRebarSettings> };
/** 예전 "좌우 같게" 값 (2026-11-04 이전 파일). 지금은 읽기만 하고 쓰지 않는다. */
wingsLinked?: boolean;
}
+3 -1
View File
@@ -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;
}
+7
View File
@@ -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'];
+321 -7
View File
@@ -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 {
$<HTMLElement>('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 {
$<HTMLElement>(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 = $<HTMLUListElement>('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 = $<HTMLDivElement>('rebar-table');
box.replaceChildren();
const issues = $<HTMLUListElement>('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<AbutFootingRebarSettings>) => {
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<SVGGElement>('[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 = `<span style="color:${C.main}">━ 주철근 폭 방향 (B1–B4${r.skewEnd ? ' · B1-S·B2-S' : ''})</span> · <span style="color:${C.dist}">━ 배력 교대축 방향 (B5–B8)</span> · <span style="color:${C.shear}">━ 전단 B9</span> · <span style="color:${C.side}">┅ 측면 B10</span> · 색은 역할 기준 (교대는 폭 방향 B1–B4가 주철근)`;
box.append(legend);
// 입력: 기본간격 · 배근 방향 · 피복 · 여유 · B9 배수.
const num = (into: HTMLElement, label: string, value: number, apply: (v: number) => Partial<AbutFootingRebarSettings>, 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<string, string> = { 주철근: 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<string, { key: 'sMain' | 'sDist' | 'sSide'; base: number } | undefined> = {
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 = `<span style="display:inline-block;width:22px;border-top:3px solid ${roleColor[b.role]};vertical-align:middle"></span> ${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($<HTMLUListElement>('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 = $<HTMLUListElement>('rebar-tabs');
tabs.replaceChildren();
for (const m of REBAR_MEMBERS) {
+13
View File
@@ -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; }
+192
View File
@@ -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) =>
`<text x="${x.toFixed(1)}" y="${y.toFixed(1)}" text-anchor="${anchor}" font-size="${size}" fill="${fill}" font-family="JetBrains Mono, monospace">${s}</text>`;
/** 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 `<g data-pick="${mark}" style="cursor:pointer" role="button" tabindex="0" aria-label="${mark} 강조"><circle cx="${x.toFixed(1)}" cy="${y.toFixed(1)}" r="${r}" fill="${on ? color : '#fff'}" stroke="${color}" stroke-width="1.4"/>${text(x, y + 4, mark, 'middle', on ? '#fff' : color, 10)}</g>`;
}
/** 교대축 직각 단면 (폭 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(`<rect x="${X(g.xb)}" y="${y0 - 26}" width="${(g.xf - g.xb) * k}" height="26" fill="${WALL}" stroke="${MUTED}" stroke-dasharray="4 3"/>`);
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(`<rect x="${X(0)}" y="${Y(g.H)}" width="${g.W * k}" height="${g.H * k}" fill="${CONC}" stroke="${MUTED}"/>`);
const line = (pts: [number, number][], color: string, marks: string[], w = 1.8, dash = '') =>
`<polyline points="${pts.map(([x, z]) => `${X(x).toFixed(1)},${Y(z).toFixed(1)}`).join(' ')}" fill="none" stroke="${color}" stroke-width="${w}"${dash ? ` stroke-dasharray="${dash}"` : ''} opacity="${opacity(pick, marks)}"/>`;
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) =>
`<circle cx="${X(x).toFixed(1)}" cy="${Y(z).toFixed(1)}" r="2.6" fill="${color}" opacity="${opacity(pick, [mark])}"/>`;
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 `<svg viewBox="0 0 ${W.toFixed(0)} ${H.toFixed(0)}" width="100%" role="img" aria-label="교대축 직각 단면: 폭 ${g.W}, 두께 ${g.H}, 하면 ${r.dist.bottomText}">${parts.join('')}</svg>`;
}
/** 교대축 단면 (길이 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(`<rect x="${X(0)}" y="${Y(g.H)}" width="${g.L * kx}" height="${g.H * kz}" fill="${CONC}" stroke="${MUTED}"/>`);
const e = r.settings.cSide / g.angle.sin;
const line = (pts: [number, number][], color: string, marks: string[], w = 1.8, dash = '') =>
`<polyline points="${pts.map(([s, z]) => `${X(s).toFixed(1)},${Y(z).toFixed(1)}`).join(' ')}" fill="none" stroke="${color}" stroke-width="${w}"${dash ? ` stroke-dasharray="${dash}"` : ''} opacity="${opacity(pick, marks)}"/>`;
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(`<line x1="${X(sc)}" y1="${Y(B57) - 6}" x2="${X(sc)}" y2="${Y(B68) + 6}" stroke="${C.dist}" stroke-dasharray="2 2" opacity="${opacity(pick, ['B5', 'B6'])}"/>`);
}
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) =>
`<circle cx="${X(s).toFixed(1)}" cy="${Y(z).toFixed(1)}" r="1.8" fill="${color}" opacity="${opacity(pick, [mark])}"/>`;
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 `<svg viewBox="0 0 ${W.toFixed(0)} ${H.toFixed(0)}" width="100%" role="img" aria-label="교대축 단면: 길이 ${Math.round(g.L)}, ${r.main.text}">${parts.join('')}</svg>`;
}
/**
* 상면 평면: 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(`<polygon points="${[P(0, 0), P(g.L, 0), P(g.L, g.W), P(0, g.W)].join(' ')}" fill="${CONC}" stroke="${MUTED}"/>`);
parts.push(`<polygon points="${[P(0, g.xb), P(g.L, g.xb), P(g.L, g.xf), P(0, g.xf)].join(' ')}" fill="${WALL}" opacity="0.7"/>`);
const seg = (a: string, b: string, color: string, mark: string, w = 1, dash = '') =>
`<line x1="${a.split(',')[0]}" y1="${a.split(',')[1]}" x2="${b.split(',')[0]}" y2="${b.split(',')[1]}" stroke="${color}" stroke-width="${w}"${dash ? ` stroke-dasharray="${dash}"` : ''} opacity="${opacity(pick, [mark])}"/>`;
// 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(`<circle cx="${cx}" cy="${cy}" r="1.2" fill="${C.shear}" opacity="${opacity(pick, ['B9'])}"/>`);
}
}
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 `<svg viewBox="0 0 ${W.toFixed(0)} ${H.toFixed(0)}" width="100%" role="img" aria-label="상면 평면: 배근 방향 ${r.dir === 'skew' ? '① skew' : '② 교대축 직각'}">${parts.join('')}</svg>`;
}
+32
View File
@@ -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"');
});
});
+6 -2
View File
@@ -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);
});
});