From 4b8b80ae97f199e2aff612e19d444abfb3ab0c92 Mon Sep 17 00:00:00 2001 From: ypshim Date: Wed, 4 Nov 2026 10:11:39 +0900 Subject: [PATCH] =?UTF-8?q?feat(abut):=20=EC=A0=91=EC=86=8D=EC=8A=AC?= =?UTF-8?q?=EB=9E=98=EB=B8=8C=20=EC=B9=B4=EB=93=9C=20=E2=80=94=20=EB=A7=A4?= =?UTF-8?q?=EB=A6=BD=ED=98=95=20/=20=EB=85=B8=EC=B6=9C=ED=98=95=20?= =?UTF-8?q?=ED=98=95=EC=8B=9D,=20=EA=B2=BD=EC=82=AC,=20=EC=B9=98=EC=88=98?= =?UTF-8?q?=20=EC=9E=85=EB=A0=A5,=20=EC=82=BD=EB=8F=84=203=EA=B0=9C,=20?= =?UTF-8?q?=EB=AA=A8=EB=94=B0=EA=B8=B0=203D=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 노출형 (삽도 기준, QA-8 가정): 상면 = 흉벽 상단, 끝은 브라켓 위(끝 두께 = 브라켓 높이), 헌치 길이 기본 2000 - 형식을 고르면 경사가 기본값으로 바뀜 (매립형 하향 4 %, 노출형 종단경사), 경사 타일로 변경 가능 - 길이 · 두께 · 흉벽과 간격 · 날개벽과 간격 입력이 3D, 폭, 날개벽 평면에 반영 - 슬래브 모따기 = 날개벽 헌치 (3D와 평면), 날개벽 없는 측은 skew 교좌면 끝선 - 3D 슬래브를 단면 기준 입체로 다시 만듦, 프로젝트 파일 저장 · 검증 Co-Authored-By: Claude Haiku 5.5 --- index.html | 1 + src/core/abut/edit.ts | 53 ++++++++- src/core/abut/geometry.ts | 7 +- src/core/abut/member-list.ts | 14 ++- src/core/abut/parse.ts | 4 + src/core/abut/slab.ts | 145 ++++++++++++++++++++++++ src/core/project.ts | 6 + src/main.ts | 184 ++++++++++++++++++++++++++++-- src/style.css | 2 + src/view/abut-scene.ts | 125 +++++++++++++++------ src/view/abut-slab-svg.ts | 200 +++++++++++++++++++++++++++++++++ src/view/abut-wing-svg.ts | 12 +- tests/abut-member-list.test.ts | 6 +- tests/abut-slab.test.ts | 155 +++++++++++++++++++++++++ 14 files changed, 854 insertions(+), 60 deletions(-) create mode 100644 src/core/abut/slab.ts create mode 100644 src/view/abut-slab-svg.ts create mode 100644 tests/abut-slab.test.ts diff --git a/index.html b/index.html index b71b739..122d174 100644 --- a/index.html +++ b/index.html @@ -198,6 +198,7 @@
+

diff --git a/src/core/abut/edit.ts b/src/core/abut/edit.ts index 20c463e..758c29d 100644 --- a/src/core/abut/edit.ts +++ b/src/core/abut/edit.ts @@ -5,7 +5,8 @@ import type { AbutRecord } from './parse'; import type { WingForm } from './geometry'; import { WING_DIMS_DEFAULT, wingDimsIssues, type WingDims } from './wings'; -import { shapeIssues, type AbutShape } from './shape'; +import { ABUT_SHAPE_DEFAULT, normalizeShape, shapeIssues, type AbutShape } from './shape'; +import { resolveSlab, slabDefaultSlope, slabIssues, type AbutSlab, type SlabForm, type SlabSlope } from './slab'; import { abutBearingRuleIssues, resolveBearingRule, type AbutBearingRule } from './bearing-table'; export type AbutEditKey = 'seatEl' | 'foundationBottomEl' | 'groundEl' | 'pileLengthMm' | 'skewDeg' | 'slopeI'; @@ -105,7 +106,7 @@ export function restoreAbutDefault(defaults: AbutRecord): AbutRecord { 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); + const issues = [...shapeIssues(shape), ...slabFitIssues(rec.slab, shape)]; if (issues.length > 0) throw new Error(issues[0]); return { ...rec, shape }; } @@ -113,11 +114,57 @@ export function applyShapeEdits(rec: AbutRecord, values: Partial): Ab export function applyShapeEdit(rec: AbutRecord, key: keyof AbutShape, value: number): AbutRecord { if (!Number.isFinite(value)) throw new Error('숫자를 입력하세요.'); const shape = { ...(rec.shape ?? {}), [key]: value }; - const issues = shapeIssues(shape); + const issues = [...shapeIssues(shape), ...slabFitIssues(rec.slab, shape)]; if (issues.length > 0) throw new Error(issues[0]); return { ...rec, shape }; } +/** 브라켓 높이 (mm) of a shape input, default when absent. */ +function bracketHeightMm(shape: Partial | undefined): number { + return { ...ABUT_SHAPE_DEFAULT, ...normalizeShape(shape) }.bracketHeightMm; +} + +/** A lower 브라켓 must still hold the 접속슬래브 under the 흉벽 상단. */ +function slabFitIssues(slab: Partial | undefined, shape: Partial): string[] { + const s = resolveSlab(slab); + const h = bracketHeightMm(shape); + return s.thicknessMm > h ? [`브라켓 높이(${h})는 접속슬래브 두께(${s.thicknessMm}) 이상이어야 합니다.`] : []; +} + +/** 접속슬래브 형식 (2026-11-04): picking a form also sets its slope (매립형 하향 4 %, 노출형 종단경사). */ +export function setSlabForm(rec: AbutRecord, form: SlabForm): AbutRecord { + const slab: Partial = { ...(rec.slab ?? {}), form }; + delete slab.slope; + const issues = slabIssues(resolveSlab(slab), bracketHeightMm(rec.shape)); + if (issues.length > 0) throw new Error(issues[0]); + return { ...rec, slab }; +} + +/** 접속슬래브 경사; the form's own slope is stored as "not set". */ +export function setSlabSlope(rec: AbutRecord, slope: SlabSlope): AbutRecord { + const slab: Partial = { ...(rec.slab ?? {}), slope }; + if (slope === slabDefaultSlope(resolveSlab(rec.slab).form)) delete slab.slope; + return { ...rec, slab }; +} + +/** 접속슬래브 입력칸 (② 접속슬래브 카드, 시안 순서). 헌치 길이는 노출형만. */ +export const SLAB_EDIT_FIELDS: { key: 'lengthMm' | 'thicknessMm' | 'parapetGapMm' | 'wingGapMm' | 'haunchLengthMm'; label: string; exposedOnly?: boolean; note?: string }[] = [ + { key: 'lengthMm', label: '길이 (mm)' }, + { key: 'thicknessMm', label: '두께 (mm)' }, + { key: 'parapetGapMm', label: '흉벽과 간격 (mm)' }, + { key: 'wingGapMm', label: '날개벽과 간격 (mm)' }, + { key: 'haunchLengthMm', label: '헌치 길이 (mm)', exposedOnly: true, note: '가정 · 삽도 비율' }, +]; + +/** 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('숫자를 입력하세요.'); + const slab = { ...(rec.slab ?? {}), [key]: value }; + const issues = slabIssues(resolveSlab(slab), bracketHeightMm(rec.shape)); + if (issues.length > 0) throw new Error(issues[0]); + return { ...rec, slab }; +} + /** 형상 입력 fields on the 교대 카드 (시안 ② 벽체·흉벽, 확대기초·말뚝), in the order of the mockup. */ export const SHAPE_EDIT_FIELDS: { key: keyof AbutShape; label: string; group: 'wall' | 'footing'; note?: string }[] = [ diff --git a/src/core/abut/geometry.ts b/src/core/abut/geometry.ts index f1dca2c..3f99de5 100644 --- a/src/core/abut/geometry.ts +++ b/src/core/abut/geometry.ts @@ -54,6 +54,8 @@ export interface AbutInput { wings?: { left: WingForm; right: WingForm }; /** 측별 날개벽 두께 (mm). 없으면 450. 접속슬래브 폭에 쓴다. */ wingThicknessMm?: { left?: number; right?: number }; + /** 접속슬래브와 날개벽 안쪽면 사이 간격 (mm, ② 접속슬래브 카드). 없으면 20. */ + slabWingGapMm?: number; /** 기초 하단 EL (m), 말뚝길이 (mm), 기초 형식 (AA3, 저판·말뚝). 없으면 저판·말뚝을 만들지 않는다. */ foundationBottomEl?: number; pileLengthMm?: number; @@ -68,7 +70,7 @@ export interface AbutWingModel { aw01Ok: boolean; /** 날개벽 경사 (%), (+) = 벽체에서 멀어질수록 높아짐. */ slopePct: number; - /** 날개벽 사이 접속슬래브 폭 (mm) = L − Σ(450 / |sin Δ| + 20), 있는 날개벽마다. */ + /** 날개벽 사이 접속슬래브 폭 (mm) = L − Σ(두께 / |sin Δ| + 날개벽과 간격), 있는 날개벽마다. */ slabWidthMm: number; wings: { left: WingForm; right: WingForm }; } @@ -193,7 +195,8 @@ function buildWing(input: AbutInput, u: AbutPoint, nLeft: AbutPoint, frontSide: const wings = input.wings ?? { left: 'general' as WingForm, right: 'general' as WingForm }; // 날개벽 두께는 측별 입력 (2026-11-04, 없으면 450): 축 방향 폭 = 두께 / |sin Δ|. const thick = { left: input.wingThicknessMm?.left ?? WING_THICKNESS_MM, right: input.wingThicknessMm?.right ?? WING_THICKNESS_MM }; - const present = (f: WingForm, side: 'left' | 'right') => (f === 'none' ? 0 : thick[side] / sinDelta + SLAB_CLEARANCE_MM); + const gap = input.slabWingGapMm ?? SLAB_CLEARANCE_MM; + const present = (f: WingForm, side: 'left' | 'right') => (f === 'none' ? 0 : thick[side] / sinDelta + gap); const slabWidthMm = Math.round(length * 1000 - present(wings.left, 'left') - present(wings.right, 'right')); const girderDiffDeg = input.girderAzimuthDeg === undefined ? NaN : lineDiff(pick, input.girderAzimuthDeg); const slopeI = input.slopeI ?? 0; diff --git a/src/core/abut/member-list.ts b/src/core/abut/member-list.ts index 522dcee..f8c4ea0 100644 --- a/src/core/abut/member-list.ts +++ b/src/core/abut/member-list.ts @@ -1,5 +1,6 @@ import type { WingForm } from './geometry'; import { ABUT_SHAPE_DEFAULT, normalizeShape, wallThicknessMm, type AbutShape } from './shape'; +import { resolveSlab, SLAB_FORM_LABEL, slabChangedKeys, slabSlopeLabel, type AbutSlab } from './slab'; /** 형상 입력 중 벽체·흉벽 카드와 확대기초·말뚝 카드의 항목 (변경 표시를 카드별로 나눈다). */ const WALL_KEYS: (keyof AbutShape)[] = ['wallFrontMm', 'parapetHeightMm', 'parapetThicknessMm', 'bracketHeightMm', 'bracketWidthMm', 'shrinkJointCount', 'parapetLevelLeftMm', 'parapetLevelRightMm', 'seatStepIgnoreMm']; @@ -11,13 +12,13 @@ const FOOTING_KEYS: (keyof AbutShape)[] = [ /** 부재 · 교대 list in ② (시안): the card each row opens. */ 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). */ +/** Cards that take input in this step; ground only shows its values (접속슬래브 입력 2026-11-04). */ export const ABUT_MEMBER_EDITABLE: Record = { bearing: true, shape: true, footing: true, wing: true, - slab: false, + slab: true, ground: false, }; @@ -28,6 +29,7 @@ export interface AbutMemberInput { bearings: { seatEl?: number }[]; shape?: Partial; wings?: { left: WingForm; right: WingForm }; + slab?: Partial; groundEl: number; } @@ -48,6 +50,7 @@ export function abutMemberRows(rec: AbutMemberInput, slabWidth: string): AbutMem 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 }; + const slab = resolveSlab(rec.slab); return [ { id: 'bearing', @@ -73,7 +76,12 @@ export function abutMemberRows(rec: AbutMemberInput, slabWidth: string): AbutMem summary: `좌 ${WING_LABEL[wings.left]} · 우 ${WING_LABEL[wings.right]}`, changed: wings.left !== 'general' || wings.right !== 'general', }, - { id: 'slab', label: '접속슬래브', summary: slabWidth, changed: false }, + { + id: 'slab', + label: '접속슬래브', + summary: `${SLAB_FORM_LABEL[slab.form]} · ${slabSlopeLabel(slab)} · 폭 ${slabWidth}`, + changed: slabChangedKeys(rec.slab).length > 0, + }, { id: 'ground', label: '지반·지층', summary: `지반고 ${rec.groundEl.toFixed(3)} m`, changed: false }, ]; } diff --git a/src/core/abut/parse.ts b/src/core/abut/parse.ts index 4e1228f..f5034b4 100644 --- a/src/core/abut/parse.ts +++ b/src/core/abut/parse.ts @@ -13,6 +13,7 @@ import type { AbutInput, AbutPoint, AbutType, WingForm } from './geometry'; import type { AbutShape } from './shape'; import type { AbutBearingRule } from './bearing-table'; import type { WingDims } from './wings'; +import type { AbutSlab } from './slab'; type Cell = string | number | null | undefined; type Grid = Cell[][]; @@ -59,6 +60,8 @@ export interface AbutRecord { bearingRule?: Partial; /** 날개벽 치수 (② 날개벽 카드, 측별, mm). Only the changed values. */ wingDims?: { left?: Partial; right?: Partial }; + /** 접속슬래브 입력 (② 접속슬래브 카드: 형식, 경사, 치수 mm). Only the changed values. Absent = 매립형 · 하향 4 % 기본값. */ + slab?: Partial; /** 예전 "좌우 같게" 값 (2026-11-04 이전 파일). 지금은 읽기만 하고 쓰지 않는다. */ wingsLinked?: boolean; } @@ -213,6 +216,7 @@ export function toAbutInput(rec: AbutRecord): AbutInput { slopeI: rec.slopeI, wings: rec.wings, wingThicknessMm: { left: rec.wingDims?.left?.thicknessMm, right: rec.wingDims?.right?.thicknessMm }, + slabWingGapMm: rec.slab?.wingGapMm, foundationBottomEl: rec.foundationBottomEl, pileLengthMm: rec.pileLengthMm, foundationType: rec.foundation, diff --git a/src/core/abut/slab.ts b/src/core/abut/slab.ts new file mode 100644 index 0000000..7699f33 --- /dev/null +++ b/src/core/abut/slab.ts @@ -0,0 +1,145 @@ +/** + * 교대 접속슬래브 (2026-11-04, spec_abut §4.8, 시안 ② 접속슬래브 카드): 형식 · 경사 · 치수 입력과 단면 (t, EL). 순수 함수. + * + * - 매립형 (type-1, 기본): 하면 = 브라켓 상면, 두께 일정. 브라켓 위(벽체 배면까지)는 수평이고 그 뒤로 경사(기본 하향 4 %). + * - 노출형 (type-2, 2026-11-04 삽도 기준, QA-8): 상면 = 흉벽 상단에서 경사(기본 종단경사)를 따른다. 끝은 브라켓 상면에 + * 얹히고(끝 두께 = 브라켓 높이), 벽체 배면부터 헌치 길이 동안 두께가 줄어 그 뒤로는 두께가 일정하다. + * - 경사: 형식을 고르면 그 형식의 기본값(매립형 하향 4 %, 노출형 종단경사)으로 바뀌고, 따로 바꿀 수 있다. + * - t: 교대축 직각 (전면 +, 배면 −, m). 슬래브는 흉벽 배면에서 "흉벽과 간격"만큼 띄워 시작하고 길이만큼 배면 쪽으로 간다. + * - 단면의 EL은 그 s의 흉벽 상단 EL에서 잰 값이다 (흉벽 상단 경사는 호출하는 쪽에서 더한다). + */ + +export type SlabForm = 'buried' | 'exposed'; +export type SlabSlope = 'down4' | 'grade'; + +export interface AbutSlab { + form: SlabForm; + slope: SlabSlope; + lengthMm: number; + thicknessMm: number; + parapetGapMm: number; + wingGapMm: number; + /** 노출형 헌치 길이 (벽체 배면부터, mm). 매립형은 쓰지 않는다. 기본 2000은 삽도 비율에서 잡은 가정값이다. */ + haunchLengthMm: number; +} + +export const ABUT_SLAB_DEFAULT: AbutSlab = { + form: 'buried', + slope: 'down4', + lengthMm: 7000, + thicknessMm: 450, + parapetGapMm: 5, + wingGapMm: 20, + haunchLengthMm: 2000, +}; + +/** 매립형 하향 경사 (%, 배면 쪽으로 내려감). */ +export const SLAB_FALL_PCT = 4; + +export const SLAB_FORM_LABEL: Record = { buried: '매립형', exposed: '노출형' }; + +/** The slope a form starts with: 매립형 하향 4 %, 노출형 종단경사 (엑셀 A555 "type-1 = 하향경사 4 %, type-2 = 종단경사"). */ +export function slabDefaultSlope(form: SlabForm): SlabSlope { + return form === 'exposed' ? 'grade' : 'down4'; +} + +/** The user's values over the defaults; the slope follows the form unless it was set. */ +export function resolveSlab(changes?: Partial): AbutSlab { + const form = changes?.form ?? ABUT_SLAB_DEFAULT.form; + return { ...ABUT_SLAB_DEFAULT, slope: slabDefaultSlope(form), ...(changes ?? {}) }; +} + +/** Keys whose value differs from the default (형식, 경사 and the dims), for the change marks. */ +export function slabChangedKeys(changes?: Partial): (keyof AbutSlab)[] { + const s = resolveSlab(changes); + return (Object.keys(ABUT_SLAB_DEFAULT) as (keyof AbutSlab)[]).filter((k) => s[k] !== ABUT_SLAB_DEFAULT[k]); +} + +/** Slope (%), + = rises away from the wall: 하향 4 % → −4, 종단경사 → the wing slope (A1 = −i, A2 = +i). */ +export function slabGradePct(slab: AbutSlab, wingSlopePct: number): number { + return slab.slope === 'down4' ? -SLAB_FALL_PCT : wingSlopePct; +} + +/** Slope text as on the card: "하향 4 %" or "종단경사 +1.91 %". */ +export function slabSlopeLabel(slab: AbutSlab, wingSlopePct?: number): string { + if (slab.slope === 'down4') return `하향 ${SLAB_FALL_PCT} %`; + if (wingSlopePct === undefined) return '종단경사'; + return `종단경사 ${wingSlopePct >= 0 ? '+' : ''}${wingSlopePct.toFixed(2)} %`; +} + +/** Input problems in Korean; empty when the slab can be built. The slab must fit under the 흉벽 상단 on the 브라켓. */ +export function slabIssues(s: AbutSlab, bracketHeightMm: number): string[] { + const out: string[] = []; + if (!(s.lengthMm > 0)) out.push('접속슬래브 길이는 0보다 커야 합니다.'); + if (!(s.thicknessMm > 0)) out.push('접속슬래브 두께는 0보다 커야 합니다.'); + if (!(s.parapetGapMm >= 0)) out.push('흉벽과 간격은 0 이상이어야 합니다.'); + if (!(s.wingGapMm >= 0)) out.push('날개벽과 간격은 0 이상이어야 합니다.'); + if (s.thicknessMm > bracketHeightMm) out.push(`접속슬래브 두께는 브라켓 높이(${bracketHeightMm}) 이하여야 합니다.`); + if (s.form === 'exposed') { + if (!(s.haunchLengthMm > 0)) out.push('노출형 헌치 길이는 0보다 커야 합니다.'); + else if (s.haunchLengthMm >= s.lengthMm) out.push('노출형 헌치 길이는 슬래브 길이보다 짧아야 합니다.'); + } + return out; +} + +/** One station of the section: t (m) and the top / bottom levels (m, from the 흉벽 상단 at that s). */ +export interface SlabStation { + t: number; + top: number; + bottom: number; +} + +/** The 벽체·흉벽 values the section needs (m, from the resolved shape). */ +export interface SlabShapeRef { + parapetBackM: number; + wallBackM: number; + bracketHeightM: number; +} + +/** + * Section stations from the near end (흉벽 쪽) to the far end, at every bend: near end, 벽체 배면, 헌치 끝 (노출형), far end. + * Between stations the top and the bottom are straight, so a strip between two stations is flat on top and below. + */ +export function slabSection(slab: AbutSlab, gradePct: number, sh: SlabShapeRef): SlabStation[] { + const g = gradePct / 100; + const th = slab.thicknessMm / 1000; + const t0 = sh.parapetBackM - slab.parapetGapMm / 1000; + const t1 = t0 - slab.lengthMm / 1000; + const bracket = -sh.bracketHeightM; + const wb = sh.wallBackM; + let level: (t: number) => { top: number; bottom: number }; + const marks = [t0, wb, t1]; + if (slab.form === 'buried') { + level = (t) => { + const bottom = bracket + g * Math.max(0, wb - t); + return { top: bottom + th, bottom }; + }; + } else { + const he = wb - slab.haunchLengthMm / 1000; + marks.push(he); + const top = (t: number) => g * (t0 - t); + level = (t) => { + if (t >= wb) return { top: top(t), bottom: bracket }; + const full = top(t) - th; + if (t <= he) return { top: top(t), bottom: full }; + const k = (wb - t) / (wb - he); + return { top: top(t), bottom: bracket + k * (top(he) - th - bracket) }; + }; + } + const ts = [...new Set(marks.filter((t) => t <= t0 + 1e-9 && t >= t1 - 1e-9).map((t) => Math.round(t * 1e9) / 1e9))].sort((a, b) => b - a); + return ts.map((t) => ({ t, ...level(t) })); +} + +/** Top and bottom at any t inside the slab, by straight lines between the stations. */ +export function slabLevelAt(stations: SlabStation[], t: number): { top: number; bottom: number } { + for (let i = 0; i + 1 < stations.length; i++) { + const a = stations[i]; + const b = stations[i + 1]; + if (t <= a.t + 1e-9 && t >= b.t - 1e-9) { + const k = a.t === b.t ? 0 : (a.t - t) / (a.t - b.t); + return { top: a.top + k * (b.top - a.top), bottom: a.bottom + k * (b.bottom - a.bottom) }; + } + } + const end = t > stations[0].t ? stations[0] : stations[stations.length - 1]; + return { top: end.top, bottom: end.bottom }; +} diff --git a/src/core/project.ts b/src/core/project.ts index 74fce07..b446a6d 100644 --- a/src/core/project.ts +++ b/src/core/project.ts @@ -142,6 +142,12 @@ export function checkStoredAbutments(list: unknown): void { } } } + if (a.slab !== undefined) { + for (const [key, v] of Object.entries(a.slab)) { + const ok = key === 'form' ? v === 'buried' || v === 'exposed' : key === 'slope' ? v === 'down4' || v === 'grade' : finite(v); + if (!ok) throw new Error(`${who}: 접속슬래브 값 ${key}이(가) 맞지 않습니다.`); + } + } if (a.wingsLinked !== undefined && typeof a.wingsLinked !== 'boolean') throw new Error(`${who}: 좌우 같게 값이 맞지 않습니다.`); if (a.wings !== undefined) { const forms = ['general', 'short', 'none']; diff --git a/src/main.ts b/src/main.ts index 0977d9e..5386597 100644 --- a/src/main.ts +++ b/src/main.ts @@ -22,7 +22,13 @@ import { restoreAbutDefault, copyWingDims, setWingForm, + applySlabEdit, + setSlabForm, + setSlabSlope, + SLAB_EDIT_FIELDS, } from './core/abut/edit'; +import { ABUT_SLAB_DEFAULT, resolveSlab, SLAB_FORM_LABEL, slabDefaultSlope, slabGradePct, slabSection, slabSlopeLabel, type SlabForm, type SlabSlope } from './core/abut/slab'; +import { abutSlabJointSvg, abutSlabPlanSvg, abutSlabSectionSvg } from './view/abut-slab-svg'; 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'; @@ -607,7 +613,7 @@ function setupScene(): void { try { const model = buildAbut(toAbutInput(rec)); const members = buildMembers(model, { bottomEl: rec.foundationBottomEl, pileLengthMm: rec.pileLengthMm }, rec.shape); - const group = buildAbutGroup(model, members, buildWings(model, members, rec.wings, rec.wingDims), ref.pivot, rec.groundEl, groundModel?.terrainGrid); + const group = buildAbutGroup(model, members, buildWings(model, members, rec.wings, rec.wingDims), ref.pivot, rec.groundEl, groundModel?.terrainGrid, rec.slab); group.visible = abutShown(state.stage, state.abutActive, rec.name); // The 부재 표시 toggles hold for a new abutment group too. for (const [name, on] of Object.entries(state.abutVisible)) { @@ -737,7 +743,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', 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' }, + plan: { wing: 'abut-panel-plan-wing', slab: 'abut-panel-plan-slab', 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). */ @@ -1073,7 +1079,7 @@ function fillAbutWingCard( addFig( abutWingPlanSvg({ lengthM: model.length, - outline: abutPlanOutline(model, members, wings), + outline: abutPlanOutline(model, members, wings, resolveSlab(rec.slab)), leftLabel: `좌 · ${NAME[forms.left]}`, rightLabel: `우 · ${NAME[forms.right]}`, leftSEnd: leftWing?.sEnd ?? (model.bearings[0].s < model.length / 2 ? 0 : model.length), @@ -1112,9 +1118,9 @@ function fillAbutWingCard( bracketMm: shapeAll.bracketWidthMm, wingThicknessMm: Math.round((wing?.thicknessM ?? 0.45) * 1000), haunchMm: Math.round((wing?.haunchM ?? 0.3) * 1000), - slabGapWingMm: 20, - slabGapParapetMm: 5, - chamferMm: 300, + slabGapWingMm: resolveSlab(rec.slab).wingGapMm, + slabGapParapetMm: resolveSlab(rec.slab).parapetGapMm, + chamferMm: Math.round((wing?.haunchM ?? 0.3) * 1000), }), '날개벽–흉벽 모서리 평면 · 모식도 · 접속슬래브 모따기', ); @@ -1176,7 +1182,165 @@ function fillAbutWingCard( box.append( el( 'p', - '시안 메모 · 형식은 ②에서만 정합니다(엑셀 입력 없음). 좌 · 우 형식과 치수는 따로 정하고, "반대쪽에도 적용"으로 치수를 한 번 복사할 수 있습니다(길이는 형식이 같을 때만). 단축형은 하단이 모두 확대기초 상면이고 길이 기본값은 뒷굽 끝이며, 늘리면 뒷굽 밖 구간의 하단이 떠 있게 됩니다(정보 AI02). 두께는 접속슬래브 폭에, 헌치는 연직 헌치와 저판 상면 헌치에 함께 반영됩니다. 모서리 상세의 슬래브 모따기는 3D에는 아직 그리지 않습니다.', + '시안 메모 · 형식은 ②에서만 정합니다(엑셀 입력 없음). 좌 · 우 형식과 치수는 따로 정하고, "반대쪽에도 적용"으로 치수를 한 번 복사할 수 있습니다(길이는 형식이 같을 때만). 단축형은 하단이 모두 확대기초 상면이고 길이 기본값은 뒷굽 끝이며, 늘리면 뒷굽 밖 구간의 하단이 떠 있게 됩니다(정보 AI02). 두께는 접속슬래브 폭에, 헌치는 연직 헌치와 저판 상면 헌치, 접속슬래브 모따기에 함께 반영됩니다.', + 'muted small abut-memo', + ), + ); +} + +/** + * 접속슬래브 타일 그림 (2026-11-04, 48 × 24, 사용자 삽도 기준): 오른쪽이 흉벽 · 브라켓 단차, 왼쪽으로 슬래브가 뻗는다. + * 매립형 = 흉벽 상단 아래 일정 두께, 노출형 = 상면이 흉벽 상단과 같고 끝에 헌치. 경사는 하향(왼쪽이 낮음) / 종단경사(높음). + */ +const SLAB_ICON = { + section(form: SlabForm, slope: SlabSlope): string { + const wall = ''; + const fall = slope === 'down4'; + const slab = + form === 'buried' + ? fall ? 'M4 11L30 8v4L4 15z' : 'M4 6L30 8v4L4 10z' + : fall ? 'M4 4.5L30 2v10L23 6.5L4 8.5z' : 'M4 0.8L30 2v10L23 5.5L4 4.8z'; + return `${wall}`; + }, +}; + +/** ② 접속슬래브 카드 (시안, 2026-11-04): 형식 · 경사 타일 → 삽도 3개 → 입력 → 자동 값 → 메모. */ +function fillAbutSlabCard( + box: HTMLDivElement, + rec: AbutRecord, + apply: (next: AbutRecord) => void, + fail: (err: unknown, input: HTMLInputElement, back: string) => void, + idOf: (suffix: string) => string, + numberInput: (id: string, value: string) => HTMLInputElement, +): void { + const slab = resolveSlab(rec.slab); + const say = (err: unknown) => ($('abut-panel-plan-msg').textContent = (err as Error).message); + const tiles = el('div', undefined, 'abut-tile-row'); + tiles.append( + optionTiles( + '형식', + (['buried', 'exposed'] as const).map((f) => ({ + label: SLAB_FORM_LABEL[f], + icon: SLAB_ICON.section(f, slabDefaultSlope(f)), + on: slab.form === f, + isDefault: f === 'buried', + pick: () => { + if (slab.form === f) return; + try { + apply(setSlabForm(rec, f)); + } catch (err) { + say(err); + } + }, + })), + ), + optionTiles( + '경사', + (['down4', 'grade'] as const).map((s) => ({ + label: s === 'down4' ? '하향 4 %' : '종단경사 따라', + icon: SLAB_ICON.section(slab.form, s), + on: slab.slope === s, + isDefault: s === slabDefaultSlope(slab.form), + pick: () => { + if (slab.slope !== s) apply(setSlabSlope(rec, s)); + }, + })), + ), + ); + box.append(tiles); + let model: AbutModel; + let members: AbutMembers; + let wings: WingSolid[]; + try { + model = buildAbut(toAbutInput(rec)); + members = buildMembers(model, { bottomEl: rec.foundationBottomEl, pileLengthMm: rec.pileLengthMm }, rec.shape); + wings = buildWings(model, members, rec.wings, rec.wingDims); + } catch { + box.append(el('p', '교대 모델을 만들 수 없어 접속슬래브를 그리지 않습니다.', 'muted small')); + return; + } + const wingSlope = model.wing?.slopePct ?? 0; + const gradePct = slabGradePct(slab, wingSlope); + const slopeText = slabSlopeLabel(slab, wingSlope); + const widthM = (model.wing?.slabWidthMm ?? Math.round(model.length * 1000)) / 1000; + const leftWing = wings.find((w) => w.side === 'left'); + const rightWing = wings.find((w) => w.side === 'right'); + const chamfer = { left: Math.round((leftWing?.haunchM ?? 0) * 1000), right: Math.round((rightWing?.haunchM ?? 0) * 1000) }; + // 단면은 교대축 가운데의 흉벽 상단에서 잰다. + const parapetTopMid = (members.parapetTopStartEl + members.parapetTopEndEl) / 2; + const sh = members.shape; + const figs = el('div', undefined, 'abut-wall-figs abut-wing-figs'); + const addFig = (svg: string, caption: string) => { + const fig = el('figure', undefined, 'abut-fig'); + fig.innerHTML = svg; + fig.append(el('figcaption', caption, 'muted small')); + figs.append(fig); + }; + addFig( + abutSlabPlanSvg({ + outline: abutPlanOutline(model, members, wings, slab), + leftSEnd: leftWing?.sEnd ?? (model.bearings[0].s < model.length / 2 ? 0 : model.length), + widthM, + lengthMm: slab.lengthMm, + chamferMm: chamfer, + }), + '평면 · 날개벽 사이 (skew 평행사변형) · 모따기 = 날개벽 헌치 · 축척 같음', + ); + addFig( + abutSlabSectionSvg({ + form: slab.form, + slopeLabel: slopeText, + section: slabSection(slab, gradePct, sh), + parapetTopEl: parapetTopMid, + bracketHeightM: sh.bracketHeightM, + parapetBackM: sh.parapetBackM, + wallBackM: sh.wallBackM, + lengthMm: slab.lengthMm, + thicknessMm: slab.thicknessMm, + parapetGapMm: slab.parapetGapMm, + haunchLengthMm: slab.haunchLengthMm, + }), + `단면 · 교대축 직각 (교대축 가운데) · ${SLAB_FORM_LABEL[slab.form]} · 경사 ${slopeText} · 축척 같음`, + ); + addFig( + abutSlabJointSvg({ + form: slab.form, + gradePct, + parapetTopEl: parapetTopMid, + bracketHeightM: sh.bracketHeightM, + parapetGapMm: slab.parapetGapMm, + thicknessMm: slab.thicknessMm, + }), + '흉벽–접속슬래브 접합 상세 · 모식도 (부속재는 표시용)', + ); + box.append(figs); + for (const f of SLAB_EDIT_FIELDS) { + const id = idOf(`slab-${f.key}`); + const lab = el('label', f.label); + lab.htmlFor = id; + const input = numberInput(id, String(slab[f.key])); + if (slab[f.key] !== ABUT_SLAB_DEFAULT[f.key]) input.classList.add('changed-input'); + const off = f.exposedOnly && slab.form !== 'exposed'; + input.disabled = Boolean(off); + input.addEventListener('change', () => { + try { + apply(applySlabEdit(rec, f.key, Number(input.value))); + } catch (err) { + fail(err, input, String(slab[f.key])); + } + }); + box.append(lab, input, el('span', off ? '노출형만' : `기본 ${ABUT_SLAB_DEFAULT[f.key]}${f.note ? ` · ${f.note}` : ''}`, 'muted small')); + } + const auto = (name: string, value: string, note: string) => box.append(el('label', name), el('span', value, 'num'), el('span', note, 'muted small')); + const side = (mm: number, w: WingSolid | undefined) => (w ? (mm > 0 ? `${mm} × ${mm}` : '없음') : '날개벽 없음'); + auto('모따기 (자동)', `좌 ${side(chamfer.left, leftWing)} · 우 ${side(chamfer.right, rightWing)}`, '= 그 측 날개벽 헌치'); + auto('폭 (자동)', `${widthM.toFixed(3)} m`, '도면 11.670 · QA-6'); + auto('경사', slopeText, slab.slope === slabDefaultSlope(slab.form) ? `${SLAB_FORM_LABEL[slab.form]} 기본` : `변경 (${SLAB_FORM_LABEL[slab.form]} 기본 = ${slabDefaultSlope(slab.form) === 'down4' ? '하향 4 %' : '종단경사'})`); + if (slab.form === 'exposed') auto('끝 두께 (자동)', `${Math.round(sh.bracketHeightM * 1000)} mm`, '= 흉벽 상단 − 브라켓 상면'); + box.append( + el( + 'p', + '시안 메모 · 매립형은 브라켓 상면에 얹혀 두께가 일정하고, 벽체 배면 뒤에서 경사를 따릅니다. 노출형(2026-11-04 삽도 기준, QA-8 가정)은 상면이 흉벽 상단과 같고 끝은 브라켓 위에 얹히며, 벽체 배면부터 헌치 길이 동안 두께가 끝 두께에서 슬래브 두께로 줄어듭니다. 형식을 바꾸면 경사가 그 형식의 기본값으로 바뀝니다. 접합 부속재(고무판, 타르페이퍼, 다웰, 강관)는 표시용이고, 배근은 철근 단계에서 정합니다(노출형 배근 규칙 미정).', 'muted small abut-memo', ), ); @@ -1277,6 +1441,7 @@ function applyAbutMemberCard(rec: AbutRecord): void { $('abut-panel-plan-shape').hidden = member !== 'shape'; $('abut-panel-plan-footing').hidden = member !== 'footing'; $('abut-panel-plan-wing').hidden = member !== 'wing'; + $('abut-panel-plan-slab').hidden = member !== 'slab'; // The card title is the picked row; its 요약 follows the name (시안 머리줄). const slabWidth = abutSummaryRows(rec).find(([label]) => label === '접속슬래브 폭')?.[1] ?? '—'; const row = abutMemberRows(rec, slabWidth).find((r) => r.id === member); @@ -1357,6 +1522,11 @@ function fillAbutSlot(slot: (typeof ABUT_SLOTS)[keyof typeof ABUT_SLOTS], rec: A } // ② draws the 날개벽 card (시안); ① keeps the two form selects. if (planCard) fillAbutWingCard(wingBox, rec, apply, fail, idOf, numberInput); + if ('slab' in slot) { + const slabBox = $(slot.slab); + slabBox.replaceChildren(); + fillAbutSlabCard(slabBox, rec, apply, fail, idOf, numberInput); + } for (const side of planCard ? [] : (['left', 'right'] as const)) { const id = idOf(`wing-${side}`); const lab = el('label', side === 'left' ? '좌 날개벽 (① 쪽)' : '우 날개벽 (n 쪽)'); diff --git a/src/style.css b/src/style.css index 47b025c..a3286c6 100644 --- a/src/style.css +++ b/src/style.css @@ -948,3 +948,5 @@ body { background: #eef1f4; font-family: 'Noto Sans KR', system-ui, sans-serif; #abut-panel-plan .abut-wing-forms .abut-grp.editing { border-color: #1f5fa8; background: #f8fbfe; } #abut-panel-plan .abut-wing-forms .abut-grp .abut-grp-title { cursor: pointer; font-weight: 600; color: #18212b; } #abut-panel-plan .abut-grp.compact .abut-opt { width: 64px; min-height: 48px; padding: 4px 2px; } +#abut-panel-plan .abut-tile-row { grid-column: 1 / -1; display: flex; flex-wrap: wrap; align-items: flex-end; gap: 8px 28px; } +#abut-panel-plan .abut-tile-row .abut-grp { margin: 0; } diff --git a/src/view/abut-scene.ts b/src/view/abut-scene.ts index 03913d4..a5354c3 100644 --- a/src/view/abut-scene.ts +++ b/src/view/abut-scene.ts @@ -9,35 +9,65 @@ import * as THREE from 'three'; import type { AbutModel, AbutPoint } from '../core/abut/geometry'; import { buildMembers, type AbutMembers } from '../core/abut/members'; import { buildWings, type WingSolid } from '../core/abut/wings'; +import { resolveSlab, slabGradePct, slabLevelAt, slabSection, type AbutSlab } from '../core/abut/slab'; import type { TerrainGrid } from '../core/ground-model'; import { terrainGeometry } from './terrain'; 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 (날개벽 축 방향)". */ -const SLAB_LENGTH_M = 7; -/** 접속슬래브 하향 경사 (배면 쪽으로), spec_abut §4.8 "하향 4 %". */ -const SLAB_FALL = 0.04; -/** 접속슬래브와 날개벽 안쪽면 사이 간격 (m), spec_abut §4.8 "날개벽 20". */ -const SLAB_CLEARANCE_M = 0.02; /** 말뚝 두부 매입 (m): the drawn pile head goes this far into the 저판 (drawing only). */ const PILE_HEAD_EMBED_M = 0.1; -/** Eight-corner slab with its top sloping away from the wall. Indexed by (s end, t end, bottom/top). Double-sided: the winding is not critical. */ -function slabGeometry(plan: [number, number][], bottomEl: (s: number, t: number) => number): THREE.BufferGeometry { - // plan: four (s, t) corners in order; vertices 0–3 are the bottom, 4–7 the top. - const corners = [...plan.map(([s, t]) => [s, bottomEl(s, t), t]), ...plan.map(([s, t]) => [s, bottomEl(s, t) + SLAB_THICKNESS_M, t])]; - const quads = [ - [0, 1, 2, 3], [4, 5, 6, 7], // bottom and top - [0, 1, 5, 4], [1, 2, 6, 5], [2, 3, 7, 6], [3, 0, 4, 7], // sides - ]; +/** Keeps the part of a convex plan polygon with tLo ≤ t ≤ tHi (two half-plane cuts). */ +function clipT(poly: [number, number][], tHi: number, tLo: number): [number, number][] { + const cut = (pts: [number, number][], keep: (t: number) => boolean, edge: number) => { + const out: [number, number][] = []; + for (let i = 0; i < pts.length; i++) { + const a = pts[i]; + const b = pts[(i + 1) % pts.length]; + if (keep(a[1])) out.push(a); + if (keep(a[1]) !== keep(b[1])) { + const k = (edge - a[1]) / (b[1] - a[1]); + out.push([a[0] + k * (b[0] - a[0]), edge]); + } + } + return out; + }; + return cut(cut(poly, (t) => t <= tHi + 1e-9, tHi), (t) => t >= tLo - 1e-9, tLo); +} + +/** + * 접속슬래브 solid (2026-11-04): the plan polygon (s, t), cut into strips at the section stations (t, descending). Inside a + * strip the top and the bottom are flat, so each strip is fanned; the sides follow the plan edges, split at the stations. + * Double-sided material: the winding is not critical. + */ +function slabGeometry(plan: [number, number][], stationsT: number[], level: (s: number, t: number) => { top: number; bottom: number }): THREE.BufferGeometry { const positions: number[] = []; + const push = (s: number, y: number, t: number) => { + positions.push(s, y, t); + return positions.length / 3 - 1; + }; const indices: number[] = []; - for (const q of quads) { - const base = positions.length / 3; - for (const i of q) positions.push(...corners[i]); - indices.push(base, base + 1, base + 2, base, base + 2, base + 3); + for (let i = 0; i + 1 < stationsT.length; i++) { + const strip = clipT(plan, stationsT[i], stationsT[i + 1]); + if (strip.length < 3) continue; + for (const face of ['top', 'bottom'] as const) { + const ids = strip.map(([s, t]) => push(s, level(s, t)[face], t)); + for (let j = 1; j + 1 < ids.length; j++) indices.push(ids[0], ids[j], ids[j + 1]); + } + } + for (let i = 0; i < plan.length; i++) { + const a = plan[i]; + const b = plan[(i + 1) % plan.length]; + const cuts = stationsT.filter((t) => (t - a[1]) * (t - b[1]) < 0).map((t) => (t - a[1]) / (b[1] - a[1])); + const ks = [0, ...cuts.sort((x, y) => x - y), 1]; + for (let j = 0; j + 1 < ks.length; j++) { + const p = [a[0] + ks[j] * (b[0] - a[0]), a[1] + ks[j] * (b[1] - a[1])]; + const q = [a[0] + ks[j + 1] * (b[0] - a[0]), a[1] + ks[j + 1] * (b[1] - a[1])]; + const lp = level(p[0], p[1]); + const lq = level(q[0], q[1]); + const v = [push(p[0], lp.bottom, p[1]), push(q[0], lq.bottom, q[1]), push(q[0], lq.top, q[1]), push(p[0], lp.top, p[1])]; + indices.push(v[0], v[1], v[2], v[0], v[2], v[3]); + } } const geom = new THREE.BufferGeometry(); geom.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)); @@ -120,27 +150,44 @@ 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 }; } -/** Plan (s, t) outlines of the abutment: the 벽체 (skew), each wing, and the 접속슬래브 between the wings (spec §4.8). */ +/** + * Plan (s, t) outlines of the abutment: the 벽체 (skew), each wing, and the 접속슬래브 between the wings (spec §4.8). + * The slab starts 흉벽과 간격 behind the 흉벽 back face, runs 길이 back, keeps 날개벽과 간격 from each wing's inner face, and + * is chamfered at the 흉벽 corners by that wing's 헌치 (2026-11-04: 모따기 = 날개벽 헌치). No wing: the 교좌면 끝선, no chamfer. + */ export interface AbutPlanOutline { wall: [number, number][]; wings: { side: 'left' | 'right'; sEnd: number; poly: [number, number][] }[]; slab: [number, number][]; } -export function abutPlanOutline(model: AbutModel, members: AbutMembers, wings: WingSolid[]): AbutPlanOutline { +export function abutPlanOutline(model: AbutModel, members: AbutMembers, wings: WingSolid[], slab: AbutSlab = resolveSlab()): AbutPlanOutline { const L = model.length; const k = abutSkewK(model); const tb = members.shape.wallBackM; const tf = members.shape.wallFrontM; - const near = members.shape.parapetBackM - 0.005; - const far = near - SLAB_LENGTH_M; - const innerS = (sEnd: number, t: number): number => { - const w = wings.find((x) => x.sEnd === sEnd); - if (!w) return sEnd; // no wing on this side: the slab ends at the 교좌면 끝선 - const a = w.sEnd === 0 ? 1 : -1; + const near = members.shape.parapetBackM - slab.parapetGapMm / 1000; + const far = near - slab.lengthMm / 1000; + const sideK = (w: WingSolid) => { const p0 = toAxis(model, w.corners[0]); const p1 = toAxis(model, w.corners[1]); - return sEnd + a * (w.thicknessM + SLAB_CLEARANCE_M) + ((p1.s - p0.s) / (p1.t - p0.t)) * t; + return (p1.s - p0.s) / (p1.t - p0.t); + }; + const innerS = (sEnd: number, t: number): number => { + const w = wings.find((x) => x.sEnd === sEnd); + if (!w) return sEnd + k * t; // no wing on this side: the slab ends at the 교좌면 끝선 (skew) + const a = w.sEnd === 0 ? 1 : -1; + return sEnd + a * (w.thicknessM + slab.wingGapMm / 1000) + sideK(w) * t; + }; + // Near corner of one side: [point on the side line, point on the near edge] (chamfer), or the corner alone. + const nearCorner = (sEnd: number): [number, number][] => { + const w = wings.find((x) => x.sEnd === sEnd); + const corner: [number, number] = [innerS(sEnd, near), near]; + if (!w || !(w.haunchM > 0)) return [corner]; + const c = w.haunchM; + const a = sEnd === 0 ? 1 : -1; + const tSide = near - c / Math.hypot(1, sideK(w)); + return [[innerS(sEnd, tSide), tSide], [corner[0] + a * c, near]]; }; const axis = (c: AbutPoint): [number, number] => { const p = toAxis(model, c); @@ -149,7 +196,7 @@ export function abutPlanOutline(model: AbutModel, members: AbutMembers, wings: W return { wall: [[k * tb, tb], [L + k * tb, tb], [L + k * tf, tf], [k * tf, tf]], wings: wings.map((w) => ({ side: w.side, sEnd: w.sEnd, poly: w.corners.map(axis) })), - slab: [[innerS(0, near), near], [innerS(L, near), near], [innerS(L, far), far], [innerS(0, far), far]], + slab: [...nearCorner(0), ...nearCorner(L).reverse(), [innerS(L, far), far], [innerS(0, far), far]], }; } @@ -180,6 +227,7 @@ export function buildAbutGroup( pivot?: { e: number; n: number }, groundEl?: number, terrain?: TerrainGrid, + slabInput?: Partial, ): THREE.Group { const root = new THREE.Group(); root.name = model.name; @@ -289,16 +337,19 @@ export function buildAbutGroup( add('blocks', placed(prism([[-0.5, blockTop], [0.5, blockTop], [0.5, b.z], [-0.5, b.z]], 1.0, new THREE.Vector3(0, 0, -0.5), ex, ey, ez)), 0x6e7a86); } - // 접속슬래브 (spec §4.8): bottom on the 브라켓 top (흉벽 상단 − 0.85), 5 mm behind the 흉벽 back face, 7 m back, falling 4 % away from the wall. - // Plan: between the wing inner faces, each 20 mm inside (spec §4.8), from abutPlanOutline (shared with the ② 날개벽 plan). - const slabNear = members.shape.parapetBackM - 0.005; - const slabPlan = abutPlanOutline(model, members, wings).slab; - const bracketTopEl = (s: number) => members.parapetTopStartEl + ((members.parapetTopEndEl - members.parapetTopStartEl) * s) / L - members.shape.bracketHeightM; + // 접속슬래브 (spec §4.8, 2026-11-04 매립형 / 노출형): the section (core/abut/slab.ts) is measured from the 흉벽 상단 at each s. + // Plan: between the wing inner faces with the gaps and chamfers, from abutPlanOutline (shared with the ② 날개벽 plan). + const slab = resolveSlab(slabInput); + const slabPlan = abutPlanOutline(model, members, wings, slab).slab; + const section = slabSection(slab, slabGradePct(slab, model.wing?.slopePct ?? 0), members.shape); const slabMesh = new THREE.Mesh( slabGeometry( slabPlan, - // Flat on the bracket where it lies over the back strip (t ≥ 벽체 배면), falling 4 % only behind the wall. - (s, t) => bracketTopEl(s) - SLAB_FALL * Math.max(0, members.shape.wallBackM - t), + section.map((p) => p.t), + (s, t) => { + const lv = slabLevelAt(section, t); + return { top: parapetTopAt(s) + lv.top, bottom: parapetTopAt(s) + lv.bottom }; + }, ), new THREE.MeshStandardMaterial({ color: 0xd8c8a0, roughness: 0.9, side: THREE.DoubleSide }), ); diff --git a/src/view/abut-slab-svg.ts b/src/view/abut-slab-svg.ts new file mode 100644 index 0000000..b5a5231 --- /dev/null +++ b/src/view/abut-slab-svg.ts @@ -0,0 +1,200 @@ +/** + * 교대 접속슬래브 삽도 (2026-11-04, 시안 ② 접속슬래브 카드): 평면(날개벽 사이), 단면(교대축 직각, 매립형 / 노출형), 흉벽–슬래브 접합 상세. + * 순수 문자열 함수. 평면·단면은 생성본 값으로 그리고 가로·세로 축척이 같다. 접합 상세는 부속재를 보여 주는 모식도다(표시용). + */ +import type { AbutPlanOutline } from './abut-scene'; +import type { SlabForm, SlabStation } from '../core/abut/slab'; +import { dimLine } from './abut-wing-svg'; + +const INK = '#18212b'; +const MUTED = '#55606e'; +const WING = '#d6bb8c'; +const WALL = '#b7c7d3'; +const SLAB = '#f3eee4'; +const SLAB_EDGE = '#b49a6b'; +const text = (x: number, y: number, s: string, anchor = 'start', fill = INK) => + `${s}`; +const poly = (pts: [number, number][], style: string) => ``; +const line = (x1: number, y1: number, x2: number, y2: number, style = `stroke="${MUTED}"`) => + ``; +const el = (v: number) => `EL.${v.toFixed(3)}`; + +export interface AbutSlabPlanDrawing { + outline: AbutPlanOutline; + /** 좌 = ① 쪽 끝 (sEnd); 날개벽 평면과 같은 좌우. */ + leftSEnd: number; + widthM: number; + lengthMm: number; + /** 측별 모따기 (mm, 0 = 없음). */ + chamferMm: { left: number; right: number }; +} + +/** 평면: 벽체, 날개벽, 접속슬래브(모따기), 폭 · 길이 치수. 왼쪽이 ① 쪽(좌), 위가 전면. */ +export function abutSlabPlanSvg(d: AbutSlabPlanDrawing): string { + const pts = [...d.outline.wall, ...d.outline.slab, ...d.outline.wings.flatMap((w) => w.poly)]; + const flip = d.leftSEnd !== 0; + const xs = pts.map(([s]) => (flip ? -s : s)); + const ts = pts.map(([, t]) => t); + const k = 30; + const xMin = Math.min(...xs) - 1.6; + const tMax = Math.max(...ts) + 0.4; + const X = (s: number) => 20 + ((flip ? -s : s) - xMin) * k; + const Y = (t: number) => 18 + (tMax - t) * k; + const W = 40 + (Math.max(...xs) - xMin + 0.6) * k; + const tMin = Math.min(...d.outline.slab.map(([, t]) => t)); + const H = Y(tMin) + 46; + const map = (p: [number, number][]) => p.map(([s, t]) => [X(s), Y(t)] as [number, number]); + const slab = d.outline.slab; + const parts = [ + poly(map(d.outline.wall), `fill="${WALL}" stroke="${MUTED}"`), + ...d.outline.wings.map((w) => poly(map(w.poly), `fill="${WING}" stroke="#9c8763"`)), + poly(map(slab), `fill="${SLAB}" stroke="${SLAB_EDGE}" stroke-width="1.4"`), + ]; + // 폭: the far edge (the last two corners), below it. 길이: the left side, from the near edge to the far edge. + const farA = slab[slab.length - 1]; + const farB = slab[slab.length - 2]; + const [fl, fr] = X(farA[0]) < X(farB[0]) ? [farA, farB] : [farB, farA]; + parts.push(dimLine([X(fl[0]), Y(fl[1])], [X(fr[0]), Y(fr[1])], Y(tMin) + 16, 'h', `폭 ${d.widthM.toFixed(3)} m`, true)); + const tNear = Math.max(...slab.map(([, t]) => t)); + const xLeft = Math.min(...slab.map(([s]) => X(s))); + const nearLeft = slab.filter(([, t]) => Math.abs(t - tNear) < 1e-9).map(([s]) => X(s)).sort((a, b) => a - b)[0]; + parts.push(dimLine([nearLeft, Y(tNear)], [X(fl[0]), Y(fl[1])], xLeft - 12, 'v', `${d.lengthMm}`)); + const c = slab.reduce((a, [s, t]) => [a[0] + s / slab.length, a[1] + t / slab.length], [0, 0]); + parts.push(text(X(c[0]), Y(c[1]), '접속슬래브', 'middle')); + for (const w of d.outline.wings) { + const mm = w.side === 'left' ? d.chamferMm.left : d.chamferMm.right; + if (!(mm > 0)) continue; + const x = X(w.sEnd) + (X(c[0]) > X(w.sEnd) ? 1 : -1) * 34; + parts.push(text(x, Y(tNear) + 26, `모따기 ${mm} × ${mm}`, X(c[0]) > X(w.sEnd) ? 'start' : 'end', MUTED)); + } + parts.push(text((X(0) + X(Math.max(...d.outline.wall.map(([s]) => s)))) / 2, 12, '흉벽 · 벽체 (위 = 전면)', 'middle', MUTED)); + return [ + ``, + ...parts, + '', + ].join(''); +} + +export interface AbutSlabSectionDrawing { + form: SlabForm; + /** "하향 4 %" / "종단경사 +1.91 %". */ + slopeLabel: string; + /** 단면 (t, 흉벽 상단에서 잰 상면 · 하면), core/abut/slab.ts slabSection. */ + section: SlabStation[]; + /** 이 단면 위치의 흉벽 상단 EL (m). */ + parapetTopEl: number; + bracketHeightM: number; + parapetBackM: number; + wallBackM: number; + lengthMm: number; + thicknessMm: number; + parapetGapMm: number; + haunchLengthMm: number; +} + +/** 단면: 날개벽 축 방향(교대축 직각), 왼쪽이 배면. 흉벽 · 브라켓 단차, 슬래브(매립형 일정 두께 / 노출형 헌치), 치수와 EL. */ +export function abutSlabSectionSvg(d: AbutSlabSectionDrawing): string { + const k = 56; + const st = d.section; + const t0 = st[0].t; + const t1 = st[st.length - 1].t; + const low = -d.bracketHeightM - 0.9; + const hi = Math.max(0, ...st.map((p) => p.top)) + 0.5; + const minBottom = Math.min(...st.map((p) => p.bottom)); + const left = t1 - 1.0; + const X = (t: number) => 20 + (t - left) * k; + const Y = (y: number) => 12 + (hi - y) * k; + const bH = d.bracketHeightM; + const parts = [ + // 흉벽 (t 흉벽 배면 … 0) and the 브라켓 단차 (벽체 배면 … 흉벽 배면), cut below. + poly( + [[X(d.wallBackM), Y(low)], [X(0), Y(low)], [X(0), Y(0)], [X(d.parapetBackM), Y(0)], [X(d.parapetBackM), Y(-bH)], [X(d.wallBackM), Y(-bH)]], + `fill="${WALL}" stroke="${MUTED}"`, + ), + poly([...st.map((p) => [X(p.t), Y(p.top)] as [number, number]), ...[...st].reverse().map((p) => [X(p.t), Y(p.bottom)] as [number, number])], `fill="${SLAB}" stroke="${SLAB_EDGE}" stroke-width="1.4"`), + ]; + // 다웰 D25 at the middle of the 브라켓 width. + const dowelT = (d.wallBackM + d.parapetBackM) / 2; + parts.push(line(X(dowelT), Y(-bH - 0.35), X(dowelT), Y(-bH + 0.2), `stroke="${INK}" stroke-width="1.6"`)); + parts.push(text(X(d.wallBackM) - 6, Y(-bH - 0.45), '다웰 D25 @400', 'end', MUTED)); + // 치수: 길이 (흉벽 · 벽체 아래), 끝 두께 (왼쪽 끝), 노출형은 흉벽 쪽 끝 두께와 헌치 길이. + const yDim = Math.max(Y(minBottom), Y(low)) + 18; + parts.push(dimLine([X(t1), Y(st[st.length - 1].bottom)], [X(t0), Y(st[0].bottom)], yDim, 'h', `${d.lengthMm}`, true)); + const far = st[st.length - 1]; + parts.push(dimLine([X(t1), Y(far.top)], [X(t1), Y(far.bottom)], X(t1) - 10, 'v', `${d.thicknessMm}`)); + if (d.form === 'exposed') { + const he = d.wallBackM - d.haunchLengthMm / 1000; + const heP = st.find((p) => Math.abs(p.t - he) < 1e-6); + if (heP) parts.push(dimLine([X(he), Y(heP.bottom)], [X(d.wallBackM), Y(-bH)], yDim + 22, 'h', `헌치 ${d.haunchLengthMm}`, true)); + parts.push(text(X(dowelT) - 6, Y((st[0].top + st[0].bottom) / 2) + 4, `끝 ${Math.round(bH * 1000)}`, 'end')); + } + // 경사 (상면 가운데 위), 간격, EL. + const mid = st[Math.floor((st.length - 1) / 2)]; + parts.push(text((X(t1) + X(mid.t)) / 2, Y(Math.max(far.top, mid.top)) - 8, `경사 ${d.slopeLabel}`, 'middle')); + parts.push(text(X(t0), Y(st[0].top) - 8, `간격 ${d.parapetGapMm}`, 'middle', MUTED)); + parts.push(text(X(0) + 6, Y(0) + 4, `흉벽 상단 ${el(d.parapetTopEl)}`, 'start')); + parts.push(text(X(0) + 6, Y(-bH) + 4, `브라켓 상면 ${el(d.parapetTopEl - bH)}`, 'start')); + parts.push(text(X(0) + 6, Y(low) - 4, '흉벽 · 벽체', 'start', MUTED)); + const W = X(0) + 190; + const H = yDim + (d.form === 'exposed' ? 44 : 22); + return [ + ``, + ...parts, + '', + ].join(''); +} + +export interface AbutSlabJointDrawing { + form: SlabForm; + /** 경사 (%, + = 배면 쪽으로 올라감). "S = 4.00 % (하향)"처럼 표시한다. */ + gradePct: number; + parapetTopEl: number; + bracketHeightM: number; + parapetGapMm: number; + thicknessMm: number; +} + +/** 흉벽–접속슬래브 접합 상세 (삽도 기준, 모식도): 간격 · 아스팔트 충진재 / 스티로폼, 고무판, 타르페이퍼, 다웰 · 강관. */ +export function abutSlabJointSvg(d: AbutSlabJointDrawing): string { + // Fixed schematic (px): 흉벽 x 300–360 from y 20, 브라켓 상면 y 150 from x 260. + const bracketY = 150; + const padH = 4; + const slabEnd = 296; + const bottomY = bracketY - padH; + const exposed = d.form === 'exposed'; + const topY = exposed ? 20 : 86; + const drop = d.gradePct < 0 ? 10 : -6; // left end lower for a fall, higher for a rise + const slab: [number, number][] = exposed + ? [[20, topY + drop + 60], [20, topY + drop], [slabEnd, topY], [slabEnd, bottomY], [150, topY + drop * 0.5 + 60]] + : [[20, bottomY + drop], [20, topY + drop], [slabEnd, topY], [slabEnd, bottomY]]; + const gapMid = topY + (bottomY - topY) * 0.3; + const parts = [ + poly([[260, 262], [360, 262], [360, 20], [300, 20], [300, bracketY], [260, bracketY]], `fill="${WALL}" stroke="${MUTED}"`), + poly(slab, `fill="${SLAB}" stroke="${SLAB_EDGE}" stroke-width="1.4"`), + // 간격: 아스팔트 충진재 (위), 스티로폼 (아래). + ``, + ``, + // 고무판 150 × 150 × 15, 타르페이퍼 T15, 다웰 D25 in 강관 Φ60 L300. + ``, + line(260, bracketY, 300, bracketY, 'stroke="#8a5a2b" stroke-width="2.4"'), + ``, + line(280, bottomY - 46, 280, bracketY + 48, `stroke="${INK}" stroke-width="1.8"`), + ]; + const lead = (x1: number, y1: number, x2: number, y2: number, s: string, anchor: 'start' | 'end') => + line(x1, y1, x2, y2, `stroke="${MUTED}" stroke-width="0.7"`) + text(x2 + (anchor === 'start' ? 3 : -3), y2 + 4, s, anchor); + parts.push(lead(298, topY + 6, 372, 58, `간격 ${d.parapetGapMm} · 아스팔트 충진재`, 'start')); + parts.push(lead(298, gapMid + 12, 372, 100, '스티로폼', 'start')); + parts.push(lead(272, bottomY + 2, 190, 196, '고무판 150 × 150 × 15', 'end')); + parts.push(lead(264, bracketY + 1, 190, 214, '타르페이퍼 T = 15', 'end')); + parts.push(lead(280, bracketY + 40, 190, 232, '다웰 D25 · 강관 Φ60 L300', 'end')); + parts.push(text(130, exposed ? 120 : (topY + bottomY) / 2 + 4, exposed ? `노출형 · 끝 ${Math.round(d.bracketHeightM * 1000)}` : `접속슬래브 ${d.thicknessMm}`, 'middle')); + parts.push(text(40, topY + drop - 6, `S = ${Math.abs(d.gradePct).toFixed(2)} % (${d.gradePct < 0 ? '하향' : '상향'})`, 'start')); + parts.push(text(368, 24, `흉벽 상단 ${el(d.parapetTopEl)}`, 'start')); + parts.push(text(368, bracketY + 4, `브라켓 상면 ${el(d.parapetTopEl - d.bracketHeightM)}`, 'start')); + parts.push(text(330, 252, '흉벽 · 벽체', 'middle', MUTED)); + return [ + ``, + ...parts, + '', + ].join(''); +} diff --git a/src/view/abut-wing-svg.ts b/src/view/abut-wing-svg.ts index 1ff22fd..7b569c1 100644 --- a/src/view/abut-wing-svg.ts +++ b/src/view/abut-wing-svg.ts @@ -43,7 +43,7 @@ export function abutWingPlanSvg(d: AbutWingPlanDrawing): string { poly(map(d.outline.wall), `fill="${WALL}" stroke="${MUTED}"`), ...d.outline.wings.map((w) => poly(map(w.poly), `fill="${WING}" stroke="#9c8763"`)), ]; - const slabC = d.outline.slab.reduce((a, [s, t]) => [a[0] + s / 4, a[1] + t / 4], [0, 0]); + const slabC = d.outline.slab.reduce((a, [s, t]) => [a[0] + s / d.outline.slab.length, a[1] + t / d.outline.slab.length], [0, 0]); parts.push(text(X(slabC[0]), Y(slabC[1]), `접속슬래브 폭 ${d.slabWidthM.toFixed(3)} m`, 'middle')); for (const w of d.outline.wings) { const far = w.poly[1]; @@ -87,17 +87,19 @@ const el = (v: number) => `EL.${v.toFixed(3)}`; * 측면 (날개벽 축 방향으로 잘라 본 모습): 벽체 쪽이 오른쪽, 날개벽 끝이 왼쪽. * 벽체 · 흉벽 · 브라켓 단차와 저판을 단면으로 함께 그린다. 가로 위치는 t를 날개벽 거리로 바꾼 값(dM = −t / k, k = 뒷굽 / 뒷굽 거리). */ -/** 치수선 (2026-11-04): 점 a, b에서 치수선 위치 at(가로면 y, 세로면 x)까지 보조선, 치수선, 양 끝 사선 틱, 치수. */ -function dimLine(a: [number, number], b: [number, number], at: number, dir: 'h' | 'v', label: string): string { +/** 치수선 (2026-11-04): 점 a, b에서 치수선 위치 at(가로면 y, 세로면 x)까지 보조선, 치수선, 양 끝 사선 틱, 치수. after = 치수를 선의 아래 · 오른쪽에. */ +export function dimLine(a: [number, number], b: [number, number], at: number, dir: 'h' | 'v', label: string, after = false): string { const line = (x1: number, y1: number, x2: number, y2: number) => ``; const tick = (x: number, y: number) => line(x - 3, y + 3, x + 3, y - 3); if (dir === 'h') { const ext = (x: number, y: number) => line(x, y + Math.sign(at - y) * 3, x, at + Math.sign(at - y) * 3); - return [ext(a[0], a[1]), ext(b[0], b[1]), line(a[0], at, b[0], at), tick(a[0], at), tick(b[0], at), text((a[0] + b[0]) / 2, at - 4, label, 'middle')].join(''); + const lab = text((a[0] + b[0]) / 2, after ? at + 13 : at - 4, label, 'middle'); + return [ext(a[0], a[1]), ext(b[0], b[1]), line(a[0], at, b[0], at), tick(a[0], at), tick(b[0], at), lab].join(''); } const ext = (x: number, y: number) => line(x + Math.sign(at - x) * 3, y, at + Math.sign(at - x) * 3, y); - return [ext(a[0], a[1]), ext(b[0], b[1]), line(at, a[1], at, b[1]), tick(at, a[1]), tick(at, b[1]), text(at - 4, (a[1] + b[1]) / 2 + 4, label, 'end')].join(''); + const lab = after ? text(at + 4, (a[1] + b[1]) / 2 + 4, label, 'start') : text(at - 4, (a[1] + b[1]) / 2 + 4, label, 'end'); + return [ext(a[0], a[1]), ext(b[0], b[1]), line(at, a[1], at, b[1]), tick(at, a[1]), tick(at, b[1]), lab].join(''); } export function abutWingSideSvg(d: AbutWingSideDrawing): string { diff --git a/tests/abut-member-list.test.ts b/tests/abut-member-list.test.ts index e72812f..b0a85fe 100644 --- a/tests/abut-member-list.test.ts +++ b/tests/abut-member-list.test.ts @@ -41,11 +41,11 @@ describe('교대 부재 목록 (시안 ② 부재 · 교대)', () => { it('shows the slab width text and the ground EL as given', () => { const rows = abutMemberRows(base, '11.253 m'); - expect(rows.find((r) => r.id === 'slab')?.summary).toBe('11.253 m'); + expect(rows.find((r) => r.id === 'slab')?.summary).toBe('매립형 · 하향 4 % · 폭 11.253 m'); expect(rows.find((r) => r.id === 'ground')?.summary).toBe('지반고 33.984 m'); }); - 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 }); + it('every card but the ground takes input in this step (접속슬래브 2026-11-04)', () => { + expect(ABUT_MEMBER_EDITABLE).toEqual({ bearing: true, shape: true, footing: true, wing: true, slab: true, ground: false }); }); }); diff --git a/tests/abut-slab.test.ts b/tests/abut-slab.test.ts new file mode 100644 index 0000000..8f71628 --- /dev/null +++ b/tests/abut-slab.test.ts @@ -0,0 +1,155 @@ +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 { applyShapeEdit, applySlabEdit, setSlabForm, setSlabSlope } from '../src/core/abut/edit'; +import { abutMemberRows } from '../src/core/abut/member-list'; +import { resolveSlab, slabChangedKeys, slabGradePct, slabIssues, slabSection, slabSlopeLabel } from '../src/core/abut/slab'; +import { checkStoredAbutments } from '../src/core/project'; +import { abutPlanOutline, buildAbutGroup } from '../src/view/abut-scene'; +import { abutSlabJointSvg, abutSlabPlanSvg, abutSlabSectionSvg } from '../src/view/abut-slab-svg'; + +const RAG = fileURLToPath(new URL('../../../RAG/운곡교P1입력.xlsx', import.meta.url)); +const rec = parseAbutments(XLSX.read(readFileSync(RAG), { type: 'buffer' })).abutments[0]; +const build = (r = rec) => { + const model = buildAbut(toAbutInput(r)); + const members = buildMembers(model, { bottomEl: r.foundationBottomEl, pileLengthMm: r.pileLengthMm }, r.shape); + const wings = buildWings(model, members, r.wings, r.wingDims); + return { model, members, wings }; +}; +const SHAPE = { parapetBackM: -0.5, wallBackM: -0.8, bracketHeightM: 0.85 }; + +describe('접속슬래브 입력 (2026-11-04, spec_abut §4.8, 시안 ② 접속슬래브 카드)', () => { + it('defaults: 매립형 · 하향 4 % · 7000 × 450 · 간격 5 / 20; the slope follows the form unless set', () => { + expect(resolveSlab()).toEqual({ form: 'buried', slope: 'down4', lengthMm: 7000, thicknessMm: 450, parapetGapMm: 5, wingGapMm: 20, haunchLengthMm: 2000 }); + expect(resolveSlab({ form: 'exposed' }).slope).toBe('grade'); + const exposed = setSlabForm({ ...rec, slab: { slope: 'down4' } }, 'exposed'); + expect(exposed.slab).toEqual({ form: 'exposed' }); // picking a form resets the slope to the form's own + expect(setSlabSlope(exposed, 'down4').slab).toEqual({ form: 'exposed', slope: 'down4' }); + expect(setSlabSlope(exposed, 'grade').slab).toEqual({ form: 'exposed' }); + expect(slabChangedKeys(exposed.slab)).toEqual(['form', 'slope']); + expect(slabChangedKeys(undefined)).toEqual([]); + }); + + it('refuses a slab that does not fit under the 흉벽 상단, and a 헌치 as long as the slab', () => { + expect(slabIssues(resolveSlab({ thicknessMm: 900 }), 850).join(' ')).toContain('브라켓 높이'); + expect(slabIssues(resolveSlab({ form: 'exposed', haunchLengthMm: 7000 }), 850).join(' ')).toContain('헌치 길이'); + expect(() => applySlabEdit(rec, 'lengthMm', 0)).toThrow('길이'); + expect(() => applyShapeEdit({ ...rec, slab: { thicknessMm: 600 } }, 'bracketHeightMm', 500)).toThrow('접속슬래브 두께'); + }); + + it('slope text and value: 하향 4 % = −4; 종단경사 = the wing slope (A1 = −i)', () => { + const { model } = build(); + expect(slabGradePct(resolveSlab(), model.wing!.slopePct)).toBe(-4); + expect(slabGradePct(resolveSlab({ form: 'exposed' }), 1.91)).toBe(1.91); + expect(slabSlopeLabel(resolveSlab({ form: 'exposed' }), 1.91)).toBe('종단경사 +1.91 %'); + }); + + it('매립형 section: on the 브라켓 상면, flat to the 벽체 배면, then falling 4 %; 450 thick everywhere', () => { + const st = slabSection(resolveSlab(), -4, SHAPE); + expect(st.map((p) => p.t)).toEqual([-0.505, -0.8, -7.505]); + for (const p of st) expect(p.top - p.bottom).toBeCloseTo(0.45, 9); + expect(st[0].bottom).toBeCloseTo(-0.85, 9); + expect(st[1].bottom).toBeCloseTo(-0.85, 9); + expect(st[2].bottom).toBeCloseTo(-0.85 - 0.04 * 6.705, 9); + }); + + it('노출형 section (삽도): top = 흉벽 상단 at the near end, end on the 브라켓 (850 thick), 헌치 2000 down to 450', () => { + const st = slabSection(resolveSlab({ form: 'exposed' }), 1.91, SHAPE); + expect(st.map((p) => p.t)).toEqual([-0.505, -0.8, -2.8, -7.505]); + expect(st[0].top).toBeCloseTo(0, 9); + expect(st[0].bottom).toBeCloseTo(-0.85, 9); + expect(st[1].bottom).toBeCloseTo(-0.85, 9); + expect(st[2].top - st[2].bottom).toBeCloseTo(0.45, 9); + expect(st[3].top - st[3].bottom).toBeCloseTo(0.45, 9); + expect(st[3].top).toBeCloseTo(0.0191 * 7, 9); // rises with the grade + }); + + it('a wider 날개벽과 간격 narrows the slab by twice the change', () => { + const base = build().model.wing!.slabWidthMm; + const wider = build({ ...rec, slab: { wingGapMm: 70 } }).model.wing!.slabWidthMm; + expect(base - wider).toBeCloseTo(100, 0); + }); + + it('plan: 모따기 at both 흉벽 corners (6 corners); none when the wing haunch is 0', () => { + const { model, members, wings } = build(); + expect(abutPlanOutline(model, members, wings).slab).toHaveLength(6); + const flat = build({ ...rec, wingDims: { left: { haunchMm: 0 }, right: { haunchMm: 0 } } }); + expect(abutPlanOutline(flat.model, flat.members, flat.wings).slab).toHaveLength(4); + const long = abutPlanOutline(model, members, wings, resolveSlab({ lengthMm: 8000, parapetGapMm: 10 })).slab; + expect(Math.max(...long.map(([, t]) => t))).toBeCloseTo(members.shape.parapetBackM - 0.01, 9); + expect(Math.min(...long.map(([, t]) => t))).toBeCloseTo(members.shape.parapetBackM - 8.01, 9); + }); + + it('3D 노출형: the slab top reaches the 흉벽 상단 and its bottom stays on the 브라켓 over the back strip', () => { + const { model, members, wings } = build(); + const group = buildAbutGroup(model, members, wings, undefined, undefined, undefined, { form: 'exposed' }); + const mesh = (group.getObjectByName('slab') as THREE.Group).children[0] as THREE.Mesh; + const pos = mesh.geometry.getAttribute('position'); + const top = (s: number) => members.parapetTopStartEl + ((members.parapetTopEndEl - members.parapetTopStartEl) * s) / model.length; + let nearTop = -Infinity; + for (let i = 0; i < pos.count; i++) { + const s = pos.getX(i); + const t = pos.getZ(i); + if (t > members.shape.wallBackM + 1e-6) expect(pos.getY(i)).toBeGreaterThanOrEqual(top(s) - members.shape.bracketHeightM - 1e-4); + if (Math.abs(t - (members.shape.parapetBackM - 0.005)) < 1e-6) nearTop = Math.max(nearTop, pos.getY(i) - top(s)); + } + expect(nearTop).toBeCloseTo(0, 4); + const box = new THREE.Box3().setFromObject(mesh); + expect(box.min.z).toBeCloseTo(members.shape.parapetBackM - 0.005 - 7, 4); + }); + + it('the member row shows the form, slope and width, and marks a change', () => { + const row = abutMemberRows({ ...rec, slab: { form: 'exposed' } }, '11.253 m').find((r) => r.id === 'slab')!; + expect(row.summary).toBe('노출형 · 종단경사 · 폭 11.253 m'); + expect(row.changed).toBe(true); + }); + + it('the project file keeps the slab input and refuses a wrong form', () => { + expect(() => checkStoredAbutments([{ ...rec, slab: { form: 'exposed', lengthMm: 8000 } }])).not.toThrow(); + expect(() => checkStoredAbutments([{ ...rec, slab: { form: 'open' } }])).toThrow('접속슬래브'); + expect(() => checkStoredAbutments([{ ...rec, slab: { lengthMm: 'x' } }])).toThrow('접속슬래브'); + }); +}); + +describe('접속슬래브 삽도 (평면 · 단면 · 접합 상세)', () => { + const { model, members, wings } = build(); + const sh = members.shape; + const sectionOf = (form: 'buried' | 'exposed') => { + const slab = resolveSlab({ form }); + return abutSlabSectionSvg({ + form, slopeLabel: slabSlopeLabel(slab, model.wing!.slopePct), section: slabSection(slab, slabGradePct(slab, model.wing!.slopePct), sh), + parapetTopEl: 46.867, bracketHeightM: sh.bracketHeightM, parapetBackM: sh.parapetBackM, wallBackM: sh.wallBackM, + lengthMm: slab.lengthMm, thicknessMm: slab.thicknessMm, parapetGapMm: slab.parapetGapMm, haunchLengthMm: slab.haunchLengthMm, + }); + }; + + it('평면: width, length and both chamfers', () => { + const svg = abutSlabPlanSvg({ + outline: abutPlanOutline(model, members, wings), leftSEnd: wings.find((w) => w.side === 'left')!.sEnd, + widthM: model.wing!.slabWidthMm / 1000, lengthMm: 7000, chamferMm: { left: 300, right: 300 }, + }); + for (const s of [`폭 ${(model.wing!.slabWidthMm / 1000).toFixed(3)} m`, '>7000<', '모따기 300 × 300', '접속슬래브']) expect(svg).toContain(s); + }); + + it('단면 매립형: 7000, 450, 하향 4 %, EL labels; 노출형 adds 헌치 2000 and 끝 850', () => { + const buried = sectionOf('buried'); + for (const s of ['>7000<', '>450<', '경사 하향 4 %', '흉벽 상단 EL.46.867', '브라켓 상면 EL.46.017', '간격 5', '다웰 D25 @400']) expect(buried).toContain(s); + expect(buried).not.toContain('헌치'); + const exposed = sectionOf('exposed'); + for (const s of ['헌치 2000', '끝 850', '경사 종단경사 +']) expect(exposed).toContain(s); + }); + + it('접합 상세: the accessories and the EL labels', () => { + const svg = abutSlabJointSvg({ form: 'buried', gradePct: -4, parapetTopEl: 46.867, bracketHeightM: 0.85, parapetGapMm: 5, thicknessMm: 450 }); + for (const s of ['간격 5 · 아스팔트 충진재', '스티로폼', '고무판 150 × 150 × 15', '타르페이퍼 T = 15', '다웰 D25 · 강관 Φ60 L300', 'S = 4.00 % (하향)', '흉벽 상단 EL.46.867', '브라켓 상면 EL.46.017', '접속슬래브 450']) { + expect(svg).toContain(s); + } + expect(abutSlabJointSvg({ form: 'exposed', gradePct: 1.91, parapetTopEl: 46.867, bracketHeightM: 0.85, parapetGapMm: 5, thicknessMm: 450 })).toContain('노출형 · 끝 850'); + }); +});