Files
Pier_v1/src/core/geometry.ts
T
ypshimandClaude Haiku 5.5 0a5edfb152 feat(rebar,view): coping end height h1, taper h2 and Level width as inputs on ② (1단계)
The three values are inputs on the coping card, with the P1 values (1000 · 1500 · 2100) when absent, and they are tracked
like the other shape inputs. The geometry and the coping rebar read them from one CopingGeom: the U heights follow the
closed form (h1 − 2c) + h2·min(x_s, x_L0)/x_L0, which reproduces the P1 values. The side rows are assumed to keep their
position relative to the Level underside (명세 확인 필요). P1 results are unchanged; the full suite passes.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-23 11:44:44 +09:00

522 lines
20 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 { buildAxis, copingSpan, fromAxis, kinkArc, nearKink, toAxis } from './axis';
import { layoutText, resolveRowCounts, rowLayout, type PileGeometry } from './piles';
import type { PierInput, Row } from './types';
import { type CopingGeom, P1_COPING_GEOM } from './coping-geom';
/** 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;
}
/** Stretch k, local s from that stretch's base, g = coping-relative arc length, n = lateral offset. */
export interface BearingLocal {
no: number;
row: Row;
k: number;
s: number;
g: number;
n: number;
z: number;
}
export interface Stretch {
origin: Vec2;
u: Vec2;
nrm: Vec2;
/** Coping-relative arc length where this stretch starts (0 for k = 0, the kink for k = 1). */
base: number;
}
export interface BlockModel {
no: number;
row: Row;
k: number;
s: number;
g: number;
n: number;
bottom: number;
top: number;
padTop: number;
/** Skew (deg, −90…90] of the bearing row against the perpendicular of the stretch axis; the block sides follow the row. */
rowSkewDeg: number;
}
/** Skew of a bearing row (deg) from the bearing azimuth and the stretch axis direction u (see {@link BlockModel.rowSkewDeg}). */
export function rowSkewDeg(bearingAzimuthDeg: number, u: Vec2): number {
if (Number.isNaN(bearingAzimuthDeg)) return 0;
const axisAz = (Math.atan2(u.e, u.n) * 180) / Math.PI;
const d = (((bearingAzimuthDeg - (axisAz + 90)) % 180) + 540) % 180; // 0…180
return d > 90 ? d - 180 : d;
}
/**
* (s, n) of a point (a along the block's s side, c along its n side) measured from the block centre, after the block turns
* by its row skew. Same turn as the 3D geometry (rotateY(−skew)).
*/
export function blockFramePoint(b: Pick<BlockModel, 's' | 'n' | 'rowSkewDeg'>, a: number, c: number): [number, number] {
const d = (b.rowSkewDeg * Math.PI) / 180;
return [b.s + a * Math.cos(d) - c * Math.sin(d), b.n + a * Math.sin(d) + c * Math.cos(d)];
}
export interface ColumnModel {
s: number;
k: number;
top: number;
bottom: number;
diameter: number;
}
export interface FootingModel {
s: number;
k: number;
top: number;
bottom: number;
length: number;
width: number;
}
export interface BlindingModel {
s: number;
k: number;
top: number;
length: number;
width: number;
thickness: number;
}
export interface PileModel {
s: number;
k: number;
n: number;
top: number;
bottom: number;
diameter: number;
}
/** One coping slab across [nFrom, nTo]; profile is its (s, EL) outline, closed. */
export interface CopingPart {
k: number;
nFrom: number;
nTo: number;
profile: [number, 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 parts per stretch, split across the width by the lateral step line (one part per stretch without a step). */
copingParts: CopingPart[];
stretches: Stretch[];
/** Bend point IP4, or null for a straight axis. */
kink: Vec2 | null;
/** Mitre normal at the kink (unit). A point is on stretch 0 when (p − kink)·mitre ≤ 0. */
mitre: Vec2 | null;
levelBottom: number;
endBottom: number;
/** Coping heights and Level width in mm, as used by the coping rebar (1단계). */
copingGeom: CopingGeom;
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;
};
}
/**
* Bend point IP4 (spec §4.1-2): the point where the IP1→IP2 line meets the line through IP3 whose
* direction is IP1→IP2 turned by the bend angle. Positive angle = right (clockwise) turn when travelling IP1→IP2.
* Returns null for a zero angle, where IP4 is not defined.
*/
export function solveIp4(ip1: Vec2, ip2: Vec2, ip3: Vec2, angleDeg: number): Vec2 | null {
if (angleDeg === 0) return null;
const cross = (a: Vec2, b: Vec2) => a.e * b.n - a.n * b.e;
const len = Math.hypot(ip2.e - ip1.e, ip2.n - ip1.n);
const d1: Vec2 = { e: (ip2.e - ip1.e) / len, n: (ip2.n - ip1.n) / len };
const rad = (-angleDeg * Math.PI) / 180; // clockwise is positive, so the counter-clockwise rotation uses −θ
const d2: Vec2 = { e: d1.e * Math.cos(rad) - d1.n * Math.sin(rad), n: d1.e * Math.sin(rad) + d1.n * Math.cos(rad) };
const t = cross({ e: ip3.e - ip1.e, n: ip3.n - ip1.n }, d2) / cross(d1, d2);
return { e: ip1.e + t * d1.e, n: ip1.n + t * d1.n };
}
/**
* Builds the first-stage model of a T-type pier from one input record. Pure function.
* Coordinates: g = arc length from the coping start (BP1), so g = 0 at BP1 and g = L at BP2.
* Each object carries its stretch k and a local s measured from that stretch's base (k = 0: BP1, k = 1: IP4).
* A bent coping (IP4 angle ≠ 0) puts each footing in its stretch's frame; footings that would cross the kink are not combined.
*/
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');
const bent = ip4.angleDeg !== 0;
const L = input.copingLengthMm / 1000;
const B = input.copingWidthMm / 1000;
// Coping heights and Level width (m): inputs, with the P1 values when absent (1단계).
const copingH1 = (input.copingH1Mm ?? P1_COPING_GEOM.h1) / 1000;
const copingH2 = (input.copingH2Mm ?? P1_COPING_GEOM.h2) / 1000;
const levelW = (input.levelWidthMm ?? P1_COPING_GEOM.levelWidth) / 1000;
const planHalfB = B / 2;
const ip1v: Vec2 = { e: ip1.x, n: ip1.y };
const ip2v: Vec2 = { e: ip2.x, n: ip2.y };
const ip3v: Vec2 = { e: ip3.x, n: ip3.y };
const kink = bent ? solveIp4(ip1v, ip2v, ip3v, ip4.angleDeg) : null;
const axis = buildAxis(ip1v, ip3v, kink);
if (kink) {
// The bend must move the axis forward: the kink ahead of IP1 along IP1→IP2, and IP3 ahead of the kink along the turned direction.
const d1 = { e: ip2v.e - ip1v.e, n: ip2v.n - ip1v.n };
const len1 = Math.hypot(d1.e, d1.n);
const rad = (-ip4.angleDeg * Math.PI) / 180;
const d2 = { e: (d1.e * Math.cos(rad) - d1.n * Math.sin(rad)) / len1, n: (d1.e * Math.sin(rad) + d1.n * Math.cos(rad)) / len1 };
const kinkAhead = (kink.e - ip1v.e) * d1.e + (kink.n - ip1v.n) * d1.n > 0;
const ip3Ahead = (ip3v.e - kink.e) * d2.e + (ip3v.n - kink.n) * d2.n > 0;
if (!kinkAhead || !ip3Ahead) {
throw new Error('꺾임각과 IP 배치가 맞지 않습니다. IP4가 축 뒤로 넘어갑니다. IP3이 꺾인 방향의 앞쪽에 있는지 확인하세요.');
}
}
const lengthIp = axis.length;
const sStart = copingSpan(axis, L).sStart;
const arcA = kinkArc(axis);
const kinkG = arcA === null ? null : arcA - sStart;
if (kinkG !== null && (kinkG <= 0 || kinkG >= L)) {
throw new Error('꺾임점이 코핑 범위 밖에 있습니다. 코핑 길이를 확인하세요.');
}
const base = [0, kinkG ?? 0];
const { u, nrm } = axis.segments[0];
const azimuthDeg = (((Math.atan2(u.e, u.n) * 180) / Math.PI) + 360) % 360;
// IP2 is a 1:9 reference point on a straight axis (intent.PR, spec §2.2). On a bent axis IP2 is on the first stretch, so no check.
const ip2Calc: Vec2 = kink ? ip2v : { e: ip1.x + 0.1 * (ip3.x - ip1.x), n: ip1.y + 0.1 * (ip3.y - ip1.y) };
const ip2Input: Vec2 = ip2v;
const pointAt = (g: number, n: number): Vec2 => {
const arc = sStart + g;
const k = kinkG !== null && g > kinkG ? 1 : 0;
return fromAxis(axis, { k, s: arc, n });
};
const bp1 = pointAt(0, 0);
const bp2 = pointAt(L, 0);
const pivot = pointAt(L / 2, 0);
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 place = (b: { x: number; y: number }) => {
const q = toAxis(axis, { e: b.x, n: b.y });
const g = q.s - sStart;
return { k: q.k, g, s: g - base[q.k], n: q.n };
};
const frontPlaced = front.map((b) => ({ no: b.no, row: '전열' as Row, z: b.z, ...place(b) }));
const backPlaced = back.map((b) => ({ no: b.no, row: '후열' as Row, z: b.z, ...place(b) }));
// Step boundaries sit midway between consecutive front bearings in coping coordinates (spec §4.3).
const bounds = [0];
for (let k = 0; k < frontPlaced.length - 1; k++) {
bounds.push((frontPlaced[k].g + frontPlaced[k + 1].g) / 2);
}
bounds.push(L);
const segments: Segment[] = frontPlaced.map((b, k) => ({ s0: bounds[k], s1: bounds[k + 1], top: b.z - h1 }));
// Lateral step (spec §4.3, Q1·Q2 추천안): front and back tops differ by 5 mm or more in some segment.
const backTops = backPlaced.map((b) => b.z - h2);
const lateralStep = backTops.some((top, k) => Math.abs(segments[k].top - top) >= DIM.lateralTolerance);
// Columns (spec §4.5). Positions are coping coordinates; a column must stay a Level half-width away from the kink.
const colG = columnPositions(input.type, L);
if (kinkG !== null) {
const near = nearKink(axis, colG.map((g) => sStart + g), levelW / 2);
if (near.length) throw new Error('미실시: 꺾임점 근처(Level 반폭 이내)에 기둥이 있습니다. 기둥 위치를 확인하세요.');
}
const colK = colG.map((g) => (kinkG !== null && g > kinkG ? 1 : 0));
// Coping underside: ends at (top − end height h1 = 1000), Level zone at (top − h1 − h2 taper height = 1500).
const levelBottom = input.copingTopEl - copingH1 - copingH2;
const endBottom = input.copingTopEl - copingH1;
const levelStart = Math.min(...colG) - levelW / 2;
const levelEnd = Math.max(...colG) + levelW / 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 = [...colG].sort((a, b) => b - a).map((s) => [s - levelW / 2, s + levelW / 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]);
});
// Elevation outline in coping coordinates for one row of tops, closed by the shared underside.
const outline = (tops: number[]): [number, number][] => {
const pts: [number, number][] = [[0, endBottom]];
segments.forEach((seg, k) => pts.push([seg.s0, tops[k]], [seg.s1, tops[k]]));
pts.push(...underside);
return pts;
};
// Each stretch uses the same outline shifted to its own base; the mitre plane trims the overlap at the kink.
const shifted = (pts: [number, number][], b: number): [number, number][] => pts.map(([s, z]) => [s - b, z]);
const meanN = (rows: { n: number }[]) => rows.reduce((sum, r) => sum + r.n, 0) / rows.length;
const frontTops = segments.map((seg) => seg.top);
const copingParts: CopingPart[] = [];
const stretchCount = kinkG === null ? 1 : 2;
for (let k = 0; k < stretchCount; k++) {
const fRows = frontPlaced.filter((b) => b.k === k);
const bRows = backPlaced.filter((b) => b.k === k);
const nStep = fRows.length && bRows.length
? (meanN(fRows) + meanN(bRows)) / 2
: (meanN(frontPlaced) + meanN(backPlaced)) / 2;
if (lateralStep) {
copingParts.push(
{ k, nFrom: -planHalfB, nTo: nStep, profile: shifted(outline(frontTops), base[k]) },
{ k, nFrom: nStep, nTo: planHalfB, profile: shifted(outline(backTops), base[k]) },
);
} else {
copingParts.push({ k, nFrom: -planHalfB, nTo: planHalfB, profile: shifted(outline(frontTops), base[k]) });
}
}
// Footings are grouped per stretch so that a 일체기초 never crosses the kink; each group is aligned with its stretch.
const footingSets: { s: number; k: number; length: number }[] = [];
for (let k = 0; k < stretchCount; k++) {
for (const g of footingGroups(colG.filter((_, i) => colK[i] === k))) {
footingSets.push({ s: g.s - base[k], k, length: g.length });
}
}
const footingTop = input.foundationType === '현장타설말뚝' ? input.footingBottomEl : input.footingBottomEl + DIM.footingH;
const columns: ColumnModel[] = colG.map((g, i) => ({
s: g - base[colK[i]],
k: colK[i],
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;
if (input.foundationType === '현장타설말뚝') {
columns.forEach((c) => {
piles.push({ s: c.s, k: c.k, n: 0, top: input.footingBottomEl, bottom: pileTip, diameter: DIM.columnDiameter });
});
} else {
for (const f of footingSets) {
footings.push({ s: f.s, k: f.k, top: footingTop, bottom: input.footingBottomEl, length: f.length, width: DIM.footingW });
blindings.push({
s: f.s,
k: f.k,
top: input.footingBottomEl,
length: f.length + DIM.blindingMargin,
width: DIM.footingW + DIM.blindingMargin,
thickness: DIM.blindingT,
});
if (input.foundationType === '말뚝기초') {
const g = pileGeometry(f.length);
const counts = resolveRowCounts(input.pileRows, g);
piles.push(...pilesFor(f.s, input.footingBottomEl, pileLength, g, counts).map((p) => ({ ...p, k: f.k })));
}
}
}
const blocks: BlockModel[] = [];
const bearingAz = new Map(input.bearings.map((r) => [r.no, r.azimuthDeg]));
const skewOf = (no: number, k: number) => rowSkewDeg(bearingAz.get(no) ?? NaN, axis.segments[k].u);
frontPlaced.forEach((b) => {
const bottom = segmentTop(segments, b.g);
blocks.push({ no: b.no, row: '전열', k: b.k, s: b.s, g: b.g, n: b.n, bottom, top: b.z - DIM.padThickness, padTop: b.z, rowSkewDeg: skewOf(b.no, b.k) });
});
backPlaced.forEach((b) => {
const bottom = b.z - h2;
blocks.push({ no: b.no, row: '후열', k: b.k, s: b.s, g: b.g, n: b.n, bottom, top: b.z - DIM.padThickness, padTop: b.z, rowSkewDeg: skewOf(b.no, b.k) });
});
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 = frontPlaced.map((f, k) => f.z - backPlaced[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,
copingL: L,
copingB: B,
copingRefTop: input.copingTopEl,
h1,
h2,
segments,
copingParts,
stretches: axis.segments.map((seg, k) => ({ origin: k === 0 ? bp1 : seg.start, u: seg.u, nrm: seg.nrm, base: base[k] })),
kink,
mitre: axis.mitre,
levelBottom,
endBottom,
copingGeom: { h1: copingH1 * 1000, h2: copingH2 * 1000, levelWidth: levelW * 1000 },
bearings: [...frontPlaced, ...backPlaced].map((b) => ({ no: b.no, row: b.row, k: b.k, s: b.s, g: b.g, 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 === '말뚝기초' ? layoutText(resolveRowCounts(input.pileRows, pileGeometry(footings[0].length)), pileGeometry(footings[0].length)) : 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;
}
/**
* Footings (spec §4.6). Adjacent columns closer than one footing length share a 일체기초 whose length is
* the distance between its outer columns + DIM.footingL, centred between them. Other columns get their own footing.
*/
export function footingGroups(colS: number[]): { s: number; length: number }[] {
const groups: number[][] = [];
for (const s of [...colS].sort((a, b) => a - b)) {
const last = groups[groups.length - 1];
if (last && s - last[last.length - 1] < DIM.footingL) last.push(s);
else groups.push([s]);
}
return groups.map((g) => ({
s: (g[0] + g[g.length - 1]) / 2,
length: g[g.length - 1] - g[0] + DIM.footingL,
}));
}
/**
* 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.
*/
/** Pile geometry of one footing, from the 1단계 constants (spec §4.6). */
export function pileGeometry(footingLength: number): PileGeometry {
return { footingLength, footingWidth: DIM.footingW, end: DIM.pileEnd, minSpacing: DIM.pileSpacing };
}
/**
* Piles under one footing (spec §4.6): one 열 per row across the width, each with its own count along the length.
* counts holds the 본수 of each 열 (see core/piles.ts).
*/
export function pilesFor(s: number, topEl: number, length: number, g: PileGeometry, counts: number[]): PileModel[] {
const rows = rowLayout(counts, g);
const spacingN = (g.footingWidth - 2 * g.end) / (counts.length - 1);
const out: PileModel[] = [];
rows.forEach((row, j) => {
for (let i = 0; i < row.count; i++) {
out.push({
s: s + (i - (row.count - 1) / 2) * row.spacing,
k: 0,
n: (j - (counts.length - 1) / 2) * spacingN,
top: topEl,
bottom: topEl - length,
diameter: DIM.pileDiameter,
});
}
});
return out;
}