/** * 교대 형상 · AA1 (2026-11-03, spec_abut §1·§4.1–4.2): 교대축, 전면 쪽, 교좌면 위의 받침, 교좌면 EL과 블럭 높이를 계산한다. * 순수 함수. 좌표는 (E, N) m 단위, EL은 m 단위, 길이는 별도 표기가 없으면 mm 단위다. * * - 축: IP1 → IP3 선(교대중심선), 길이 L. u = IP1 → IP3 단위벡터, nLeft = u를 반시계 90° 돌린 벡터. * - 받침 s: IP1에서 u 방향으로 잰 거리(m). 축에서 벗어난 거리(lateral): nLeft 방향 거리(m). * - z = 패드 상면(EL.X), 교각과 같다(2026-11-03 결정 1). 교좌면에서 패드 상면까지 블럭 높이는 125 mm * (콘크리트 75 + 패드 50). 교좌면 EL은 콘크리트 하면이다. * - 교좌면: 교대 전체에 교좌면 EL 하나를 입력받는다(첫 받침 ①). 블럭 높이 h = min(z) − 교좌면으로 모든 받침이 같고, * 받침 i의 교좌면 = z_i − h. 받침이 자기 교좌면 EL(seatEl)을 직접 주면 그 값을 쓰고, 블럭 높이는 z_i − seatEl이 된다 * (2026-11-03 결정 3). * - 전면: 받침이 있는 쪽. A1은 전면이 1 → n 방향의 왼쪽, 흉벽은 오른쪽에 있다. A2는 반대다(spec_abut §1). * 맞지 않으면 AW03 경고 대상이다. */ export type AbutType = 'A1' | 'A2'; export interface AbutPoint { e: number; n: number; } export interface AbutBearing { no: number; /** 위치 (E, N), m. */ pos: AbutPoint; /** 패드 상면 EL (z), m. */ z: number; /** 이 받침의 교좌면 EL (m). 없으면 z에서 교대 블럭 높이를 뺀 값을 쓴다. */ seatEl?: number; /** 받침 방위각 (deg, model-xy: 북에서 시계방향, 교각과 같은 변환 swapXYAzimuth). 없으면 블럭은 교대축에 맞춘다. */ azimuthDeg?: number; } /** 날개벽 형식, 측별(spec_abut §4.7): 일반형 / 단축형 / 없음. */ export type WingForm = 'general' | 'short' | 'none'; export interface AbutInput { name: string; type: AbutType; /** 첫 받침(구간 ①)의 교좌면 EL (m). 블럭 높이 h = min(z) − seatEl. */ seatEl: number; ip1: AbutPoint; ip2: AbutPoint; ip3: AbutPoint; bearings: AbutBearing[]; /** skew (deg): 받침축을 시계방향으로 돌린 선이 날개벽 선이다(spec_abut §1). 없으면 날개벽 검사를 하지 않는다. */ skewDeg?: number; /** 종단경사 i (소수, 교량진행방향 기준, (−) = 낮아짐). 날개벽 경사는 A1이 −i, A2가 +i다. */ slopeI?: number; /** AW01용 거더 방향 CAD 각 (deg). 없으면 AW01을 검사하지 않는다. */ girderAzimuthDeg?: number; /** 측별 날개벽 형식: 'left' = 받침 ① 쪽 끝, 'right' = 받침 n 쪽 끝(spec §4.7). 기본은 양측 일반형. */ wings?: { left: WingForm; right: WingForm }; /** 측별 날개벽 두께 (mm). 없으면 450. 접속슬래브 폭에 쓴다. */ wingThicknessMm?: { left?: number; right?: number }; /** 접속슬래브와 날개벽 안쪽면 사이 간격 (mm, ② 접속슬래브 카드). 없으면 20. */ slabWingGapMm?: number; /** 기초 하단 EL (m), 말뚝길이 (mm), 기초 형식 (AA3, 저판·말뚝). 없으면 저판·말뚝을 만들지 않는다. */ foundationBottomEl?: number; pileLengthMm?: number; foundationType?: string; } export interface AbutWingModel { /** 날개벽 선, CAD 각 (deg), 배면 쪽(예제: A1 ≈ 255.32, A2 ≈ 75.32). */ lineDeg: number; /** 날개벽 선과 거더 방향의 사잇각, 0–90°로 접음(AW01 허용 1°). */ girderDiffDeg: number; aw01Ok: boolean; /** 날개벽 경사 (%), (+) = 벽체에서 멀어질수록 높아짐. */ slopePct: number; /** 날개벽 사이 접속슬래브 폭 (mm) = L − Σ(두께 / |sin Δ| + 날개벽과 간격), 있는 날개벽마다. */ slabWidthMm: number; wings: { left: WingForm; right: WingForm }; } export interface AbutBearingModel { no: number; /** IP1에서 u 방향 거리 (m). */ s: number; /** nLeft 방향으로 잰 축에서의 부호 있는 거리 (m). */ lateral: number; z: number; seatEl: number; /** 교좌면에서 패드 상면까지 블럭 높이 (mm), 패드 포함. */ blockHeightMm: number; /** 받침 방위각 (deg, model-xy). 입력이 없으면 undefined. */ azimuthDeg?: number; /** 블럭 평면 회전 (deg, −90 … 90): 받침 방위각과 교대축 직각(전면) 방향 사이의 각. 장면에서는 rotateY(이 값). 입력이 없으면 0. */ rotDeg: number; } export interface AbutModel { name: string; type: AbutType; /** IP1 (축의 시작점, s = 0). */ ip1: AbutPoint; /** 축 길이 IP1 → IP3 (m). */ length: number; u: AbutPoint; nLeft: AbutPoint; /** 받침이 축의 nLeft 쪽이면 +1, 반대쪽이면 −1. */ frontSide: 1 | -1; /** 받침이 1 → n 방향의 왼쪽이면 true (A1 배치). */ frontLeftOfBearingDir: boolean; /** 입력 순서의 받침. */ bearings: AbutBearingModel[]; /** 축 위 계단 경계(IP1에서 m), 이웃 받침 사이의 중간. */ stepBounds: number[]; /** AW03: 배치가 형식과 맞으면 true. */ layoutMatchesType: boolean; /** 날개벽 선, 경사, AW01, 접속슬래브 폭. 입력에 skew가 없으면 없다. */ wing?: AbutWingModel; } const WING_THICKNESS_MM = 450; const SLAB_CLEARANCE_MM = 20; const AW01_TOLERANCE_DEG = 1; /** 공통 규칙의 패드 두께 (mm). 블럭 높이에 포함된다(패드 포함). */ export const ABUT_PAD_MM = 50; const sub = (a: AbutPoint, b: AbutPoint): AbutPoint => ({ e: a.e - b.e, n: a.n - b.n }); /** * 블럭 평면 회전 (deg): the bearing azimuth direction d = (sin az, cos az) seen in the axis frame (s along u, t along the front), * folded to −90 … 90 because the block is symmetric. 0 when the azimuth is missing. */ export function bearingRotDeg(azimuthDeg: number | undefined, u: AbutPoint, front: AbutPoint): number { if (azimuthDeg === undefined || !Number.isFinite(azimuthDeg)) return 0; const r = (azimuthDeg * Math.PI) / 180; const d = { e: Math.sin(r), n: Math.cos(r) }; let phi = (Math.atan2(d.e * u.e + d.n * u.n, d.e * front.e + d.n * front.n) * 180) / Math.PI; if (phi > 90) phi -= 180; if (phi < -90) phi += 180; return phi; } const dot = (a: AbutPoint, b: AbutPoint) => a.e * b.e + a.n * b.n; const cross = (a: AbutPoint, b: AbutPoint) => a.e * b.n - a.n * b.e; export function buildAbut(input: AbutInput): AbutModel { if (input.bearings.length < 2) throw new Error(`교대 ${input.name}: 받침이 2개 이상 필요합니다.`); const axisVec = sub(input.ip3, input.ip1); const length = Math.hypot(axisVec.e, axisVec.n); if (!(length > 0)) throw new Error(`교대 ${input.name}: IP1과 IP3이 같은 점입니다.`); const u = { e: axisVec.e / length, n: axisVec.n / length }; const nLeft = { e: -u.n, n: u.e }; const lateral = input.bearings.map((b) => dot(sub(b.pos, input.ip1), nLeft)); const meanLateral = lateral.reduce((a, x) => a + x, 0) / lateral.length; const frontSide: 1 | -1 = meanLateral >= 0 ? 1 : -1; // 1 → n 방향: 첫 받침(①)에서 마지막 받침까지. 전면 = 받침 쪽. 형식과 맞는지 확인한다. const first = input.bearings[0]; const last = input.bearings[input.bearings.length - 1]; const dir = sub(last.pos, first.pos); const frontVec = { e: nLeft.e * frontSide, n: nLeft.n * frontSide }; const frontLeftOfBearingDir = cross(dir, frontVec) > 0; const layoutMatchesType = input.type === 'A1' ? frontLeftOfBearingDir : !frontLeftOfBearingDir; const minZ = Math.min(...input.bearings.map((b) => b.z)); const bearings: AbutBearingModel[] = input.bearings.map((b, i) => { const s = dot(sub(b.pos, input.ip1), u); const seatEl = b.seatEl ?? input.seatEl + (b.z - minZ); const blockHeightMm = Math.round((b.z - seatEl) * 1000); return { no: b.no, s, lateral: lateral[i], z: b.z, seatEl, blockHeightMm, azimuthDeg: b.azimuthDeg, rotDeg: bearingRotDeg(b.azimuthDeg, u, frontVec) }; }); const sorted = [...bearings].sort((a, b) => a.s - b.s); const stepBounds = sorted.slice(1).map((b, i) => (sorted[i].s + b.s) / 2); const wing = input.skewDeg === undefined ? undefined : buildWing(input, u, nLeft, frontSide, dir, length); return { name: input.name, type: input.type, ip1: input.ip1, length, u, nLeft, frontSide, frontLeftOfBearingDir, bearings, stepBounds, layoutMatchesType, wing }; } const deg = (rad: number) => (rad * 180) / Math.PI; const norm360 = (x: number) => ((x % 360) + 360) % 360; /** 두 선 방향의 차이(deg), 0–90°로 접는다. 선에는 방향이 없다. */ const lineDiff = (a: number, b: number) => { const d = norm360(a - b) % 180; return Math.min(d, 180 - d); }; function buildWing(input: AbutInput, u: AbutPoint, nLeft: AbutPoint, frontSide: 1 | -1, dir: AbutPoint, length: number): AbutWingModel { const skew = input.skewDeg!; const bearingCad = norm360(deg(Math.atan2(dir.n, dir.e))); // skew만큼 시계방향으로 돌린 선이 날개벽 선이다. 배면 쪽(받침 반대쪽)이 방향을 정한다. const line = norm360(bearingCad - skew); const back = { e: -nLeft.e * frontSide, n: -nLeft.n * frontSide }; const radLine = (line * Math.PI) / 180; 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 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 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; return { lineDeg: pick, girderDiffDeg, aw01Ok: Number.isNaN(girderDiffDeg) ? true : girderDiffDeg <= AW01_TOLERANCE_DEG, slopePct: (input.type === 'A1' ? -slopeI : slopeI) * 100, slabWidthMm, wings, }; }