diff --git a/src/core/abut/edit.ts b/src/core/abut/edit.ts index 7d406ac..670720b 100644 --- a/src/core/abut/edit.ts +++ b/src/core/abut/edit.ts @@ -4,6 +4,7 @@ */ 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 { abutBearingRuleIssues, resolveBearingRule, type AbutBearingRule } from './bearing-table'; @@ -32,11 +33,45 @@ export function applyAbutEdit(rec: AbutRecord, key: AbutEditKey, value: number): return next; } +/** 좌우 같게 (기본): the form and dims apply to both sides. Off: each side keeps its own. */ +export function wingsLinked(rec: AbutRecord): boolean { + return rec.wingsLinked !== false; +} + export function setWingForm(rec: AbutRecord, side: 'left' | 'right', form: WingForm): AbutRecord { const current = rec.wings ?? { left: 'general' as WingForm, right: 'general' as WingForm }; + if (wingsLinked(rec)) return { ...rec, wings: { left: form, right: form } }; return { ...rec, wings: { ...current, [side]: form } }; } +/** Turns 좌우 같게 on or off. Turning it on copies the left side (form and dims) to the right. */ +export function setWingsLinked(rec: AbutRecord, linked: boolean): AbutRecord { + if (!linked) return { ...rec, wingsLinked: false }; + const left = rec.wings?.left ?? 'general'; + const dims = rec.wingDims?.left; + return { ...rec, wingsLinked: true, wings: { left, right: left }, wingDims: dims ? { left: { ...dims }, right: { ...dims } } : undefined }; +} + +/** 날개벽 치수 입력 칸 (② 날개벽 카드, 시안 순서). 단축형에는 끝 높이가 없다. */ +export const WING_DIM_FIELDS: { key: keyof WingDims; label: string; general: boolean; short: boolean }[] = [ + { key: 'lengthMm', label: '길이 (mm)', general: true, short: true }, + { key: 'thicknessMm', label: '두께 (mm)', general: true, short: true }, + { key: 'endHeightMm', label: '끝 높이 (mm)', general: true, short: false }, + { key: 'haunchMm', label: '헌치 (mm, 300 × 300의 한 변)', general: true, short: true }, +]; + +/** Changes one wing dim of a side (both sides when 좌우 같게). Length, thickness and end height must be > 0, the haunch ≥ 0. */ +export function applyWingDim(rec: AbutRecord, side: 'left' | 'right', key: keyof WingDims, value: number): AbutRecord { + if (!Number.isFinite(value)) throw new Error('숫자를 입력하세요.'); + const probe = { ...WING_DIMS_DEFAULT, [key]: value }; + const issues = wingDimsIssues(probe); + if (issues.length > 0) throw new Error(issues[0]); + const sides: ('left' | 'right')[] = wingsLinked(rec) ? ['left', 'right'] : [side]; + const next = { left: { ...(rec.wingDims?.left ?? {}) }, right: { ...(rec.wingDims?.right ?? {}) } }; + for (const s of sides) next[s] = { ...next[s], [key]: value }; + return { ...rec, wingDims: next }; +} + /** 받침 값 편집 (2026-11-03): z (패드 상면, m) or 교좌면 override (m, undefined = 자동 — 블럭 높이 규칙을 따른다). */ export function applyBearingEdit(rec: AbutRecord, no: number, key: 'z' | 'seatEl', value: number | undefined): AbutRecord { const target = rec.bearings.find((b) => b.no === no); diff --git a/src/core/abut/geometry.ts b/src/core/abut/geometry.ts index f5aa696..f1dca2c 100644 --- a/src/core/abut/geometry.ts +++ b/src/core/abut/geometry.ts @@ -52,6 +52,8 @@ export interface AbutInput { girderAzimuthDeg?: number; /** 측별 날개벽 형식: 'left' = 받침 ① 쪽 끝, 'right' = 받침 n 쪽 끝(spec §4.7). 기본은 양측 일반형. */ wings?: { left: WingForm; right: WingForm }; + /** 측별 날개벽 두께 (mm). 없으면 450. 접속슬래브 폭에 쓴다. */ + wingThicknessMm?: { left?: number; right?: number }; /** 기초 하단 EL (m), 말뚝길이 (mm), 기초 형식 (AA3, 저판·말뚝). 없으면 저판·말뚝을 만들지 않는다. */ foundationBottomEl?: number; pileLengthMm?: number; @@ -188,10 +190,11 @@ function buildWing(input: AbutInput, u: AbutPoint, nLeft: AbutPoint, frontSide: const pick = Math.cos(radLine) * back.e + Math.sin(radLine) * back.n > 0 ? line : norm360(line + 180); const axisCad = norm360(deg(Math.atan2(u.n, u.e))); const sinDelta = Math.abs(Math.sin(((pick - axisCad) * Math.PI) / 180)); - const wingAxisWidth = WING_THICKNESS_MM / sinDelta; const wings = input.wings ?? { left: 'general' as WingForm, right: 'general' as WingForm }; - const present = (f: WingForm) => (f === 'none' ? 0 : wingAxisWidth + SLAB_CLEARANCE_MM); - const slabWidthMm = Math.round(length * 1000 - present(wings.left) - present(wings.right)); + // 날개벽 두께는 측별 입력 (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 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; return { diff --git a/src/core/abut/parse.ts b/src/core/abut/parse.ts index 7dca362..094b4f2 100644 --- a/src/core/abut/parse.ts +++ b/src/core/abut/parse.ts @@ -12,6 +12,7 @@ import type { Issue } from '../types'; import type { AbutInput, AbutPoint, AbutType, WingForm } from './geometry'; import type { AbutShape } from './shape'; import type { AbutBearingRule } from './bearing-table'; +import type { WingDims } from './wings'; type Cell = string | number | null | undefined; type Grid = Cell[][]; @@ -56,6 +57,10 @@ export interface AbutRecord { shape?: Partial; /** 받침 규칙 (② 교량받침 카드: h3, 솔플레이트 T·여유, 받침 종류). Only the changed values. Absent = 교각 P1 가정값 (QA-15). */ bearingRule?: Partial; + /** 날개벽 치수 (② 날개벽 카드, 측별, mm). Only the changed values. */ + wingDims?: { left?: Partial; right?: Partial }; + /** 좌우 같게 (기본 true): 형식과 치수를 양쪽에 함께 적용한다. */ + wingsLinked?: boolean; } export interface AbutParseResult { @@ -207,6 +212,7 @@ export function toAbutInput(rec: AbutRecord): AbutInput { skewDeg: rec.skewDeg, slopeI: rec.slopeI, wings: rec.wings, + wingThicknessMm: { left: rec.wingDims?.left?.thicknessMm, right: rec.wingDims?.right?.thicknessMm }, foundationBottomEl: rec.foundationBottomEl, pileLengthMm: rec.pileLengthMm, foundationType: rec.foundation, diff --git a/src/core/abut/summary.ts b/src/core/abut/summary.ts index e658229..e211bf1 100644 --- a/src/core/abut/summary.ts +++ b/src/core/abut/summary.ts @@ -14,7 +14,7 @@ const mm = (v: number) => `${Math.round(v)} mm`; export function abutSummaryRows(rec: AbutRecord): [string, string][] { const model: AbutModel = buildAbut(toAbutInput(rec)); const members = buildMembers(model, { bottomEl: rec.foundationBottomEl, pileLengthMm: rec.pileLengthMm }, rec.shape); - const wings = buildWings(model, members, rec.wings); + const wings = buildWings(model, members, rec.wings, rec.wingDims); const shape = members.shape; const first = model.bearings[0]; const L = model.length; diff --git a/src/core/abut/wings.ts b/src/core/abut/wings.ts index 1c72592..06da349 100644 --- a/src/core/abut/wings.ts +++ b/src/core/abut/wings.ts @@ -21,11 +21,43 @@ export const WING_THICKNESS_M = 0.45; /** 단부 높이 (끝점 상단에서 단부 하단까지, m). */ export const WING_END_HEIGHT_M = 1.5; +/** 날개벽 치수 입력 (측별, mm, 2026-11-04 시안 ② 날개벽 카드). 단축형 길이의 기본값은 뒷굽 끝까지의 거리다. */ +export interface WingDims { + lengthMm: number; + thicknessMm: number; + endHeightMm: number; + haunchMm: number; +} + +export const WING_DIMS_DEFAULT: WingDims = { lengthMm: 6500, thicknessMm: 450, endHeightMm: 1500, haunchMm: 300 }; + +/** The dims of one side: the user's values over the defaults; 단축형 without a length input runs to the 뒷굽 끝. */ +export function resolveWingDims(form: WingForm, changes: Partial | undefined, heelDistanceM: number): WingDims { + const base = { ...WING_DIMS_DEFAULT, ...(changes ?? {}) }; + if (form === 'short' && changes?.lengthMm === undefined) base.lengthMm = Math.round(heelDistanceM * 1000); + return base; +} + +/** Input problems of one side's dims, in Korean; empty when the wing can be built. */ +export function wingDimsIssues(d: WingDims): string[] { + const out: string[] = []; + if (!(d.lengthMm > 0)) out.push('날개벽 길이는 0보다 커야 합니다.'); + if (!(d.thicknessMm > 0)) out.push('날개벽 두께는 0보다 커야 합니다.'); + if (!(d.endHeightMm > 0)) out.push('날개벽 끝 높이는 0보다 커야 합니다.'); + if (!(d.haunchMm >= 0)) out.push('헌치는 0 이상이어야 합니다.'); + return out; +} + export interface WingSolid { side: 'left' | 'right'; form: Exclude; /** 날개벽이 시작하는 축 끝: 0 (IP1 끝) 또는 L (IP3 끝). */ sEnd: number; + /** 이 측의 치수 (m): 길이, 두께, 끝 높이, 헌치 다리. */ + lengthM: number; + thicknessM: number; + endHeightM: number; + haunchM: number; /** 평면 네 꼭짓점 (E, N), 순서: 시작, 끝, 끝 안쪽, 시작 안쪽. */ corners: [AbutPoint, AbutPoint, AbutPoint, AbutPoint]; topStartEl: number; @@ -66,7 +98,12 @@ export interface Haunch { export const HAUNCH_LEG_M = 0.3; -export function buildWings(model: AbutModel, members: AbutMembers, forms?: { left: WingForm; right: WingForm }): WingSolid[] { +export function buildWings( + model: AbutModel, + members: AbutMembers, + forms?: { left: WingForm; right: WingForm }, + dims?: { left?: Partial; right?: Partial }, +): WingSolid[] { if (!model.wing) return []; const L = model.length; const wings = forms ?? model.wing.wings; @@ -89,26 +126,30 @@ export function buildWings(model: AbutModel, members: AbutMembers, forms?: { lef const build = (side: 'left' | 'right', sEnd: number, topStart: number, form: Exclude): WingSolid => { const a = sEnd === 0 ? 1 : -1; + const dm = resolveWingDims(form, dims?.[side], heelDistanceM); + const length = dm.lengthMm / 1000; + const thick = dm.thicknessMm / 1000; + const leg = dm.haunchMm / 1000; const p0 = at(sEnd); - const p1 = { e: p0.e + w.e * WING_LENGTH_M, n: p0.n + w.n * WING_LENGTH_M }; - const inward = { e: model.u.e * a * WING_THICKNESS_M, n: model.u.n * a * WING_THICKNESS_M }; - const topEnd = topStart + slope * WING_LENGTH_M; - const bottomEnd = form === 'general' ? topEnd - WING_END_HEIGHT_M : footingTop; + const p1 = { e: p0.e + w.e * length, n: p0.n + w.n * length }; + const inward = { e: model.u.e * a * thick, n: model.u.n * a * thick }; + const topEnd = topStart + slope * length; + const bottomEnd = form === 'general' ? topEnd - dm.endHeightMm / 1000 : footingTop; const topAt = (d: number) => topStart + slope * d; const topLine = [ { dM: 0, el: topStart }, - { dM: WING_LENGTH_M, el: topEnd }, + { dM: length, el: topEnd }, ]; const bottomLine = - form === 'general' && heelDistanceM < WING_LENGTH_M + form === 'general' && heelDistanceM < length ? [ { dM: 0, el: footingTop }, { dM: heelDistanceM, el: footingTop }, - { dM: WING_LENGTH_M, el: bottomEnd }, + { dM: length, el: bottomEnd }, ] : [ { dM: 0, el: footingTop }, - { dM: WING_LENGTH_M, el: form === 'general' ? bottomEnd : footingTop }, + { dM: length, el: form === 'general' ? bottomEnd : footingTop }, ]; // 헌치: 위 헌치는 흉벽 배면(−0.5), 아래 헌치는 벽체 배면(−0.8)의 모서리에 둔다. @@ -122,8 +163,8 @@ export function buildWings(model: AbutModel, members: AbutMembers, forms?: { lef const d = Math.abs(t) / k; // The corner lies on the 날개벽 내측면 (the face between the two wings), WING_THICKNESS_M in from the axis end. const inwardUnit = { e: model.u.e * a, n: model.u.n * a }; - const c = shift(pointAt(d), inwardUnit, WING_THICKNESS_M); - const corners: [AbutPoint, AbutPoint, AbutPoint] = [c, shift(c, w, HAUNCH_LEG_M), shift(c, inwardUnit, HAUNCH_LEG_M)]; + const c = shift(pointAt(d), inwardUnit, thick); + const corners: [AbutPoint, AbutPoint, AbutPoint] = [c, shift(c, w, leg), shift(c, inwardUnit, leg)]; return { kind, corners, @@ -132,14 +173,17 @@ export function buildWings(model: AbutModel, members: AbutMembers, forms?: { lef }; }; // 연직 헌치 (2026-11-03, 내측면 모서리): upper at the 흉벽 배면, lower at the 벽체 배면. - const haunches = [haunchAt('upper', members.shape.parapetBackM), haunchAt('lower', members.shape.wallBackM)]; + const haunches = leg > 0 ? [haunchAt('upper', members.shape.parapetBackM), haunchAt('lower', members.shape.wallBackM)] : []; // 저판 상면 헌치 (수평): from the 벽체 배면 to the 뒷굽, on the 저판 상면 next to the 날개벽 내측면. - const footingHaunch = { - dMFrom: Math.abs(members.shape.wallBackM) / k, - dMTo: heelDistanceM, - el: footingTop, - }; + const footingHaunch = + leg > 0 + ? { + dMFrom: Math.abs(members.shape.wallBackM) / k, + dMTo: Math.min(heelDistanceM, length), + el: footingTop, + } + : undefined; // 수평 단 (QA-12): 등단면 = 끝 상단 − 1.5 m 위, 변화단면1 = 그 아래 ~ 교좌면 EL, 변화단면2 = 교좌면 EL ~ 저판 상면. // 교좌면 EL은 날개벽 끝에 가장 가까운 받침의 교좌면이다. @@ -157,6 +201,10 @@ export function buildWings(model: AbutModel, members: AbutMembers, forms?: { lef side, form, sEnd, + lengthM: length, + thicknessM: thick, + endHeightM: dm.endHeightMm / 1000, + haunchM: leg, corners: [p0, p1, { e: p1.e + inward.e, n: p1.n + inward.n }, { e: p0.e + inward.e, n: p0.n + inward.n }], topStartEl: topStart, topEndEl: topEnd, diff --git a/src/core/project.ts b/src/core/project.ts index a978afd..74fce07 100644 --- a/src/core/project.ts +++ b/src/core/project.ts @@ -135,6 +135,14 @@ export function checkStoredAbutments(list: unknown): void { if (key === 'kind' ? typeof v !== 'string' : !finite(v)) throw new Error(`${who}: 받침 규칙 ${key}이(가) 맞지 않습니다.`); } } + if (a.wingDims !== undefined) { + for (const sideDims of [a.wingDims.left, a.wingDims.right]) { + for (const [key, v] of Object.entries(sideDims ?? {})) { + if (!finite(v)) 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']; if (!forms.includes(a.wings.left) || !forms.includes(a.wings.right)) throw new Error(`${who}: 날개벽 형식이 맞지 않습니다.`); diff --git a/src/main.ts b/src/main.ts index 906bf69..01ca98b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -8,7 +8,22 @@ 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, applyShapeEdits, ABUT_EDIT_FIELDS, BEARING_RULE_FIELDS, SHAPE_EDIT_FIELDS, restoreAbutDefault, setWingForm } from './core/abut/edit'; +import { + applyAbutEdit, + applyBearingEdit, + applyBearingRule, + applyShapeEdit, + applyShapeEdits, + applyWingDim, + ABUT_EDIT_FIELDS, + BEARING_RULE_FIELDS, + SHAPE_EDIT_FIELDS, + WING_DIM_FIELDS, + restoreAbutDefault, + setWingForm, + setWingsLinked, + wingsLinked, +} 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'; @@ -27,8 +42,9 @@ import { import { abutMemberRows, ABUT_MEMBER_EDITABLE, type AbutMemberId } from './core/abut/member-list'; import type { AbutModel, WingForm } from './core/abut/geometry'; import { buildMembers, type AbutMembers } from './core/abut/members'; -import { buildWings } from './core/abut/wings'; -import { abutSkewK, buildAbutGroup, cameraForBox } from './view/abut-scene'; +import { buildWings, resolveWingDims, type WingSolid } from './core/abut/wings'; +import { abutPlanOutline, abutSkewK, buildAbutGroup, cameraForBox } from './view/abut-scene'; +import { abutWingCornerSvg, abutWingPlanSvg, abutWingSideSvg } from './view/abut-wing-svg'; import { fitCamera } from './view/fit'; import { buildPier, type PierModel } from './core/geometry'; import { NOT_IMPLEMENTED_RULES, validateInput, validatePier } from './core/validate'; @@ -151,6 +167,8 @@ const state = { abutMember: 'bearing' as AbutMemberId, /** The bearing the ② 교량받침 삽도 draw (상세 보기). */ abutBearingNo: 1, + /** The side the ② 날개벽 card edits (편집할 측). */ + abutWingSide: 'left' as 'left' | 'right', /** The editable part of the 교대 panel is open (2026-11-03). */ abutEditOpen: false, /** 교대 기본값 (가져온 그대로), for 기본값 복원. */ @@ -590,7 +608,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), ref.pivot, rec.groundEl, groundModel?.terrainGrid); + const group = buildAbutGroup(model, members, buildWings(model, members, rec.wings, rec.wingDims), ref.pivot, rec.groundEl, groundModel?.terrainGrid); 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)) { @@ -948,6 +966,168 @@ function fillAbutBearingCard( box.append(el('p', `검사 · ${abutBearingChecks(model, members, rule, H).join(' · ')}`, 'muted small abut-checks')); } +/** skew (deg) as 95°02′52″. */ +function dms(deg: number): string { + const total = Math.round(Math.abs(deg) * 3600); + const d = Math.floor(total / 3600); + const m = Math.floor((total % 3600) / 60); + const s = total % 60; + return `${d}°${String(m).padStart(2, '0')}′${String(s).padStart(2, '0')}″`; +} + +/** ② 날개벽 카드 (시안, 2026-11-04): 좌우 · 편집할 측 · 형식 → 삽도 3개 → 측별 치수 → 자동 값 → 생성되는 날개벽 → 메모. */ +function fillAbutWingCard( + 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 NAME: Record = { general: '일반형', short: '단축형', none: '없음' }; + const forms = rec.wings ?? { left: 'general' as WingForm, right: 'general' as WingForm }; + const linked = wingsLinked(rec); + const side = state.abutWingSide; + const lastNo = rec.bearings.length; + const circled = (n: number) => ['①', '②', '③', '④', '⑤', '⑥', '⑦', '⑧', '⑨', '⑩'][n - 1] ?? String(n); + const sideName = (s: 'left' | 'right') => (s === 'left' ? '좌 · ① 쪽' : `우 · ${circled(lastNo)} 쪽`); + box.append( + optionTiles('좌우', [ + { label: '좌우 같게', on: linked, isDefault: true, pick: () => apply(setWingsLinked(rec, true)) }, + { label: '따로 정하기', on: !linked, pick: () => apply(setWingsLinked(rec, false)) }, + ]), + optionTiles( + '편집할 측', + (['left', 'right'] as const).map((s) => ({ + label: `${sideName(s)} · ${NAME[forms[s]]}`, + on: side === s, + pick: () => { + state.abutWingSide = s; + renderAll(); + }, + })), + linked ? '좌우 같게: 형식과 치수가 양쪽에 함께 적용됩니다' : undefined, + ), + optionTiles( + `${side === 'left' ? '좌' : '우'} 날개벽 (${sideName(side).split(' · ')[1]}) 형식`, + (['general', 'short', 'none'] as const).map((f) => ({ + label: NAME[f], + on: forms[side] === f, + isDefault: f === 'general', + pick: () => apply(setWingForm(rec, side, f)), + })), + ), + ); + 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, forms, rec.wingDims); + } catch { + box.append(el('p', '교대 모델을 만들 수 없어 날개벽을 그리지 않습니다.', 'muted small')); + return; + } + const shapeAll = { ...ABUT_SHAPE_DEFAULT, ...normalizeShape(rec.shape) }; + const wing = wings.find((w) => w.side === side); + const leftWing = wings.find((w) => w.side === 'left'); + // 뒷굽 끝까지의 날개벽 거리 (단축형 길이의 기본값); with no wing drawn, the 뒷굽 width is a fair stand-in. + const heelM = wing?.heelDistanceM ?? leftWing?.heelDistanceM ?? Math.abs(members.shape.footingBackM); + // 삽도 3개. + 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( + abutWingPlanSvg({ + lengthM: model.length, + outline: abutPlanOutline(model, members, wings), + leftLabel: `좌 · ${NAME[forms.left]}`, + rightLabel: `우 · ${NAME[forms.right]}`, + leftSEnd: leftWing?.sEnd ?? (model.bearings[0].s < model.length / 2 ? 0 : model.length), + slabWidthM: (model.wing?.slabWidthMm ?? 0) / 1000, + }), + '평면 · 좌우 날개벽과 접속슬래브 · 축척 같음', + ); + if (wing) { + addFig( + abutWingSideSvg({ + name: `${side === 'left' ? '좌' : '우'} 날개벽`, + form: wing.form, + lengthM: wing.lengthM, + endHeightM: wing.endHeightM, + heelDistanceM: wing.heelDistanceM, + topLine: wing.topLine, + bottomLine: wing.bottomLine, + bands: wing.bands, + seatEl: rec.seatEl, + footingTopEl: members.footingTopEl, + }), + `${side === 'left' ? '좌' : '우'} 날개벽 측면 · ${NAME[wing.form]} (날개벽 축 방향으로 잘라 본 모습) · 축척 같음`, + ); + } + addFig( + abutWingCornerSvg({ + parapetMm: shapeAll.parapetThicknessMm, + 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, + }), + '날개벽–흉벽 모서리 평면 · 모식도 · 접속슬래브 모따기', + ); + box.append(figs); + // 측별 치수 (선택한 측의 형식에 맞는 칸). + const form = forms[side]; + box.append(el('p', `${side === 'left' ? '좌' : '우'} 날개벽 (${sideName(side).split(' · ')[1]}) · ${NAME[form]}`, 'lbl')); + if (form === 'none' || !wing) { + box.append(el('p', '입력 없음 · 접속슬래브 측면은 교좌면 끝선에 맞춥니다.', 'muted small')); + } else { + const dims = resolveWingDims(form, rec.wingDims?.[side], heelM); + const defaults = resolveWingDims(form, undefined, heelM); + for (const f of WING_DIM_FIELDS.filter((x) => (form === 'general' ? x.general : x.short))) { + const id = idOf(`wing-${side}-${f.key}`); + const lab = el('label', form === 'short' && f.key === 'lengthMm' ? '길이 (mm) · 기본 = 뒷굽 끝' : f.label); + lab.htmlFor = id; + const input = numberInput(id, String(dims[f.key])); + if (dims[f.key] !== defaults[f.key]) input.classList.add('changed-input'); + input.addEventListener('change', () => { + try { + apply(applyWingDim(rec, side, f.key, Number(input.value))); + } catch (err) { + fail(err, input, String(dims[f.key])); + } + }); + box.append(lab, input, el('span', `기본 ${defaults[f.key]}${linked ? ' · 양쪽' : ''}`, '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 slope = model.wing?.slopePct ?? 0; + auto(`경사 (자동, ${rec.type} = ${rec.type === 'A1' ? '−i' : '+i'})`, `${slope >= 0 ? '+' : ''}${slope.toFixed(2)} %`, '종단경사에서'); + if (form === 'general') { + auto('등단면 · 변화단면 경계 (자동)', `${wing.heelDistanceM.toFixed(3)} m`, '뒷굽 끝'); + auto('끝 EL 상단 / 하단', `${wing.topEndEl.toFixed(3)} / ${wing.bottomEndEl.toFixed(3)}`, '가정'); + } else { + auto('하단 (자동)', `확대기초 상면 ${members.footingTopEl.toFixed(3)}`, wing.lengthM > wing.heelDistanceM + 1e-6 ? '뒷굽 밖은 하단이 떠 있음 (정보 AI02)' : ''); + auto('끝 EL 상단', wing.topEndEl.toFixed(3), '가정'); + } + } + const made = (s: 'left' | 'right') => (forms[s] === 'none' ? `${s === 'left' ? '좌' : '우'} 없음` : `${s === 'left' ? '좌' : '우'} ${NAME[forms[s]]}`); + box.append(el('p', `생성되는 날개벽: ${made('left')} · ${made('right')} · 각도는 좌우 모두 skew ${dms(rec.skewDeg)}`, 'muted small')); + box.append( + el( + 'p', + '시안 메모 · 형식은 ②에서만 정합니다(엑셀 입력 없음). "좌우 같게"를 끄면 우측을 따로 정할 수 있습니다. 단축형은 하단이 모두 확대기초 상면이고 길이 기본값은 뒷굽 끝이며, 늘리면 뒷굽 밖 구간의 하단이 떠 있게 됩니다(정보 AI02). 두께는 접속슬래브 폭에, 헌치는 연직 헌치와 저판 상면 헌치에 함께 반영됩니다. 모서리 상세의 슬래브 모따기는 3D에는 아직 그리지 않습니다.', + 'muted small abut-memo', + ), + ); +} + /** 확대기초 · 말뚝 카드의 자동 값, 열 위치, 열별 본수 표(− / +), 메모 (시안, 2026-11-04). 말뚝 길이는 ①에서 고친다. */ function fillAbutFootingExtras( box: HTMLDivElement, @@ -1121,7 +1301,9 @@ function fillAbutSlot(slot: (typeof ABUT_SLOTS)[keyof typeof ABUT_SLOTS], rec: A }); basic.append(lab, input); } - for (const side of ['left', 'right'] as const) { + // ② draws the 날개벽 card (시안); ① keeps the two form selects. + if (planCard) fillAbutWingCard(wingBox, 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 쪽)'); lab.htmlFor = id; diff --git a/src/style.css b/src/style.css index e9dc80a..0f4f1d3 100644 --- a/src/style.css +++ b/src/style.css @@ -934,3 +934,9 @@ body { background: #eef1f4; font-family: 'Noto Sans KR', system-ui, sans-serif; .abut-opt:disabled, .abut-optt:disabled { cursor: not-allowed; border-style: dashed; color: #5f6a76; background: #f6f8fa; } .abut-def { position: absolute; top: -9px; right: 4px; font-size: 11px; font-weight: 600; line-height: 1; padding: 3px 6px; border-radius: 8px; background: #4a5868; color: #ffffff; } .abut-tbdn { display: block; font-size: 12px; color: #55606e; } + +/* ② 날개벽 카드 (2026-11-04): tiles and the three 삽도 across the card, then the per-side inputs in the 3-column grid. */ +#abut-panel-plan-wing { display: grid; grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr) minmax(0, 1fr); gap: 6px 10px; align-items: center; } +#abut-panel-plan-wing > p, #abut-panel-plan-wing > .abut-wall-figs { grid-column: 1 / -1; } +#abut-panel-plan .abut-wing-figs { grid-template-columns: minmax(0, 1.2fr) minmax(0, 1.2fr) minmax(0, 1fr); } +@media (max-width: 1100px) { #abut-panel-plan .abut-wing-figs { grid-template-columns: minmax(0, 1fr); } } diff --git a/src/view/abut-scene.ts b/src/view/abut-scene.ts index e90cbe2..03913d4 100644 --- a/src/view/abut-scene.ts +++ b/src/view/abut-scene.ts @@ -8,7 +8,7 @@ import * as THREE from 'three'; import type { AbutModel, AbutPoint } from '../core/abut/geometry'; import { buildMembers, type AbutMembers } from '../core/abut/members'; -import { buildWings, HAUNCH_LEG_M, WING_THICKNESS_M, type WingSolid } from '../core/abut/wings'; +import { buildWings, type WingSolid } from '../core/abut/wings'; import type { TerrainGrid } from '../core/ground-model'; import { terrainGeometry } from './terrain'; @@ -120,6 +120,39 @@ 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). */ +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 { + 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 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; + }; + const axis = (c: AbutPoint): [number, number] => { + const p = toAxis(model, c); + return [p.s, p.t]; + }; + 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]], + }; +} + /** 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); @@ -257,27 +290,13 @@ export function buildAbutGroup( } // 접속슬래브 (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 "측면 = 날개벽 안쪽면 + 20"), so it never meets a wing. - // The wing is drawn as a prism over the wing direction with its 0.45 m thickness along s, so its inner face at t is s = sEnd + a·0.45 + (ws/wt)·t. + // 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 slabFar = slabNear - 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 p0 = toAxis(model, w.corners[0]); - const p1 = toAxis(model, w.corners[1]); - return sEnd + a * (WING_THICKNESS_M + SLAB_CLEARANCE_M) + ((p1.s - p0.s) / (p1.t - p0.t)) * t; - }; + const slabPlan = abutPlanOutline(model, members, wings).slab; const bracketTopEl = (s: number) => members.parapetTopStartEl + ((members.parapetTopEndEl - members.parapetTopStartEl) * s) / L - members.shape.bracketHeightM; const slabMesh = new THREE.Mesh( slabGeometry( - [ - [innerS(0, slabNear), slabNear], - [innerS(L, slabNear), slabNear], - [innerS(L, slabFar), slabFar], - [innerS(0, slabFar), slabFar], - ], + 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), ), @@ -319,21 +338,21 @@ export function buildAbutGroup( // The part in front of those faces lies inside the 벽체 or 흉벽, so it is cut off. The bracket falls along s, so the cut follows it. // The wing is 0.45 thick along s, so its footprint spans two s values at each dM; the cut uses the end where the 브라켓 is highest. const slopeS = (members.parapetTopEndEl - members.parapetTopStartEl) / L; - const highEnd = slopeS < 0 ? Math.min(0, a * WING_THICKNESS_M) : Math.max(0, a * WING_THICKNESS_M); + const highEnd = slopeS < 0 ? Math.min(0, a * wing.thicknessM) : Math.max(0, a * wing.thicknessM); const bracketAtDm = (dM: number) => members.parapetTopStartEl + slopeS * (sEnd + dir.ws * dM + highEnd) - members.shape.bracketHeightM; const above = clipByLine(profile, (x, y) => y - bracketAtDm(x)); const below = clipByLine(profile, (x, y) => bracketAtDm(x) - y); - const upper = prism(clipBelowX(above, members.shape.parapetBackM / dir.wt), WING_THICKNESS_M, new THREE.Vector3(sEnd, 0, 0), ax, ey, new THREE.Vector3(a, 0, 0)); - const lower = prism(clipBelowX(below, members.shape.wallBackM / dir.wt), WING_THICKNESS_M, new THREE.Vector3(sEnd, 0, 0), ax, ey, new THREE.Vector3(a, 0, 0)); + const upper = prism(clipBelowX(above, members.shape.parapetBackM / dir.wt), wing.thicknessM, new THREE.Vector3(sEnd, 0, 0), ax, ey, new THREE.Vector3(a, 0, 0)); + const lower = prism(clipBelowX(below, members.shape.wallBackM / dir.wt), wing.thicknessM, new THREE.Vector3(sEnd, 0, 0), ax, ey, new THREE.Vector3(a, 0, 0)); add('wings', upper, 0xc9a77c); add('wings', lower, 0xc9a77c); // 저판 상면 헌치 (수평, 2026-11-03): a right triangle in the plane EL–s on the 저판 상면, next to the 날개벽 내측면, // extruded along the wing from the 벽체 배면 to the 뒷굽. if (wing.footingHaunch) { const f = wing.footingHaunch; - const leg = HAUNCH_LEG_M; + const leg = wing.haunchM; const triangle: [number, number][] = [[0, 0], [leg, 0], [0, leg]]; - const origin = new THREE.Vector3(sEnd + a * WING_THICKNESS_M + dir.ws * f.dMFrom, f.el, dir.wt * f.dMFrom); + const origin = new THREE.Vector3(sEnd + a * wing.thicknessM + dir.ws * f.dMFrom, f.el, dir.wt * f.dMFrom); add('haunches', prism(triangle, f.dMTo - f.dMFrom, origin, new THREE.Vector3(a, 0, 0), ey, new THREE.Vector3(dir.ws, 0, dir.wt)), 0xc9a77c); } for (const h of wing.haunches) { diff --git a/src/view/abut-wing-svg.ts b/src/view/abut-wing-svg.ts new file mode 100644 index 0000000..bae4e36 --- /dev/null +++ b/src/view/abut-wing-svg.ts @@ -0,0 +1,170 @@ +/** + * 교대 날개벽 삽도 (2026-11-04, 시안 ② 날개벽 카드): 평면(좌우 날개벽과 접속슬래브), 측면(날개벽 축 방향), 날개벽–흉벽 모서리 평면. + * 순수 문자열 함수. 평면·측면은 생성본 값으로 그리고 가로·세로 축척이 같다. 모서리 상세는 치수를 나타내는 모식도다. + */ +import type { AbutPlanOutline } from './abut-scene'; + +const INK = '#18212b'; +const MUTED = '#55606e'; +const WING = '#d6bb8c'; +const WALL = '#b7c7d3'; +const SLAB = '#f3eee4'; +const text = (x: number, y: number, s: string, anchor = 'start', fill = INK) => + `${s}`; +const poly = (pts: [number, number][], style: string) => ``; + +export interface AbutWingPlanDrawing { + lengthM: number; + outline: AbutPlanOutline; + /** 좌 = ① 쪽 끝 (sEnd), 우 = 반대 끝. 이름에 형식을 붙여 표시한다. */ + leftLabel: string; + rightLabel: string; + leftSEnd: number; + slabWidthM: number; +} + +/** 평면: 벽체(skew), 좌우 날개벽, 그 사이 접속슬래브. 왼쪽이 ① 쪽(좌), 위가 전면. */ +export function abutWingPlanSvg(d: AbutWingPlanDrawing): string { + const pts = [...d.outline.wall, ...d.outline.slab, ...d.outline.wings.flatMap((w) => w.poly)]; + // ① 쪽(좌)을 왼쪽에 둔다: 좌가 s = L이면 s를 뒤집는다. + 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) - 0.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 H = Y(Math.min(...ts) - 0.4) + 30; + const map = (p: [number, number][]) => p.map(([s, t]) => [X(s), Y(t)] as [number, number]); + const parts = [ + poly(map(d.outline.slab), `fill="${SLAB}" stroke="#b49a6b" stroke-dasharray="5 3"`), + 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]); + 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]; + parts.push(text(X(far[0]), Y(far[1]) + 16, w.side === 'left' ? d.leftLabel : d.rightLabel, 'middle')); + } + parts.push(text(X(d.lengthM / 2), 12, '흉벽 · 벽체 (위 = 전면)', 'middle', MUTED)); + return [ + ``, + ...parts, + '', + ].join(''); +} + +export interface AbutWingSideDrawing { + name: string; + form: 'general' | 'short'; + lengthM: number; + endHeightM: number; + heelDistanceM: number; + topLine: { dM: number; el: number }[]; + bottomLine: { dM: number; el: number }[]; + bands: { name: string; zTopEl: number; zBottomEl: number }[]; + seatEl: number; + footingTopEl: number; +} + +/** 측면 (날개벽 축 방향으로 잘라 본 모습): 벽체 쪽이 오른쪽, 날개벽 끝이 왼쪽. 수평 단 경계와 뒷굽 끝을 표시한다. */ +export function abutWingSideSvg(d: AbutWingSideDrawing): string { + const k = 40; + const els = [...d.topLine.map((p) => p.el), ...d.bottomLine.map((p) => p.el), d.footingTopEl]; + const elMax = Math.max(...els) + 0.8; + const elMin = Math.min(...els) - 0.6; + const X = (dM: number) => 40 + (d.lengthM - dM) * k; // wall (dM 0) on the right + const Y = (el: number) => 14 + (elMax - el) * k; + const W = X(-1.2) + 70; + const H = Y(elMin) + 26; + const outline: [number, number][] = [ + ...d.topLine.map((p) => [X(p.dM), Y(p.el)] as [number, number]), + ...[...d.bottomLine].reverse().map((p) => [X(p.dM), Y(p.el)] as [number, number]), + ]; + const parts = [ + // 벽체 (단면) on the right, 저판 under it. + poly([[X(0), Y(d.footingTopEl)], [X(-1.0), Y(d.footingTopEl)], [X(-1.0), Y(d.seatEl)], [X(0), Y(d.seatEl)]], `fill="${WALL}" stroke="${MUTED}"`), + poly([[X(Math.min(d.heelDistanceM, d.lengthM)), Y(d.footingTopEl)], [X(-1.2), Y(d.footingTopEl)], [X(-1.2), Y(d.footingTopEl - 0.5)], [X(Math.min(d.heelDistanceM, d.lengthM)), Y(d.footingTopEl - 0.5)]], `fill="#c9d1da" stroke="${MUTED}"`), + poly(outline, `fill="${WING}" stroke="#9c8763"`), + ]; + // 수평 단 경계 (등단면 / 변화단면) as dashed lines across the wing. + for (const b of d.bands.slice(0, -1)) { + parts.push(``); + } + for (const b of d.bands) { + const mid = (b.zTopEl + b.zBottomEl) / 2; + parts.push(text(X(d.lengthM * 0.45), Y(mid) + 4, b.name, 'middle', MUTED)); + } + parts.push(text((X(d.lengthM) + X(0)) / 2, 11, `${Math.round(d.lengthM * 1000)} (교좌면 끝에서, 날개벽 축 방향)`, 'middle')); + if (d.form === 'general') parts.push(text(X(d.lengthM) - 4, (Y(d.topLine[d.topLine.length - 1].el) + Y(d.bottomLine[d.bottomLine.length - 1].el)) / 2 + 4, `${Math.round(d.endHeightM * 1000)}`, 'end')); + parts.push(text(X(-1.0) + 4, Y(d.seatEl) + 4, `교좌면 ${d.seatEl.toFixed(3)}`, 'start')); + parts.push(text(X(-1.2) + 4, Y(d.footingTopEl) + 14, d.footingTopEl.toFixed(3), 'start')); + if (d.heelDistanceM < d.lengthM) { + parts.push(``); + } + parts.push(text(X(Math.min(d.heelDistanceM, d.lengthM)), Y(d.footingTopEl) + 30, `뒷굽 끝 ${d.heelDistanceM.toFixed(3)} m`, 'middle')); + return [ + ``, + ...parts, + '', + ].join(''); +} + +export interface AbutWingCornerDrawing { + parapetMm: number; + bracketMm: number; + wingThicknessMm: number; + haunchMm: number; + /** 접속슬래브 간격: 날개벽과 20, 흉벽과 5. 모따기 300 × 300 (spec §4.8). */ + slabGapWingMm: number; + slabGapParapetMm: number; + chamferMm: number; +} + +/** 좌 날개벽–흉벽 모서리 평면 (모식도): 흉벽, 브라켓(슬래브 아래), 날개벽, 헌치, 접속슬래브(모따기, 간격). */ +export function abutWingCornerSvg(d: AbutWingCornerDrawing): string { + const k = 0.14; // px per mm + const x0 = 70; // 날개벽 내측면 + const y0 = 60; // 흉벽 배면 + const X = (mm: number) => x0 + mm * k; + const Y = (mm: number) => y0 + mm * k; + const span = 2200; // drawn extent from the corner (mm) + const h = d.haunchMm; + const c = d.chamferMm; + const sx = d.slabGapWingMm; + const sy = d.slabGapParapetMm; + const parts = [ + // 흉벽 (above the 배면 line) and the 교대축 at its front face. + poly([[X(-d.wingThicknessMm), Y(-d.parapetMm)], [X(span), Y(-d.parapetMm)], [X(span), Y(0)], [X(-d.wingThicknessMm), Y(0)]], `fill="${WALL}" stroke="${MUTED}"`), + ``, + // 브라켓 (under the slab, dashed). + ``, + // 날개벽 (left, running back = down). + poly([[X(-d.wingThicknessMm), Y(0)], [X(0), Y(0)], [X(0), Y(span)], [X(-d.wingThicknessMm), Y(span)]], `fill="${WING}" stroke="#9c8763"`), + ]; + if (h > 0) parts.push(poly([[X(0), Y(0)], [X(h), Y(0)], [X(0), Y(h)]], `fill="${WING}" stroke="#9c8763"`)); + // 접속슬래브 with the corner cut (모따기) and the gaps. + parts.push( + poly( + [[X(sx + c), Y(sy)], [X(span), Y(sy)], [X(span), Y(span)], [X(sx), Y(span)], [X(sx), Y(sy + c)]], + `fill="${SLAB}" stroke="#b49a6b"`, + ), + ); + parts.push(text(X(-d.wingThicknessMm / 2), Y(span) - 6, `날개벽 ${d.wingThicknessMm}`, 'middle')); + parts.push(text(X(span / 2), Y(-d.parapetMm / 2) + 4, `흉벽 ${d.parapetMm}`, 'middle')); + parts.push(text(X(span) - 4, Y(-d.parapetMm) - 6, '교대축 (흉벽 전면)', 'end', '#9b2c1f')); + parts.push(text(X(span) - 4, Y(d.bracketMm) - 4, `브라켓 ${d.bracketMm} (슬래브 아래)`, 'end', MUTED)); + if (h > 0) parts.push(text(X(h) + 6, Y(h / 2) + 4, `헌치 ${h} × ${h}`, 'start')); + parts.push(text(X(sx + c) + 8, Y(sy + c) + 14, `슬래브 모따기 ${c} × ${c}`, 'start')); + parts.push(text(X(span / 2), Y(span) - 6, `접속슬래브 · 날개벽과 ${sx} · 흉벽과 ${sy}`, 'middle')); + const W = X(span) + 20; + const H = Y(span) + 16; + return [ + ``, + ...parts, + '', + ].join(''); +} diff --git a/tests/abut-edit.test.ts b/tests/abut-edit.test.ts index eaa01f6..af3a9ff 100644 --- a/tests/abut-edit.test.ts +++ b/tests/abut-edit.test.ts @@ -2,7 +2,7 @@ import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import * as XLSX from 'xlsx'; import { describe, expect, it } from 'vitest'; -import { applyAbutEdit, applyBearingEdit, restoreAbutDefault, setWingForm } from '../src/core/abut/edit'; +import { applyAbutEdit, applyBearingEdit, restoreAbutDefault, setWingForm, setWingsLinked, applyWingDim } from '../src/core/abut/edit'; import { parseAbutments } from '../src/core/abut/parse'; const RAG = fileURLToPath(new URL('../../../RAG/운곡교P1입력.xlsx', import.meta.url)); @@ -25,11 +25,23 @@ describe('교대 입력 편집 (교대 편집 카드)', () => { expect(() => applyAbutEdit(rec, 'seatEl', rec.foundationBottomEl)).toThrow('기초 하단 EL보다 높아야'); }); - it('a wing form change sets one side and keeps the other', () => { - const next = setWingForm(rec, 'left', 'short'); - expect(next.wings).toEqual({ left: 'short', right: 'general' }); + it('a wing form change sets both sides while 좌우 같게 (default), one side once it is off', () => { + expect(setWingForm(rec, 'left', 'short').wings).toEqual({ left: 'short', right: 'short' }); + const apart = setWingsLinked(rec, false); + expect(setWingForm(apart, 'left', 'short').wings).toEqual({ left: 'short', right: 'general' }); expect(rec.wings).toBeUndefined(); }); + + it('wing dims: 좌우 같게 applies to both sides, off applies to one; turning it on copies the left side', () => { + const both = applyWingDim(rec, 'left', 'lengthMm', 6000); + expect(both.wingDims).toEqual({ left: { lengthMm: 6000 }, right: { lengthMm: 6000 } }); + const apart = applyWingDim(setWingsLinked(rec, false), 'right', 'thicknessMm', 500); + expect(apart.wingDims).toEqual({ left: {}, right: { thicknessMm: 500 } }); + const relinked = setWingsLinked(applyWingDim(apart, 'left', 'lengthMm', 5000), true); + expect(relinked.wingDims).toEqual({ left: { lengthMm: 5000 }, right: { lengthMm: 5000 } }); + expect(() => applyWingDim(rec, 'left', 'thicknessMm', 0)).toThrow('두께'); + expect(() => applyWingDim(rec, 'left', 'haunchMm', -1)).toThrow('헌치'); + }); }); describe('교대 기본값 복원과 받침 편집', () => { diff --git a/tests/abut-wing-card.test.ts b/tests/abut-wing-card.test.ts new file mode 100644 index 0000000..c7ec775 --- /dev/null +++ b/tests/abut-wing-card.test.ts @@ -0,0 +1,68 @@ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import * as XLSX from 'xlsx'; +import { describe, expect, it } from 'vitest'; +import { buildAbut } from '../src/core/abut/geometry'; +import { parseAbutments, toAbutInput } from '../src/core/abut/parse'; +import { buildMembers } from '../src/core/abut/members'; +import { buildWings } from '../src/core/abut/wings'; +import { abutPlanOutline } from '../src/view/abut-scene'; +import { abutWingCornerSvg, abutWingPlanSvg, abutWingSideSvg } from '../src/view/abut-wing-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); + return { model, members, wings: buildWings(model, members, r.wings, r.wingDims) }; +}; + +describe('날개벽 치수 입력 (측별) 과 연동', () => { + it('defaults: 6500 · 450 · 1500 · haunch 300 on both sides; 단축형 runs to the 뒷굽 끝 (1.807 m)', () => { + const { wings } = build(); + for (const w of wings) expect([w.lengthM, w.thicknessM, w.endHeightM, w.haunchM]).toEqual([6.5, 0.45, 1.5, 0.3]); + const short = build({ ...rec, wings: { left: 'short', right: 'short' } }).wings[0]; + expect(short.lengthM).toBeCloseTo(short.heelDistanceM, 3); + expect(short.heelDistanceM).toBeCloseTo(1.807, 3); + }); + + it('a thicker wing narrows the 접속슬래브 (slab width = L − Σ(t/|sin Δ| + 20))', () => { + const base = build().model.wing!.slabWidthMm; + const thick = build({ ...rec, wingDims: { left: { thicknessMm: 550 }, right: { thicknessMm: 550 } } }); + expect(base - thick.model.wing!.slabWidthMm).toBeGreaterThan(195); // 2 × 100 / |sin Δ| ≈ 200.8 + expect(thick.wings[0].thicknessM).toBeCloseTo(0.55, 9); + }); + + it('haunch 0 drops the 연직 헌치 and the 저판 상면 헌치 of that wing', () => { + const w = build({ ...rec, wingDims: { left: { haunchMm: 0 }, right: {} } }).wings.find((x) => x.side === 'left')!; + expect(w.haunches).toHaveLength(0); + expect(w.footingHaunch).toBeUndefined(); + }); +}); + +describe('날개벽 삽도 (시안 ② 날개벽 카드)', () => { + it('평면: both wings, the slab between them and its width', () => { + const { model, members, wings } = build(); + const svg = abutWingPlanSvg({ + lengthM: model.length, outline: abutPlanOutline(model, members, wings), leftLabel: '좌 · 일반형', rightLabel: '우 · 일반형', + leftSEnd: wings.find((w) => w.side === 'left')!.sEnd, slabWidthM: model.wing!.slabWidthMm / 1000, + }); + for (const s of ['좌 · 일반형', '우 · 일반형', `접속슬래브 폭 ${(model.wing!.slabWidthMm / 1000).toFixed(3)} m`]) expect(svg).toContain(s); + expect(svg.match(/ { + const { members, wings } = build(); + const w = wings[0]; + const svg = abutWingSideSvg({ + name: '좌 날개벽', form: w.form, lengthM: w.lengthM, endHeightM: w.endHeightM, heelDistanceM: w.heelDistanceM, + topLine: w.topLine, bottomLine: w.bottomLine, bands: w.bands, seatEl: rec.seatEl, footingTopEl: members.footingTopEl, + }); + for (const s of ['6500 (교좌면 끝에서, 날개벽 축 방향)', '1500', '등단면', '변화단면1', '변화단면2', '뒷굽 끝 1.807 m', '교좌면 44.367']) expect(svg).toContain(s); + }); + + it('모서리: haunch, bracket, slab chamfer and gaps from the inputs', () => { + const svg = abutWingCornerSvg({ parapetMm: 500, bracketMm: 300, wingThicknessMm: 450, haunchMm: 300, slabGapWingMm: 20, slabGapParapetMm: 5, chamferMm: 300 }); + for (const s of ['헌치 300 × 300', '브라켓 300 (슬래브 아래)', '슬래브 모따기 300 × 300', '날개벽과 20 · 흉벽과 5', '흉벽 500', '날개벽 450']) expect(svg).toContain(s); + }); +});