diff --git a/index.html b/index.html index ba2aa1d..b71b739 100644 --- a/index.html +++ b/index.html @@ -199,6 +199,7 @@
+

diff --git a/src/core/abut/edit.ts b/src/core/abut/edit.ts index c0d935e..0535fc5 100644 --- a/src/core/abut/edit.ts +++ b/src/core/abut/edit.ts @@ -71,20 +71,23 @@ export function applyShapeEdit(rec: AbutRecord, key: keyof AbutShape, value: num /** 형상 입력 fields on the 교대 카드 (시안 ② 벽체·흉벽, 확대기초·말뚝), in the order of the mockup. */ -export const SHAPE_EDIT_FIELDS: { key: keyof AbutShape; label: string }[] = [ - { key: 'wallFrontMm', label: '교좌면 폭 (흉벽 전면 기준, mm)' }, - { key: 'wallThicknessMm', label: '벽체 두께 (mm)' }, - { key: 'parapetHeightMm', label: '흉벽 높이 (mm)' }, - { key: 'parapetThicknessMm', label: '흉벽 두께 (mm)' }, - { key: 'bracketHeightMm', label: '브라켓 높이 (mm)' }, - { key: 'footingThicknessMm', label: '저판 두께 (mm)' }, - { key: 'footingFrontMm', label: '저판 앞굽 끝 (mm)' }, - { key: 'footingBackMm', label: '저판 뒷굽 끝 (mm)' }, - { key: 'pileEdgeMm', label: '말뚝 연단 (mm)' }, - { key: 'pileSpacingMm', label: '말뚝 간격 (mm)' }, - { key: 'shrinkJointCount', label: '수축줄눈 개수 (개)' }, - { key: 'parapetLevelLeftMm', label: '흉벽 상단 Level 좌 (mm, 계산 미사용)' }, - { key: 'parapetLevelRightMm', label: '흉벽 상단 Level 우 (mm, 계산 미사용)' }, +export const SHAPE_EDIT_FIELDS: { key: keyof AbutShape; label: string; group: 'wall' | 'footing'; note?: string }[] = [ + // 벽체 · 흉벽 (시안 입력 격자 순서). 벽체 두께는 자동 = 브라켓 폭 + 흉벽 두께 + 교좌면 폭. + { key: 'wallFrontMm', label: '교좌면 폭 (mm)', group: 'wall' }, + { key: 'parapetHeightMm', label: '흉벽 높이 (mm)', group: 'wall' }, + { key: 'parapetThicknessMm', label: '흉벽 두께 (mm)', group: 'wall' }, + { key: 'bracketHeightMm', label: '브라켓 높이 (mm)', group: 'wall' }, + { key: 'bracketWidthMm', label: '브라켓 폭 (mm)', group: 'wall' }, + { key: 'shrinkJointCount', label: '수축줄눈 개수', group: 'wall' }, + { key: 'parapetLevelLeftMm', label: '흉벽 상단 Level 좌 (mm)', group: 'wall', note: '기준 확인 QA-4' }, + { key: 'parapetLevelRightMm', label: '흉벽 상단 Level 우 (mm)', group: 'wall', note: '기준 확인 QA-4' }, + { key: 'seatStepIgnoreMm', label: '인접 무시 기준 (mm)', group: 'wall' }, + // 확대기초 · 말뚝 (분리 카드). + { key: 'footingThicknessMm', label: '저판 두께 (mm)', group: 'footing' }, + { key: 'footingFrontMm', label: '저판 앞굽 끝 (mm)', group: 'footing' }, + { key: 'footingBackMm', label: '저판 뒷굽 끝 (mm)', group: 'footing' }, + { key: 'pileEdgeMm', label: '말뚝 연단 (mm)', group: 'footing' }, + { key: 'pileSpacingMm', label: '말뚝 간격 (mm)', group: 'footing' }, ]; /** 받침 규칙 입력칸 (② 교량받침 카드): 교각 솔플레이트 규칙과 같은 칸. 받침높이 H는 교대 받침 시트 값으로 고정 (2026-11-03). */ diff --git a/src/core/abut/member-list.ts b/src/core/abut/member-list.ts index 6e220f9..74297e0 100644 --- a/src/core/abut/member-list.ts +++ b/src/core/abut/member-list.ts @@ -1,13 +1,18 @@ import type { WingForm } from './geometry'; -import { ABUT_SHAPE_DEFAULT, type AbutShape } from './shape'; +import { ABUT_SHAPE_DEFAULT, normalizeShape, wallThicknessMm, type AbutShape } from './shape'; + +/** 형상 입력 중 벽체·흉벽 카드와 확대기초·말뚝 카드의 항목 (변경 표시를 카드별로 나눈다). */ +const WALL_KEYS: (keyof AbutShape)[] = ['wallFrontMm', 'parapetHeightMm', 'parapetThicknessMm', 'bracketHeightMm', 'bracketWidthMm', 'shrinkJointCount', 'parapetLevelLeftMm', 'parapetLevelRightMm', 'seatStepIgnoreMm']; +const FOOTING_KEYS: (keyof AbutShape)[] = ['footingThicknessMm', 'footingFrontMm', 'footingBackMm', 'pileEdgeMm', 'pileSpacingMm']; /** 부재 · 교대 list in ② (시안): the card each row opens. */ -export type AbutMemberId = 'bearing' | 'shape' | 'wing' | 'slab' | 'ground'; +export type AbutMemberId = 'bearing' | 'shape' | 'footing' | 'wing' | 'slab' | 'ground'; /** Cards that take input in this step; slab and ground only show their values (phase 1, 2026-11-03). */ export const ABUT_MEMBER_EDITABLE: Record = { bearing: true, shape: true, + footing: true, wing: true, slab: false, ground: false, @@ -32,14 +37,13 @@ export interface AbutMemberRow { } /** - * The five rows of the 부재 · 교대 list. `slabWidth` is the computed 접속슬래브 폭 text, passed in + * The six rows of the 부재 · 교대 list (시안 순서). `slabWidth` is the computed 접속슬래브 폭 text, passed in * so this stays a pure function of the record. */ export function abutMemberRows(rec: AbutMemberInput, slabWidth: string): AbutMemberRow[] { - const shape = { ...ABUT_SHAPE_DEFAULT, ...(rec.shape ?? {}) }; - const shapeChanged = (Object.keys(ABUT_SHAPE_DEFAULT) as (keyof AbutShape)[]).some( - (key) => (rec.shape?.[key] ?? ABUT_SHAPE_DEFAULT[key]) !== ABUT_SHAPE_DEFAULT[key], - ); + const changes = normalizeShape(rec.shape); + const shape = { ...ABUT_SHAPE_DEFAULT, ...changes }; + const changed = (keys: (keyof AbutShape)[]) => keys.some((key) => (changes[key] ?? ABUT_SHAPE_DEFAULT[key]) !== ABUT_SHAPE_DEFAULT[key]); const wings = rec.wings ?? { left: 'general' as WingForm, right: 'general' as WingForm }; return [ { @@ -51,8 +55,14 @@ export function abutMemberRows(rec: AbutMemberInput, slabWidth: string): AbutMem { id: 'shape', label: '벽체·흉벽', - summary: `벽체 두께 ${shape.wallThicknessMm} · 흉벽 ${shape.parapetHeightMm}`, - changed: shapeChanged, + summary: `벽체 두께 ${wallThicknessMm(shape)} (자동) · 흉벽 ${shape.parapetHeightMm}`, + changed: changed(WALL_KEYS), + }, + { + id: 'footing', + label: '확대기초 · 말뚝', + summary: `저판 ${shape.footingThicknessMm} · 말뚝 연단 ${shape.pileEdgeMm} · 간격 ${shape.pileSpacingMm}`, + changed: changed(FOOTING_KEYS), }, { id: 'wing', diff --git a/src/core/abut/members.ts b/src/core/abut/members.ts index 77bd738..287d95c 100644 --- a/src/core/abut/members.ts +++ b/src/core/abut/members.ts @@ -55,12 +55,17 @@ export function buildMembers(model: AbutModel, foundation: { bottomEl?: number; const footingBottomEl = foundation.bottomEl; const footingTopEl = footingBottomEl === undefined ? NaN : footingBottomEl + shape.footingThicknessM; - const wallSegments: WallSegment[] = sorted.map((b, k) => ({ - sFrom: bounds[k], - sTo: bounds[k + 1], - seatEl: b.seatEl, - heightM: b.seatEl - footingTopEl, - })); + // 인접 무시 기준 (2026-11-03): a step smaller than shape.seatStepIgnoreM joins the segment before it (one 교좌면 EL). + const wallSegments: WallSegment[] = []; + sorted.forEach((b, k) => { + const prev = wallSegments[wallSegments.length - 1]; + // Compared with the neighbouring bearing (인접), not with the first step of the joined run. + if (prev && k > 0 && Math.abs(b.seatEl - sorted[k - 1].seatEl) < shape.seatStepIgnoreM) { + prev.sTo = bounds[k + 1]; + return; + } + wallSegments.push({ sFrom: bounds[k], sTo: bounds[k + 1], seatEl: b.seatEl, heightM: b.seatEl - footingTopEl }); + }); // 받침 ①(첫 입력)과 마지막 입력 받침이 흉벽 상단선을 정한다 (spec §4.4). const first = model.bearings[0]; diff --git a/src/core/abut/shape.ts b/src/core/abut/shape.ts index 391620d..f636c17 100644 --- a/src/core/abut/shape.ts +++ b/src/core/abut/shape.ts @@ -2,15 +2,17 @@ * 교대 형상 입력 (2026-11-03, 시안 ② 벽체·흉벽 · 확대기초·말뚝 카드): the values that used to be constants, in mm. * The defaults are the spec_abut values. A record keeps only the values the user changed; the rest come from here. * - * Axis frame: t (교대축 직각) is + toward the front. 벽체 전면 = wallFrontMm; 배면 = wallFrontMm − wallThicknessMm. - * 흉벽 sits behind the axis (t −parapetThicknessMm … 0). 저판: footingFrontMm (앞굽 끝) … footingBackMm (뒷굽 끝). + * Axis frame: t (교대축 직각) is + toward the front. 벽체 전면 = wallFrontMm (교좌면 폭, 흉벽 전면 t 0에서). + * 흉벽 sits behind the axis (t −parapetThicknessMm … 0); the 브라켓 (bracketWidthMm) behind it. 벽체 배면 = −(흉벽 두께 + 브라켓 폭), + * so 벽체 두께 = 브라켓 폭 + 흉벽 두께 + 교좌면 폭 (시안: 300 + 500 + 1600 = 2400, 자동). 저판: footingFrontMm … footingBackMm. */ export interface AbutShape { wallFrontMm: number; - wallThicknessMm: number; parapetHeightMm: number; parapetThicknessMm: number; bracketHeightMm: number; + /** 브라켓 폭 (mm): 흉벽 배면에서 벽체 배면까지 (시안 300). */ + bracketWidthMm: number; footingThicknessMm: number; footingFrontMm: number; footingBackMm: number; @@ -21,14 +23,16 @@ export interface AbutShape { /** 흉벽 상단 Level 좌·우 (mm). Not used by the geometry (QA-4, 가정). */ parapetLevelLeftMm: number; parapetLevelRightMm: number; + /** 인접 무시 기준 (mm): 이웃 교좌면 EL 차이가 이 값보다 작으면 계단을 두지 않고 한 구간으로 묶는다 (교각 Q2와 같은 뜻). */ + seatStepIgnoreMm: number; } export const ABUT_SHAPE_DEFAULT: AbutShape = { wallFrontMm: 1600, - wallThicknessMm: 2400, parapetHeightMm: 2500, parapetThicknessMm: 500, bracketHeightMm: 850, + bracketWidthMm: 300, footingThicknessMm: 1100, footingFrontMm: 2800, footingBackMm: -1800, @@ -37,8 +41,27 @@ export const ABUT_SHAPE_DEFAULT: AbutShape = { shrinkJointCount: 1, parapetLevelLeftMm: 450, parapetLevelRightMm: 995, + seatStepIgnoreMm: 5, }; +/** + * The user's changes with the old 벽체 두께 input (before 2026-11-03) turned into the 브라켓 폭: + * 브라켓 폭 = 벽체 두께 − 교좌면 폭 − 흉벽 두께. Saved projects keep opening with the same wall. + */ +export function normalizeShape(changes?: Partial & { wallThicknessMm?: number }): Partial { + if (!changes) return {}; + const { wallThicknessMm, ...rest } = changes; + if (wallThicknessMm === undefined || rest.bracketWidthMm !== undefined) return rest; + const front = rest.wallFrontMm ?? ABUT_SHAPE_DEFAULT.wallFrontMm; + const parapet = rest.parapetThicknessMm ?? ABUT_SHAPE_DEFAULT.parapetThicknessMm; + return { ...rest, bracketWidthMm: wallThicknessMm - front - parapet }; +} + +/** 벽체 두께 (mm, 자동) = 브라켓 폭 + 흉벽 두께 + 교좌면 폭. */ +export function wallThicknessMm(v: AbutShape): number { + return v.bracketWidthMm + v.parapetThicknessMm + v.wallFrontMm; +} + /** The same values in m, for the geometry (the model works in m). */ export interface ResolvedShape { wallFrontM: number; @@ -52,6 +75,8 @@ export interface ResolvedShape { pileEdgeM: number; pileSpacingM: number; pileRowsT: number[]; + /** 인접 무시 기준 (m). */ + seatStepIgnoreM: number; } /** The pile rows (t, m) of the spec: 배면 열, 중간 열, 전면 열. Not an input on the card. */ @@ -59,8 +84,8 @@ export const PILE_ROWS_T = [-1.15, 0.85, 2.15]; /** Merges the user's changes over the defaults and converts to m. */ export function resolveShape(changes?: Partial): ResolvedShape { - const v: AbutShape = { ...ABUT_SHAPE_DEFAULT, ...(changes ?? {}) }; - const wallBackMm = v.wallFrontMm - v.wallThicknessMm; + const v: AbutShape = { ...ABUT_SHAPE_DEFAULT, ...normalizeShape(changes) }; + const wallBackMm = -(v.parapetThicknessMm + v.bracketWidthMm); return { wallFrontM: v.wallFrontMm / 1000, wallBackM: wallBackMm / 1000, @@ -73,22 +98,25 @@ export function resolveShape(changes?: Partial): ResolvedShape { pileEdgeM: v.pileEdgeMm / 1000, pileSpacingM: v.pileSpacingMm / 1000, pileRowsT: PILE_ROWS_T, + seatStepIgnoreM: v.seatStepIgnoreMm / 1000, }; } /** Input problems in Korean; empty when the shape can be built. */ export function shapeIssues(changes: Partial): string[] { - const v: AbutShape = { ...ABUT_SHAPE_DEFAULT, ...changes }; + const v: AbutShape = { ...ABUT_SHAPE_DEFAULT, ...normalizeShape(changes) }; const out: string[] = []; - if (!(v.wallThicknessMm > 0)) out.push('벽체 두께는 0보다 커야 합니다.'); + if (!(v.wallFrontMm > 0)) out.push('교좌면 폭은 0보다 커야 합니다.'); + if (!(v.bracketWidthMm >= 0)) out.push('브라켓 폭은 0 이상이어야 합니다.'); if (!(v.parapetHeightMm > 0)) out.push('흉벽 높이는 0보다 커야 합니다.'); if (!(v.parapetThicknessMm > 0)) out.push('흉벽 두께는 0보다 커야 합니다.'); if (!(v.bracketHeightMm >= 0)) out.push('브라켓 높이는 0 이상이어야 합니다.'); if (!(v.footingThicknessMm > 0)) out.push('저판 두께는 0보다 커야 합니다.'); if (!(v.pileEdgeMm > 0) || !(v.pileSpacingMm > 0)) out.push('말뚝 연단과 간격은 0보다 커야 합니다.'); - const wallBack = v.wallFrontMm - v.wallThicknessMm; + const wallBack = -(v.parapetThicknessMm + v.bracketWidthMm); if (!(v.footingFrontMm > v.wallFrontMm)) out.push('저판 앞굽 끝은 벽체 전면보다 앞에 있어야 합니다.'); if (!(v.footingBackMm < wallBack)) out.push('저판 뒷굽 끝은 벽체 배면보다 뒤에 있어야 합니다.'); if (!(Number.isInteger(v.shrinkJointCount) && v.shrinkJointCount >= 0)) out.push('수축줄눈 개수는 0 이상의 정수여야 합니다.'); + if (!(v.seatStepIgnoreMm >= 0)) out.push('인접 무시 기준은 0 이상이어야 합니다.'); return out; } diff --git a/src/main.ts b/src/main.ts index 80887b6..74cea2e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -9,7 +9,8 @@ import { buildAbut } from './core/abut/geometry'; import { structureOrder } from './core/abut/order'; import { ABUT_DETAIL_LABELS, ABUT_SEGMENT_HEADER, abutInputRows, abutSegmentRows, abutSummaryRows } from './core/abut/summary'; import { applyAbutEdit, applyBearingEdit, applyBearingRule, applyShapeEdit, ABUT_EDIT_FIELDS, BEARING_RULE_FIELDS, SHAPE_EDIT_FIELDS, restoreAbutDefault, setWingForm } from './core/abut/edit'; -import { ABUT_SHAPE_DEFAULT, resolveShape } from './core/abut/shape'; +import { ABUT_SHAPE_DEFAULT, normalizeShape, resolveShape, wallThicknessMm } from './core/abut/shape'; +import { abutWallElevationSvg, abutWallSectionSvg, type AbutWallDrawing } from './view/abut-wall-svg'; import { abutShown, piersShown } from './core/abut/visibility'; import { abutBearingPlanSvg, abutBearingSectionSvg, abutSolplateSvg, type AbutBearingDetail } from './view/abut-bearing-svg'; import { @@ -718,7 +719,7 @@ const ABUT_QA_NOTES = [ const ABUT_SLOTS = { shape: { root: 'abut-panel', name: 'abut-panel-name', input: 'abut-panel-input', toggle: 'abut-panel-edit-toggle', editor: 'abut-panel-editor', summary: 'abut-panel-summary', basic: 'abut-panel-basic', bearings: 'abut-panel-bearings', shape: 'abut-panel-shape', msg: 'abut-panel-msg', prefix: 'abs' }, // ② has no 편집 toggle and no basic items: the card sits under the 3D view and shows the picked member (2026-11-03). - plan: { wing: 'abut-panel-plan-wing', root: 'abut-panel-plan', name: 'abut-panel-plan-name', bearings: 'abut-panel-plan-bearings', shape: 'abut-panel-plan-shape', msg: 'abut-panel-plan-msg', prefix: 'abp' }, + plan: { wing: 'abut-panel-plan-wing', footing: 'abut-panel-plan-footing', root: 'abut-panel-plan', name: 'abut-panel-plan-name', bearings: 'abut-panel-plan-bearings', shape: 'abut-panel-plan-shape', msg: 'abut-panel-plan-msg', prefix: 'abp' }, } as const; /** Shows the 교대 입력 in place of the pier info when an abutment is selected (2026-11-03). */ @@ -896,6 +897,7 @@ function applyAbutMemberCard(rec: AbutRecord): void { const member = state.abutMember; $('abut-panel-plan-bearings').hidden = member !== 'bearing'; $('abut-panel-plan-shape').hidden = member !== 'shape'; + $('abut-panel-plan-footing').hidden = member !== 'footing'; $('abut-panel-plan-wing').hidden = member !== 'wing'; // The card title is the picked row; its 요약 follows the name (시안 머리줄). const slabWidth = abutSummaryRows(rec).find(([label]) => label === '접속슬래브 폭')?.[1] ?? '—'; @@ -1022,8 +1024,69 @@ function fillAbutSlot(slot: (typeof ABUT_SLOTS)[keyof typeof ABUT_SLOTS], rec: A bearings.append(zLab, zIn, sLab, sIn); } + // ② 벽체·흉벽 카드 (시안 순서, 2026-11-03): 벽체 형식 → 삽도 2개 → 입력 격자 → 자동 값 → 구간 표 → 확인 필요 → 메모. + // 저판·말뚝 입력은 확대기초 · 말뚝 카드로 나눈다. ①은 모든 형상 입력을 한 곳에 둔다. + const footingBox = 'footing' in slot ? $(slot.footing) : shape; + footingBox.replaceChildren(); + let built: { model: AbutModel; members: AbutMembers } | null = null; + if (planCard) { + try { + const model = buildAbut(toAbutInput(rec)); + built = { model, members: buildMembers(model, { bottomEl: rec.foundationBottomEl, pileLengthMm: rec.pileLengthMm }, rec.shape) }; + } catch { + // A model that does not build shows no 삽도 or 구간 표 (the errors are reported on import). + } + const kinds = el('div', undefined, 'abut-bearing-pick'); + kinds.append(el('span', '벽체 형식', 'muted small')); + for (const [name, on] of [['기본형', true], ['브라켓형', false], ['오목형', false], ['반일체식', false]] as const) { + const b = el('button', name, 'btn small'); + b.type = 'button'; + b.setAttribute('aria-pressed', String(on)); + if (!on) { + b.disabled = true; + b.title = '규칙 미정 (QA-8)'; + } + kinds.append(b); + } + kinds.append(el('span', '브라켓형 · 오목형 · 반일체식은 규칙 미정 (QA-8)', 'muted small')); + shape.append(kinds); + if (built) { + const v = { ...ABUT_SHAPE_DEFAULT, ...normalizeShape(rec.shape) }; + const s = built.members.shape; + const drawing: AbutWallDrawing = { + lengthM: built.model.length, + segments: built.members.wallSegments, + bearings: built.model.bearings, + footingTopEl: built.members.footingTopEl, + footingBottomEl: built.members.footingBottomEl ?? built.members.footingTopEl - s.footingThicknessM, + parapetTopStartEl: built.members.parapetTopStartEl, + parapetTopEndEl: built.members.parapetTopEndEl, + wallFrontM: s.wallFrontM, + parapetBackM: s.parapetBackM, + wallBackM: s.wallBackM, + footingFrontM: s.footingFrontM, + footingBackM: s.footingBackM, + bracketHeightM: s.bracketHeightM, + shrinkJointCount: v.shrinkJointCount, + bracketWidthMm: v.bracketWidthMm, + parapetThicknessMm: v.parapetThicknessMm, + seatWidthMm: v.wallFrontMm, + }; + const figs = el('div', undefined, 'abut-wall-figs'); + for (const [svg, caption] of [ + [abutWallSectionSvg(drawing), '교대축 직각 단면 · ① 구간 · 축척 같음'], + [abutWallElevationSvg(drawing), '정면 · 배면에서 본 모습 · 왼쪽 IP3(① 쪽) → 오른쪽 IP1 · 수축줄눈 표시'], + ]) { + const fig = el('figure', undefined, 'abut-fig'); + fig.innerHTML = svg; + fig.append(el('figcaption', caption, 'muted small')); + figs.append(fig); + } + shape.append(figs); + } + } for (const f of SHAPE_EDIT_FIELDS) { - const current = rec.shape?.[f.key] ?? ABUT_SHAPE_DEFAULT[f.key]; + const current = normalizeShape(rec.shape)[f.key] ?? ABUT_SHAPE_DEFAULT[f.key]; const id = idOf(`shape-${f.key}`); const lab = el('label', f.label); lab.htmlFor = id; @@ -1036,33 +1099,46 @@ function fillAbutSlot(slot: (typeof ABUT_SLOTS)[keyof typeof ABUT_SLOTS], rec: A fail(err, input, String(current)); } }); - shape.append(lab, input, el('span', `기본 ${ABUT_SHAPE_DEFAULT[f.key]}`, 'muted small')); + const note = el('span', `기본 ${ABUT_SHAPE_DEFAULT[f.key]}${f.note ? ` · ${f.note}` : ''}`, f.note ? 'muted small abut-note-warn' : 'muted small'); + (planCard && f.group === 'footing' ? footingBox : shape).append(lab, input, note); } - // 구간 표 (시안 하단): one row per 교좌면 step, from the model (2026-11-03). if (planCard) { - try { - const model = buildAbut(toAbutInput(rec)); - const members = buildMembers(model, { bottomEl: rec.foundationBottomEl, pileLengthMm: rec.pileLengthMm }, rec.shape); + // 자동 값 (시안 입력 격자 마지막 줄): 벽체 두께, 흉벽 상단 IP3 / IP1 끝 (가정, QA-4), 교대축. + const v = { ...ABUT_SHAPE_DEFAULT, ...normalizeShape(rec.shape) }; + shape.append(el('label', '벽체 두께 (자동)'), el('span', `${wallThicknessMm(v)} mm`, 'num'), el('span', '= 브라켓 폭 + 흉벽 두께 + 교좌면 폭', 'muted small')); + if (built) { + shape.append( + el('label', '흉벽 상단 IP3 / IP1 끝 (m)'), + el('span', `${built.members.parapetTopEndEl.toFixed(3)} / ${built.members.parapetTopStartEl.toFixed(3)}`, 'num'), + el('span', '가정 (QA-4)', 'muted small abut-note-warn'), + el('label', '교대축 (자동)'), + el('span', `${built.model.length.toFixed(3)} m`, 'num'), + el('span', 'IP1 → IP3', 'muted small'), + ); + // 구간 표 (시안 하단): one row per 교좌면 step. const table = document.createElement('table'); table.className = 'abut-seg'; const thead = table.createTHead().insertRow(); for (const h of ABUT_SEGMENT_HEADER) thead.append(el('th', h)); const tbody = table.createTBody(); - for (const row of abutSegmentRows(model, members)) { + for (const row of abutSegmentRows(built.model, built.members)) { const tr = tbody.insertRow(); for (const cell of row) tr.append(el('td', cell)); } - shape.append(table); - } catch { - // A model that does not build shows no 구간 표 (the errors are reported on import). + shape.append(el('p', '교좌면 계단 구간 · IP3(① 쪽)에서 잰 거리', 'lbl'), table); } - } - // 확인 필요 (spec_abut, 시안 오른쪽 패널): the open 교대 questions that touch the shape (2026-11-03). - if (planCard) { + // 확인 필요 (spec_abut, 시안 오른쪽 패널): the open 교대 questions that touch the shape (2026-11-03). const qa = el('div', undefined, 'abut-qa'); qa.append(el('p', '확인 필요 (spec_abut)', 'lbl')); for (const line of ABUT_QA_NOTES) qa.append(el('p', line, 'muted small')); shape.append(qa); + shape.append( + el( + 'p', + '시안 메모 · 벽체는 교좌면 계단 구간마다 높이가 다릅니다. 흉벽 높이 2500은 ① 구간 교좌면 기준(가정, QA-4)이고, Level 좌·우는 계산에 쓰지 않습니다. 인접 무시 기준보다 작은 교좌면 단차는 한 구간으로 묶습니다.', + 'muted small abut-memo', + ), + ); } } diff --git a/src/style.css b/src/style.css index 6da6275..ffc6394 100644 --- a/src/style.css +++ b/src/style.css @@ -910,3 +910,11 @@ body { background: #eef1f4; font-family: 'Noto Sans KR', system-ui, sans-serif; #abut-panel-plan .abut-fig svg { display: block; width: 100%; height: auto; } #abut-panel-plan > .edit-row { padding: 0 12px 10px; } @media (max-width: 900px) { #abut-panel-plan .abut-bearing-figs { grid-template-columns: minmax(0, 1fr); } } + +/* ② 벽체·흉벽 / 확대기초·말뚝 카드 (2026-11-03): the two 삽도 side by side, notes and tables across the card. */ +#abut-panel-plan-footing.abut-shape-view { display: grid; grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr) minmax(0, 1fr); gap: 6px 10px; align-items: center; } +#abut-panel-plan .abut-wall-figs { grid-column: 1 / -1; display: grid; grid-template-columns: minmax(0, 2fr) minmax(0, 3fr); gap: 10px; } +#abut-panel-plan-shape > p, #abut-panel-plan-shape > .abut-wall-figs { grid-column: 1 / -1; } +#abut-panel-plan .abut-note-warn { color: #8a5a00; } +#abut-panel-plan .abut-memo { background: #f6f8fa; border: 1px solid #d5dbe2; border-radius: 6px; padding: 8px; } +@media (max-width: 900px) { #abut-panel-plan .abut-wall-figs { grid-template-columns: minmax(0, 1fr); } } diff --git a/src/view/abut-wall-svg.ts b/src/view/abut-wall-svg.ts new file mode 100644 index 0000000..9ab81b3 --- /dev/null +++ b/src/view/abut-wall-svg.ts @@ -0,0 +1,134 @@ +/** + * 교대 벽체·흉벽 삽도 (2026-11-03, 시안 ② 벽체·흉벽 카드): 교대축 직각 단면(① 구간)과 정면·배면도. 순수 문자열 함수. + * 생성본(교대 모델·부재)의 값으로 그린다. 두 그림 모두 가로·세로 축척이 같다(시안 "축척 같음"). + */ + +export interface AbutWallDrawing { + /** 교대축 길이 L (m), IP1 → IP3. */ + lengthM: number; + /** 교좌면 계단 구간 (s, m는 IP1에서), 구간마다 교좌면 EL. */ + segments: { sFrom: number; sTo: number; seatEl: number }[]; + /** 받침 (s, m), 받침 번호, 패드 상면 z (m), 교좌면 EL (m). */ + bearings: { no: number; s: number; z: number; seatEl: number }[]; + footingTopEl: number; + footingBottomEl: number; + /** 흉벽 상단 EL at s = 0 (IP1 끝) and s = L (IP3 끝). */ + parapetTopStartEl: number; + parapetTopEndEl: number; + /** t (m, 전면 +): 벽체 전면, 흉벽 배면, 벽체 배면, 저판 앞굽 끝·뒷굽 끝. */ + wallFrontM: number; + parapetBackM: number; + wallBackM: number; + footingFrontM: number; + footingBackM: number; + bracketHeightM: number; + shrinkJointCount: number; + /** 시안 치수 줄 "브라켓 폭 + 흉벽 두께 + 교좌면 폭 = 벽체 두께" (mm). */ + bracketWidthMm: number; + parapetThicknessMm: number; + seatWidthMm: number; +} + +const INK = '#18212b'; +const MUTED = '#55606e'; +const CONC = '#d9c9a8'; +const CONC_DARK = '#c7b48d'; +const FOOT = '#b9c3ce'; +const t3 = (v: number) => v.toFixed(3); +const label = (x: number, y: number, s: string, anchor = 'middle', fill = INK) => + `${s}`; +const poly = (pts: [number, number][], style: string) => ``; + +/** 흉벽 상단 EL at s (linear between the two ends). */ +function parapetTopAt(d: AbutWallDrawing, s: number): number { + return d.parapetTopStartEl + ((d.parapetTopEndEl - d.parapetTopStartEl) * s) / d.lengthM; +} + +/** + * 교대축 직각 단면 (① 구간 = 받침 1번이 놓인 구간): 저판, 벽체(기초 상면 ~ 교좌면), 흉벽 배면 단차부(브라켓 상면까지), + * 흉벽(흉벽 상단까지), 받침 블럭. 왼쪽이 배면, 오른쪽이 전면. + */ +export function abutWallSectionSvg(d: AbutWallDrawing): string { + const b1 = d.bearings.find((b) => b.no === 1) ?? d.bearings[0]; + const seg = d.segments.find((g) => b1.s >= g.sFrom && b1.s <= g.sTo) ?? d.segments[0]; + const sAt = b1.s; + const top = parapetTopAt(d, sAt); + const bracket = top - d.bracketHeightM; + const k = 46; // px per m, both directions + const tMin = d.footingBackM - 0.6; + const elMax = top + 0.7; + const X = (t: number) => (t - tMin) * k; + const Y = (el: number) => (elMax - el) * k + 10; + const W = (d.footingFrontM + 1.4 - tMin) * k; + const H = Y(d.footingBottomEl) + 40; + const rect = (t0: number, t1: number, e0: number, e1: number, style: string) => + poly([[X(t0), Y(e0)], [X(t1), Y(e0)], [X(t1), Y(e1)], [X(t0), Y(e1)]], style); + const halfBlock = 0.6; + const blockT = (d.wallFrontM + 0) / 2; // drawn at the middle of the 교좌면 width + return [ + ``, + rect(d.footingBackM, d.footingFrontM, d.footingBottomEl, d.footingTopEl, `fill="${FOOT}" stroke="${MUTED}"`), + rect(d.wallBackM, d.wallFrontM, d.footingTopEl, seg.seatEl, `fill="${CONC}" stroke="${MUTED}"`), + rect(d.wallBackM, d.parapetBackM, seg.seatEl, bracket, `fill="${CONC_DARK}" stroke="${MUTED}"`), + rect(d.parapetBackM, 0, seg.seatEl, top, `fill="${CONC_DARK}" stroke="${MUTED}"`), + rect(blockT - halfBlock, blockT + halfBlock, seg.seatEl, b1.z, `fill="#9aa7b5" stroke="${INK}"`), + ``, + label(X(0), 14, '교대축', 'middle', '#c0392b'), + label((X(d.wallBackM) + X(d.wallFrontM)) / 2, Y(top) - 18, `${d.bracketWidthMm} + ${d.parapetThicknessMm} + ${d.seatWidthMm} = ${d.bracketWidthMm + d.parapetThicknessMm + d.seatWidthMm}`), + label(X(0) + 6, Y(top) - 4, `흉벽 상단 ${t3(top)}`, 'start'), + label(X(d.wallBackM) - 6, Y(bracket) + 4, `브라켓 상면 ${t3(bracket)}`, 'end'), + label(X(d.wallFrontM) + 6, Y(seg.seatEl) + 4, `교좌면 ${t3(seg.seatEl)}`, 'start'), + label(X(d.wallFrontM) + 6, Y((seg.seatEl + d.footingTopEl) / 2) + 4, `벽체 ${(seg.seatEl - d.footingTopEl).toFixed(3)}`, 'start'), + label(X(d.footingFrontM) + 6, Y(d.footingTopEl) + 4, `상면 ${t3(d.footingTopEl)}`, 'start'), + label(X(d.footingFrontM) + 6, Y(d.footingBottomEl) + 4, `기초 하단 ${t3(d.footingBottomEl)}`, 'start'), + label(X(d.footingBackM), H - 8, '← 배면', 'start', MUTED), + label(X(d.footingFrontM), H - 8, '전면 →', 'end', MUTED), + '', + ].join(''); +} + +/** + * 정면·배면도: 교좌면 계단(구간별 교좌면 EL), 흉벽 상단 경사, 받침 블럭, 수축줄눈, 저판. 왼쪽 IP3(① 쪽), 오른쪽 IP1. + */ +export function abutWallElevationSvg(d: AbutWallDrawing): string { + const k = 46; + const top = Math.max(d.parapetTopStartEl, d.parapetTopEndEl); + const elMax = top + 0.6; + const pad = 40; + const X = (s: number) => pad + (d.lengthM - s) * k; // IP3 (s = L) on the left + const Y = (el: number) => (elMax - el) * k + 10; + const W = d.lengthM * k + 2 * pad; + const H = Y(d.footingBottomEl) + 40; + const parts: string[] = []; + parts.push(poly([[X(d.lengthM), Y(d.footingTopEl)], [X(0), Y(d.footingTopEl)], [X(0), Y(d.footingBottomEl)], [X(d.lengthM), Y(d.footingBottomEl)]], `fill="${FOOT}" stroke="${MUTED}"`)); + // 흉벽 (behind) from the 교좌면 steps up to the sloped top line. + parts.push(poly([[X(d.lengthM), Y(d.footingTopEl)], [X(0), Y(d.footingTopEl)], [X(0), Y(d.parapetTopStartEl)], [X(d.lengthM), Y(d.parapetTopEndEl)]], `fill="${CONC_DARK}" stroke="${MUTED}"`)); + // 벽체 front: one block per 교좌면 step. + for (const g of d.segments) { + parts.push(poly([[X(g.sTo), Y(d.footingTopEl)], [X(g.sFrom), Y(d.footingTopEl)], [X(g.sFrom), Y(g.seatEl)], [X(g.sTo), Y(g.seatEl)]], `fill="${CONC}" stroke="${MUTED}"`)); + } + for (const b of d.bearings) { + parts.push(poly([[X(b.s + 0.6), Y(b.seatEl)], [X(b.s - 0.6), Y(b.seatEl)], [X(b.s - 0.6), Y(b.z)], [X(b.s + 0.6), Y(b.z)]], `fill="#9aa7b5" stroke="${INK}"`)); + parts.push(label(X(b.s), Y(b.z) - 6, ['①', '②', '③', '④', '⑤', '⑥', '⑦', '⑧'][b.no - 1] ?? String(b.no))); + } + for (let j = 1; j <= d.shrinkJointCount; j++) { + const s = (d.lengthM * j) / (d.shrinkJointCount + 1); + parts.push(``); + parts.push(label(X(s) + 4, Y(d.footingTopEl) - 8, `수축줄눈 ${j}`, 'start')); + } + const first = d.segments.find((g) => d.lengthM >= g.sFrom && d.lengthM <= g.sTo) ?? d.segments[d.segments.length - 1]; + const last = d.segments.find((g) => g.sFrom <= 0 && g.sTo >= 0) ?? d.segments[0]; + parts.push(label(X(d.lengthM), Y(d.parapetTopEndEl) - 6, t3(d.parapetTopEndEl), 'start')); + parts.push(label(X(0), Y(d.parapetTopStartEl) - 6, t3(d.parapetTopStartEl), 'end')); + parts.push(label(X(d.lengthM) - 4, Y(first.seatEl) + 4, t3(first.seatEl), 'end')); + parts.push(label(X(0) + 4, Y(last.seatEl) + 4, t3(last.seatEl), 'start')); + parts.push(label(X(d.lengthM) - 4, Y(d.footingBottomEl) + 4, t3(d.footingBottomEl), 'end')); + parts.push(label(X(d.lengthM), H - 8, 'IP3', 'middle', MUTED)); + parts.push(label(X(0), H - 8, 'IP1', 'middle', MUTED)); + parts.push(label((X(0) + X(d.lengthM)) / 2, H - 8, `${d.lengthM.toFixed(3)} m`)); + return [ + ``, + ...parts, + '', + ].join(''); +} diff --git a/tests/abut-member-list.test.ts b/tests/abut-member-list.test.ts index 419998e..e72812f 100644 --- a/tests/abut-member-list.test.ts +++ b/tests/abut-member-list.test.ts @@ -7,9 +7,9 @@ const base = { }; describe('교대 부재 목록 (시안 ② 부재 · 교대)', () => { - it('has the five rows of the mockup, in order', () => { + it('has the six rows of the mockup, in order', () => { const rows = abutMemberRows(base, '11.253 m'); - expect(rows.map((r) => r.id)).toEqual(['bearing', 'shape', 'wing', 'slab', 'ground']); + expect(rows.map((r) => r.id)).toEqual(['bearing', 'shape', 'footing', 'wing', 'slab', 'ground']); expect(rows[0].label).toBe('교량받침·받침블럭·솔플레이트'); }); @@ -20,7 +20,11 @@ describe('교대 부재 목록 (시안 ② 부재 · 교대)', () => { it('marks the shape when a value differs from the default', () => { const rows = abutMemberRows({ ...base, shape: { parapetHeightMm: 2200 } }, '—'); expect(rows.find((r) => r.id === 'shape')?.changed).toBe(true); - expect(rows.find((r) => r.id === 'shape')?.summary).toBe('벽체 두께 2400 · 흉벽 2200'); + expect(rows.find((r) => r.id === 'shape')?.summary).toBe('벽체 두께 2400 (자동) · 흉벽 2200'); + expect(rows.find((r) => r.id === 'footing')?.changed).toBe(false); + const foot = abutMemberRows({ ...base, shape: { footingThicknessMm: 1300 } }, '—'); + expect(foot.find((r) => r.id === 'footing')?.changed).toBe(true); + expect(foot.find((r) => r.id === 'shape')?.changed).toBe(false); }); it('marks the wing when a side is not the general form, and names both sides', () => { @@ -41,7 +45,7 @@ describe('교대 부재 목록 (시안 ② 부재 · 교대)', () => { expect(rows.find((r) => r.id === 'ground')?.summary).toBe('지반고 33.984 m'); }); - it('only the bearing, shape and wing cards take input in this step', () => { - expect(ABUT_MEMBER_EDITABLE).toEqual({ bearing: true, shape: true, wing: true, slab: false, ground: false }); + it('only the bearing, shape, footing and wing cards take input in this step', () => { + expect(ABUT_MEMBER_EDITABLE).toEqual({ bearing: true, shape: true, footing: true, wing: true, slab: false, ground: false }); }); }); diff --git a/tests/abut-shape.test.ts b/tests/abut-shape.test.ts index 814eb39..4ccae87 100644 --- a/tests/abut-shape.test.ts +++ b/tests/abut-shape.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { ABUT_SHAPE_DEFAULT, resolveShape, shapeIssues } from '../src/core/abut/shape'; +import { ABUT_SHAPE_DEFAULT, normalizeShape, resolveShape, shapeIssues, wallThicknessMm, type AbutShape } from '../src/core/abut/shape'; describe('교대 형상 입력 (시안 ② 벽체·흉벽 · 확대기초·말뚝)', () => { it('the defaults equal the spec values (wall +1.6 … −0.8, footing +2.8 … −1.8, piles 650 / 2000)', () => { @@ -11,17 +11,21 @@ describe('교대 형상 입력 (시안 ② 벽체·흉벽 · 확대기초·말 expect(r.footingBackM).toBeCloseTo(-1.8, 9); expect(r.pileEdgeM).toBeCloseTo(0.65, 9); expect(shapeIssues({})).toEqual([]); - expect(ABUT_SHAPE_DEFAULT.wallThicknessMm).toBe(2400); + expect(ABUT_SHAPE_DEFAULT.bracketWidthMm).toBe(300); + expect(wallThicknessMm(ABUT_SHAPE_DEFAULT)).toBe(2400); // 300 + 500 + 1600 (자동) }); - it('a changed wall thickness moves the back face (front − thickness)', () => { - expect(resolveShape({ wallThicknessMm: 2000 }).wallBackM).toBeCloseTo(-0.4, 9); + it('a changed 브라켓 폭 moves the back face; an old saved 벽체 두께 still opens as the same wall', () => { + expect(resolveShape({ bracketWidthMm: 0 }).wallBackM).toBeCloseTo(-0.5, 9); + expect(normalizeShape({ wallThicknessMm: 2000 } as Partial)).toEqual({ bracketWidthMm: -100 }); + expect(resolveShape({ wallThicknessMm: 2000 } as Partial).wallBackM).toBeCloseTo(-0.4, 9); }); it('refuses a footing that does not cover the wall, and non-positive sizes', () => { expect(shapeIssues({ footingBackMm: -500 }).join(' ')).toContain('뒷굽'); expect(shapeIssues({ footingFrontMm: 1000 }).join(' ')).toContain('앞굽'); - expect(shapeIssues({ wallThicknessMm: 0 }).join(' ')).toContain('벽체 두께'); + expect(shapeIssues({ bracketWidthMm: -1 }).join(' ')).toContain('브라켓 폭'); + expect(shapeIssues({ seatStepIgnoreMm: -1 }).join(' ')).toContain('인접 무시 기준'); expect(shapeIssues({ pileSpacingMm: 0 }).join(' ')).toContain('말뚝'); }); }); @@ -40,7 +44,8 @@ const rec = parseAbutments(XLSX.read(readFileSync(RAG), { type: 'buffer' })).abu describe('형상 입력이 계산에 반영됨 (시안 ② 카드)', () => { it('the card has one field per shape value, in the mockup order', () => { expect(SHAPE_EDIT_FIELDS.map((f) => f.key)[0]).toBe('wallFrontMm'); - expect(SHAPE_EDIT_FIELDS).toHaveLength(13); + expect(SHAPE_EDIT_FIELDS).toHaveLength(14); + expect(SHAPE_EDIT_FIELDS.filter((f) => f.group === 'footing').map((f) => f.key)).toEqual(['footingThicknessMm', 'footingFrontMm', 'footingBackMm', 'pileEdgeMm', 'pileSpacingMm']); }); it('the new drawing values do not change the geometry (the model ignores them)', () => { @@ -71,4 +76,11 @@ describe('형상 입력이 계산에 반영됨 (시안 ② 카드)', () => { const wider = buildMembers(model, {}, { pileSpacingMm: 3000 }); expect(wider.piles[0].s.length).toBeLessThan(6); }); + + it('인접 무시 기준: steps smaller than the value join one segment (A1 steps are 71 mm)', () => { + const model = buildAbut({ name: 'A1', type: 'A1', seatEl: rec.seatEl, ip1: rec.ip1, ip2: rec.ip2, ip3: rec.ip3, bearings: rec.bearings.map((b) => ({ no: b.no, pos: b.pos, z: b.z, seatEl: b.seatEl })) }); + const foundation = { bottomEl: rec.foundationBottomEl, pileLengthMm: rec.pileLengthMm }; + expect(buildMembers(model, foundation).wallSegments).toHaveLength(5); + expect(buildMembers(model, foundation, { seatStepIgnoreMm: 100 }).wallSegments).toHaveLength(1); + }); }); diff --git a/tests/abut-wall-svg.test.ts b/tests/abut-wall-svg.test.ts new file mode 100644 index 0000000..4f623d4 --- /dev/null +++ b/tests/abut-wall-svg.test.ts @@ -0,0 +1,56 @@ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import * as XLSX from 'xlsx'; +import { describe, expect, it } from 'vitest'; +import { buildAbut } from '../src/core/abut/geometry'; +import { parseAbutments, toAbutInput } from '../src/core/abut/parse'; +import { buildMembers } from '../src/core/abut/members'; +import { abutWallElevationSvg, abutWallSectionSvg, type AbutWallDrawing } from '../src/view/abut-wall-svg'; + +const RAG = fileURLToPath(new URL('../../../RAG/운곡교P1입력.xlsx', import.meta.url)); +const rec = parseAbutments(XLSX.read(readFileSync(RAG), { type: 'buffer' })).abutments[0]; +const input = toAbutInput(rec); +const model = buildAbut(input); +const m = buildMembers(model, { bottomEl: input.foundationBottomEl, pileLengthMm: input.pileLengthMm }); +const d: AbutWallDrawing = { + lengthM: model.length, + segments: m.wallSegments, + bearings: model.bearings, + footingTopEl: m.footingTopEl, + footingBottomEl: m.footingBottomEl!, + parapetTopStartEl: m.parapetTopStartEl, + parapetTopEndEl: m.parapetTopEndEl, + wallFrontM: m.shape.wallFrontM, + parapetBackM: m.shape.parapetBackM, + wallBackM: m.shape.wallBackM, + footingFrontM: m.shape.footingFrontM, + footingBackM: m.shape.footingBackM, + bracketHeightM: m.shape.bracketHeightM, + shrinkJointCount: 1, + bracketWidthMm: 300, + parapetThicknessMm: 500, + seatWidthMm: 1600, +}; + +describe('교대 벽체·흉벽 삽도 (시안 ② 벽체·흉벽 카드)', () => { + it('단면 (① 구간): the width line and the levels of the generated model', () => { + const svg = abutWallSectionSvg(d); + for (const s of ['300 + 500 + 1600 = 2400', '교좌면 44.367', '벽체 2.372', '상면 41.995', '기초 하단 40.895', '교대축', '← 배면', '전면 →']) { + expect(svg).toContain(s); + } + expect(svg).toMatch(/흉벽 상단 4[67]\.\d{3}/); + expect(svg).toMatch(/브라켓 상면 4[56]\.\d{3}/); + }); + + it('정면·배면도: five steps, five bearings, the sloped 흉벽 top (IP3 46.835 → IP1 47.180) and the 수축줄눈', () => { + const svg = abutWallElevationSvg(d); + for (const s of ['IP3', 'IP1', `${model.length.toFixed(3)} m`, '수축줄눈 1', '46.835', '47.180', '①', '⑤']) expect(svg).toContain(s); + expect(svg).toContain('교좌면 계단 5구간'); + }); + + it('the 수축줄눈 count follows the input', () => { + const svg = abutWallElevationSvg({ ...d, shrinkJointCount: 2 }); + expect(svg).toContain('수축줄눈 2'); + expect(svg.match(/stroke-dasharray="5 3"/g)).toHaveLength(2); + }); +});