/** * 교대 날개벽 · AA3b (2026-11-03, spec_abut §4.7): 날개벽 평면, 상단선, 단부 하단. 순수 함수. * * - 평면: 교좌면 끝(IP1 끝 s = 0 또는 IP3 끝 s = L)에서 날개벽 선(AA2)을 따라 길이 6.5 m. 두께 0.45 m는 교대 안쪽으로 둔다. * - 상단: 시작점 = 흉벽 상단 EL(그 끝의 s), 끝까지 날개벽 경사(i)로 직선. * - 하단 (일반형): 단부 하단 = 끝점 상단 − 1.5 m. 단축형: 하단 = 저판 상면 높이(평평하게). * - 좌(①쪽) 날개벽은 받침 ①에 가까운 끝, 우(n쪽)는 반대 끝 (spec_abut §4.7). * - 하단 선 (AA3c): 저판 뒷굽 끝(배면 −1.8 m)까지는 저판 상면, 그 밖은 뒷굽 끝 상단 모서리에서 단부 하단까지 직선 * (변화단면). 뒷굽 끝까지의 날개벽 거리 = 1.8 / |sin Δ| (단축형 1.807 m와 같은 식). * - 헌치 300 × 300 (AA3c): 흉벽 배면(−0.5) 모서리에 위 헌치, 벽체 배면(−0.8) 모서리에 아래 헌치. 수직 삼각기둥. * 위 헌치는 브라켓 상면(흉벽 상단 − 0.85)부터 날개벽 상단까지, 아래 헌치는 저판 상면부터 브라켓 상면까지다. * - 아직 없음: 등단면·변화단면 수평 단 구분 (QA-12), 저판 측면 윤곽의 정밀 모서리. * * 수치는 spec_abut §12 A2 합성 예제(좌 46.838 → 46.714, 하단 45.214 / 우 47.183 → 47.059, 하단 45.559)와 맞춘다. */ import type { AbutModel, AbutPoint, WingForm } from './geometry'; import type { AbutMembers } from './members'; export const WING_LENGTH_M = 6.5; 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; topEndEl: number; /** 단부 하단 EL (일반형: 끝 상단 − 1.5; 단축형: 저판 상면). */ bottomEndEl: number; /** 축 끝에서 날개벽 선을 따라 잰 뒷굽 끝까지의 거리 (m). */ heelDistanceM: number; /** 상단선 (d = 축 끝에서 날개벽 방향 거리 m, EL m). */ topLine: { dM: number; el: number }[]; /** 하단선: 저판 상면 → 뒷굽 끝 → 단부 하단 (일반형), 또는 저판 상면 평탄 (단축형). */ bottomLine: { dM: number; el: number }[]; /** 헌치 300 × 300 (위 헌치, 흉벽 배면 모서리). 평면 삼각형과 높이 범위. */ haunches: Haunch[]; /** * 저판 상면 헌치 (수평, 2026-11-03): between the 저판 상면 and the 날개벽 내측면. The triangle lies in the plane EL–s, * with legs HAUNCH_LEG_M along the 저판 상면 and along the 내측면; it runs along the wing from dMFrom (벽체 배면) to dMTo (뒷굽). */ footingHaunch?: { dMFrom: number; dMTo: number; el: number }; /** 수평 단 (QA-12, spec_abut §4.7): 등단면 / 변화단면1 / 변화단면2. 경계는 단부 높이 1.5 m와 교좌면 EL. */ bands: WingBand[]; } export interface WingBand { name: '등단면' | '변화단면1' | '변화단면2' | '단축형 전구간'; /** 높이 범위 (EL m). */ zTopEl: number; zBottomEl: number; } export interface Haunch { kind: 'upper' | 'lower'; /** 평면 삼각형 꼭짓점 (E, N): 모서리, 날개벽 쪽 점, 벽체·흉벽 쪽 점. */ corners: [AbutPoint, AbutPoint, AbutPoint]; zBottomEl: number; zTopEl: number; } export const HAUNCH_LEG_M = 0.3; 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; // 좌 = 받침 ① 쪽 끝 (① 에 가까운 축 끝). const firstS = model.bearings[0].s; const leftS = firstS < L / 2 ? 0 : L; const rightS = leftS === 0 ? L : 0; const slope = model.wing.slopePct / 100; const dirRad = (model.wing.lineDeg * Math.PI) / 180; const w = { e: Math.cos(dirRad), n: Math.sin(dirRad) }; const ip1 = model.ip1; // 축 끝에서 안쪽 방향: s = 0이면 +u, s = L이면 −u. const at = (s: number) => ({ e: ip1.e + model.u.e * s, n: ip1.n + model.u.n * s }); // 배면 쪽으로 가는 날개벽 방향의 횡방향 성분 k = |w · (전면 방향)|: 날개벽 거리 d는 횡방향 t = −d·k. const frontVec = { e: model.nLeft.e * model.frontSide, n: model.nLeft.n * model.frontSide }; const k = Math.abs(w.e * frontVec.e + w.n * frontVec.n); const footingTop = members.footingTopEl; const heelDistanceM = Math.abs(members.shape.footingBackM) / k; 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 * 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: length, el: topEnd }, ]; const bottomLine = form === 'general' && heelDistanceM < length ? [ { dM: 0, el: footingTop }, { dM: heelDistanceM, el: footingTop }, { dM: length, el: bottomEnd }, ] : [ { dM: 0, el: footingTop }, { dM: length, el: form === 'general' ? bottomEnd : footingTop }, ]; // 헌치: 위 헌치는 흉벽 배면(−0.5), 아래 헌치는 벽체 배면(−0.8)의 모서리에 둔다. const pointAt = (d: number) => ({ e: p0.e + w.e * d, n: p0.n + w.n * d }); const shift = (q: AbutPoint, v: AbutPoint, f: number) => ({ e: q.e + v.e * f, n: q.n + v.n * f }); const bracketTop = topStart - members.shape.bracketHeightM; // The 브라켓 상면 falls along s (the 흉벽 top slopes), so the upper haunch rests on the highest point of its own footprint. const sAt = (q: AbutPoint) => (q.e - model.ip1.e) * model.u.e + (q.n - model.ip1.n) * model.u.n; const backTopAt = (s: number) => members.parapetTopStartEl + ((members.parapetTopEndEl - members.parapetTopStartEl) * s) / model.length - members.shape.bracketHeightM; const haunchAt = (kind: 'upper' | 'lower', t: number): Haunch => { 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, thick); const corners: [AbutPoint, AbutPoint, AbutPoint] = [c, shift(c, w, leg), shift(c, inwardUnit, leg)]; return { kind, corners, zBottomEl: kind === 'upper' ? Math.max(...corners.map((q) => backTopAt(sAt(q)))) : footingTop, zTopEl: kind === 'upper' ? topAt(d) : bracketTop, }; }; // 연직 헌치 (2026-11-03, 내측면 모서리): upper at the 흉벽 배면, lower at the 벽체 배면. 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 = 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은 날개벽 끝에 가장 가까운 받침의 교좌면이다. const nearest = model.bearings.reduce((best, b) => (Math.abs(b.s - sEnd) < Math.abs(best.s - sEnd) ? b : best)); const bands: WingBand[] = form === 'general' ? [ { name: '등단면', zTopEl: topStart, zBottomEl: bottomEnd }, { name: '변화단면1', zTopEl: bottomEnd, zBottomEl: nearest.seatEl }, { name: '변화단면2', zTopEl: nearest.seatEl, zBottomEl: footingTop }, ] : [{ name: '단축형 전구간', zTopEl: topStart, zBottomEl: footingTop }]; return { 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, bottomEndEl: bottomEnd, heelDistanceM, topLine, bottomLine, haunches, footingHaunch, bands, }; }; const out: WingSolid[] = []; if (wings.left !== 'none') out.push(build('left', leftS, leftS === 0 ? members.parapetTopStartEl : members.parapetTopEndEl, wings.left)); if (wings.right !== 'none') out.push(build('right', rightS, rightS === 0 ? members.parapetTopStartEl : members.parapetTopEndEl, wings.right)); return out; }