Files
Pier_v1/src/core/geometry.ts
T
ypshimandClaude Haiku 5.5 5466b3522e feat(core): haunch-and-Level-300 coping underside, steel pile 609.6, W03/W04 checks
- Multi-column coping (2·3주형): underside between columns drops 300 below
  Level with 300 × 300 haunches (spec §4.5, Q3 추천안).
- Steel pipe pile diameter 609.6 mm (spec §4.5, Q5 추천안).
- Direct foundation keeps the 100 mm blinding concrete (Q4 추천안, test added).
- W03: bearing centre closer than half a block width to a step boundary.
- W04: block footprint outside the coping plan.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-22 08:58:57 +09:00

367 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}