From bac087ce6304218852d248ead310b10d2f590b83 Mon Sep 17 00:00:00 2001 From: ypshim Date: Wed, 4 Nov 2026 18:00:41 +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=82=A0=EA=B0=9C=EB=B2=BD=20=EC=B9=B4=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 측 선택 (좌 · 우, ② 날개벽 카드와 같은 측), 보기 탭 세 개 (전면도 · 배면도 · 헌치 · 모서리 평면) - 입력 (좌우 공통): 기본간격 수평 · 연직, 피복, 첫 철근 최소 여유, δ, WR20 배수. WR9 개수는 없앰 (자동) - 자동 값: 형식, 배면 s · 전면 2s, 연직 기준선, 수평 단, WR9 개수, l_d. 실간격 묶음 수정 WR1 (연직) · WR3 (수평) - 공통 카드 함수: 위쪽 컨트롤 줄 · 두 숫자 입력 선택 사항 Co-Authored-By: Claude Haiku 5.5 --- src/main.ts | 128 +++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 122 insertions(+), 6 deletions(-) diff --git a/src/main.ts b/src/main.ts index 6a46b0b..4ebd5c2 100644 --- a/src/main.ts +++ b/src/main.ts @@ -29,9 +29,12 @@ import { applyFootingRebar as applyAbutFootingRebar, applyWallRebar, applyParapetRebar, + applyWingRebar, } 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 { lenText, skewAngle } from './core/abut/rebar/skew-layout'; +import { ABUT_WING_REBAR_DEFAULT, ABUT_WING_SIZES, abutWingGeom, abutWingRebar, type AbutWingGeom, type AbutWingRebar, type AbutWingRebarSettings } from './core/abut/rebar/wing'; +import { ABUT_WING_COLORS, abutWingCornerSvg as abutWingRebarCornerSvg, abutWingFaceSvg } from './view/abut-wing-rebar-svg'; import { ABUT_PARAPET_REBAR_DEFAULT, ABUT_PARAPET_SIZES, abutParapetGeom, abutParapetRebar, slabSpanAt, type AbutParapetGeom, type AbutParapetRebar, type AbutParapetRebarSettings } from './core/abut/rebar/parapet'; import { ABUT_PARAPET_COLORS, abutParapetDowelSvg, abutParapetElevationSvg, abutParapetSectionSvg } from './view/abut-parapet-rebar-svg'; import { ABUT_WALL_REBAR_DEFAULT, ABUT_WALL_SIZES, abutWallGeom, abutWallRebar, type AbutWallGeom, type AbutWallRebar, type AbutWallRebarSettings } from './core/abut/rebar/wall'; @@ -224,6 +227,8 @@ const state = { abutWallView: 'sec' as 'sec' | 'elev', /** ③ 교대 흉벽 보기 탭 (단면 / 교대축 단면 / P10 다웰 배치). */ abutParapetView: 'sec' as 'sec' | 'elev' | 'dw', + /** ③ 교대 날개벽 보기 탭 (전면도 / 배면도 / 헌치 · 모서리 평면). 측은 ② 날개벽 카드와 같은 abutWingSide. */ + abutWingView: 'front' as 'front' | 'back' | 'corner', abutRebarNotes: false, /** Member card selected on ② (시안 ② 부재 목록). */ member: 'coping' as MemberId, @@ -3096,14 +3101,14 @@ function renderCopingDrawings(box: HTMLDivElement, pier: Pier): void { box.appendChild(wrap); } -/** ③ 교대 부재 (시안 순서, plan_abut AD9). 확대기초 (AD3) · 벽체 (AD4) · 흉벽 (AD5) have rules; the rest come next. */ +/** ③ 교대 부재 (시안 순서, plan_abut AD9). 확대기초 (AD3) · 벽체 (AD4) · 흉벽 (AD5) · 날개벽 (AD7) have rules; 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: true }, { id: 'para', label: '흉벽·브라켓', marks: 'P1–P11', ready: true }, { id: 'foot', label: '확대기초', marks: 'B1–B10', ready: true }, - { id: 'wing', label: '날개벽', marks: 'WR1–WR20', ready: false }, + { id: 'wing', label: '날개벽', marks: 'WR1–WR20', ready: true }, { id: 'slab', label: '접속슬래브', marks: 'R1–R8', ready: false }, ]; @@ -3151,12 +3156,29 @@ function abutParapetBuilt(rec: AbutRecord, wall: { geom: AbutWallGeom; result: A } } -/** ③ with an abutment selected (2026-11-04): 부재 · 교대 list, the member card, and the RB-AF / RB-AB / RB-AP alerts. */ +/** The 날개벽 rebar of an abutment, per side that has a wing (좌우 공통 입력). */ +function abutWingsBuilt(rec: AbutRecord, footing: AbutFootingRebar): { geom: AbutWingGeom; result: AbutWingRebar }[] { + try { + const model = buildAbut(toAbutInput(rec)); + const members = buildMembers(model, { bottomEl: rec.foundationBottomEl, pileLengthMm: rec.pileLengthMm }, rec.shape); + const material = materialFor(state.material, state.fckByMember, 'abutBody'); + return buildWings(model, members, rec.wings, rec.wingDims).map((w) => { + const geom = abutWingGeom(model, members, w, footing.z.B68); + return { geom, result: abutWingRebar(geom, rec.rebar?.wing, material, rec.name) }; + }); + } catch { + return []; + } +} + +/** ③ with an abutment selected (2026-11-04): 부재 · 교대 list, the member card, and the RB-AF / RB-AB / RB-AP / RB-AW alerts. */ function renderAbutRebar(rec: AbutRecord): void { const built = abutFootingBuilt(rec); const wall = built ? abutWallBuilt(rec, built.result) : null; const para = wall ? abutParapetBuilt(rec, wall) : null; - const issuesOf = (id: AbutRebarMemberId) => (id === 'foot' ? built?.result.issues : id === 'wall' ? wall?.result.issues : id === 'para' ? para?.result.issues : undefined) ?? []; + const wings = built ? abutWingsBuilt(rec, built.result) : []; + const issuesOf = (id: AbutRebarMemberId) => + (id === 'foot' ? built?.result.issues : id === 'wall' ? wall?.result.issues : id === 'para' ? para?.result.issues : id === 'wing' ? wings.flatMap((w) => w.result.issues) : undefined) ?? []; const warnOf = (id: AbutRebarMemberId) => issuesOf(id).filter((i) => i.level !== 'I').length; const tabs = $('rebar-tabs'); tabs.replaceChildren(); @@ -3193,6 +3215,10 @@ function renderAbutRebar(rec: AbutRecord): void { const high = g.segments.find((sg) => sg.no === g.segments.length)!; $('rebar-note').textContent = `${rec.name} · Type-1 · 두께 ${Math.round(g.xf - g.xb)} · 높이 ${Math.round(low.H)} (구간 1) ~ ${Math.round(high.H)} (구간 ${high.no}) · 교좌면 계단 ${g.segments.length}구간 · 피복 ${wall.result.settings.c}`; fillAbutWallRebarCard(box, rec, wall); + } else if (member.id === 'wing') { + const forms = rec.wings ?? { left: 'general', right: 'general' }; + $('rebar-note').textContent = `${rec.name} · Type-1 · 좌 ${WING_FORM_TEXT[forms.left]} · 우 ${WING_FORM_TEXT[forms.right]}`; + fillAbutWingRebarCard(box, rec, wings); } else if (member.id === 'para' && para) { const g = para.geom; $('rebar-note').textContent = `${rec.name} · Type-1 · 흉벽 두께 ${Math.round(-g.parapetBack)} · 높이 ${Math.round(g.Hp)} (대표 단면) · 브라켓 ${Math.round(g.parapetBack - g.wall.xb)} × ${Math.round(g.bracketH)} · 피복 ${para.result.settings.c}`; @@ -3257,6 +3283,8 @@ interface AbutCardBar { /** What a ③ 교대 벽체 · 흉벽 card shows (시안 순서: 보기 탭 · 삽도 → 메모 · 범례 → 기본간격 · 입력 → 자동 값 → 철근표 → 근거). */ interface AbutCardSpec { idPrefix: string; + /** A control row above the 보기 탭 (날개벽: 측 선택). */ + headExtra?: HTMLElement; views: readonly (readonly [string, string])[]; view: string; setView: (id: string) => void; @@ -3269,7 +3297,7 @@ interface AbutCardSpec { base: [string, number, number, (v: number) => Partial][]; inputs: [string, number, number, (v: number) => Partial][]; /** "8 × 4" or "400 · 600" text input of two numbers. */ - pair: { label: string; value: [number, number]; def: [number, number]; sep: string; error: string; patch: (a: number, b: number) => Partial }; + pair?: { label: string; value: [number, number]; def: [number, number]; sep: string; error: string; patch: (a: number, b: number) => Partial }; autos: [string, string][]; bars: AbutCardBar[]; roleColor: Record; @@ -3287,6 +3315,7 @@ function abutRebarCard(box: HTMLDivElement, o: AbutCardSpec): void { state.rebarSelection = alone ? new Set() : new Set([mark]); renderAll(); }; + if (o.headExtra) box.append(o.headExtra); // 보기 탭. const head = el('div', undefined, 'abut-rebar-head'); for (const [id, label] of o.views) { @@ -3351,7 +3380,7 @@ function abutRebarCard(box: HTMLDivElement, o: AbutCardSpec): void { box.append(grid); const grid2 = el('div', undefined, 'abut-rebar-inputs'); for (const f of o.inputs) num(grid2, f); - { + if (o.pair) { const p = o.pair; const id = `${o.idPrefix}-pair`; const lab = el('label', p.label); @@ -3509,6 +3538,93 @@ function fillAbutWallRebarCard(box: HTMLDivElement, rec: AbutRecord, built: { ge }); } +const WING_FORM_TEXT: Record = { general: '일반형', short: '단축형', none: '없음' }; + +/** ③ 교대 날개벽 카드 (시안, 교대날개벽철근.md §10): 측 선택 + 공통 카드. 입력은 좌우 공통 (사용자 결정 2026-11-04). */ +function fillAbutWingRebarCard(box: HTMLDivElement, rec: AbutRecord, wings: { geom: AbutWingGeom; result: AbutWingRebar }[]): void { + // 측 선택 (시안): 날개벽이 있는 측만. 고른 측이 없으면 있는 측으로. + const sideRow = el('div', undefined, 'abut-rebar-head'); + sideRow.append(el('span', '측 선택', 'muted small')); + const forms = rec.wings ?? { left: 'general', right: 'general' }; + for (const side of ['left', 'right'] as const) { + const has = wings.some((w) => w.geom.side === side); + const label = `${side === 'left' ? '좌 (① 쪽)' : '우 (⑤ 쪽)'} · ${WING_FORM_TEXT[forms[side]]}`; + const b = el('button', label, `abut-optt${state.abutWingSide === side ? ' on' : ''}`); + b.type = 'button'; + b.disabled = !has; + b.setAttribute('aria-pressed', String(state.abutWingSide === side)); + b.addEventListener('click', () => { + state.abutWingSide = side; + renderAll(); + }); + sideRow.append(b); + } + const cur = wings.find((w) => w.geom.side === state.abutWingSide) ?? wings[0]; + if (!cur) { + box.append(sideRow, el('p', '날개벽이 없습니다 (② 날개벽 형식이 양쪽 모두 "없음"). 벽체 끝면은 W8 · W11, 흉벽 끝면은 P8 · P9 · P11로 마감합니다.', 'muted')); + return; + } + const { geom: g, result: r } = cur; + const s = r.settings; + const D = ABUT_WING_REBAR_DEFAULT; + const C = ABUT_WING_COLORS; + const SZ = ABUT_WING_SIZES; + const fck = materialFor(state.material, state.fckByMember, 'abutBody').fck; + const view = state.abutWingView; + const sideText = g.side === 'left' ? '좌' : '우'; + abutRebarCard(box, { + idPrefix: `awg-${rec.name}`, + headExtra: sideRow, + // 사용자 결정 2026-11-04: 시안 두 탭에 배면도를 더해 세 탭. + views: [['front', '전면도'], ['back', '배면도'], ['corner', '헌치 · 모서리 평면']], + view, + setView: (id) => (state.abutWingView = id as 'front' | 'back' | 'corner'), + svg: (pick) => (view === 'corner' ? abutWingRebarCornerSvg(g, r, pick) : abutWingFaceSvg(g, r, view, pick)), + caption: + view === 'corner' + ? `${sideText} 날개벽 · 헌치 · 모서리 평면 · WR7-1 · 7-2 · 7-3 · WR10 각 띠의 한 단, WR9 점` + : `${sideText} 날개벽 · ${view === 'front' ? '전면도 (바깥)' : '배면도 (흙 쪽)'} · 왼쪽 교대 → 오른쪽 끝 · 수평 띠 등단면 실선 / 변화단면1 파선 / 변화단면2 점선`, + memo: `${sideText} 날개벽 ${WING_FORM_TEXT[g.form]} · 길이 ${Math.round(g.L)} · 경사 ${(g.slope * 100).toFixed(2)} % · 끝 높이 ${Math.round(g.endH)} · 헌치 ${Math.round(g.haunch)}. 맨 위 2단은 상단 경사와 나란히, 교대 철근과의 간섭은 보지 않음 (사용자 결정 2026-11-04). 좌우는 같은 입력, skew로 배면 길이가 측마다 다릅니다.`, + legendHtml: `━ 연직 (WR1 · 2 · 11 · 12) · ━ 수평 등단면 (WR3 · 13) · ┅ 변화단면1 (WR4 · 14) · ┈ 변화단면2 (WR5 · 15) · ━ 헌치 WR7 · WR9 · ┅ 모서리 WR10 · ━ WR6 · ┈ WR20`, + baseTitle: '기본간격 · 날개벽 (부재별 입력, 좌우 공통)', + base: [ + ['수평 (mm)', s.sH, D.sH, (v) => ({ sH: v })], + ['연직 (mm)', s.sV, D.sV, (v) => ({ sV: v })], + ], + // 사용자 결정 2026-11-04: c · e_min · WR20 배수도 보임, WR9 개수는 없앰 (@2·s_h 자동). + inputs: [ + ['피복 (mm)', s.c, D.c, (v) => ({ c: v })], + ['첫 철근 최소 여유 (mm)', s.eMin, D.eMin, (v) => ({ eMin: v })], + ['등단면 올림 허용차 δ (mm)', s.delta, D.delta, (v) => ({ delta: v })], + ['WR20 배수', s.k20, D.k20, (v) => ({ k20: v })], + ], + autos: [ + ['형식 (②에서 정함)', WING_FORM_TEXT[g.form]], + ['배면 s · 전면 · 헌치 2s', `${lenText(s.sVert ?? s.sH)} · ${lenText(2 * (s.sVert ?? s.sH))}`], + ['연직 기준선 (뒷굽 끝 − c)', `전면 ${lenText(r.vertical.front.base)} · 배면 ${lenText(r.vertical.back.base)}`], + ['수평 단 (교대 쪽 · 끝)', `전면 ${r.levels.front.start} · ${r.levels.front.end} / 배면 ${r.levels.back.start} · ${r.levels.back.end}`], + ['WR9 개수 (헌치 대각, 자동)', String(r.bars.find((b) => b.mark === 'WR9')?.count ?? 0)], + [`l_d (fck ${fck})`, `H13 ${r.ld.H13} · H16 ${r.ld.H16} · H25 ${r.ld.H25}`], + ], + bars: r.bars, + roleColor: { 연직: C.vertical, 수평: C.horizontal, 헌치: C.haunch, 모서리: C.haunch, 간격재: C.other, '경사 하단': C.other }, + // 묶음별 간격 수정 (사용자 결정 2026-11-04): 연직 WR1 · 2 · 11 · 12, 수평 WR3–5 · 13–15 (배면 값, 전면은 2배). + overrides: { + WR1: { key: 'sVert', base: s.sH, label: 'WR1 · 2 · 11 · 12 수평 간격 (배면)' }, + WR3: { key: 'sHoriz', base: s.sV, label: 'WR3–5 · 13–15 연직 간격 (배면)' }, + }, + basisNote: `근거 · 교대날개벽철근.md · spec_abut.md §4.7 · §6.5 · 규격 WR1 · 2 · 6 · 9 · 11 · 12 ${SZ.WR1} / WR3–5 · 7 · 10 · 13–15 ${SZ.WR3} / WR20 ${SZ.WR20} · 연직 배치 전면 ${r.vertical.front.text} · 배면 ${r.vertical.back.text} · WR8 · WR16–19 결번`, + set: (patch) => { + try { + applyAbutRecord(applyWingRebar(rec, patch)); + } catch (err) { + setError((err as Error).message); + renderAll(); + } + }, + }); +} + /** ③ 교대 흉벽 · 브라켓 카드 (시안, 교대흉벽철근.md §10). */ function fillAbutParapetRebarCard(box: HTMLDivElement, rec: AbutRecord, built: { geom: AbutParapetGeom; result: AbutParapetRebar }): void { const { geom, result: r } = built;