Files
Aislo/E03_StabSection/logic/E03_StabSection_Logic.ts
T
eomsangdonandClaude Opus 5.5 ecd027f028 feat(532-3): E03 2D 측점 해석 길 · 화면 — 파생 저장본(측점 지문) · 진행률 · 임계면 · 등안전율 · 절편 자유물체도 · 평면 띠 · 3D 색칠
- 길: POST analyze2d(뒤에서 · 일꾼 프로세스 병렬 · 바뀐 측점만) · GET progress · result2d · section · corridor3d · PUT representative
- 입력 = E01 복사본(측점 · 횡단 · DTM 넓힘) + E02 지반 조건 · 엔진 = sub4 analyze_station · 벽 = 다음 일감(532-4)
- 화면: 측점 목록(조건 · 정렬 · N.G 만 · 대표 단면) · 횡단(지층 · 지하수선 · 임계면 · 후보 · 등안전율 지도 · 중심 격자 · 절편) · 결과 · 절편표 · 자유물체도 · 절편력 그래프 · 평면 띠 · 3D 보기
- 계산 = logic/E03_StabSection_Logic.ts · 시험 test_532_3_e03_section.py

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 20:35:36 +09:00

372 lines
14 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.
/* =============================================================================
* E03_StabSection_Logic.ts
* E03 2D 측점 해석 화면 계산(PLAN 532-3) — 화면 · three · fetch · sessionStorage 없음 · 순수 함수만.
* 좌표 = 서버 결과 그대로(x = B06 offset · 좌 + · z = 표고). 화면은 B06 처럼 좌측(+offset)을 왼쪽에 둠
* → 화면 x = −k·x + tx · y = −k·z + ty (`fitView`). SVG 도형 좌표는 결과 좌표를 그대로 쓰고
* 묶음 transform 하나(`viewMatrix`)로 옮김 — 「SVG 좌표 = 결과 좌표」.
* 판정 = 서버 `E03_StabSection_View.overall_verdict` 와 같은 규칙(ng > ok > none).
* ========================================================================== */
export type Condition = "dry" | "wet" | "seismic";
export type Verdict = "ok" | "ng" | "none";
export type Point = [number, number];
export interface SideCase {
fs: number | null;
required: number | null;
ok: boolean | null;
kind: string | null;
converged: boolean | null;
}
export interface StationRow {
key: string;
chainage_m: number;
label: string | null;
section_mode: string | null;
ground_type: string | null;
center: [number | null, number | null] | null;
sides: { side: string; role: "cut" | "fill"; height_m: number | null }[];
cases: Partial<Record<Condition, Record<string, SideCase>>>;
min: Partial<Record<Condition, number | null>>;
verdict: Partial<Record<Condition, Verdict>>;
error: string | null;
representative?: { auto: string[]; user: boolean | null; on: boolean };
}
export interface Surface {
method: string;
kind: string | null;
fs: number | null;
points: Point[];
entry: Point | null;
exit: Point | null;
converged: boolean | null;
iterations: number | null;
center?: Point;
radius?: number;
}
export interface SliceRow {
i: number;
x_left: number;
x_right: number;
width: number;
height: number;
alpha_deg: number;
weight: number;
surcharge: number;
pore_pressure: number;
base_length: number;
c: number;
phi_deg: number;
material: string | null;
N: number;
N_eff: number;
U: number;
S: number;
E_left: number;
E_right: number;
X_left: number;
X_right: number;
}
export interface Analysis {
condition: Condition;
side: string;
role: "cut" | "fill";
height_m: number | null;
limits: [number, number];
direction: number;
required: number | null;
ok: boolean | null;
min_by_method: Record<string, number | null>;
critical: Surface | null;
candidates: Surface[];
slices: SliceRow[];
grid: { method: string; x: number[]; z: number[]; fs: (number | null)[][] } | null;
}
export interface SectionView {
key: string;
chainage_m: number;
label: string | null;
materials: {
id: string;
name: string;
gamma_t: number;
gamma_sat: number;
c: number;
phi: number;
}[];
regions: { material_id: string; polygon: Point[]; holes: Point[][] }[];
surface: Point[];
bottom_z: number | null;
surcharge: { x0: number; x1: number; q: number }[];
water: Partial<Record<Condition, Point[] | null>>;
analyses: Analysis[];
}
/* ---- 판정 · 목록 ---------------------------------------------------------- */
/** 측점 판정 — 조건 하나(또는 전 조건 중 가장 나쁜 것) */
export function verdictOf(row: StationRow, condition?: Condition): Verdict {
const values = condition ? [row.verdict[condition] ?? "none"] : Object.values(row.verdict);
if (values.includes("ng")) return "ng";
return values.includes("ok") ? "ok" : "none";
}
export type SortMode = "chainage" | "fs";
/** 목록 정렬 · 거름 — fs = 그 조건 최소 안전율 낮은 순(없으면 맨 뒤) */
export function listRows(
rows: StationRow[],
condition: Condition,
sort: SortMode,
ngOnly: boolean,
): StationRow[] {
const kept = ngOnly ? rows.filter((r) => verdictOf(r, condition) === "ng") : rows.slice();
if (sort === "fs") {
const fs = (r: StationRow): number => r.min[condition] ?? Number.POSITIVE_INFINITY;
kept.sort((a, b) => fs(a) - fs(b) || a.chainage_m - b.chainage_m);
} else kept.sort((a, b) => a.chainage_m - b.chainage_m);
return kept;
}
/** 깎기 · 쌓기 줄임 — 비탈 역할 좌/우(예 「쌓기 · 깎기」) */
export function roleText(row: StationRow, names: { cut: string; fill: string }): string {
const order = ["left", "right"];
return row.sides
.slice()
.sort((a, b) => order.indexOf(a.side) - order.indexOf(b.side))
.map((s) => names[s.role])
.join(" · ");
}
/** 평면 띠 — 측점 중심을 이은 선을 측점 판정으로 나눈 구간(이웃 측점 사이 중간에서 끊음) */
export function bandSegments(
rows: StationRow[],
condition: Condition | undefined,
): { verdict: Verdict; points: Point[]; keys: string[] }[] {
const pts = rows
.filter((r) => r.center && r.center[0] !== null && r.center[1] !== null)
.sort((a, b) => a.chainage_m - b.chainage_m)
.map((r) => ({ row: r, p: [r.center![0]!, r.center![1]!] as Point }));
const out: { verdict: Verdict; points: Point[]; keys: string[] }[] = [];
const mid = (a: Point, b: Point): Point => [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
pts.forEach((item, i) => {
const v = verdictOf(item.row, condition);
const start = i > 0 ? mid(pts[i - 1].p, item.p) : item.p;
const end = i < pts.length - 1 ? mid(item.p, pts[i + 1].p) : item.p;
const last = out[out.length - 1];
if (last && last.verdict === v) {
last.points.push(item.p, end);
last.keys.push(item.row.key);
} else out.push({ verdict: v, points: [start, item.p, end], keys: [item.row.key] });
});
return out;
}
/** 평면 띠 보기 틀 — 북쪽 위 · 같은 축척 · 여백 pad(px) */
export function planFit(
points: Point[],
width: number,
height: number,
pad: number,
): { k: number; tx: number; ty: number } {
const xs = points.map((p) => p[0]);
const ys = points.map((p) => p[1]);
const [x0, x1, y0, y1] = [Math.min(...xs), Math.max(...xs), Math.min(...ys), Math.max(...ys)];
const k = Math.min(
(width - 2 * pad) / Math.max(x1 - x0, 1),
(height - 2 * pad) / Math.max(y1 - y0, 1),
);
return { k, tx: width / 2 - (k * (x0 + x1)) / 2, ty: height / 2 + (k * (y0 + y1)) / 2 };
}
/* ---- 안전율 색 ------------------------------------------------------------ */
/** 등안전율 띠 — 안전율 ÷ 기준안전율 경계(빨강 셋 · 회색 하나 · 파랑 셋 · 가운데 = 기준 통과 직후) */
export const RATIO_BREAKS = [0.8, 0.9, 1.0, 1.1, 1.25, 1.5] as const;
/** 띠 번호 0~6 — 0 = 가장 위험 · 3 = 기준 바로 위 · null = 값 없음 */
export function bandIndex(fs: number | null | undefined, required: number | null): number | null {
if (fs === null || fs === undefined || !Number.isFinite(fs)) return null;
const ratio = Math.round((fs / (required && required > 0 ? required : 1)) * 1e9) / 1e9;
const i = RATIO_BREAKS.findIndex((b) => ratio < b);
return i < 0 ? RATIO_BREAKS.length : i;
}
/** 범례 — 띠마다 안전율 구간(기준안전율을 곱한 실제 값) */
export function bandLegend(
required: number | null,
): { index: number; from: number | null; to: number | null }[] {
const base = required && required > 0 ? required : 1;
const edges = RATIO_BREAKS.map((b) => Math.round(b * base * 100) / 100);
return [...Array(RATIO_BREAKS.length + 1).keys()].map((i) => ({
index: i,
from: i === 0 ? null : edges[i - 1],
to: i === RATIO_BREAKS.length ? null : edges[i],
}));
}
/* ---- 횡단 보기 틀 --------------------------------------------------------- */
export interface ViewFit {
k: number; // px / m (가로 · 세로 같음 — 참 모양)
tx: number;
ty: number;
}
/** 범위(지표 · 바닥 · 격자 중심 · 임계면) 를 판에 맞춤 — 좌측(+offset) 이 왼쪽 */
export function fitView(view: SectionView, width: number, height: number, pad: number): ViewFit {
const xs: number[] = [];
const zs: number[] = [];
const add = (p: Point): void => {
xs.push(p[0]);
zs.push(p[1]);
};
view.surface.forEach(add);
if (view.bottom_z !== null) zs.push(view.bottom_z);
for (const a of view.analyses) {
if (a.grid) {
a.grid.x.forEach((x) => xs.push(x));
a.grid.z.forEach((z) => zs.push(z));
}
a.critical?.points.forEach(add);
}
const [x0, x1, z0, z1] = [Math.min(...xs), Math.max(...xs), Math.min(...zs), Math.max(...zs)];
const k = Math.min(
(width - 2 * pad) / Math.max(x1 - x0, 1),
(height - 2 * pad) / Math.max(z1 - z0, 1),
);
return { k, tx: width / 2 + (k * (x0 + x1)) / 2, ty: height / 2 + (k * (z0 + z1)) / 2 };
}
/** 결과 좌표 → 화면 px */
export function toScreen(fit: ViewFit, p: Point): Point {
return [-fit.k * p[0] + fit.tx, -fit.k * p[1] + fit.ty];
}
/** 화면 px → 결과 좌표(마우스 아래 값) */
export function toModel(fit: ViewFit, p: Point): Point {
return [(fit.tx - p[0]) / fit.k, (fit.ty - p[1]) / fit.k];
}
/** 결과 좌표 도형 묶음에 거는 transform — 도형 points 는 결과 좌표 그대로 */
export function viewMatrix(fit: ViewFit): string {
return `matrix(${-fit.k} 0 0 ${-fit.k} ${fit.tx} ${fit.ty})`;
}
/** 폴리곤 · 선 → SVG points 글(결과 좌표 그대로) */
export function pointsText(points: Point[]): string {
return points.map((p) => `${p[0]},${p[1]}`).join(" ");
}
/** 꺾은선 위 x 의 z(선형 · 범위 밖은 끝값) — 선은 x 오름 · 내림 무관 */
export function zAt(line: Point[], x: number): number | null {
if (!line.length) return null;
const pts = line.slice().sort((a, b) => a[0] - b[0]);
if (x <= pts[0][0]) return pts[0][1];
for (let i = 1; i < pts.length; i += 1) {
if (x <= pts[i][0]) {
const [a, b] = [pts[i - 1], pts[i]];
const t = b[0] === a[0] ? 0 : (x - a[0]) / (b[0] - a[0]);
return a[1] + t * (b[1] - a[1]);
}
}
return pts[pts.length - 1][1];
}
/** 절편 네 꼭짓점 — 바닥 = 임계면 · 위 = 지표 */
export function sliceShape(slice: SliceRow, surface: Point[], base: Point[]): Point[] {
const at = (line: Point[], x: number): number => zAt(line, x) ?? 0;
return [
[slice.x_left, at(base, slice.x_left)],
[slice.x_right, at(base, slice.x_right)],
[slice.x_right, at(surface, slice.x_right)],
[slice.x_left, at(surface, slice.x_left)],
];
}
/* ---- 자유물체도 --------------------------------------------------------- */
export interface ForceArrow {
name: "W" | "Q" | "N" | "U" | "S" | "EL" | "ER" | "XL" | "XR";
value: number;
/** 화살 시작 · 끝(결과 좌표 · 길이 = 값 × scale) */
from: Point;
to: Point;
}
/**
* 절편 하나 힘 화살 — W · Q 아래 · N · U 바닥 법선(절편 안쪽) · S 바닥 따라 미끄럼 반대 ·
* E 좌우 면 수평 · X 좌우 면 연직. 엔진 절편표는 활동 방향(direction)으로 뒤집은 틀의 차례라
* 그 틀의 「왼쪽 면」은 direction < 0 이면 결과 좌표의 오른쪽 면. 길이 = 값 × lengthPerKn.
*/
export function forceArrows(
slice: SliceRow,
shape: Point[],
direction: number,
lengthPerKn: number,
): ForceArrow[] {
const s = direction < 0 ? -1 : 1;
const [bl, br, tr, tl] = shape;
const midBase: Point = [(bl[0] + br[0]) / 2, (bl[1] + br[1]) / 2];
const midTop: Point = [(tl[0] + tr[0]) / 2, (tl[1] + tr[1]) / 2];
const centroid: Point = [(midBase[0] + midTop[0]) / 2, (midBase[1] + midTop[1]) / 2];
const len = Math.hypot(br[0] - bl[0], br[1] - bl[1]) || 1;
const t: Point = [(br[0] - bl[0]) / len, (br[1] - bl[1]) / len]; // 바닥 왼→오
const n: Point = [-t[1], t[0]]; // 바닥 법선 위쪽
const faceMid = (a: Point, b: Point): Point => [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
const [nearFace, farFace] =
s > 0 ? [faceMid(bl, tl), faceMid(br, tr)] : [faceMid(br, tr), faceMid(bl, tl)];
const arrow = (
name: ForceArrow["name"],
value: number,
at: Point,
dir: Point,
ending = false,
): ForceArrow => {
const d: Point = [dir[0] * value * lengthPerKn, dir[1] * value * lengthPerKn];
return ending
? { name, value, from: [at[0] - d[0], at[1] - d[1]], to: at }
: { name, value, from: at, to: [at[0] + d[0], at[1] + d[1]] };
};
return [
arrow("W", slice.weight, centroid, [0, -1]),
arrow("Q", slice.surcharge, midTop, [0, -1], true),
arrow("N", slice.N_eff, midBase, n),
arrow("U", slice.U, midBase, n),
arrow("S", slice.S, midBase, [-s * t[0], -s * t[1]]),
arrow("EL", slice.E_left, nearFace, [s, 0], true),
arrow("ER", slice.E_right, farFace, [-s, 0], true),
arrow("XL", slice.X_left, nearFace, [0, 1], true),
arrow("XR", slice.X_right, farFace, [0, -1], true),
].filter((a) => Math.abs(a.value) > 1e-9);
}
/** 절편력 그래프 꼴 — 절편 가운데 x 마다 바닥 유효 수직응력 · 간극수압 · 동원 전단응력(kPa · 한 축) */
export function sliceStress(
slices: SliceRow[],
): { x: number; sigma: number; u: number; tau: number }[] {
return slices.map((s) => {
const l = s.base_length > 0 ? s.base_length : 1;
return { x: (s.x_left + s.x_right) / 2, sigma: s.N_eff / l, u: s.pore_pressure, tau: s.S / l };
});
}
/** 그래프 눈금 — 0 포함 · 1 · 2 · 5 × 10ⁿ 간격 · 대략 count 칸 */
export function niceTicks(min: number, max: number, count = 5): number[] {
const lo = Math.min(0, min);
const hi = Math.max(lo + 1e-9, max);
const raw = (hi - lo) / Math.max(count, 1);
const mag = 10 ** Math.floor(Math.log10(raw));
const step = [1, 2, 5, 10].map((m) => m * mag).find((v) => v >= raw) ?? raw;
const out: number[] = [];
for (let v = Math.floor(lo / step) * step; v <= hi + step * 1e-6; v += step)
out.push(Math.round(v * 1e6) / 1e6);
if (out[out.length - 1] < hi) out.push(out[out.length - 1] + step);
return out;
}