Files
Pier_v1/src/core/geometry.ts
T
ypshimandClaude Haiku 5.5 01d2bf4958 feat(parse): report per-sheet recognition and block error sheets
- parseWorkbook returns a report per sheet (table counts, issues, ok)
- sheets with E-level issues cannot be selected (spec section 2.3)
- pier input carries its source sheet name

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-21 17:43:36 +09:00

352 lines
11 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,
padThickness: 0.05,
padSize: 1.0,
blockSize: 1.2,
footingL: 10.4,
footingW: 8.15,
footingH: 2.0,
blindingMargin: 0.2,
blindingT: 0.1,
pileDiameter: 0.6,
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 구간보다 짧습니다.');
const profile: [number, number][] = [[0, endBottom]];
for (const seg of segments) {
profile.push([seg.s0, seg.top], [seg.s1, seg.top]);
}
profile.push([L, endBottom], [levelEnd, levelBottom], [levelStart, levelBottom], [0, endBottom]);
profile.pop(); // the last point repeats the first one
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;
}