Files
Pier_v1/src/core/geometry.ts
T
ypshimandClaude Haiku 5.5 bd84ac6176 fix(shape): 후열 계단 경계를 후열 받침 사이 중앙에 둔다
전·후열 받침 수가 같으면 후열 계단 경계를 전열 경계로 썼기 때문에, 후열 받침이 전열과 마주 보지 않는 교각
(산외교 P11 ⑥|⑦: 경계 12.75, 받침 11.64 · 18.49)에서 경계가 중앙에서 벗어났다. 후열도 자기 받침 사이 중앙을 쓰되,
전열 경계와 블럭 반폭(0.6 m) 이내로 가까우면 전열 경계에 맞춘다 (P1처럼 약간 엇갈린 열에 얇은 단이 생기지 않게).

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

655 lines
30 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 { buildIntegratedBlocks, type IntegratedBlock } from './integrated-block';
import { type BlockRebarSettings, DEFAULT_BLOCK_REBAR } from './rebar/bearing-block';
import { type CopingRebarSettings, copingDefaults, DEFAULT_TOP_SPLICE, type TopSpliceSettings } from './rebar/coping-settings';
import { type ColumnRebarSettings, DEFAULT_COLUMN_REBAR } from './rebar/column-settings';
import { type FootingRebarSettings, DEFAULT_FOOTING_REBAR } from './rebar/footing-settings';
import { layoutText, resolveRowCounts, rowLayout, type PileGeometry } from './piles';
import type { PierInput, Row, SolplatePlan } from './types';
import type { BearingPair } from './bearing-pair';
import { type BearingSole, SOLE_DEFAULTS, structureSoles } from './sole-plate';
import { type CopingGeom, P1_COPING_GEOM } from './coping-geom';
import { castShape } from './cast-in-place-shape';
/** Default Level width (mm) when no input: T형 2100 (P1), 2·3주형 D + 200 (spec §3, 2주형 DWG 2026-11-06). */
export function defaultLevelWidthMm(type: PierInput['type'], columnDiameterMm: number): number {
return type === 'T형' ? P1_COPING_GEOM.levelWidth : columnDiameterMm + DIM.multiLevelExtra * 1000;
}
/** 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 (spec §4.4, 3주형형상배근.md §2.2, 2주형 DWG 2026-11-06):
* from each Level end it rises concaveRise over the horizontal haunch length concaveHaunch, stays flat, and comes back down.
*/
concaveRise: 0.5,
concaveHaunch: 1.5,
/** Multi-column Level width = D + this (spec §3, 2주형 DWG). T형 keeps levelWidth (= D + 100 for P1). */
multiLevelExtra: 0.2,
padThickness: 0.05,
padSize: 1.0,
blockSize: 1.2,
/** W11 (제안 2026-11-10): 받침블럭 콘크리트(패드 제외) 최소 높이 = 피복 50 + D13 = 63 mm (교대 I01과 같은 기준). */
blockMinHeight: 0.063,
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;
/** Solplate of this bearing (2026-11-06): S from the girder pair, T per row, T1 on the 오르막 쪽. */
plan: SolplatePlan;
/** Block bottom from the 두부보 상면 slope (before any 교좌면 EL input), for the check against the input (2026-11-03). */
seatCalc: 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[];
/** Rear-row steps. Unequal rows use their own coordinate-based boundaries. */
backSegments?: 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;
/** Column diameter D (mm) used by the model and the column rebar. */
columnDiameterMm: number;
bearings: BearingLocal[];
blocks: BlockModel[];
/** 일체 블록 (승인 2026-10-26): empty unless 일체 is chosen and allowed. The per-bearing blocks stay for the solplate and pads. */
integratedBlocks: IntegratedBlock[];
/** 받침블럭 철근 설정 used by the rebar (승인 2026-10-26). */
blockRebar: BlockRebarSettings;
/** 코핑 배근 입력 used by the coping rebar (승인 2026-10-26). */
copingRebar: CopingRebarSettings;
/** 상면 주철근 이음 used by the coping rebar (승인 2026-10-26). */
topSplice: TopSpliceSettings;
/** 기둥 배근 입력 used by the column rebar (승인 2026-10-26). */
columnRebar: ColumnRebarSettings;
/** 확대기초 배근 입력 used by the footing rebar (승인 2026-10-26). */
footingRebar: FootingRebarSettings;
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;
};
/** Dedicated rebar source; never substitute footing-anchored column inputs. */
castInput?: PierInput;
/** Solplate of every bearing with its S source, pair and warnings (2026-11-06), for the ② card and checks. */
soles: BearingSole[];
}
/**
* 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.
*/
/** Bridge context for one pier: the girder pairs over A1, P1 … Pn, A2 (girderPairs), for the solplate slope S. */
export interface PierContext { pairs?: BearingPair[] }
export function buildPier(input: PierInput, ctx: PierContext = {}): 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;
// Column diameter (m): the input, with 2.0 m when absent (승인 2026-10-23).
const colD = (input.columnDiameterMm ?? DIM.columnDiameter * 1000) / 1000;
const levelW = (input.levelWidthMm ?? defaultLevelWidthMm(input.type, colD * 1000)) / 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('전열과 후열에 각각 받침이 1개 이상 필요합니다.');
const unequalRows = front.length !== back.length;
// 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) }));
if (unequalRows) { frontPlaced.sort((a,b)=>a.g-b.g); backPlaced.sort((a,b)=>a.g-b.g); }
// 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);
// 교좌면 EL per bearing (2026-11-10): when given, it is the coping top of that bearing's step; otherwise z − h1 (z − h2 behind).
const seatOf = new Map(input.bearings.map((r) => [r.no, r.seatEl]));
const segments: Segment[] = frontPlaced.map((b, k) => ({ s0: bounds[k], s1: bounds[k + 1], top: seatOf.get(b.no) ?? b.z - h1 }));
// Lateral step (spec §4.3, Q1·Q2 추천안): front and back tops differ by 5 mm or more in some segment.
// The back steps break midway between consecutive back bearings (2026-11-10), as the front ones do: back bearings need
// not sit opposite the front ones (산외교 P11 ⑥ ↔ ⑦). With equal counts, a back bound less than half a block from the
// front bound snaps to it, so slightly staggered rows (P1, ≈ 0.09 m) keep one boundary and no sliver step.
const backBound = (k: number) => {
const own = (backPlaced[k-1].g + backPlaced[k].g) / 2;
return !unequalRows && Math.abs(own - bounds[k]) < DIM.blockSize / 2 ? bounds[k] : own;
};
const backSegments: Segment[] = backPlaced.map((b,k) => ({
s0: k === 0 ? 0 : backBound(k),
s1: k === backPlaced.length-1 ? L : backBound(k + 1),
top: seatOf.get(b.no) ?? b.z-h2,
}));
const stepBounds = [...new Set([0,L,...segments.slice(1).map(s=>s.s0),...backSegments.slice(1).map(s=>s.s0)])].sort((a,b)=>a-b);
const stepSamples = stepBounds.slice(1).map((end,k)=>(stepBounds[k]+end)/2);
const lateralStep = stepSamples.some(s => Math.abs(segmentTop(segments,s)-segmentTop(backSegments,s)) >= 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, input.columnEndOffsetMm, input.columnSpacingMm);
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 is 오목형: from each Level end it rises
// h_r (500) over the horizontal haunch l_h (1500), stays flat, and comes back down (spec §4.4, 3주형형상배근.md §2.2).
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];
// RB-M01: no flat part left between the two haunches.
if (gap <= 2 * DIM.concaveHaunch + 1e-9) {
throw new Error(`기둥 사이 간격 ${Math.round(gap * 1000)} mm가 오목형 헌치(${DIM.concaveHaunch * 1000} × 2)보다 짧아 코핑 하면을 만들 수 없습니다 (RB-M01).`);
}
const raisedEl = levelBottom + DIM.concaveRise;
underside.push([zoneStart - DIM.concaveHaunch, raisedEl], [next[1] + DIM.concaveHaunch, raisedEl], [next[1], levelBottom]);
});
// Elevation outline in coping coordinates for one row of tops, closed by the shared underside.
const outline = (steps: Segment[]): [number, number][] => {
const pts: [number, number][] = [[0, endBottom]];
steps.forEach(seg => pts.push([seg.s0, seg.top], [seg.s1, seg.top]));
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 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) {
// The front row may lie on either side of the axis (it depends on the IP1 → IP3 direction), so each part takes the
// side of its own row; the order stays [front, back].
const frontLow = (fRows.length && bRows.length ? meanN(fRows) - meanN(bRows) : meanN(frontPlaced) - meanN(backPlaced)) <= 0;
copingParts.push(
{ k, nFrom: frontLow ? -planHalfB : nStep, nTo: frontLow ? nStep : planHalfB, profile: shifted(outline(segments), base[k]) },
{ k, nFrom: frontLow ? nStep : -planHalfB, nTo: frontLow ? planHalfB : nStep, profile: shifted(outline(backSegments), base[k]) },
);
} else {
copingParts.push({ k, nFrom: -planHalfB, nTo: planHalfB, profile: shifted(outline(segments), base[k]) });
}
}
const fd = footingDims(input);
// 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), fd.L)) {
footingSets.push({ s: g.s - base[k], k, length: g.length });
}
}
const cast = castShape(input);
if (input.foundationType === '현장타설말뚝' && (!Number.isFinite(cast.pileTopEl) || cast.pileTopEl >= levelBottom || !Number.isFinite(input.pileLengthMm) || input.pileLengthMm <= 0)) throw new Error('현장타설말뚝은 지반고가 코핑 Level 하면보다 낮고 말뚝 길이가 양수여야 합니다.');
const footingTop = input.foundationType === '현장타설말뚝' ? cast.pileTopEl : input.footingBottomEl + fd.H;
const columns: ColumnModel[] = colG.map((g, i) => ({
s: g - base[colK[i]],
k: colK[i],
top: levelBottom,
bottom: footingTop,
diameter: colD,
}));
const footings: FootingModel[] = [];
const blindings: BlindingModel[] = [];
const piles: PileModel[] = [];
const pileLength = input.pileLengthMm / 1000;
const pileTip = input.foundationType === '현장타설말뚝' ? cast.pileBottomEl : input.footingBottomEl - pileLength;
if (input.foundationType === '현장타설말뚝') {
columns.forEach((c) => {
piles.push({ s: c.s, k: c.k, n: 0, top: cast.pileTopEl, bottom: pileTip, diameter: colD });
});
} else {
for (const f of footingSets) {
footings.push({ s: f.s, k: f.k, top: footingTop, bottom: input.footingBottomEl, length: f.length, width: fd.W });
blindings.push({
s: f.s,
k: f.k,
top: input.footingBottomEl,
length: f.length + fd.blM,
width: fd.W + fd.blM,
thickness: fd.blT,
});
if (input.foundationType === '말뚝기초') {
const g = pileGeometry(f.length, fd.W);
const counts = resolveRowCounts(input.pileRows, g);
piles.push(...pilesFor(f.s, input.footingBottomEl, pileLength, g, counts, fd.pileD).map((p) => ({ ...p, k: f.k })));
}
}
}
const blocks: BlockModel[] = [];
// 계산 방위각을 적용한 받침 (2026-11-06): that value turns the block, bearing and solplate; the workbook value stays in input.
const azimuthOf = (r: { no: number; azimuthDeg: number }) => input.azimuthApplied?.[String(r.no)] ?? r.azimuthDeg;
const bearingAz = new Map(input.bearings.map((r) => [r.no, azimuthOf(r)]));
const skewOf = (no: number, k: number) => rowSkewDeg(bearingAz.get(no) ?? NaN, axis.segments[k].u);
// Solplate of every bearing (결정 2026-11-06): S from the girder pair (ctx.pairs over the whole bridge), else the
// 종단경사 input, else the other row; T per row; the girder underside stays, so the block top drops by T − T_ref.
// 받침높이 per bearing (2026-11-10): h3 = H − T_ref keeps the girder underside at z + H; a typed h3 still wins.
const tRef = input.sole?.tRefMm ?? SOLE_DEFAULTS.tRefMm;
const h3ByNo = Object.fromEntries(input.bearings.filter((b) => b.heightMm !== undefined).map((b) => [String(b.no), b.heightMm! - tRef]));
const soles = new Map(structureSoles(input.name, input.bearings.map((b) => ({ no: b.no, row: b.row, azimuthDeg: azimuthOf(b), inputAzimuthDeg: b.azimuthDeg })),
{ ...input.sole, h3ByNo }, ctx.pairs ?? [], input.longitudinalSlope === undefined ? undefined : input.longitudinalSlope * 100).map((s) => [s.no, s]));
const planOf = (no: number, k: number, skewDeg: number): SolplatePlan => {
const s = soles.get(no)!;
// The block's c axis in (E, N): (−sin d)·u + (cos d)·nrm; T1 goes to the end of c that points uphill.
const { u, nrm } = axis.segments[k];
const d = (skewDeg * Math.PI) / 180;
const c = { e: -Math.sin(d) * u.e + Math.cos(d) * nrm.e, n: -Math.sin(d) * u.n + Math.cos(d) * nrm.n };
const t1Side = s.uphill && c.e * s.uphill.e + c.n * s.uphill.n > 0 ? 1 : -1;
return { L: s.L, W: s.W, slopePct: s.slopePct, T: s.T, T1: s.T1, T2: s.T2, h3: s.h3, t1Side };
};
const dropOf = (no: number) => soles.get(no)!.dropMm / 1000;
// 교좌면 EL (2026-11-03): when the input gives it, the block bottom is that EL; otherwise the 두부보 상면 slope.
// seatCalc stays the slope value (z − h1, z − h2) as the W10 reference.
frontPlaced.forEach((b) => {
const seatCalc = b.z - h1;
const bottom = seatOf.get(b.no) ?? seatCalc;
const skew = skewOf(b.no, b.k), drop = dropOf(b.no);
blocks.push({ no: b.no, row: '전열', k: b.k, s: b.s, g: b.g, n: b.n, bottom, top: b.z - DIM.padThickness - drop, padTop: b.z - drop, rowSkewDeg: skew, plan: planOf(b.no, b.k, skew), seatCalc });
});
backPlaced.forEach((b) => {
const seatCalc = b.z - h2;
const bottom = seatOf.get(b.no) ?? seatCalc;
const skew = skewOf(b.no, b.k), drop = dropOf(b.no);
blocks.push({ no: b.no, row: '후열', k: b.k, s: b.s, g: b.g, n: b.n, bottom, top: b.z - DIM.padThickness - drop, padTop: b.z - drop, rowSkewDeg: skew, plan: planOf(b.no, b.k, skew), seatCalc });
});
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 = unequalRows ? stepSamples.map(s=>segmentTop(segments,s)-segmentTop(backSegments,s)) : 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,
backSegments,
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 },
columnDiameterMm: colD * 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,
integratedBlocks: buildIntegratedBlocks(blocks, input.bearingLayout === '일체'),
blockRebar: { ...DEFAULT_BLOCK_REBAR, ...input.blockRebar },
copingRebar: { ...copingDefaults(columns.length), ...input.copingRebar },
topSplice: { ...DEFAULT_TOP_SPLICE, ...input.topSplice },
columnRebar: { ...DEFAULT_COLUMN_REBAR, ...input.columnRebar },
footingRebar: { ...DEFAULT_FOOTING_REBAR, ...input.footingRebar },
columns,
footings,
blindings,
piles,
groundEl: input.groundEl,
derived: {
columnHeight: levelBottom - columns[0].bottom,
soilCover: input.foundationType === '현장타설말뚝' ? 0 : input.groundEl - footingTop,
pileCount: piles.length,
pileLayout: input.foundationType === '말뚝기초' ? layoutText(resolveRowCounts(input.pileRows, pileGeometry(footings[0].length, fd.W)), pileGeometry(footings[0].length, fd.W)) : null,
pileTipEl: piles.length ? pileTip : null,
blockHeightFront,
blockHeightBack,
frontBackDiffSpread,
},
soles: [...soles.values()],
...(input.foundationType === '현장타설말뚝' ? { castInput: { ...input } } : {}),
};
}
/**
* Column centres along the coping (m). spec §4.5: L/5 · (L/2) · 4L/5. For 2·3주형 the 끝 여유 e_c and 간격 s_c may be typed
* (Q-M10 미결, 3주형형상배근.md §2.1): e_c + k·s_c, s_c defaulting to the even spacing between e_c and L − e_c.
*/
export function columnPositions(type: PierInput['type'], L: number, endOffsetMm?: number, spacingMm?: number): number[] {
if (type === 'T형') return [L / 2];
const n = type === '2주형' ? 2 : 3;
if (endOffsetMm !== undefined) {
const e = endOffsetMm / 1000;
const step = spacingMm !== undefined ? spacingMm / 1000 : (L - 2 * e) / (n - 1);
return Array.from({ length: n }, (_, k) => e + k * step);
}
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[], footL: number = DIM.footingL): { 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] < footL) 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] + footL,
}));
}
/**
* 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, footingWidth: number = DIM.footingW): PileGeometry {
return { footingLength, footingWidth, end: DIM.pileEnd, minSpacing: DIM.pileSpacing };
}
/** Footing and pile dimensions in m: the input when given, else the 1단계 constants (승인 2026-10-23). */
export function footingDims(input: PierInput): { L: number; W: number; H: number; blM: number; blT: number; pileD: number } {
return {
L: (input.footingLengthMm ?? DIM.footingL * 1000) / 1000,
W: (input.footingWidthMm ?? DIM.footingW * 1000) / 1000,
H: (input.footingHeightMm ?? DIM.footingH * 1000) / 1000,
// 비림 여유 is the margin on each side (삽도 한쪽 100); the blinding grows by twice that on each length and width.
blM: (2 * (input.blindingMarginMm ?? DIM.blindingMargin * 500)) / 1000,
blT: (input.blindingThicknessMm ?? DIM.blindingT * 1000) / 1000,
pileD: (input.pileOuterMm ?? DIM.pileDiameter * 1000) / 1000,
};
}
/**
* 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[], diameter: number = DIM.pileDiameter): 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,
});
}
});
return out;
}