From 372d759eb7514ca922be36f466d4086eb63c6cea Mon Sep 17 00:00:00 2001 From: ypshim Date: Wed, 4 Nov 2026 08:46:58 +0900 Subject: [PATCH] =?UTF-8?q?feat(abut):=20=E2=91=A1=20=ED=99=95=EB=8C=80?= =?UTF-8?q?=EA=B8=B0=EC=B4=88=20=C2=B7=20=EB=A7=90=EB=9A=9D=20=EC=B9=B4?= =?UTF-8?q?=EB=93=9C=EB=A5=BC=20=EC=8B=9C=EC=95=88=EB=8C=80=EB=A1=9C=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1=20(=ED=8F=89=EB=A9=B4=20=EC=82=BD=EB=8F=84,?= =?UTF-8?q?=20=EB=B2=84=EB=A6=BC,=20=EC=97=B4=EB=B3=84=20=EB=B3=B8?= =?UTF-8?q?=EC=88=98)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 선택: 평면 skew 평행사변형(기본) / 직사각(비활성, 다음 단계), 말뚝 원기둥 D600 / 강관 · PHC(비활성, 규칙 미정) - 평면 삽도 (src/view/abut-footing-svg.ts): 저판, 벽체 윤곽, 교대축, 말뚝 3열(skew만큼 밀림), 열 위치 · 본수, 폭 치수 - 입력: 폭 4600, 두께 1100, 앞굽 1200 (뒷굽 자동 1000), 버림 여유 · 두께 100 / 100, 말뚝 지름 600 (QA-5), 연단 650, 본수 규칙 간격 2000 - 열 위치 "650 · 2000 · 1300 · 650" 입력 (전면 연단 자동, 합 = 폭 검사), 열별 말뚝 본수 표 − / + (0 = 본수 규칙) - 자동 값: 뒷굽, 길이(교좌면 길이), 말뚝 길이(①에서 수정), 선단 EL, 본수 규칙 문구. 시안 메모 - 3D: 교대 버림을 저판 아래에 그림 (부재 표시 "버림" 토글 적용), 말뚝 지름 입력 반영, 말뚝 열을 skew만큼 s 방향으로 밀어 배치 - 예전 저장 파일의 앞굽 끝 · 뒷굽 끝은 폭 · 앞굽으로 바꿔 읽음 - 테스트 추가 · 갱신. 전체 661건 통과, 타입 검사 · 빌드 통과. 브라우저 시각 확인은 미실시 Co-Authored-By: Claude Opus 5.5 --- src/core/abut/edit.ts | 24 ++++-- src/core/abut/member-list.ts | 5 +- src/core/abut/members.ts | 13 ++-- src/core/abut/shape.ts | 103 +++++++++++++++++++++----- src/main.ts | 140 ++++++++++++++++++++++++++++++++++- src/style.css | 7 ++ src/view/abut-footing-svg.ts | 59 +++++++++++++++ src/view/abut-scene.ts | 30 +++++++- tests/abut-footing.test.ts | 59 +++++++++++++++ tests/abut-shape.test.ts | 31 ++++++-- 10 files changed, 425 insertions(+), 46 deletions(-) create mode 100644 src/view/abut-footing-svg.ts create mode 100644 tests/abut-footing.test.ts diff --git a/src/core/abut/edit.ts b/src/core/abut/edit.ts index 0535fc5..7d406ac 100644 --- a/src/core/abut/edit.ts +++ b/src/core/abut/edit.ts @@ -61,6 +61,15 @@ export function restoreAbutDefault(defaults: AbutRecord): AbutRecord { } /** 형상 입력 change (벽체·흉벽, 확대기초·말뚝, mm): refused with a Korean message when the shape would not build. */ +/** Several shape values at once (e.g. the 열 위치 "650 · 2000 · 1300 · 650"), checked together. */ +export function applyShapeEdits(rec: AbutRecord, values: Partial): AbutRecord { + for (const v of Object.values(values)) if (!Number.isFinite(v)) throw new Error('숫자를 입력하세요.'); + const shape = { ...(rec.shape ?? {}), ...values }; + const issues = shapeIssues(shape); + if (issues.length > 0) throw new Error(issues[0]); + return { ...rec, shape }; +} + export function applyShapeEdit(rec: AbutRecord, key: keyof AbutShape, value: number): AbutRecord { if (!Number.isFinite(value)) throw new Error('숫자를 입력하세요.'); const shape = { ...(rec.shape ?? {}), [key]: value }; @@ -82,12 +91,15 @@ export const SHAPE_EDIT_FIELDS: { key: keyof AbutShape; label: string; group: 'w { 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' }, + // 확대기초 · 말뚝 (분리 카드, 시안 순서). 뒷굽 · 길이 · 선단 EL은 자동, 열 위치와 열별 본수는 카드에서 따로 입력한다. + { key: 'footingWidthMm', label: '폭 (mm)', group: 'footing' }, + { key: 'footingThicknessMm', label: '두께 (mm)', group: 'footing' }, + { key: 'footingToeMm', label: '앞굽 (mm)', group: 'footing' }, + { key: 'blindingMarginMm', label: '버림 여유 한쪽 (mm)', group: 'footing' }, + { key: 'blindingThicknessMm', label: '버림 두께 (mm)', group: 'footing' }, + { key: 'pileDiameterMm', label: '말뚝 지름 (mm)', group: 'footing', note: 'QA-5' }, + { 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 74297e0..522dcee 100644 --- a/src/core/abut/member-list.ts +++ b/src/core/abut/member-list.ts @@ -3,7 +3,10 @@ import { ABUT_SHAPE_DEFAULT, normalizeShape, wallThicknessMm, type AbutShape } f /** 형상 입력 중 벽체·흉벽 카드와 확대기초·말뚝 카드의 항목 (변경 표시를 카드별로 나눈다). */ const WALL_KEYS: (keyof AbutShape)[] = ['wallFrontMm', 'parapetHeightMm', 'parapetThicknessMm', 'bracketHeightMm', 'bracketWidthMm', 'shrinkJointCount', 'parapetLevelLeftMm', 'parapetLevelRightMm', 'seatStepIgnoreMm']; -const FOOTING_KEYS: (keyof AbutShape)[] = ['footingThicknessMm', 'footingFrontMm', 'footingBackMm', 'pileEdgeMm', 'pileSpacingMm']; +const FOOTING_KEYS: (keyof AbutShape)[] = [ + 'footingWidthMm', 'footingThicknessMm', 'footingToeMm', 'blindingMarginMm', 'blindingThicknessMm', 'pileDiameterMm', 'pileEdgeMm', 'pileSpacingMm', + 'pileRowBackEdgeMm', 'pileRowGap1Mm', 'pileRowGap2Mm', 'pileCountRow1', 'pileCountRow2', 'pileCountRow3', +]; /** 부재 · 교대 list in ② (시안): the card each row opens. */ export type AbutMemberId = 'bearing' | 'shape' | 'footing' | 'wing' | 'slab' | 'ground'; diff --git a/src/core/abut/members.ts b/src/core/abut/members.ts index 287d95c..3e1542e 100644 --- a/src/core/abut/members.ts +++ b/src/core/abut/members.ts @@ -74,12 +74,13 @@ export function buildMembers(model: AbutModel, foundation: { bottomEl?: number; const parapetTopStartEl = parapetTopEl(first.seatEl, first.s, slope, 0, shape.parapetHeightM); const parapetTopEndEl = parapetTopEl(first.seatEl, first.s, slope, L, shape.parapetHeightM); - const count = Math.floor((L - 2 * shape.pileEdgeM) / shape.pileSpacingM) + 1; - const spacing = count > 1 ? (L - 2 * shape.pileEdgeM) / (count - 1) : 0; - const piles: PileRow[] = shape.pileRowsT.map((t) => ({ - t, - s: Array.from({ length: count }, (_, k) => shape.pileEdgeM + k * spacing), - })); + // 본수 규칙 int((L − 2·연단)/간격) + 1; 열별 본수가 0이 아니면 그 값 (2026-11-04, 시안 열별 말뚝 본수). + const ruleCount = Math.floor((L - 2 * shape.pileEdgeM) / shape.pileSpacingM) + 1; + const piles: PileRow[] = shape.pileRowsT.map((t, r) => { + const count = shape.pileCounts[r] > 0 ? shape.pileCounts[r] : ruleCount; + const spacing = count > 1 ? (L - 2 * shape.pileEdgeM) / (count - 1) : 0; + return { t, s: Array.from({ length: count }, (_, k) => shape.pileEdgeM + k * spacing) }; + }); const pileBottomEl = footingBottomEl === undefined || foundation.pileLengthMm === undefined ? undefined : footingBottomEl - foundation.pileLengthMm / 1000; return { diff --git a/src/core/abut/shape.ts b/src/core/abut/shape.ts index f636c17..4d9db45 100644 --- a/src/core/abut/shape.ts +++ b/src/core/abut/shape.ts @@ -4,7 +4,8 @@ * * 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. + * so 벽체 두께 = 브라켓 폭 + 흉벽 두께 + 교좌면 폭 (시안: 300 + 500 + 1600 = 2400, 자동). + * 저판 (시안 확대기초·말뚝): 폭과 앞굽을 입력하고 뒷굽은 자동 (폭 − 앞굽 − 벽체 두께). 앞굽 끝 t = 교좌면 폭 + 앞굽, 뒷굽 끝 = 앞굽 끝 − 폭. */ export interface AbutShape { wallFrontMm: number; @@ -14,10 +15,25 @@ export interface AbutShape { /** 브라켓 폭 (mm): 흉벽 배면에서 벽체 배면까지 (시안 300). */ bracketWidthMm: number; footingThicknessMm: number; - footingFrontMm: number; - footingBackMm: number; + /** 저판 폭 (mm, 교대축 직각) and 앞굽 (mm, 벽체 전면에서). 뒷굽은 자동. */ + footingWidthMm: number; + footingToeMm: number; + /** 말뚝 연단 (길이 방향, mm)과 본수 규칙의 간격 (mm): 본수 = int((L − 2·연단)/간격) + 1. */ pileEdgeMm: number; pileSpacingMm: number; + /** 말뚝 지름 (mm, 원기둥, QA-5). */ + pileDiameterMm: number; + /** 말뚝 열 위치 (mm, 저판 배면부터): 배면 연단, 3열→2열 간격, 2열→1열 간격. 전면 연단은 자동 (폭 − 합). */ + pileRowBackEdgeMm: number; + pileRowGap1Mm: number; + pileRowGap2Mm: number; + /** 열별 본수 (1열 전면, 2열, 3열 배면). 0 = 본수 규칙대로. */ + pileCountRow1: number; + pileCountRow2: number; + pileCountRow3: number; + /** 버림 여유 (한쪽, mm)와 두께 (mm). */ + blindingMarginMm: number; + blindingThicknessMm: number; /** 수축줄눈 개수 (개). Shown in the 삽도; not used by the geometry yet. */ shrinkJointCount: number; /** 흉벽 상단 Level 좌·우 (mm). Not used by the geometry (QA-4, 가정). */ @@ -34,10 +50,19 @@ export const ABUT_SHAPE_DEFAULT: AbutShape = { bracketHeightMm: 850, bracketWidthMm: 300, footingThicknessMm: 1100, - footingFrontMm: 2800, - footingBackMm: -1800, + footingWidthMm: 4600, + footingToeMm: 1200, pileEdgeMm: 650, pileSpacingMm: 2000, + pileDiameterMm: 600, + pileRowBackEdgeMm: 650, + pileRowGap1Mm: 2000, + pileRowGap2Mm: 1300, + pileCountRow1: 0, + pileCountRow2: 0, + pileCountRow3: 0, + blindingMarginMm: 100, + blindingThicknessMm: 100, shrinkJointCount: 1, parapetLevelLeftMm: 450, parapetLevelRightMm: 995, @@ -48,13 +73,39 @@ export const ABUT_SHAPE_DEFAULT: AbutShape = { * 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 { +export function normalizeShape( + changes?: Partial & { wallThicknessMm?: number; footingFrontMm?: number; footingBackMm?: 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 { wallThicknessMm, footingFrontMm, footingBackMm, ...rest } = changes; + const seat = rest.wallFrontMm ?? ABUT_SHAPE_DEFAULT.wallFrontMm; const parapet = rest.parapetThicknessMm ?? ABUT_SHAPE_DEFAULT.parapetThicknessMm; - return { ...rest, bracketWidthMm: wallThicknessMm - front - parapet }; + const out: Partial = { ...rest }; + if (wallThicknessMm !== undefined && rest.bracketWidthMm === undefined) out.bracketWidthMm = wallThicknessMm - seat - parapet; + // 저판 끝 (before 2026-11-04) → 폭 · 앞굽: 앞굽 = 앞굽 끝 − 교좌면 폭, 폭 = 앞굽 끝 − 뒷굽 끝. + if ((footingFrontMm !== undefined || footingBackMm !== undefined) && rest.footingWidthMm === undefined && rest.footingToeMm === undefined) { + const front = footingFrontMm ?? seat + ABUT_SHAPE_DEFAULT.footingToeMm; + const back = footingBackMm ?? front - ABUT_SHAPE_DEFAULT.footingWidthMm; + out.footingToeMm = front - seat; + out.footingWidthMm = front - back; + } + return out; +} + +/** 저판 앞굽 끝·뒷굽 끝 (mm, t) and the 뒷굽 (mm, 자동). */ +export function footingEnds(v: AbutShape): { frontMm: number; backMm: number; heelMm: number } { + const frontMm = v.wallFrontMm + v.footingToeMm; + const backMm = frontMm - v.footingWidthMm; + return { frontMm, backMm, heelMm: v.footingWidthMm - v.footingToeMm - wallThicknessMm(v) }; +} + +/** 말뚝 열 위치 t (mm): 배면 3열, 2열, 전면 1열 (저판 배면부터 잰 값으로). 전면 연단은 자동. */ +export function pileRowsMm(v: AbutShape): { back: number; middle: number; front: number; frontEdgeMm: number } { + const { backMm } = footingEnds(v); + const back = backMm + v.pileRowBackEdgeMm; + const middle = back + v.pileRowGap1Mm; + const front = middle + v.pileRowGap2Mm; + return { back, middle, front, frontEdgeMm: v.footingWidthMm - v.pileRowBackEdgeMm - v.pileRowGap1Mm - v.pileRowGap2Mm }; } /** 벽체 두께 (mm, 자동) = 브라켓 폭 + 흉벽 두께 + 교좌면 폭. */ @@ -74,14 +125,17 @@ export interface ResolvedShape { footingBackM: number; pileEdgeM: number; pileSpacingM: number; + /** 말뚝 열 t (m): 배면 3열, 2열, 전면 1열. */ pileRowsT: number[]; + /** 열별 본수 (배면 3열, 2열, 전면 1열의 순서), 0 = 본수 규칙. */ + pileCounts: number[]; + pileDiameterM: number; + blindingMarginM: number; + blindingThicknessM: number; /** 인접 무시 기준 (m). */ seatStepIgnoreM: number; } -/** The pile rows (t, m) of the spec: 배면 열, 중간 열, 전면 열. Not an input on the card. */ -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, ...normalizeShape(changes) }; @@ -93,11 +147,15 @@ export function resolveShape(changes?: Partial): ResolvedShape { parapetBackM: -v.parapetThicknessMm / 1000, bracketHeightM: v.bracketHeightMm / 1000, footingThicknessM: v.footingThicknessMm / 1000, - footingFrontM: v.footingFrontMm / 1000, - footingBackM: v.footingBackMm / 1000, + footingFrontM: footingEnds(v).frontMm / 1000, + footingBackM: footingEnds(v).backMm / 1000, pileEdgeM: v.pileEdgeMm / 1000, pileSpacingM: v.pileSpacingMm / 1000, - pileRowsT: PILE_ROWS_T, + pileRowsT: [pileRowsMm(v).back / 1000, pileRowsMm(v).middle / 1000, pileRowsMm(v).front / 1000], + pileCounts: [v.pileCountRow3, v.pileCountRow2, v.pileCountRow1], + pileDiameterM: v.pileDiameterMm / 1000, + blindingMarginM: v.blindingMarginMm / 1000, + blindingThicknessM: v.blindingThicknessMm / 1000, seatStepIgnoreM: v.seatStepIgnoreMm / 1000, }; } @@ -113,9 +171,16 @@ export function shapeIssues(changes: Partial): string[] { 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.parapetThicknessMm + v.bracketWidthMm); - if (!(v.footingFrontMm > v.wallFrontMm)) out.push('저판 앞굽 끝은 벽체 전면보다 앞에 있어야 합니다.'); - if (!(v.footingBackMm < wallBack)) out.push('저판 뒷굽 끝은 벽체 배면보다 뒤에 있어야 합니다.'); + if (!(v.footingToeMm > 0)) out.push('저판 앞굽은 0보다 커야 합니다.'); + if (!(footingEnds(v).heelMm > 0)) out.push(`저판 뒷굽(자동 ${footingEnds(v).heelMm})이 0보다 커야 합니다: 폭 > 앞굽 + 벽체 두께.`); + if (!(v.pileDiameterMm > 0)) out.push('말뚝 지름은 0보다 커야 합니다.'); + if (!(v.pileRowBackEdgeMm > 0) || !(v.pileRowGap1Mm > 0) || !(v.pileRowGap2Mm > 0) || !(pileRowsMm(v).frontEdgeMm > 0)) { + out.push('말뚝 열 위치는 0보다 크고, 합이 저판 폭보다 작아야 합니다 (전면 연단 > 0).'); + } + for (const n of [v.pileCountRow1, v.pileCountRow2, v.pileCountRow3]) { + if (!(Number.isInteger(n) && n >= 0)) out.push('열별 말뚝 본수는 0(규칙) 이상의 정수여야 합니다.'); + } + if (!(v.blindingMarginMm >= 0) || !(v.blindingThicknessMm >= 0)) out.push('버림 여유와 두께는 0 이상이어야 합니다.'); 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 74cea2e..d442c25 100644 --- a/src/main.ts +++ b/src/main.ts @@ -8,8 +8,9 @@ import { parseAbutments, toAbutInput, type AbutRecord } from './core/abut/parse' 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, normalizeShape, resolveShape, wallThicknessMm } from './core/abut/shape'; +import { applyAbutEdit, applyBearingEdit, applyBearingRule, applyShapeEdit, applyShapeEdits, ABUT_EDIT_FIELDS, BEARING_RULE_FIELDS, SHAPE_EDIT_FIELDS, restoreAbutDefault, setWingForm } from './core/abut/edit'; +import { ABUT_SHAPE_DEFAULT, footingEnds, normalizeShape, pileRowsMm, resolveShape, wallThicknessMm, type AbutShape } from './core/abut/shape'; +import { abutFootingPlanSvg } from './view/abut-footing-svg'; 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'; @@ -27,7 +28,7 @@ import { abutMemberRows, ABUT_MEMBER_EDITABLE, type AbutMemberId } from './core/ import type { AbutModel, WingForm } from './core/abut/geometry'; import { buildMembers, type AbutMembers } from './core/abut/members'; import { buildWings } from './core/abut/wings'; -import { buildAbutGroup, cameraForBox } from './view/abut-scene'; +import { abutSkewK, buildAbutGroup, cameraForBox } from './view/abut-scene'; import { fitCamera } from './view/fit'; import { buildPier, type PierModel } from './core/geometry'; import { NOT_IMPLEMENTED_RULES, validateInput, validatePier } from './core/validate'; @@ -104,7 +105,7 @@ const PART_GROUPS: { label: string; pier: PartName[]; abut: string[] }[] = [ { label: '기둥', pier: ['columns'], abut: [] }, { label: '확대기초', pier: ['footings'], abut: ['footing'] }, { label: '말뚝', pier: ['piles'], abut: ['piles'] }, - { label: '버림', pier: ['blinding'], abut: [] }, + { label: '버림', pier: ['blinding'], abut: ['blinding'] }, { label: '지반면', pier: ['ground'], abut: ['ground'] }, { label: '벽체·흉벽', pier: [], abut: ['wall', 'parapet'] }, // 헌치 sits at the wing ends (wings.ts builds it per wing), so it follows the 날개벽 toggle (2026-11-03). @@ -892,6 +893,94 @@ function fillAbutBearingCard( box.append(el('p', `검사 · ${abutBearingChecks(model, members, rule, H).join(' · ')}`, 'muted small abut-checks')); } +/** 확대기초 · 말뚝 카드의 자동 값, 열 위치, 열별 본수 표(− / +), 메모 (시안, 2026-11-04). 말뚝 길이는 ①에서 고친다. */ +function fillAbutFootingExtras( + box: HTMLDivElement, + rec: AbutRecord, + v: AbutShape, + built: { model: AbutModel; members: AbutMembers }, + apply: (next: AbutRecord) => void, + fail: (err: unknown, input: HTMLInputElement, back: string) => void, + idOf: (suffix: string) => string, +): void { + const ends = footingEnds(v); + const rows = pileRowsMm(v); + const L = built.model.length; + const auto = (name: string, value: string, note: string) => box.append(el('label', name), el('span', value, 'num'), el('span', note, 'muted small')); + auto('뒷굽 (자동)', `${ends.heelMm} mm`, '= 폭 − 앞굽 − 벽체 두께'); + auto('길이 (자동)', `${L.toFixed(3)} m`, '교좌면 길이 (skew)'); + auto('말뚝 길이', `${Math.round(rec.pileLengthMm)} mm`, '① 기본 정보에서 수정'); + auto('선단 EL (자동)', built.members.pileBottomEl === undefined ? '—' : `${built.members.pileBottomEl.toFixed(3)} m`, '= 기초 하단 − 말뚝 길이'); + // 열 위치 (배면부터): "650 · 2000 · 1300 · 650". The last one (전면 연단) follows from the 폭. + const id = idOf('pile-rows'); + const lab = el('label', '열 위치 · 배면부터 (mm)'); + lab.htmlFor = id; + const text = `${v.pileRowBackEdgeMm} · ${v.pileRowGap1Mm} · ${v.pileRowGap2Mm} · ${rows.frontEdgeMm}`; + const input = el('input'); + input.id = id; + input.type = 'text'; + input.value = text; + input.addEventListener('change', () => { + const n = input.value.split(/[·,\s]+/).filter(Boolean).map(Number); + try { + if (n.length < 3 || n.some((x) => !Number.isFinite(x))) throw new Error('열 위치는 "650 · 2000 · 1300 · 650"처럼 숫자 3~4개로 입력하세요.'); + if (n.length === 4 && Math.abs(n.reduce((a, b) => a + b, 0) - v.footingWidthMm) > 0.5) throw new Error(`열 위치의 합이 폭 ${v.footingWidthMm}과 같아야 합니다.`); + apply(applyShapeEdits(rec, { pileRowBackEdgeMm: n[0], pileRowGap1Mm: n[1], pileRowGap2Mm: n[2] })); + } catch (err) { + fail(err, input, text); + } + }); + box.append(lab, input, el('span', `전면 연단 ${rows.frontEdgeMm} (자동)`, 'muted small')); + auto('본수 규칙', `int((L − ${2 * v.pileEdgeMm})/${v.pileSpacingMm}) + 1`, `= ${Math.floor((L * 1000 - 2 * v.pileEdgeMm) / v.pileSpacingMm) + 1}본`); + // 열별 말뚝 본수 (− / +): 0 = 본수 규칙. + const piles = built.members.piles; + const total = piles.reduce((a, r) => a + r.s.length, 0); + box.append(el('p', `열별 말뚝 본수 · 합계 ${total}본`, 'lbl')); + const table = document.createElement('table'); + table.className = 'abut-seg'; + const head = table.createTHead().insertRow(); + for (const h of ['열', '위치 (교대축 기준)', '본수', '본 간격']) head.append(el('th', h)); + const body = table.createTBody(); + const order: { key: 'pileCountRow1' | 'pileCountRow2' | 'pileCountRow3'; name: string; row: number }[] = [ + { key: 'pileCountRow1', name: '1열 · 전면', row: 2 }, + { key: 'pileCountRow2', name: '2열', row: 1 }, + { key: 'pileCountRow3', name: '3열 · 배면', row: 0 }, + ]; + for (const o of order) { + const r = piles[o.row]; + if (!r) continue; + const n = r.s.length; + const gap = n > 1 ? Math.round(((L - 2 * v.pileEdgeMm / 1000) / (n - 1)) * 1000) : 0; + const tr = body.insertRow(); + tr.append(el('td', o.name), el('td', `${r.t >= 0 ? '+' : '−'}${Math.round(Math.abs(r.t) * 1000)}`)); + const cell = el('td'); + const step = (delta: number) => { + const b = el('button', delta < 0 ? '−' : '+', 'btn small'); + b.type = 'button'; + b.disabled = delta < 0 && n <= 2; + b.addEventListener('click', () => { + try { + apply(applyShapeEdit(rec, o.key, n + delta)); + } catch (err) { + $('abut-panel-plan-msg').textContent = (err as Error).message; + } + }); + return b; + }; + cell.append(step(-1), el('span', ` ${n} `, 'num'), step(1)); + if (v[o.key] > 0) cell.append(el('span', ' 수정됨', 'muted small abut-note-warn')); + tr.append(cell, el('td', n > 1 ? `${v.pileEdgeMm} · ${n - 1}@${gap} · ${v.pileEdgeMm}` : `${v.pileEdgeMm}`)); + } + box.append(table); + box.append( + el( + 'p', + '시안 메모 · 확대기초는 교대 저판입니다(엑셀 "저판"). skew 평면에서는 말뚝 열이 skew만큼 밀리고, 연단 650은 그 열의 끝에서 잽니다. 직사각 평면은 다음 단계입니다. 말뚝 지름(D600 / 609.6)과 전면 열 본수는 확인 필요(QA-5).', + 'muted small abut-memo', + ), + ); +} + /** Shows only the card of the open row in the ② 교대 editor; the basic items stay visible. */ function applyAbutMemberCard(rec: AbutRecord): void { const member = state.abutMember; @@ -1085,6 +1174,48 @@ function fillAbutSlot(slot: (typeof ABUT_SLOTS)[keyof typeof ABUT_SLOTS], rec: A shape.append(figs); } } + // ② 확대기초 · 말뚝 카드 (시안, 2026-11-04): 평면 · 말뚝 선택 → 평면 삽도 → 입력 격자 → 자동 값 → 열 위치 → 열별 본수 → 메모. + const shapeAll = { ...ABUT_SHAPE_DEFAULT, ...normalizeShape(rec.shape) }; + if (planCard) { + const pick = (title: string, options: [string, boolean, string?][]) => { + const row = el('div', undefined, 'abut-bearing-pick'); + row.append(el('span', title, 'muted small')); + for (const [name, on, why] of options) { + const b = el('button', name, 'btn small'); + b.type = 'button'; + b.setAttribute('aria-pressed', String(on)); + if (why) { + b.disabled = true; + b.title = why; + } + row.append(b); + } + return row; + }; + footingBox.append( + pick('평면', [['skew 평행사변형 (기본)', true], ['직사각', false, '다음 단계 (길이 = 교좌면 길이 + 폭 × |cot skew|, 100 올림)']]), + pick('말뚝', [[`원기둥 D${shapeAll.pileDiameterMm}`, true], ['강관 · PHC', false, '규칙 미정']]), + ); + if (built) { + const rows = pileRowsMm(shapeAll); + const names = ['3열 · 배면', '2열', '1열 · 전면']; + const fig = el('figure', undefined, 'abut-fig abut-footing-fig'); + fig.innerHTML = abutFootingPlanSvg({ + lengthM: built.model.length, + skewK: abutSkewK(built.model), + footingFrontM: built.members.shape.footingFrontM, + footingBackM: built.members.shape.footingBackM, + wallFrontM: built.members.shape.wallFrontM, + wallBackM: built.members.shape.wallBackM, + rows: built.members.piles.map((r, i) => ({ t: r.t, s: r.s, name: names[i] ?? `${i + 1}열` })), + pileDiameterM: built.members.shape.pileDiameterM, + rowDimsMm: [shapeAll.pileRowBackEdgeMm, shapeAll.pileRowGap1Mm, shapeAll.pileRowGap2Mm, rows.frontEdgeMm], + footingWidthMm: shapeAll.footingWidthMm, + }); + fig.append(el('figcaption', '평면 · 축척 같음 · 실선 = 벽체, 굵은 선 = 확대기초 · 열마다 skew만큼 밀림', 'muted small')); + footingBox.append(fig); + } + } for (const f of SHAPE_EDIT_FIELDS) { const current = normalizeShape(rec.shape)[f.key] ?? ABUT_SHAPE_DEFAULT[f.key]; const id = idOf(`shape-${f.key}`); @@ -1132,6 +1263,7 @@ function fillAbutSlot(slot: (typeof ABUT_SLOTS)[keyof typeof ABUT_SLOTS], rec: A qa.append(el('p', '확인 필요 (spec_abut)', 'lbl')); for (const line of ABUT_QA_NOTES) qa.append(el('p', line, 'muted small')); shape.append(qa); + if (built) fillAbutFootingExtras(footingBox, rec, shapeAll, built, apply, fail, idOf); shape.append( el( 'p', diff --git a/src/style.css b/src/style.css index ffc6394..04f7096 100644 --- a/src/style.css +++ b/src/style.css @@ -918,3 +918,10 @@ body { background: #eef1f4; font-family: 'Noto Sans KR', system-ui, sans-serif; #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); } } + +/* ② 확대기초 · 말뚝 카드 (2026-11-04): the plan drawing, notes and the 열별 본수 table span the card. */ +#abut-panel-plan-footing > figure, #abut-panel-plan-footing > p, #abut-panel-plan-footing > table { grid-column: 1 / -1; } +#abut-panel-plan-footing > .abut-seg { border-collapse: collapse; font-size: 12px; width: 100%; } +#abut-panel-plan-footing > .abut-seg th, #abut-panel-plan-footing > .abut-seg td { border-bottom: 1px solid #e6eaef; padding: 4px 6px; text-align: left; font-family: 'JetBrains Mono', ui-monospace, monospace; } +#abut-panel-plan-footing > .abut-seg th { color: #55606e; font-weight: 500; font-family: inherit; } +#abut-panel-plan .abut-footing-fig { max-width: 760px; } diff --git a/src/view/abut-footing-svg.ts b/src/view/abut-footing-svg.ts new file mode 100644 index 0000000..fa35d4e --- /dev/null +++ b/src/view/abut-footing-svg.ts @@ -0,0 +1,59 @@ +/** + * 교대 확대기초 · 말뚝 평면 삽도 (2026-11-04, 시안 ② 확대기초·말뚝 카드). 순수 문자열 함수. + * 생성본 값으로 그린다: 저판(skew 평행사변형, 굵은 선), 벽체 윤곽(실선), 교대축(점선), 말뚝 3열(원), 열 위치, 폭 치수. + * 가로(s)와 세로(t) 축척이 같다. 위가 전면, 아래가 배면이다. + */ + +export interface AbutFootingDrawing { + /** 교대축 길이 L (m). */ + lengthM: number; + /** s per m of t along the wing (skew): s = s_axis + k·t. */ + skewK: number; + /** t (m): 저판 앞굽 끝, 뒷굽 끝, 벽체 전면, 벽체 배면. */ + footingFrontM: number; + footingBackM: number; + wallFrontM: number; + wallBackM: number; + /** 말뚝 열: t (m), s (m, 교대축 기준, skew 전), 열 이름 (1열 · 전면 …). */ + rows: { t: number; s: number[]; name: string }[]; + pileDiameterM: number; + /** 열 위치 치수 (mm, 배면부터): 배면 연단, 간격, 간격, 전면 연단. */ + rowDimsMm: [number, number, number, number]; + footingWidthMm: number; +} + +const INK = '#18212b'; +const MUTED = '#55606e'; +const label = (x: number, y: number, s: string, anchor = 'start', fill = INK) => + `${s}`; + +export function abutFootingPlanSvg(d: AbutFootingDrawing): string { + const k = 36; // px per m, both directions + const sMin = Math.min(d.skewK * d.footingFrontM, d.skewK * d.footingBackM) - 0.4; + const sMax = d.lengthM + Math.max(d.skewK * d.footingFrontM, d.skewK * d.footingBackM) + 0.4; + const tTop = d.footingFrontM + 0.4; + const X = (s: number, t: number) => 20 + (s + d.skewK * t - sMin) * k; + const Y = (t: number) => 14 + (tTop - t) * k; + const W = 20 + (sMax - sMin) * k + 170; + const H = Y(d.footingBackM - 0.4) + 34; + const quad = (t0: number, t1: number, style: string) => + ``; + const parts: string[] = []; + parts.push(quad(d.footingBackM, d.footingFrontM, `fill="#e6ebf0" stroke="${INK}" stroke-width="2"`)); + parts.push(quad(d.wallBackM, d.wallFrontM, `fill="none" stroke="${MUTED}"`)); + parts.push(``); + parts.push(label(X(d.lengthM, 0) + 6, Y(0) + 4, '교대축', 'start', '#c0392b')); + const r = Math.max(2, (d.pileDiameterM / 2) * k); + for (const row of d.rows) { + for (const s of row.s) parts.push(``); + const tLabel = `${row.t >= 0 ? '+' : '−'}${Math.round(Math.abs(row.t) * 1000)}`; + parts.push(label(X(d.lengthM, row.t) + r + 8, Y(row.t) + 4, `${row.name} · t ${tLabel} · ${row.s.length}본`)); + } + const [e1, g1, g2, e2] = d.rowDimsMm; + parts.push(label(20, H - 10, `${d.footingWidthMm} = 뒷굽쪽 ${e1} · ${g1} · ${g2} · ${e2} 전면 · 위 = 전면`, 'start', MUTED)); + return [ + ``, + ...parts, + '', + ].join(''); +} diff --git a/src/view/abut-scene.ts b/src/view/abut-scene.ts index b554477..0823449 100644 --- a/src/view/abut-scene.ts +++ b/src/view/abut-scene.ts @@ -12,7 +12,7 @@ import { buildWings, HAUNCH_LEG_M, WING_THICKNESS_M, type WingSolid } from '../c import type { TerrainGrid } from '../core/ground-model'; import { terrainGeometry } from './terrain'; -export const ABUT_GROUP_NAMES = ['wall', 'parapet', 'footing', 'piles', 'wings', 'haunches', 'ground', 'blocks', 'slab'] as const; +export const ABUT_GROUP_NAMES = ['wall', 'parapet', 'footing', 'blinding', 'piles', 'wings', 'haunches', 'ground', 'blocks', 'slab'] as const; /** 접속슬래브 두께 (m), spec_abut §4.8. */ const SLAB_THICKNESS_M = 0.45; /** 접속슬래브 길이 (배면 쪽, m), spec_abut §4.8 "7000 (날개벽 축 방향)". */ @@ -118,6 +118,12 @@ function wingDirectionAxis(model: AbutModel): { ws: number; wt: number } { return { ws: w.e * model.u.e + w.n * model.u.n, wt: w.e * front.e + w.n * front.n }; } +/** Skew of the plan (s per m of t along the wing), the same one the 저판 · 벽체 · 말뚝 열 use. */ +export function abutSkewK(model: AbutModel): number { + const d = wingDirectionAxis(model); + return d.ws / d.wt; +} + /** * Placement of the abutment in the pier scene (2026-11-03). The pier scene maps (E, N, EL) to * (E − pivot.E, EL, −(N − pivot.N)); the abutment's local (s, t, EL) → (E, N) with s along u and t along the front @@ -220,6 +226,20 @@ export function buildAbutGroup( [skewK * members.shape.footingFrontM, members.shape.footingFrontM], ]; add('footing', prism(footProfile, members.shape.footingThicknessM, new THREE.Vector3(0, footingBottom, 0), ex, ez, ey), 0x5a6a78); + // 버림 (2026-11-04): under the 저판, wider by the 여유 on every side, the same skew. + const m = members.shape.blindingMarginM; + const th = members.shape.blindingThicknessM; + if (th > 0) { + const tb = members.shape.footingBackM - m; + const tf = members.shape.footingFrontM + m; + const blindProfile: [number, number][] = [ + [skewK * tb - m, tb], + [L + skewK * tb + m, tb], + [L + skewK * tf + m, tf], + [skewK * tf - m, tf], + ]; + add('blinding', prism(blindProfile, th, new THREE.Vector3(0, footingBottom - th, 0), ex, ez, ey), 0x9aa29a); + } } // 받침블럭 (spec §4.2, §6.6): concrete 1200 × 1200 from the 교좌면 to 50 below z, and the 몰탈패드 1000 × 1000 × 50 up to z. @@ -267,13 +287,15 @@ export function buildAbutGroup( slabGroup.add(slabMesh); root.add(slabGroup); - // 말뚝: D 600 (r 0.3) at each row and bar, from the 기초 하단 down to the 선단. + // 말뚝: 원기둥 (지름 입력, 기본 D600) at each row, from the 기초 하단 down to the 선단. if (members.pileBottomEl !== undefined && footingBottom !== undefined) { const height = footingBottom - members.pileBottomEl; for (const row of members.piles) { for (const s of row.s) { - const geom = new THREE.CylinderGeometry(0.3, 0.3, height, 16); - geom.translate(s, members.pileBottomEl + height / 2, row.t); + // 말뚝 지름은 입력값 (QA-5). Each row moves along s with the skew, like the 저판 (시안: 열마다 skew만큼 밀림). + const r = members.shape.pileDiameterM / 2; + const geom = new THREE.CylinderGeometry(r, r, height, 16); + geom.translate(s + skewK * row.t, members.pileBottomEl + height / 2, row.t); add('piles', geom, 0x7d8b99); } } diff --git a/tests/abut-footing.test.ts b/tests/abut-footing.test.ts new file mode 100644 index 0000000..aa4bd4a --- /dev/null +++ b/tests/abut-footing.test.ts @@ -0,0 +1,59 @@ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import * as XLSX from 'xlsx'; +import * as THREE from 'three'; +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 { buildWings } from '../src/core/abut/wings'; +import { abutSkewK, buildAbutGroup } from '../src/view/abut-scene'; +import { abutFootingPlanSvg } from '../src/view/abut-footing-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 group = (changes = {}) => { + const m = buildMembers(model, { bottomEl: input.foundationBottomEl, pileLengthMm: input.pileLengthMm }, changes); + return { m, g: buildAbutGroup(model, m, buildWings(model, m)) }; +}; + +describe('교대 확대기초 · 말뚝 (시안 ②, 3D)', () => { + it('draws the 버림 under the 저판: 100 thick, 100 wider on each side', () => { + const { m, g } = group(); + const blind = new THREE.Box3().setFromObject(g.getObjectByName('blinding') as THREE.Object3D); + const foot = new THREE.Box3().setFromObject(g.getObjectByName('footing') as THREE.Object3D); + expect(blind.max.y).toBeCloseTo(m.footingBottomEl!, 4); // float32 vertices + expect(blind.max.y - blind.min.y).toBeCloseTo(0.1, 4); + expect(foot.min.z - blind.min.z).toBeCloseTo(0.1, 4); + expect(blind.max.z - foot.max.z).toBeCloseTo(0.1, 4); + }); + + it('no 버림 when its thickness is 0', () => { + expect(group({ blindingThicknessMm: 0 }).g.getObjectByName('blinding')).toBeUndefined(); + }); + + it('the piles follow the 지름 input and shift with the skew row by row', () => { + const { m, g } = group({ pileDiameterMm: 800 }); + const piles = (g.getObjectByName('piles') as THREE.Group).children; + expect(piles).toHaveLength(18); + const box = new THREE.Box3().setFromObject(piles[0]); + expect(box.max.x - box.min.x).toBeCloseTo(0.8, 2); + const k = abutSkewK(model); + const row = m.piles[0]; + expect((box.min.x + box.max.x) / 2).toBeCloseTo(row.s[0] + k * row.t, 6); + }); + + it('평면 삽도: the rows, their t and counts, and the width line', () => { + const { m } = group(); + const svg = abutFootingPlanSvg({ + lengthM: model.length, skewK: abutSkewK(model), footingFrontM: m.shape.footingFrontM, footingBackM: m.shape.footingBackM, + wallFrontM: m.shape.wallFrontM, wallBackM: m.shape.wallBackM, + rows: m.piles.map((r, i) => ({ t: r.t, s: r.s, name: ['3열 · 배면', '2열', '1열 · 전면'][i] })), + pileDiameterM: m.shape.pileDiameterM, rowDimsMm: [650, 2000, 1300, 650], footingWidthMm: 4600, + }); + for (const s of ['1열 · 전면 · t +2150 · 6본', '2열 · t +850 · 6본', '3열 · 배면 · t −1150 · 6본', '4600 = 뒷굽쪽 650 · 2000 · 1300 · 650 전면', '교대축']) expect(svg).toContain(s); + expect(svg.match(/ { it('the defaults equal the spec values (wall +1.6 … −0.8, footing +2.8 … −1.8, piles 650 / 2000)', () => { @@ -22,8 +22,10 @@ describe('교대 형상 입력 (시안 ② 벽체·흉벽 · 확대기초·말 }); 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({ footingWidthMm: 3300 }).join(' ')).toContain('뒷굽'); // 3300 − 1200 − 2400 < 0 + expect(shapeIssues({ footingToeMm: 0 }).join(' ')).toContain('앞굽'); + expect(shapeIssues({ pileRowGap2Mm: 2000 }).join(' ')).toContain('열 위치'); // 650 + 2000 + 2000 ≥ 4600 − 0 + expect(shapeIssues({ pileCountRow1: 1.5 }).join(' ')).toContain('열별 말뚝 본수'); expect(shapeIssues({ bracketWidthMm: -1 }).join(' ')).toContain('브라켓 폭'); expect(shapeIssues({ seatStepIgnoreMm: -1 }).join(' ')).toContain('인접 무시 기준'); expect(shapeIssues({ pileSpacingMm: 0 }).join(' ')).toContain('말뚝'); @@ -44,8 +46,10 @@ 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(14); - expect(SHAPE_EDIT_FIELDS.filter((f) => f.group === 'footing').map((f) => f.key)).toEqual(['footingThicknessMm', 'footingFrontMm', 'footingBackMm', 'pileEdgeMm', 'pileSpacingMm']); + expect(SHAPE_EDIT_FIELDS).toHaveLength(17); + expect(SHAPE_EDIT_FIELDS.filter((f) => f.group === 'footing').map((f) => f.key)).toEqual([ + 'footingWidthMm', 'footingThicknessMm', 'footingToeMm', 'blindingMarginMm', 'blindingThicknessMm', 'pileDiameterMm', 'pileEdgeMm', 'pileSpacingMm', + ]); }); it('the new drawing values do not change the geometry (the model ignores them)', () => { @@ -59,7 +63,7 @@ describe('형상 입력이 계산에 반영됨 (시안 ② 카드)', () => { }); it('a footing that does not cover the wall is refused by the edit rule', () => { - expect(() => applyShapeEdit(rec, 'footingBackMm', -500)).toThrow('뒷굽'); + expect(() => applyShapeEdit(rec, 'footingWidthMm', 3300)).toThrow('뒷굽'); expect(rec.shape).toBeUndefined(); }); @@ -83,4 +87,19 @@ describe('형상 입력이 계산에 반영됨 (시안 ② 카드)', () => { expect(buildMembers(model, foundation).wallSegments).toHaveLength(5); expect(buildMembers(model, foundation, { seatStepIgnoreMm: 100 }).wallSegments).toHaveLength(1); }); + + it('저판: 폭 4600 · 앞굽 1200 → 뒷굽 1000 (자동), the ends +2.8 / −1.8; old 앞굽 끝 · 뒷굽 끝 files open the same', () => { + expect(footingEnds(ABUT_SHAPE_DEFAULT)).toEqual({ frontMm: 2800, backMm: -1800, heelMm: 1000 }); + expect(normalizeShape({ footingFrontMm: 3000, footingBackMm: -2000 } as Partial)).toEqual({ footingToeMm: 1400, footingWidthMm: 5000 }); + }); + + it('열 위치 650 · 2000 · 1300 · (650) gives the rows −1.15 / +0.85 / +2.15; 열별 본수 overrides the rule', () => { + expect(pileRowsMm(ABUT_SHAPE_DEFAULT)).toEqual({ back: -1150, middle: 850, front: 2150, frontEdgeMm: 650 }); + 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 rule = buildMembers(model, {}); + expect(rule.piles.map((r) => r.s.length)).toEqual([6, 6, 6]); + const more = buildMembers(model, {}, { pileCountRow1: 7 }); + expect(more.piles.map((r) => r.s.length)).toEqual([6, 6, 7]); // 3열 배면, 2열, 1열 전면 + expect(more.piles[2].t).toBeCloseTo(2.15, 9); + }); });