import type { PierInput, Row } from './types'; /** Dimensions in m. Sources: intent.PR sheet (P1 rules) and spec.md §3 defaults. */ export const DIM = { columnDiameter: 2.0, copingH1: 1.0, copingH2: 1.5, levelWidth: 2.1, /** Multi-column coping: underside between columns sits this far below Level, with 45° haunches of the same size (spec §4.5, Q3 추천안). */ haunch: 0.3, padThickness: 0.05, padSize: 1.0, blockSize: 1.2, footingL: 10.4, footingW: 8.15, footingH: 2.0, blindingMargin: 0.2, blindingT: 0.1, /** 강관 609.6 × 12 (spec §4.5 강관 type, Q5 추천안 적용). */ pileDiameter: 0.6096, pileEnd: 0.7, pileSpacing: 2.25, /** Front/back EL difference below this is treated as no lateral step (spec §4.3, Q2). */ lateralTolerance: 0.005, } as const; export interface Vec2 { e: number; n: number; } export interface Segment { s0: number; s1: number; top: number; } export interface BearingLocal { no: number; row: Row; s: number; n: number; z: number; } export interface BlockModel { no: number; row: Row; s: number; n: number; bottom: number; top: number; padTop: number; } export interface ColumnModel { s: number; top: number; bottom: number; diameter: number; } export interface FootingModel { s: number; top: number; bottom: number; length: number; width: number; } export interface BlindingModel { s: number; top: number; length: number; width: number; thickness: number; } export interface PileModel { s: number; n: number; top: number; bottom: number; diameter: number; } export interface PierModel { name: string; lengthIp: number; azimuthDeg: number; /** Unit vector along the pier axis (east, north). */ u: Vec2; /** Unit vector across the axis: (u.n, -u.e). Front row is negative, back row positive. */ nrm: Vec2; ip1: Vec2; ip2Input: Vec2; ip2Calc: Vec2; dIp2: number; bp1: Vec2; bp2: Vec2; /** Horizontal centre of the drawing frame: the centre of the first column. */ pivot: Vec2; copingL: number; copingB: number; copingRefTop: number; h1: number; h2: number; segments: Segment[]; /** Coping elevation outline as (s, EL) points, closed. */ profile: [number, number][]; levelBottom: number; endBottom: number; bearings: BearingLocal[]; blocks: BlockModel[]; columns: ColumnModel[]; footings: FootingModel[]; blindings: BlindingModel[]; piles: PileModel[]; groundEl: number; derived: { columnHeight: number; soilCover: number; pileCount: number; pileLayout: string | null; pileTipEl: number | null; blockHeightFront: number; blockHeightBack: number; frontBackDiffSpread: number; }; } function toLocal(p: Vec2, origin: Vec2, u: Vec2, nrm: Vec2): { s: number; n: number } { const dx = p.e - origin.e; const dy = p.n - origin.n; return { s: dx * u.e + dy * u.n, n: dx * nrm.e + dy * nrm.n }; } /** * Builds the first-stage (shape only) model of a T-type pier from one input record. * Pure function: same input always gives the same output. Throws for cases not implemented yet. */ export function buildPier(input: PierInput): PierModel { const ip = (name: string) => { const p = input.ips.find((q) => q.name === name); if (!p) throw new Error(`IP 누락: ${name}`); return p; }; const ip1 = ip('IP1'); const ip2 = ip('IP2'); const ip3 = ip('IP3'); const ip4 = ip('IP4'); if (ip4.angleDeg !== 0) { throw new Error('미실시: 꺾인 코핑(IP4 꺾임각 ≠ 0)은 아직 지원하지 않습니다.'); } const dx = ip3.x - ip1.x; const dy = ip3.y - ip1.y; const lengthIp = Math.hypot(dx, dy); const u: Vec2 = { e: dx / lengthIp, n: dy / lengthIp }; const nrm: Vec2 = { e: u.n, n: -u.e }; const azimuthDeg = (((Math.atan2(u.e, u.n) * 180) / Math.PI) + 360) % 360; // IP2 is a reference point at a 1:9 division of IP1–IP3 (intent.PR, spec §2.2). const ip2Calc: Vec2 = { e: ip1.x + 0.1 * dx, n: ip1.y + 0.1 * dy }; const ip2Input: Vec2 = { e: ip2.x, n: ip2.y }; const L = input.copingLengthMm / 1000; const B = input.copingWidthMm / 1000; const ip1v: Vec2 = { e: ip1.x, n: ip1.y }; const bp1: Vec2 = { e: ip1v.e + u.e * (lengthIp / 2 - L / 2), n: ip1v.n + u.n * (lengthIp / 2 - L / 2) }; const bp2: Vec2 = { e: bp1.e + u.e * L, n: bp1.n + u.n * L }; const front = input.bearings.filter((b) => b.row === '전열').sort((a, b) => a.no - b.no); const back = input.bearings.filter((b) => b.row === '후열').sort((a, b) => a.no - b.no); if (front.length !== back.length) throw new Error('E02: 전열·후열 받침 수가 다릅니다.'); if (front.length < 2) throw new Error('받침이 2개 미만입니다.'); // Bearing block heights (spec §4.3 h1OshoeBlock / h2, pad 50 included). Not the coping end heights (DIM.copingH1/H2). const h1 = front[0].z - input.copingTopEl; const h2 = h1 + (back[0].z - front[0].z); const frontLocal = front.map((b) => ({ no: b.no, row: '전열' as Row, z: b.z, ...toLocal({ e: b.x, n: b.y }, bp1, u, nrm) })); const backLocal = back.map((b) => ({ no: b.no, row: '후열' as Row, z: b.z, ...toLocal({ e: b.x, n: b.y }, bp1, u, nrm) })); // Step boundaries sit midway between consecutive front bearings (spec §4.3). const bounds = [0]; for (let k = 0; k < frontLocal.length - 1; k++) { bounds.push((frontLocal[k].s + frontLocal[k + 1].s) / 2); } bounds.push(L); const segments: Segment[] = frontLocal.map((b, k) => ({ s0: bounds[k], s1: bounds[k + 1], top: b.z - h1 })); // Lateral step: front and back coping tops must match segment by segment (not supported yet). frontLocal.forEach((f, k) => { const backTop = backLocal[k].z - h2; if (Math.abs(segments[k].top - backTop) > DIM.lateralTolerance) { throw new Error('미실시: 횡방향 단차(전열·후열 상면 차 ≥ 5 mm)는 아직 지원하지 않습니다.'); } }); // Columns (spec §4.5). Multi-column positions are placed; haunch and separate-footing checks are not. const colS = columnPositions(input.type, L); // Coping underside: ends at (top − end height h1 = 1000), Level zone at (top − h1 − h2 taper height = 1500). const levelBottom = input.copingTopEl - DIM.copingH1 - DIM.copingH2; const endBottom = input.copingTopEl - DIM.copingH1; const levelStart = Math.min(...colS) - DIM.levelWidth / 2; const levelEnd = Math.max(...colS) + DIM.levelWidth / 2; if (levelStart < 0 || levelEnd > L) throw new Error('코핑 길이가 Level 구간보다 짧습니다.'); // Underside from the right end to the left end. Between columns (2·3주형) it drops 300 below Level with 300 × 300 haunches (spec §4.5, Q3). const underside: [number, number][] = [[L, endBottom]]; const levelZones = [...colS].sort((a, b) => b - a).map((s) => [s - DIM.levelWidth / 2, s + DIM.levelWidth / 2] as const); levelZones.forEach(([zoneStart, zoneEnd], k) => { underside.push([zoneEnd, levelBottom], [zoneStart, levelBottom]); const next = levelZones[k + 1]; if (!next) return; const gap = zoneStart - next[1]; if (gap <= 2 * DIM.haunch) throw new Error('기둥 사이 간격이 헌치(300 × 2)보다 좁아 코핑 하면을 만들 수 없습니다.'); const lowerEl = levelBottom - DIM.haunch; underside.push([zoneStart - DIM.haunch, lowerEl], [next[1] + DIM.haunch, lowerEl], [next[1], levelBottom]); }); const profile: [number, number][] = [[0, endBottom]]; for (const seg of segments) { profile.push([seg.s0, seg.top], [seg.s1, seg.top]); } profile.push(...underside); const footingTop = input.foundationType === '현장타설말뚝' ? input.footingBottomEl : input.footingBottomEl + DIM.footingH; const columns: ColumnModel[] = colS.map((s) => ({ s, top: levelBottom, bottom: footingTop, diameter: DIM.columnDiameter, })); const footings: FootingModel[] = []; const blindings: BlindingModel[] = []; const piles: PileModel[] = []; const pileLength = input.pileLengthMm / 1000; const pileTip = input.footingBottomEl - pileLength; for (const s of colS) { if (input.foundationType === '현장타설말뚝') { piles.push({ s, n: 0, top: input.footingBottomEl, bottom: pileTip, diameter: DIM.columnDiameter }); continue; } footings.push({ s, top: footingTop, bottom: input.footingBottomEl, length: DIM.footingL, width: DIM.footingW }); blindings.push({ s, top: input.footingBottomEl, length: DIM.footingL + DIM.blindingMargin, width: DIM.footingW + DIM.blindingMargin, thickness: DIM.blindingT, }); if (input.foundationType === '말뚝기초') { piles.push(...pilesFor(s, input.footingBottomEl, pileLength)); } } const blocks: BlockModel[] = []; frontLocal.forEach((b) => { const bottom = segmentTop(segments, b.s); blocks.push({ no: b.no, row: '전열', s: b.s, n: b.n, bottom, top: b.z - DIM.padThickness, padTop: b.z }); }); backLocal.forEach((b) => { const bottom = b.z - h2; blocks.push({ no: b.no, row: '후열', s: b.s, n: b.n, bottom, top: b.z - DIM.padThickness, padTop: b.z }); }); const blockHeightFront = blocks.find((b) => b.row === '전열')!.top - blocks.find((b) => b.row === '전열')!.bottom; const blockHeightBack = blocks.find((b) => b.row === '후열')!.top - blocks.find((b) => b.row === '후열')!.bottom; const diffs = frontLocal.map((f, k) => f.z - backLocal[k].z); const frontBackDiffSpread = Math.max(...diffs) - Math.min(...diffs); return { name: input.name, lengthIp, azimuthDeg, u, nrm, ip1: ip1v, ip2Input, ip2Calc, dIp2: (ip2Calc.e - bp1.e) * u.e + (ip2Calc.n - bp1.n) * u.n, bp1, bp2, pivot: { e: bp1.e + u.e * (L / 2), n: bp1.n + u.n * (L / 2) }, copingL: L, copingB: B, copingRefTop: input.copingTopEl, h1, h2, segments, profile, levelBottom, endBottom, bearings: [...frontLocal, ...backLocal].map((b) => ({ no: b.no, row: b.row, s: b.s, n: b.n, z: b.z })), blocks, columns, footings, blindings, piles, groundEl: input.groundEl, derived: { columnHeight: levelBottom - columns[0].bottom, soilCover: input.groundEl - (input.foundationType === '현장타설말뚝' ? input.footingBottomEl : footingTop), pileCount: piles.length, pileLayout: input.foundationType === '말뚝기초' ? pileLayoutText() : null, pileTipEl: piles.length ? pileTip : null, blockHeightFront, blockHeightBack, frontBackDiffSpread, }, }; } export function columnPositions(type: PierInput['type'], L: number): number[] { if (type === 'T형') return [L / 2]; if (type === '2주형') return [L / 5, L - L / 5]; return [L / 5, L / 2, L - L / 5]; } function segmentTop(segments: Segment[], s: number): number { const hit = segments.find((seg) => s >= seg.s0 && s < seg.s1) ?? segments[segments.length - 1]; return hit.top; } /** * Piles under one footing (spec §4.6): rows across the footing width, piles along the length, * both with an end distance of 0.7 m and an even spacing that never exceeds 2.25 m. */ export function pileGrid(): { countS: number; countN: number; spacingS: number; spacingN: number } { const span = (size: number) => Math.floor((size - 2 * DIM.pileEnd) / DIM.pileSpacing) + 1; const countS = span(DIM.footingL); const countN = span(DIM.footingW); return { countS, countN, spacingS: (DIM.footingL - 2 * DIM.pileEnd) / (countS - 1), spacingN: (DIM.footingW - 2 * DIM.pileEnd) / (countN - 1), }; } /** Display string such as "4@2250 × 3@2250 = 20본" (gaps along the length × gaps across the width). */ export function pileLayoutText(): string { const g = pileGrid(); return `${g.countS - 1}@${Math.round(g.spacingS * 1000)} × ${g.countN - 1}@${Math.round(g.spacingN * 1000)} = ${g.countS * g.countN}본`; } export function pilesFor(s: number, topEl: number, length: number): PileModel[] { const { countS, countN, spacingS, spacingN } = pileGrid(); const out: PileModel[] = []; for (let i = 0; i < countS; i++) { for (let j = 0; j < countN; j++) { out.push({ s: s + (i - (countS - 1) / 2) * spacingS, n: (j - (countN - 1) / 2) * spacingN, top: topEl, bottom: topEl - length, diameter: DIM.pileDiameter, }); } } return out; }