feat(bearing): 교각 솔플레이트를 2026-11-06 결정으로 (짝 받침 S · 열 T · 거더 하면 고정)

받침방위각_솔플레이트검토.md §3과 시안(Version 110–113)을 따른다 (사용자 결정 2026-11-06: 시안 적용).
11-03 C안(받침높이 H 고정, h3 = H − T, S 입력)을 대체한다.
- S: 교량 전체(A1, P1 … Pn, A2) 거더 짝 받침의 z 차 / 수평거리. 없으면 교각 종단경사 입력 →
  다른 열 같은 순번 값(가정, WS03) → 0 %(WS03). 받침별 직접 수정(수정됨)
- 평면 L = A + 100, W = B + 100 (받침별 A × B, 기본 700 × 640 가정), h3 181
- T = 열마다 max(22, ⌈22 + L·|S|/2⌉), 열 T 직접 수정 가능(T2 < T_min → WS02).
  T1은 오르막 쪽(3D 쐐기의 두꺼운 끝도 그쪽), T2 반대쪽
- 거더 하면 고정: 블럭 상면 = z − (T − T_ref), T_ref 30
- 짝 받침은 불러오기·프로젝트 열기 때 교량 전체로 계산해 교각 생성에 넘김
- ② 카드: h3·T_ref·T_min·여유·열 T·종단경사 입력, 표에 짝 받침·S 근거·받침별 S/A/B 수정·검산 W08·검사
- P1 검증(A1과 함께): S −1.908 … −1.902 %, L 800 · W 740 · T 30 · T1 38 · T2 22, 블럭 높이 변화 없음
- 블럭·솔플레이트 테스트는 A1–P1 짝 문맥으로 실행 (짝이 없으면 S 0 · T 22 · 블럭 +8 mm)
교대(QA-15 C안)와 Pier 시트 종단경사 열 읽기는 아직 남음.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ypshimandClaude Opus 5.5 committed 2026-11-06 14:48:10 +09:00
1 parent 5fec2743cb
commit a0b0929e13
19 files changed
+486 -109

No files matched your search

+1 -2
View File
@@ -1,7 +1,6 @@
/**
* 거더 짝 받침 · 종단경사 S · 솔플레이트 열 두께 (결정 2026-11-06, RAG/받침방위각_솔플레이트검토.md §3, Q-SH1·4·8·9).
* Pure. Not wired into the model yet: the height rule (Q-SH7, 블럭 상면 = z − (T − T_ref)) conflicts with the 2026-11-03
* C안 already in use (받침높이 H 고정, h3 = H − T, src/core/solplate-rule.ts), so only the parts both rules need live here.
* Pure. Used by src/core/sole-plate.ts (pier solplates, 2026-11-06) through src/core/bridge-pairs.ts.
*
* Frame: (E, N) in m as in the model (x = E, y = N). Azimuth: the model's 진북 방위각 (deg, clockwise from north), i.e. the
* workbook CAD angle θ_CAD converted by 90 − θ_CAD. A girder at azimuth θ runs along (sin θ, cos θ).
+18 -2
View File
@@ -10,6 +10,7 @@
* along 교각축.
* - S1·S2: block rebar bars from the model (bearing-block): count and bar length. For 일체 the pair's bars sit on the front row.
*/
import type { SlopeSource } from './sole-plate';
import type { PierModel } from './geometry';
import { bearingEdgeDistances, pairEccentricities } from './bearing-layout';
import { INTEGRATED_BLOCK } from './integrated-block';
@@ -54,12 +55,24 @@ export interface SolplateRow {
T: number;
T1: number;
T2: number;
/** Check: T1 − T2 against L·|S|/100 (mm). */
/** Check W08: T1 − T2 against L·|S|/100 (mm), within 1 mm (T1 and T2 are rounded to whole mm). */
checkLhs: number;
checkRhs: number;
ok: boolean;
/** 짝 받침 (같은 거더), e.g. "A1-①"; absent when none (2026-11-06). */
pair?: string;
/** Where S comes from: 계산 (짝) / 입력 (종단경사) / 가정 (다른 열) / 수정됨 / 없음. */
source: SlopeSource;
/** T typed by the user (수정됨) rather than automatic. */
tTyped: boolean;
/** 받침 평면 A × B (mm). */
A: number;
B: number;
warnings: string[];
}
const CIRCLED_NO = ['①', '②', '③', '④', '⑤', '⑥', '⑦', '⑧', '⑨', '⑩', '⑪', '⑫', '⑬', '⑭', '⑮', '⑯', '⑰', '⑱', '⑲', '⑳'];
function barSummary(bars: BarRow[], name: 'S1' | 'S2'): BarSummary | null {
const hit = bars.filter((x) => x.name === name);
if (hit.length === 0) return null;
@@ -112,9 +125,12 @@ export function solplateRows(m: PierModel): SolplateRow[] {
.sort((a, b) => a.no - b.no)
.map((b) => {
const p = b.plan;
const s = m.soles.find((x) => x.no === b.no)!;
const checkLhs = p.T1 - p.T2;
const checkRhs = (p.L * Math.abs(p.slopePct)) / 100;
return {
pair: s.pair ? `${s.pair.structure}-${CIRCLED_NO[s.pair.no - 1] ?? s.pair.no}` : undefined,
source: s.slopeSource, tTyped: s.tTyped, A: s.aMm, B: s.bMm, warnings: s.warnings,
no: b.no,
S: p.slopePct,
L: p.L,
@@ -124,7 +140,7 @@ export function solplateRows(m: PierModel): SolplateRow[] {
T2: p.T2,
checkLhs,
checkRhs,
ok: Math.abs(checkLhs - checkRhs) < 1e-6,
ok: Math.abs(checkLhs - checkRhs) <= 1 + 1e-9,
};
});
}
+22
View File
@@ -0,0 +1,22 @@
/**
* Girder pairs over the whole bridge (A1, P1 … Pn, A2 — structureOrder) for the solplate slope S (결정 2026-11-06, Q-SH1).
* Pure. Bearing coordinates and z cannot be edited on the screens, so the pairs change only when structures are loaded.
*/
import type { AbutRecord } from './abut/parse';
import { structureOrder } from './abut/order';
import { type BearingPair, girderPairs, type PairStructure } from './bearing-pair';
import type { PierInput } from './types';
export function bridgePairs(piers: Pick<PierInput, 'name' | 'bearings'>[], abuts: AbutRecord[]): BearingPair[] {
const byPier = new Map(piers.map((p) => [p.name, p]));
const byAbut = new Map(abuts.map((a) => [a.name, a]));
const structures: PairStructure[] = structureOrder(abuts, piers.map((p) => p.name)).map((ref) => {
if (ref.kind === 'pier') {
const p = byPier.get(ref.name)!;
return { name: p.name, bearings: p.bearings.filter((b) => Number.isFinite(b.azimuthDeg)).map((b) => ({ no: b.no, x: b.x, y: b.y, z: b.z, azimuthDeg: b.azimuthDeg })) };
}
const a = byAbut.get(ref.name)!;
return { name: a.name, bearings: a.bearings.filter((b) => b.azimuthDeg !== undefined).map((b) => ({ no: b.no, x: b.pos.e, y: b.pos.n, z: b.z, azimuthDeg: b.azimuthDeg! })) };
});
return girderPairs(structures);
}
+28 -7
View File
@@ -6,7 +6,8 @@ import { type ColumnRebarSettings, DEFAULT_COLUMN_REBAR } from './rebar/column-s
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 { SOLPLATE_RULE_DEFAULT, solplatePlan } from './solplate-rule';
import type { BearingPair } from './bearing-pair';
import { type BearingSole, structureSoles } from './sole-plate';
import { type CopingGeom, P1_COPING_GEOM } from './coping-geom';
import { castShape } from './cast-in-place-shape';
@@ -87,7 +88,7 @@ export interface BlockModel {
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 (C안, 2026-11-03): the pier's rule, the same for every bearing. */
/** 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;
@@ -221,6 +222,8 @@ export interface PierModel {
};
/** 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[];
}
/**
@@ -245,7 +248,10 @@ export function solveIp4(ip1: Vec2, ip2: Vec2, ip3: Vec2, angleDeg: number): Vec
* 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 {
/** 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}`);
@@ -455,19 +461,33 @@ export function buildPier(input: PierInput): PierModel {
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);
// Solplate of every bearing from the pier's rule (C안, 2026-11-03): the same plan for each bearing.
const plan = solplatePlan(input.solplate ?? SOLPLATE_RULE_DEFAULT);
// 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.
const soles = new Map(structureSoles(input.name, input.bearings.map((b) => ({ no: b.no, row: b.row, azimuthDeg: b.azimuthDeg })),
input.sole, 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.
const seatOf = new Map(input.bearings.map((r) => [r.no, r.seatEl]));
frontPlaced.forEach((b) => {
const seatCalc = segmentTop(segments, b.g);
const bottom = seatOf.get(b.no) ?? seatCalc;
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), plan, 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;
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), plan, 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;
@@ -526,6 +546,7 @@ export function buildPier(input: PierInput): PierModel {
blockHeightBack,
frontBackDiffSpread,
},
soles: [...soles.values()],
...(input.foundationType === '현장타설말뚝' ? { castInput: { ...input } } : {}),
};
}
+140
View File
@@ -0,0 +1,140 @@
/**
* 솔플레이트 (결정 2026-11-06, RAG/받침방위각_솔플레이트검토.md §3, spec.md §5.3, 시안 Version 110–113). Pure.
* Replaces the 2026-11-03 C안 (받침높이 H 고정, h3 = H − T, S 입력) by the user's decision to follow the 시안:
*
* - 평면: L = A + 여유 (거더 방향 = 방위각), W = B + 여유 (여유 100). A × B per bearing; absent = 700 × 640 (P1 전열 값, 가정).
* - S: 같은 거더 양끝 받침의 z 차 / 수평거리 (girderPairs). 없으면 교각 종단경사 입력 → 같은 구조물 다른 열의 같은 순번 값
* ("가정", WS03) → 0 % (WS03). 받침별 직접 수정 = "수정됨".
* - T: 열마다 max(T_min, ⌈max(T_min + L·|S|/2)⌉). 직접 고치면 "수정됨", T2 < T_min이면 WS02.
* T1 = round(T + L·|S|/2) 오르막 쪽, T2 = round(T − L·|S|/2).
* - 높이: 거더 하면 고정 — 블럭 상면 = z − (T − T_ref); 받침 본체 h3 고정 (181, P1 상세도).
*/
import { type BearingPair, pairOf } from './bearing-pair';
export interface SoleSettings {
/** 받침 본체 높이 h3 (mm). */
h3Mm?: number;
/** T_ref: the solplate thickness the workbook z assumed (mm). */
tRefMm?: number;
/** T_min: the thin-end minimum (mm). */
tMinMm?: number;
/** 솔플 여유 L − A = W − B (mm). */
marginMm?: number;
/** 받침 평면 A (거더 방향) × B per bearing number (mm). */
plates?: Record<string, { aMm: number; bMm: number }>;
/** T per row, when typed (mm). Absent = 자동. */
tByRow?: Partial<Record<string, number>>;
/** S per bearing number, when typed (%). */
slopePct?: Record<string, number>;
}
export const SOLE_DEFAULTS = { h3Mm: 181, tRefMm: 30, tMinMm: 22, marginMm: 100, aMm: 700, bMm: 640 } as const;
export type SlopeSource = '계산' | '입력' | '가정' | '수정됨' | '없음';
export interface BearingSole {
no: number;
row: string;
pair?: { structure: string; no: number };
slopePct: number;
slopeSource: SlopeSource;
aMm: number;
bMm: number;
/** L along the girder, W across it (mm). */
L: number;
W: number;
/** Row thickness T and whether it was typed; T1 on the 오르막 쪽, T2 on the other end (mm). */
T: number;
tTyped: boolean;
T1: number;
T2: number;
/** This bearing's requirement T_min + L·|S|/2 (mm). */
tReq: number;
h3: number;
/** Block top drop T − T_ref (mm, + = the block gets lower). */
dropMm: number;
/** Unit vector (E, N) towards the 오르막 쪽, or null when S = 0. */
uphill: { e: number; n: number } | null;
warnings: ('WS01' | 'WS02' | 'WS03' | 'WS04')[];
}
export interface SoleBearing {
no: number;
row: string;
azimuthDeg: number;
}
const DEG = Math.PI / 180;
/**
* Solplates of one structure. `pairs` are the girder pairs of the whole bridge (girderPairs over A1, P1 … Pn, A2);
* `fallbackSlopePct` is the structure's 종단경사 input (%), when given.
*/
export function structureSoles(structure: string, bearings: SoleBearing[], settings: SoleSettings = {}, pairs: BearingPair[] = [], fallbackSlopePct?: number): BearingSole[] {
const s = { ...SOLE_DEFAULTS, ...settings };
const rows = [...new Set(bearings.map((b) => b.row))];
const order = (b: SoleBearing) => bearings.filter((q) => q.row === b.row).indexOf(b);
const paired = new Map(bearings.map((b) => [b.no, pairOf(pairs, structure, b.no)]));
const pairSlope = (b: SoleBearing) => paired.get(b.no)?.slopePct;
const slopes = bearings.map((b) => {
const typed = settings.slopePct?.[String(b.no)];
if (typed !== undefined) return { slopePct: typed, source: '수정됨' as SlopeSource };
const own = pairSlope(b);
if (own !== undefined) return { slopePct: own, source: '계산' as SlopeSource };
if (fallbackSlopePct !== undefined) return { slopePct: fallbackSlopePct, source: '입력' as SlopeSource };
// The same position in another row that has a pair (시안: 후열 ⑥ ← 전열 ①).
for (const other of rows.filter((r) => r !== b.row)) {
const twin = bearings.filter((q) => q.row === other)[order(b)];
const v = twin && pairSlope(twin);
if (v !== undefined) return { slopePct: v, source: '가정' as SlopeSource };
}
return { slopePct: 0, source: '없음' as SlopeSource };
});
const plate = (b: SoleBearing) => s.plates?.[String(b.no)] ?? { aMm: s.aMm, bMm: s.bMm };
const req = bearings.map((b, i) => s.tMinMm + ((plate(b).aMm + s.marginMm) * Math.abs(slopes[i].slopePct)) / 200);
const rowT = new Map(rows.map((r) => {
const typed = settings.tByRow?.[r];
const auto = Math.max(s.tMinMm, Math.ceil(Math.max(...bearings.map((b, i) => (b.row === r ? req[i] : -Infinity))) - 1e-9));
return [r, { T: typed ?? auto, typed: typed !== undefined }];
}));
return bearings.map((b, i) => {
const { aMm, bMm } = plate(b);
const L = aMm + s.marginMm, W = bMm + s.marginMm;
const { slopePct, source } = slopes[i];
const { T, typed } = rowT.get(b.row)!;
const half = (L * Math.abs(slopePct)) / 200;
const T1 = Math.round(T + half), T2 = Math.round(T - half);
const pair = paired.get(b.no);
const warnings: BearingSole['warnings'] = [...(pair?.warnings ?? [])];
if (T2 < s.tMinMm) warnings.push('WS02');
if (source === '가정' || source === '없음') warnings.push('WS03');
// S runs along the structure order, which is the azimuth direction (girderPairs looks ahead along it).
const dir = { e: Math.sin(b.azimuthDeg * DEG), n: Math.cos(b.azimuthDeg * DEG) };
const sign = Math.sign(slopePct);
return {
no: b.no, row: b.row, pair: pair ? (pair.from.structure === structure ? pair.to : pair.from) : undefined,
slopePct, slopeSource: source, aMm, bMm, L, W, T, tTyped: typed, T1, T2, tReq: req[i], h3: s.h3Mm,
dropMm: T - s.tRefMm, uphill: sign ? { e: sign * dir.e, n: sign * dir.n } : null, warnings,
};
});
}
/** Input problems of the settings, in Korean; empty when they can build solplates. */
export function soleSettingsIssues(settings: SoleSettings | undefined): string[] {
if (!settings) return [];
const out: string[] = [];
const positive = (v: unknown, label: string) => {
if (v !== undefined && !(typeof v === 'number' && Number.isFinite(v) && v > 0)) out.push(`${label}은(는) 0보다 커야 합니다.`);
};
positive(settings.h3Mm, '받침 높이 h3');
positive(settings.tRefMm, 'T_ref');
positive(settings.tMinMm, 'T_min');
if (settings.marginMm !== undefined && !(Number.isFinite(settings.marginMm) && settings.marginMm >= 0)) out.push('솔플 여유는 0 이상이어야 합니다.');
for (const [no, p] of Object.entries(settings.plates ?? {})) { positive(p.aMm, `받침 ${no} A`); positive(p.bMm, `받침 ${no} B`); }
for (const [row, t] of Object.entries(settings.tByRow ?? {})) positive(t, `${row} T`);
for (const [no, v] of Object.entries(settings.slopePct ?? {})) if (!Number.isFinite(v)) out.push(`받침 ${no} S는 숫자여야 합니다.`);
return out;
}
+6 -4
View File
@@ -1,7 +1,7 @@
/**
* 솔플레이트 기하 (승인 2026-10-23, C안 2026-11-03). Pure: the solplate of one bearing, from its block (position, rowSkew)
* and the pier's solplate plan. The plan is L across the 교각축 직각 (n) and W along the 교축 (s). The top tapers across L:
* the thick end T1 is at the (−) side of the 교각축 직각 direction (n = −L/2), the thin end T2 at (+) (n = +L/2).
* 솔플레이트 기하 (승인 2026-10-23, 2026-11-06 결정). Pure: the solplate of one bearing, from its block (position, rowSkew)
* and the bearing's solplate plan. L runs along the block's c axis (the girder, by the azimuth) and W along a. The top
* tapers across L: the thick end T1 is on the 오르막 쪽 (c = t1Side·L/2), the thin end T2 at the other end.
* The underside is the bearing top (block top EL.X + h3). Lengths in m, as in the model.
*/
import { blockFramePoint, type BlockModel } from './geometry';
@@ -29,7 +29,9 @@ export function solplateGeometry(block: BlockModel, plan: SolplatePlan): Solplat
const T1 = plan.T1 / 1000;
const T2 = plan.T2 / 1000;
const bottom = block.padTop + plan.h3 / 1000;
const thickAt = (c: number) => T1 + ((T2 - T1) * (c + L / 2)) / L;
// T1 at c = t1Side·L/2 (the 오르막 쪽), T2 at the other end.
const side = plan.t1Side ?? -1;
const thickAt = (c: number) => T1 + ((T2 - T1) * (side * -c + L / 2)) / L;
const corners: [number, number, number][] = [];
for (const a of [-W / 2, W / 2]) {
for (const c of [-L / 2, L / 2]) {
+10 -3
View File
@@ -1,3 +1,4 @@
import type { SoleSettings } from './sole-plate';
import type { CopingRebarSettings, TopSpliceSettings } from './rebar/coping-settings';
import type { ColumnRebarSettings } from './rebar/column-settings';
import type { FootingRebarSettings } from './rebar/footing-settings';
@@ -62,8 +63,12 @@ export interface PierInput {
pileThicknessMm?: number;
/** 말뚝 종류 (규격 목록): 강관 (default) or PHC. */
pileKind?: '강관' | 'PHC';
/** 솔플레이트 규칙 of this pier (C안, 2026-11-03). Absent = the default (211 · 1000 × 1100 · −0.5 %). */
/** Obsolete C안 (2026-11-03) rule: kept so that old project files still open; no longer used (replaced by `sole`, 2026-11-06). */
solplate?: SolplateRule;
/** 솔플레이트 입력 (결정 2026-11-06, 시안): h3, T_ref, T_min, 여유, 받침별 A × B, 열 T·받침 S 직접 수정. Absent = 기본값. */
sole?: SoleSettings;
/** 교각 종단경사 (소수, (−) = 교량진행방향으로 낮아짐; Pier 시트 "종단경사" 열, Q-SH2). 짝 받침이 없을 때 S에 쓴다. */
longitudinalSlope?: number;
/** 지반·지층 (승인 2026-10-26): 위치, 지하수위 고려와 EL, 지층별 행 (위에서 아래, 행의 EL = 그 층의 하단 EL).
* Absent = 육상, 지하수위 없음, 지층 행 없음. */
groundPosition?: GroundPosition;
@@ -109,15 +114,17 @@ export interface SolplateRule {
slopePct: number;
}
/** The solplate every bearing of a pier gets from its rule (mm): T is the centre thickness, h3 = H − T. */
/** The solplate of one bearing (mm): T is the row thickness, T1 the 오르막 쪽 end, h3 the bearing body (2026-11-06). */
export interface SolplatePlan {
L: number;
W: number;
slopePct: number;
T: number;
/** Thickness at the (−) side (T1) and the (+) side (T2) of the 교각축 직각 direction. */
/** Thickness at the 오르막 쪽 (T1) and the other end (T2). */
T1: number;
T2: number;
/** Side of the block's c axis (L direction) where T1 sits: −1 = c = −L/2, +1 = c = +L/2. Absent = −1. */
t1Side?: -1 | 1;
/** Bearing body height h3 (mm) between the mortar top and the solplate underside. */
h3: number;
}
+111 -39
View File
@@ -119,8 +119,10 @@ import { columnDiameterIssues } from './core/column-check';
import { findPileSpec, PHC_PILES, STEEL_PILES } from './core/pile-spec';
import { footingPlanSvg, footingSideSvg } from './view/footing-svg';
import { P1_COPING_GEOM } from './core/coping-geom';
import { SOLPLATE_MIN_T2_MM, SOLPLATE_RULE_DEFAULT, solplatePlan, solplateRuleIssues } from './core/solplate-rule';
import type { SolplateRule } from './core/types';
import { SOLE_DEFAULTS, type SoleSettings, soleSettingsIssues } from './core/sole-plate';
import { SOLPLATE_MIN_T2_MM } from './core/solplate-rule';
import { bridgePairs } from './core/bridge-pairs';
import type { BearingPair } from './core/bearing-pair';
import { type BarSummary, elevationRows, solplateRows } from './core/bearing-table';
/** 지반 모델 (ground_model.json) for the project. Its values reach a pier's 지반고 only when the user applies them. */
@@ -254,6 +256,8 @@ const state = {
pending: null as Pending | null,
source: { fileName: '', sheets: [] as string[] },
history: [] as ProjectFile['history'],
/** Girder pairs over the bridge (A1, P1 … Pn, A2) for the solplate S (2026-11-06); recomputed when structures load. */
bridgePairs: [] as BearingPair[],
/** Snapshot of the inputs at the last save or open. null until the project is first saved. */
savedSnapshot: null as string | null,
};
@@ -396,12 +400,12 @@ function buildEntry(rawInput: PierInput, checked: boolean): Pier {
const input = withCopingDefaults(rawInput);
const issues: Issue[] = [];
let model: PierModel | null = null;
// 솔플레이트 규칙 (C안, 2026-11-03): a rule that cannot make a solplate is reported, and no model is built from it.
const solIssues = solplateRuleIssues(input.solplate ?? SOLPLATE_RULE_DEFAULT);
// 솔플레이트 입력 (2026-11-06): settings that cannot make a solplate are reported, and no model is built from them.
const solIssues = soleSettingsIssues(input.sole);
for (const message of solIssues) issues.push({ code: 'S01', level: 'E', pier: input.name, message });
if (validateInput(input, issues) && solIssues.length === 0) {
try {
model = buildPier(input);
model = buildPier(input, { pairs: state.bridgePairs });
validatePier(input, model, issues);
for (const message of columnDiameterIssues(input.columnDiameterMm ?? 2000, model)) {
issues.push({ code: 'D01', level: 'E', pier: input.name, message });
@@ -5116,20 +5120,50 @@ function renderElevationTable(wrap: HTMLDivElement, pier: Pier): void {
wrap.appendChild(el('div', undefined, 'table-scroll')).appendChild(table);
}
/** 솔플레이트 TABLE (모델값, mm, 2026-11-03): S, L, W, T, T1, T2 and the check T1 − T2 ≈ L·|S|/100. */
/**
* 솔플레이트 TABLE (모델값, mm, 2026-11-06): 짝 받침, S와 근거, A × B, L, W, T, T1 (오르막 쪽), T2, 검산 W08, 검사.
* S, A and B can be typed per bearing (수정됨); clearing the cell goes back to the computed or default value.
*/
function renderSolplateTable(wrap: HTMLDivElement, pier: Pier): void {
if (!pier.model) return;
wrap.appendChild(el('p', '솔플레이트 TABLE (모델값 · mm)', 'lbl'));
wrap.appendChild(el('p', '솔플레이트 TABLE (모델값 · mm) · S는 같은 거더 양끝 받침에서 계산', 'lbl'));
const table = el('table', undefined, 'grid-table rebar-table');
const head = el('thead');
const hr = el('tr');
for (const h of ['No', 'S (%)', 'L (교직)', 'W (교축)', 'T (중심)', 'T1 (−측)', 'T2 (+측)', '검산 T1 − T2 ≈ L·|S|/100']) hr.appendChild(el('th', h));
for (const h of ['No', '짝 받침', 'S (%)', 'S 근거', 'A', 'B', 'L', 'W', 'T', 'T1 (오르막 쪽)', 'T2', '검산 T1 − T2 ≈ L·|S|/100', '검사']) hr.appendChild(el('th', h));
head.appendChild(hr);
table.appendChild(head);
const body = el('tbody');
const settings = pier.draft.sole ?? {};
const cellInput = (value: number, typed: boolean, label: string, apply: (v: number | undefined) => void) => {
const input = el('input');
input.type = 'number'; input.step = 'any'; input.value = String(value); input.setAttribute('aria-label', label);
input.style.width = '6.5em';
if (typed) input.classList.add('changed-input');
input.addEventListener('change', () => apply(input.value.trim() === '' ? undefined : Number(input.value)));
const td = el('td'); td.appendChild(input); return td;
};
for (const r of solplateRows(pier.model)) {
const key = String(r.no);
const tr = el('tr');
const cells = [String(r.no), r.S.toFixed(2), String(r.L), String(r.W), String(r.T), r.T1.toFixed(1), r.T2.toFixed(1), `${r.checkLhs.toFixed(1)} ≈ ${r.checkRhs.toFixed(1)} ${r.ok ? '일치' : '불일치'}`];
tr.append(el('td', String(r.no)), el('td', r.pair ?? '없음'));
tr.appendChild(cellInput(Math.round(r.S * 1000) / 1000, r.source === '수정됨', `받침 ${r.no} S (%)`, (v) => {
const next = { ...(settings.slopePct ?? {}) };
if (v === undefined) delete next[key]; else next[key] = v;
setSole(pier.input.name, { ...settings, slopePct: next });
}));
tr.appendChild(el('td', r.source));
const plate = settings.plates?.[key];
for (const dim of ['aMm', 'bMm'] as const) {
tr.appendChild(cellInput(dim === 'aMm' ? r.A : r.B, plate !== undefined, `받침 ${r.no} ${dim === 'aMm' ? 'A' : 'B'} (mm)`, (v) => {
const next = { ...(settings.plates ?? {}) };
const cur = next[key] ?? { aMm: r.A, bMm: r.B };
if (v === undefined) delete next[key]; else next[key] = { ...cur, [dim]: v };
setSole(pier.input.name, { ...settings, plates: next });
}));
}
const cells = [String(r.L), String(r.W), `${r.T}${r.tTyped ? ' (수정됨)' : ''}`, String(r.T1), String(r.T2),
`${r.checkLhs} ≈ ${r.checkRhs.toFixed(1)} ${r.ok ? '일치' : '불일치 (W08)'}`, r.warnings.length ? r.warnings.join(' · ') : '—'];
for (const c of cells) tr.appendChild(el('td', c));
body.appendChild(tr);
}
@@ -5137,47 +5171,79 @@ function renderSolplateTable(wrap: HTMLDivElement, pier: Pier): void {
wrap.appendChild(el('div', undefined, 'table-scroll')).appendChild(table);
}
/** Sets one pier's solplate rule (C안, 2026-11-03) and rebuilds that pier. */
function setSolplateRule(name: string, rule: SolplateRule): void {
/** Sets one pier's solplate settings (2026-11-06) and rebuilds that pier. */
function setSole(name: string, sole: SoleSettings): void {
const p = state.piers.find((q) => q.input.name === name);
if (!p) return;
p.draft = { ...p.draft, solplate: rule };
const issues = soleSettingsIssues(sole);
if (issues.length) { setError(issues.join(' · ')); return; }
p.draft = { ...p.draft, sole };
state.piers = state.piers.map((q) => (q === p ? regenerateEntry(q) : q));
setupScene();
renderAll();
}
/** 솔플레이트 규칙 (C안, 2026-11-03): per pier. The plan L × W and the slope S are inputs; 받침높이 H is fixed (211 mm, not on the UI). T and h3 follow. */
/**
* 솔플레이트 입력 (결정 2026-11-06, 시안): h3, T_ref, T_min, 여유 (L − A = W − B), 열마다 T (비우면 자동), 교각 종단경사
* (짝 받침이 없을 때). 거더 하면을 고정하므로 T가 T_ref와 다르면 블럭 상면 = z − (T − T_ref).
*/
function renderSolplateRule(wrap: HTMLDivElement, pier: Pier): void {
const rule = pier.draft.solplate ?? SOLPLATE_RULE_DEFAULT;
wrap.appendChild(el('p', `솔플레이트 규칙 (이 교각 · 기본 1000 × 1100 · −0.5 %) · 받침높이 H ${rule.heightMm} mm 고정 (솔플레이트 포함)`, 'lbl'));
const fields: [keyof SolplateRule, string][] = [
['lengthMm', '솔플레이트 교직 L (mm)'],
['widthMm', '솔플레이트 교축 W (mm)'],
['slopePct', '종단 기울기 S (%)'],
];
const sole = pier.draft.sole ?? {};
wrap.appendChild(el('p', `솔플레이트 (결정 2026-11-06) · T = 열마다 max(${sole.tMinMm ?? SOLE_DEFAULTS.tMinMm}, ⌈${sole.tMinMm ?? SOLE_DEFAULTS.tMinMm} + L·|S|/2⌉) · 거더 하면 고정`, 'lbl'));
const grid = el('div', undefined, 'edit-row');
for (const [key, label] of fields) {
const id = `sol-${key}`;
const lab = el('label', label);
lab.htmlFor = id;
const input = el('input');
input.id = id;
input.type = 'number';
input.step = 'any';
input.value = String(rule[key]);
if (rule[key] !== SOLPLATE_RULE_DEFAULT[key]) input.classList.add('changed-input');
input.addEventListener('change', () => setSolplateRule(pier.input.name, { ...rule, [key]: Number(input.value) }));
grid.append(lab, input, el('span', `기본 ${SOLPLATE_RULE_DEFAULT[key]}`, 'muted small'));
const field = (id: string, label: string, value: number | undefined, base: string, apply: (v: number | undefined) => void) => {
const lab = el('label', label); lab.htmlFor = id;
const input = el('input'); input.id = id; input.type = 'number'; input.step = 'any';
input.value = value === undefined ? '' : String(value);
input.placeholder = base;
if (value !== undefined) input.classList.add('changed-input');
input.addEventListener('change', () => apply(input.value.trim() === '' ? undefined : Number(input.value)));
grid.append(lab, input, el('span', `기본 ${base}`, 'muted small'));
};
const set = (patch: Partial<SoleSettings>) => setSole(pier.input.name, { ...sole, ...patch });
field('sole-h3', '받침 높이 h3 (mm)', sole.h3Mm, String(SOLE_DEFAULTS.h3Mm), (v) => set({ h3Mm: v }));
field('sole-tref', 'T_ref · 엑셀 z 산정 때 두께 (mm)', sole.tRefMm, String(SOLE_DEFAULTS.tRefMm), (v) => set({ tRefMm: v }));
field('sole-tmin', '솔플 최소두께 T_min (mm)', sole.tMinMm, String(SOLE_DEFAULTS.tMinMm), (v) => set({ tMinMm: v }));
field('sole-margin', '솔플 여유 L − A = W − B (mm)', sole.marginMm, String(SOLE_DEFAULTS.marginMm), (v) => set({ marginMm: v }));
const rows = [...new Set(pier.input.bearings.map((b) => b.row))];
for (const row of rows) {
const auto = pier.model?.soles.find((s) => s.row === row);
field(`sole-t-${row}`, `솔플레이트 T · ${row} (mm, 비우면 자동)`, sole.tByRow?.[row], auto && !auto.tTyped ? `자동 ${auto.T}` : '자동', (v) => {
const next = { ...(sole.tByRow ?? {}) };
if (v === undefined) delete next[row]; else next[row] = v;
set({ tByRow: next });
});
}
const lsId = 'sole-longitudinal';
const lsLab = el('label', '교각 종단경사 (소수, 짝 받침이 없을 때)'); lsLab.htmlFor = lsId;
const ls = el('input'); ls.id = lsId; ls.type = 'number'; ls.step = 'any';
ls.value = pier.draft.longitudinalSlope === undefined ? '' : String(pier.draft.longitudinalSlope);
ls.placeholder = '입력 없음';
ls.addEventListener('change', () => {
const p = state.piers.find((q) => q.input.name === pier.input.name);
if (!p) return;
const v = ls.value.trim() === '' ? undefined : Number(ls.value);
if (v !== undefined && !Number.isFinite(v)) { setError('종단경사는 숫자여야 합니다.'); return; }
p.draft = { ...p.draft, longitudinalSlope: v };
state.piers = state.piers.map((q) => (q === p ? regenerateEntry(q) : q));
setupScene();
renderAll();
});
grid.append(lsLab, ls, el('span', '(−) = 교량진행방향으로 낮아짐 · Pier 시트 새 열(Q-SH2)', 'muted small'));
wrap.appendChild(grid);
const issues = solplateRuleIssues(rule);
if (issues.length > 0) {
wrap.appendChild(el('p', issues.join(' '), 'warn-text'));
return;
}
const plan = solplatePlan(rule);
wrap.appendChild(el('p', `중심 두께 T = ${plan.T} mm (T2 ≥ 22 mm) · 받침 본체 h3 = H − T = ${plan.h3} mm · T1 = ${plan.T1.toFixed(1)} mm · T2 = ${plan.T2.toFixed(1)} mm`, 'muted small'));
const m = pier.model;
if (!m) return;
const unpaired = m.soles.filter((s) => s.warnings.includes('WS03')).map((s) => s.no);
const notes = [
`받침 A × B 기본 ${SOLE_DEFAULTS.aMm} × ${SOLE_DEFAULTS.bMm} (P1 전열 값, 가정) · 표에서 받침별 수정`,
unpaired.length ? `WS03 짝 없음: ${unpaired.join(', ')}번 → ${m.soles.find((s) => s.no === unpaired[0])?.slopeSource === '입력' ? '종단경사 입력' : '다른 열 값 또는 0 %'} 사용` : '모든 받침이 짝 받침으로 S 계산',
...['WS01', 'WS02', 'WS04'].flatMap((w) => {
const hit = m.soles.filter((s) => s.warnings.includes(w as 'WS01')).map((s) => s.no);
return hit.length ? [`${w}: ${hit.join(', ')}번`] : [];
}),
...rows.map((row) => { const s = m.soles.find((x) => x.row === row)!; return `${row} 블럭 상면 보정 ${s.dropMm === 0 ? '0' : `${-s.dropMm > 0 ? '+' : ''}${-s.dropMm} mm`}`; }),
];
wrap.appendChild(el('p', `검사 · ${notes.join(' · ')}`, 'muted small'));
}
/** ② 코핑 상세 (2단계, 읽기 전용): d_IP2 and the 계단 구간 표 from the model (승인 2026-10-23). */
@@ -5512,6 +5578,7 @@ function openProjectText(text: string, saved: boolean): void {
groundModel = parsed.groundModel ?? null;
groundMapMode = groundModel?.mapLayers?.length ? 'satellite' : 'none';
setGroundMapMode(groundMapMode);
state.bridgePairs = bridgePairs(parsed.piers.map((p) => p.draft ?? p.input), state.abutments);
state.piers = parsed.piers.map((p) => ({
...buildEntry(p.input, false),
defaults: p.defaults ?? p.input,
@@ -5752,6 +5819,8 @@ function loadBytes(bytes: Uint8Array, fileName: string): void {
const wb = XLSX.read(bytes, { type: 'array' });
const { piers, sheets } = parseWorkbook(wb);
const abut = parseAbutments(wb, piers.map((pier) => pier.name));
// Solplate S needs the girder pairs of the whole file (2026-11-06); applyImport recomputes them for the chosen ones.
state.bridgePairs = bridgePairs(piers, abut.abutments);
const entries = piers.map((input) => buildEntry(input, false));
// A sheet is selectable only if every pier in it builds. Show the reason next to the sheet.
for (const sheet of sheets) {
@@ -5794,6 +5863,9 @@ function applyImport(): void {
state.piers = p.entries.filter((e) => e.checked && selectable(p, e));
state.abutments = p.abutments.filter((rec) => p.selectedAbutments.has(rec.name) && selectableAbut(p, rec));
state.abutDefaults = JSON.parse(JSON.stringify(state.abutments)) as AbutRecord[];
// The chosen structures may differ from the whole file: rebuild with their girder pairs (솔플레이트 S, 2026-11-06).
state.bridgePairs = bridgePairs(state.piers.map((e) => e.input), state.abutments);
state.piers = state.piers.map((e) => ({ ...buildEntry(e.input, e.checked), defaults: e.defaults, draft: e.draft }));
state.active = state.piers[0]?.input.name ?? null;
state.filter = 'all';
state.pending = null;