feat(E04): 3D 해석 화면 — 구간 · 탐색 설정 · 평면 안전율 · 체적 지도 · 3D 보기(잘린 활동면) · 결과 · 진행 · 끊기 + logic 시험 (533-3 화면 부분)

- 3D 보기 = 해석 격자(합성 지표) 직접 그림 — E 복사본에 지표면 모델 행 · GLB 없음(loadSurface 못 씀)
- 2D 최소 = E03 GET …/stab/section 의 stations[].fs_min(없으면 「2D 결과 없음」)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-25 20:24:38 +09:00
co-authored by Claude Opus 5.5
parent cfd1e3eb2c
commit cbc4cf1a59
13 changed files with 2039 additions and 8 deletions
+20 -3
View File
@@ -19,9 +19,10 @@ from typing import Any, Literal
from uuid import UUID
import aiomysql
from fastapi import APIRouter, HTTPException
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from common_util.common_util_auth import require_company
from common_util.common_util_storage import resolve_stored_project_path
from config.config_db import get_db_pool
from E01_StabRegister import E01_StabRegister_Source as source
@@ -205,13 +206,29 @@ def _overview(project_root: Path, root: Path) -> dict[str, Any]:
}
async def _editable(project_id: UUID, session: dict[str, Any]) -> bool:
"""[해석] 가능 = 시스템 관리자 · 같은 회사(지정받은 회사는 보기만)."""
if session.get("role") == "SYSTEM_ADMIN":
return True
async with get_db_pool().acquire() as connection, connection.cursor() as cursor:
await cursor.execute("SELECT company_id FROM projects WHERE id = %s", (str(project_id),))
row = await cursor.fetchone()
return bool(row) and row[0] == session.get("company_id")
@router.get("/{project_id}/stab/result3d", response_model=None)
async def result3d(project_id: UUID, key: str | None = None) -> dict[str, Any]:
async def result3d(
project_id: UUID,
key: str | None = None,
session: dict[str, Any] = Depends(require_company),
) -> dict[str, Any]:
"""key 없음 = 구간 · 노선 · 저장본 요약 / key = 그 구간 래스터 · 임계 활동면."""
project_root = await _project_root(project_id)
root = _source_root(project_root)
if key is None:
return {"status": "success", **await asyncio.to_thread(_overview, project_root, root)}
overview = await asyncio.to_thread(_overview, project_root, root)
editable = await _editable(project_id, session)
return {"status": "success", "editable": editable, **overview}
if not all(ch.isdigit() or ch in "._" for ch in key):
raise HTTPException(status_code=400, detail="구간 열쇠가 틀렸습니다.")
doc = await asyncio.to_thread(run.load, project_root, key)
+366
View File
@@ -0,0 +1,366 @@
/* =============================================================================
* E04_Stab3D_UI_Map.ts
* E04 가운데 평면 지도(PLAN 533-3) — 셀별 최소 안전율 · 임계 체적 색 지도 · 임계 활동면 평면 윤곽 ·
* 최소 위치 · 노선 · 측점 · 고른 구간 · 방위 · 축척 · 셀 툴팁. 휠 = 확대 · 끌기 = 옮김 · 두 번 누름 = 맞춤.
* 색 · 범례 · 셀 찾기 = logic/E04_Stab3D_Raster · 윤곽 셀 = logic/E04_Stab3D_Mesh(slipMesh).
* ========================================================================== */
import { el } from "@ui/ui_template_elements";
import { t } from "@ui/ui_template_locale";
import { slipMesh } from "./logic/E04_Stab3D_Mesh";
import {
FS_STEPS,
VOLUME_STEPS,
cellAt,
rasterRgba,
type Result3d,
} from "./logic/E04_Stab3D_Raster";
export type MapLayer = "fs" | "volume";
export interface StationPoint {
chainage_m: number;
label: string | null;
x: number;
y: number;
}
export interface PlanMap {
root: HTMLElement;
setRoute: (line: number[][], stations: StationPoint[]) => void;
setRange: (start: number, end: number) => void;
setResult: (result: Result3d | null) => void;
setLayer: (layer: MapLayer) => void;
dispose: () => void;
}
interface View {
cx: number;
cy: number;
scale: number; // px / m
}
const css = (name: string, fallback: string): string =>
getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback;
export function createPlanMap(): PlanMap {
const root = el("div", { className: "e04-map" });
const canvas = el("canvas", { className: "e04-map__canvas" });
const tip = el("div", { className: "e04-map__tip" });
const empty = el("p", { className: "e04-map__empty", text: t("E04_Map_Empty") });
tip.hidden = true;
root.append(canvas, tip, empty);
const ctx = canvas.getContext("2d")!;
let line: number[][] = [];
let stations: StationPoint[] = [];
let range: [number, number] | null = null;
let result: Result3d | null = null;
let layer: MapLayer = "fs";
let image: HTMLCanvasElement | null = null;
let outline: Set<number> = new Set();
let view: View = { cx: 0, cy: 0, scale: 1 };
let width = 0;
let height = 0;
const toScreen = (x: number, y: number): [number, number] => [
width / 2 + (x - view.cx) * view.scale,
height / 2 - (y - view.cy) * view.scale,
];
const toModel = (px: number, py: number): [number, number] => [
view.cx + (px - width / 2) / view.scale,
view.cy - (py - height / 2) / view.scale,
];
function buildImage(): void {
image = null;
if (!result) return;
const { grid } = result;
const values = layer === "fs" ? result.f : result.v;
const steps = layer === "fs" ? FS_STEPS : VOLUME_STEPS;
const off = document.createElement("canvas");
off.width = grid.nx;
off.height = grid.ny;
const rgba = rasterRgba(grid, values, steps);
off.getContext("2d")!.putImageData(new ImageData(rgba, grid.nx, grid.ny), 0, 0);
image = off;
}
/** 맞춤 — 결과 격자 · 없으면 고른 구간 노선 · 없으면 노선 전체 */
function fit(): void {
let xs: number[] = [];
let ys: number[] = [];
if (result) {
const g = result.grid;
xs = [g.x0 - g.dx / 2, g.x0 + (g.nx - 0.5) * g.dx];
ys = [g.y0 - g.dx / 2, g.y0 + (g.ny - 0.5) * g.dx];
} else {
const picked = range
? line.filter((p) => p[2] >= range![0] - 1e-6 && p[2] <= range![1] + 1e-6)
: line;
const use = picked.length >= 2 ? picked : line;
xs = use.map((p) => p[0]);
ys = use.map((p) => p[1]);
if (range && picked.length >= 2) {
xs.push(Math.min(...xs) - 40, Math.max(...xs) + 40);
ys.push(Math.min(...ys) - 40, Math.max(...ys) + 40);
}
}
if (!xs.length || !width || !height) return;
const [x0, x1, y0, y1] = [Math.min(...xs), Math.max(...xs), Math.min(...ys), Math.max(...ys)];
const scale = Math.min(width / Math.max(x1 - x0, 1), height / Math.max(y1 - y0, 1)) * 0.9;
view = { cx: (x0 + x1) / 2, cy: (y0 + y1) / 2, scale };
}
function drawRoute(text: string, accent: string): void {
if (line.length < 2) return;
ctx.lineJoin = ctx.lineCap = "round";
ctx.strokeStyle = text;
ctx.lineWidth = 2;
ctx.beginPath();
line.forEach((p, i) =>
i ? ctx.lineTo(...toScreen(p[0], p[1])) : ctx.moveTo(...toScreen(p[0], p[1])),
);
ctx.stroke();
if (range) {
const part = line.filter((p) => p[2] >= range![0] - 1e-6 && p[2] <= range![1] + 1e-6);
ctx.strokeStyle = accent;
ctx.lineWidth = 5;
ctx.beginPath();
part.forEach((p, i) =>
i ? ctx.lineTo(...toScreen(p[0], p[1])) : ctx.moveTo(...toScreen(p[0], p[1])),
);
ctx.stroke();
}
// 측점 — 화면 간격이 좁으면 다섯 개마다 이름
const gap = stations.length > 1 ? 20 * view.scale : 999;
const every = gap >= 36 ? 1 : gap >= 8 ? 5 : 25;
ctx.font = "11px sans-serif";
ctx.textBaseline = "middle";
stations.forEach((s, i) => {
const [px, py] = toScreen(s.x, s.y);
if (px < -50 || py < -50 || px > width + 50 || py > height + 50) return;
ctx.fillStyle = text;
ctx.beginPath();
ctx.arc(px, py, i % every === 0 ? 3 : 1.5, 0, Math.PI * 2);
ctx.fill();
if (i % every === 0 && s.label) ctx.fillText(s.label, px + 6, py - 8);
});
}
/** 임계 활동면 평면 윤곽 — 잘린 셀 무리의 바깥 변 */
function drawOutline(color: string): void {
if (!result || !outline.size) return;
const { grid } = result;
const h = grid.dx / 2;
ctx.strokeStyle = color;
ctx.lineWidth = 2;
ctx.setLineDash([6, 4]);
ctx.beginPath();
for (const index of outline) {
const ix = index % grid.nx;
const iy = Math.floor(index / grid.nx);
const x = grid.x0 + ix * grid.dx;
const y = grid.y0 + iy * grid.dx;
const edges: [number, number, number, number, number][] = [
[index - 1, x - h, y - h, x - h, y + h],
[index + 1, x + h, y - h, x + h, y + h],
[index - grid.nx, x - h, y - h, x + h, y - h],
[index + grid.nx, x - h, y + h, x + h, y + h],
];
for (const [next, ax, ay, bx, by] of edges) {
const sameRow =
next === index - 1 || next === index + 1 ? Math.floor(next / grid.nx) === iy : true;
if (sameRow && outline.has(next)) continue;
ctx.moveTo(...toScreen(ax, ay));
ctx.lineTo(...toScreen(bx, by));
}
}
ctx.stroke();
ctx.setLineDash([]);
}
function drawMarker(text: string): void {
if (!result) return;
const { best } = result;
const [px, py] = toScreen(best.x, best.y);
ctx.lineWidth = 2;
ctx.strokeStyle = "#ffffff";
ctx.beginPath();
ctx.arc(px, py, 9, 0, Math.PI * 2);
ctx.stroke();
ctx.strokeStyle = "#000000";
ctx.beginPath();
ctx.arc(px, py, 11, 0, Math.PI * 2);
ctx.stroke();
ctx.font = "bold 12px sans-serif";
ctx.fillStyle = text;
ctx.fillText(`Fs ${best.f.toFixed(3)}`, px + 14, py);
}
function drawFurniture(text: string): void {
// 방위(오른쪽 위)
ctx.fillStyle = text;
ctx.strokeStyle = text;
ctx.beginPath();
ctx.moveTo(width - 24, 14);
ctx.lineTo(width - 30, 34);
ctx.lineTo(width - 18, 34);
ctx.closePath();
ctx.fill();
ctx.font = "bold 12px sans-serif";
ctx.textAlign = "center";
ctx.fillText("N", width - 24, 46);
// 축척(오른쪽 아래) — 1 · 2 · 5 × 10ⁿ m
const target = 100 / view.scale;
const pow = 10 ** Math.floor(Math.log10(target));
const meters = [1, 2, 5, 10].map((k) => k * pow).find((m) => m >= target) ?? target;
const px = meters * view.scale;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(width - 16 - px, height - 16);
ctx.lineTo(width - 16, height - 16);
ctx.stroke();
ctx.fillText(`${meters} m`, width - 16 - px / 2, height - 26);
ctx.textAlign = "start";
}
function draw(): void {
const dpr = window.devicePixelRatio || 1;
width = root.clientWidth;
height = root.clientHeight;
if (!width || !height) return;
if (canvas.width !== Math.round(width * dpr) || canvas.height !== Math.round(height * dpr)) {
canvas.width = Math.round(width * dpr);
canvas.height = Math.round(height * dpr);
}
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, width, height);
const text = css("--color-text", "#1f2937");
const accent = css("--color-primary", "#6d28d9");
if (result && image) {
const g = result.grid;
const [left, top] = toScreen(g.x0 - g.dx / 2, g.y0 + (g.ny - 0.5) * g.dx);
ctx.imageSmoothingEnabled = false;
ctx.drawImage(image, left, top, g.nx * g.dx * view.scale, g.ny * g.dx * view.scale);
}
drawRoute(text, accent);
drawOutline(text);
drawMarker(text);
drawFurniture(text);
empty.hidden = Boolean(result);
}
// 조작 — 휠 확대(커서 자리 고정) · 끌기 옮김 · 두 번 누름 맞춤 · 셀 툴팁
let drag: { x: number; y: number; view: View } | null = null;
canvas.addEventListener("wheel", (event) => {
event.preventDefault();
const rect = canvas.getBoundingClientRect();
const [mx, my] = toModel(event.clientX - rect.left, event.clientY - rect.top);
const k = Math.exp(-event.deltaY * 0.0015);
view = { scale: view.scale * k, cx: mx - (mx - view.cx) / k, cy: my - (my - view.cy) / k };
draw();
});
canvas.addEventListener("pointerdown", (event) => {
drag = { x: event.clientX, y: event.clientY, view: { ...view } };
canvas.setPointerCapture(event.pointerId);
});
canvas.addEventListener("pointerup", () => (drag = null));
canvas.addEventListener("dblclick", () => {
fit();
draw();
});
canvas.addEventListener("pointerleave", () => (tip.hidden = true));
canvas.addEventListener("pointermove", (event) => {
if (drag) {
view = {
...drag.view,
cx: drag.view.cx - (event.clientX - drag.x) / view.scale,
cy: drag.view.cy + (event.clientY - drag.y) / view.scale,
};
draw();
return;
}
const rect = canvas.getBoundingClientRect();
const [mx, my] = toModel(event.clientX - rect.left, event.clientY - rect.top);
const cell = result ? cellAt(result.grid, mx, my) : null;
const index = cell && result ? cell.iy * result.grid.nx + cell.ix : -1;
const f = index >= 0 ? result!.f[index] : null;
if (f === null) return void (tip.hidden = true);
const v = result!.v[index];
const near = stations.reduce<StationPoint | null>(
(best, s) =>
!best || (s.x - mx) ** 2 + (s.y - my) ** 2 < (best.x - mx) ** 2 + (best.y - my) ** 2
? s
: best,
null,
);
tip.textContent = [
`${t("E04_Layer_FS")} ${f.toFixed(3)}`,
`${t("E04_Layer_Volume")} ${v === null ? "-" : `${Math.round(v).toLocaleString()} m³`}`,
near ? `${t("E04_Tip_Station")} ${near.label ?? near.chainage_m.toFixed(1)}` : "",
]
.filter(Boolean)
.join("\n");
tip.style.left = `${event.clientX - rect.left + 14}px`;
tip.style.top = `${event.clientY - rect.top + 14}px`;
tip.hidden = false;
});
const resize = new ResizeObserver(() => {
const first = !width;
draw();
if (first) {
fit();
draw();
}
});
resize.observe(root);
const theme = new MutationObserver(draw);
theme.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] });
// 검증 창구 — 모델 좌표 → 화면(client) 좌표 · 지금 결과
const probe = {
project: (x: number, y: number) => {
const rect = canvas.getBoundingClientRect();
const [px, py] = toScreen(x, y);
return { x: rect.left + px, y: rect.top + py };
},
result: () => result,
layer: () => layer,
};
(window as unknown as { __e04Map?: typeof probe }).__e04Map = probe;
return {
root,
setRoute(nextLine, nextStations) {
line = nextLine;
stations = nextStations;
fit();
draw();
},
setRange(start, end) {
range = [start, end];
if (!result) fit();
draw();
},
setResult(next) {
result = next;
outline = new Set(next ? Array.from(slipMesh(next.grid, next.z, next.best).cells) : []);
buildImage();
fit();
draw();
},
setLayer(next) {
layer = next;
buildImage();
draw();
},
dispose() {
resize.disconnect();
theme.disconnect();
if ((window as unknown as { __e04Map?: unknown }).__e04Map === probe)
delete (window as unknown as { __e04Map?: unknown }).__e04Map;
},
};
}
+438
View File
@@ -0,0 +1,438 @@
/* =============================================================================
* E04_Stab3D_UI_Page.ts
* E04 안정성평가 3D 해석(요구 347-1 · 요구서 4-2 · 4-3 · PLAN 533-3) — B05~B09 틀(group_workflow).
* 좌 = 구간 목록 · 탐색 설정(기본 · 고급 접힘) · [해석] · 진행률 · [끊기]
* 가운데 = 평면 지도(셀별 최소 안전율 · 임계 체적) · [3D 보기](지표 + 노선면 + 임계 활동면)
* 우 = 구간 결과(3D 최소 · 2D 최소(같은 구간) · 3D/2D 비 · 체적 · 방법)
* 계산 = 서버 단독(POST …/stab/analyze3d · 뒤에서) · 화면은 값 · 그림만. 탐색 설정 = sessionStorage.
* ========================================================================== */
import "./E04_Stab3D_UI_Style.css";
import { API_BASE_URL, CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend";
import { attachCollapsible, createPanelCard } from "@ui/ui_template_collapsible";
import { createButton, el, showToast } from "@ui/ui_template_elements";
import { t, type LocaleKey } from "@ui/ui_template_locale";
import { createSidePanels } from "@ui/ui_template_side_panels";
import { mountGroupStep } from "../A00_Common/group_workflow";
import { createPlanMap, type MapLayer, type StationPoint } from "./E04_Stab3D_UI_Map";
import { createView3D } from "./E04_Stab3D_UI_View3D";
import { buildSettingsCards, type Settings } from "./E04_Stab3D_UI_Settings";
import {
FS_STEPS,
VOLUME_STEPS,
legendRows,
min2d,
ratio3d2d,
stepColor,
type Result3d,
} from "./logic/E04_Stab3D_Raster";
interface SavedSummary {
key: string;
start_m: number;
end_m: number;
f_min: number;
volume: number;
method: string;
stale: boolean;
preview: boolean;
}
interface Overview {
editable: boolean;
sections: { start_m: number; end_m: number }[];
stations: StationPoint[];
line: number[][];
saved: SavedSummary[];
defaults: Settings;
max_section_m: number;
}
interface Progress {
running: boolean;
percent: number;
message: string | null;
error: string | null;
cancelled: boolean;
key: string | null;
}
const cacheKey = (projectId: string): string => `e04_settings:${projectId}`;
const sectionKey = (start: number, end: number): string => `${start.toFixed(2)}_${end.toFixed(2)}`;
async function api<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(`${API_BASE_URL}${path}`, {
credentials: "include",
headers: { "Content-Type": "application/json" },
...init,
});
const body = (await response.json().catch(() => ({}))) as T & { detail?: unknown };
if (!response.ok) {
const detail = body.detail;
throw new Error(typeof detail === "string" ? detail : `HTTP ${response.status}`);
}
return body;
}
/** E03 2D 측점 결과(sub2 · GET …/stab/section) — 없으면 빈 목록 */
async function fetch2d(
projectId: string,
): Promise<{ chainage_m: number; fs_min: number | null }[]> {
try {
const body = await api<{ stations?: Record<string, unknown>[] }>(
`/projects/${projectId}/stab/section`,
);
return (body.stations ?? []).map((s) => ({
chainage_m: Number(s.chainage_m),
fs_min: typeof s.fs_min === "number" ? s.fs_min : null,
}));
} catch {
return [];
}
}
function legend(layer: MapLayer): HTMLElement {
const box = el("div", { className: "e04-legend" });
box.append(el("strong", { text: t(layer === "fs" ? "E04_Legend_FS" : "E04_Legend_Volume") }));
const rows = layer === "fs" ? legendRows(FS_STEPS, 1) : legendRows(VOLUME_STEPS, 0);
for (const row of [...rows, { color: stepColor(FS_STEPS, null), label: t("E04_NoData") }]) {
const item = el("span", { className: "e04-legend__row" });
const swatch = el("i", { className: "e04-legend__swatch" });
swatch.style.background = row.color;
item.append(swatch, el("span", { text: row.label }));
box.append(item);
}
return box;
}
export function renderE04Stab3D(root: HTMLElement): void {
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
const panel = el("div", { className: "ui-sidebar-panel e04-panel" });
const main = el("div", { className: "e04-page" });
mountGroupStep(root, ROUTES.E04_STAB_3D, { mainContent: main, leftPanel: panel });
if (!projectId)
return void main.append(el("p", { className: "e04-empty", text: t("E04_NoProject") }));
// ---- 가운데 ----
const map = createPlanMap();
const view3d = createView3D();
view3d.root.hidden = true;
let mode: "plan" | "3d" = "plan";
let layer: MapLayer = "fs";
const toggle = (labels: [LocaleKey, string][], onPick: (value: string) => void) => {
const group = el("div", { className: "e04-toggle" });
const buttons = labels.map(([key, value], i) => {
const button = createButton({ label: t(key), variant: i ? "ghost" : "filled", size: "sm" });
button.dataset.value = value;
button.addEventListener("click", () => {
buttons.forEach((b) => b.classList.replace("ui-btn--filled", "ui-btn--ghost"));
button.classList.replace("ui-btn--ghost", "ui-btn--filled");
onPick(value);
});
return button;
});
group.append(...buttons);
return group;
};
const legendSlot = el("div", { className: "e04-legend-slot" });
const paintLegend = (): void => legendSlot.replaceChildren(legend(layer));
const stage = el("div", { className: "e04-stage" });
stage.append(map.root, view3d.root, legendSlot);
const toolbar = el("div", { className: "e04-toolbar" });
toolbar.append(
toggle(
[
["E04_View_Plan", "plan"],
["E04_View_3D", "3d"],
],
(value) => {
mode = value as "plan" | "3d";
map.root.hidden = mode !== "plan";
view3d.root.hidden = mode !== "3d";
view3d.setActive(mode === "3d");
if (mode === "3d") view3d.show(result, overview?.line ?? [], layer);
},
),
toggle(
[
["E04_Layer_FS", "fs"],
["E04_Layer_Volume", "volume"],
],
(value) => {
layer = value as MapLayer;
map.setLayer(layer);
if (mode === "3d") view3d.show(result, overview?.line ?? [], layer);
paintLegend();
},
),
);
const center = el("div", { className: "e04-center" });
center.append(toolbar, stage);
paintLegend();
// ---- 우 ----
const resultBody = el("div", { className: "e04-result" });
const side = createSidePanels({
prefKey: "e04-stab3d-result",
panels: [{ id: "result", title: t("E04_Result_Title"), body: resultBody }],
defaultOpen: "result",
defaultWidth: 320,
follow: false,
});
const row = el("div", { className: "ui-side-panels-row e04-row" });
row.append(center, side.root);
main.append(row);
// ---- 좌 ----
const sectionList = el("div", { className: "e04-sections" });
const sectionCard = createPanelCard(t("E04_Card_Sections"), false);
const settings = buildSettingsCards();
sectionCard.append(sectionList, settings.rangeRow);
const progressBar = el("progress", { className: "e04-progress", attrs: { max: "100" } });
const progressText = el("p", { className: "e04-progress__text" });
const cancelButton = createButton({
label: t("E04_Cancel"),
variant: "danger-ghost",
size: "sm",
onClick: () => void api(`/projects/${projectId}/stab/analyze3d/cancel`, { method: "POST" }),
});
const progressBox = el("div", { className: "e04-progress-box" });
progressBox.append(progressBar, progressText, cancelButton);
progressBox.hidden = true;
const analyzeButton = createButton({
label: t("E04_Analyze"),
variant: "filled",
onClick: () => void analyze(false),
});
const reanalyzeButton = createButton({
label: t("E04_Reanalyze"),
variant: "ghost",
onClick: () => void analyze(true),
});
analyzeButton.disabled = reanalyzeButton.disabled = true;
const actions = el("div", { className: "ui-sidebar-actions" });
actions.append(reanalyzeButton, analyzeButton);
const readOnly = el("p", { className: "e04-hint", text: t("E04_ReadOnly") });
readOnly.hidden = true;
panel.append(
el("p", { className: "e04-hint", text: t("E04_Hint") }),
readOnly,
sectionCard,
settings.basicCard,
settings.advancedCard,
progressBox,
actions,
);
attachCollapsible(panel);
// ---- 상태 ----
let overview: Overview | null = null;
let result: Result3d | null = null;
let stations2d: { chainage_m: number; fs_min: number | null }[] = [];
let running = false;
const remember = (): void =>
sessionStorage.setItem(cacheKey(projectId), JSON.stringify(settings.read()));
settings.onChange(() => {
remember();
const { start_m, end_m } = settings.read();
map.setRange(start_m, end_m);
paintSections();
if (result && result.key !== sectionKey(start_m, end_m)) void openResult();
});
function paintButtons(): void {
const editable = Boolean(overview?.editable);
analyzeButton.disabled = reanalyzeButton.disabled = !editable || running || !overview;
readOnly.hidden = !overview || editable;
}
function paintSections(): void {
if (!overview) return;
const { start_m, end_m } = settings.read();
const savedOf = new Map(overview.saved.map((s) => [s.key, s]));
const custom = overview.saved.filter(
(s) => !overview!.sections.some((x) => sectionKey(x.start_m, x.end_m) === s.key),
);
const items = [
...overview.sections,
...custom.map((s) => ({ start_m: s.start_m, end_m: s.end_m })),
];
sectionList.replaceChildren(
...items.map((section) => {
const key = sectionKey(section.start_m, section.end_m);
const saved = savedOf.get(key);
const button = el("button", {
className: `e04-section${key === sectionKey(start_m, end_m) ? " is-active" : ""}`,
attrs: { type: "button", "data-key": key },
});
const chip = el("i", { className: "e04-section__chip" });
chip.style.background = saved ? stepColor(FS_STEPS, saved.f_min) : "transparent";
button.append(
chip,
el("span", { text: `${section.start_m.toFixed(0)} – ${section.end_m.toFixed(0)} m` }),
el("span", {
className: "e04-section__value",
text: saved
? `Fs ${saved.f_min.toFixed(2)}${saved.stale ? ` · ${t("E04_Stale")}` : ""}`
: "",
}),
);
button.addEventListener("click", () => settings.setRange(section.start_m, section.end_m));
return button;
}),
);
}
function paintResult(): void {
const { start_m, end_m } = settings.read();
if (!result) {
resultBody.replaceChildren(el("p", { className: "e04-empty", text: t("E04_Result_None") }));
return;
}
const s = result.summary;
const f2d = min2d(stations2d, start_m, end_m);
const ratio = ratio3d2d(s.f_min, f2d);
const station = overview?.stations.reduce<StationPoint | null>(
(best, x) =>
!best || Math.abs(x.chainage_m - s.chainage_m) < Math.abs(best.chainage_m - s.chainage_m)
? x
: best,
null,
);
const rows: [LocaleKey, string, string?][] = [
["E04_Result_F3d", s.f_min.toFixed(3), stepColor(FS_STEPS, s.f_min)],
["E04_Result_F2d", f2d === null ? t("E04_No2D") : f2d.toFixed(3)],
["E04_Result_Ratio", ratio === null ? "-" : ratio.toFixed(3)],
["E04_Result_Volume", `${Math.round(s.volume).toLocaleString()} m³`],
["E04_Result_Method", t(`E04_MethodName_${s.method}` as LocaleKey)],
["E04_Result_Where", station?.label ?? `${s.chainage_m.toFixed(1)} m`],
["E04_Result_Cells", s.cells.toLocaleString()],
["E04_Result_Trials", s.trials.toLocaleString()],
["E04_Result_Time", `${result.elapsed_s.toFixed(1)} s`],
];
const list = el("dl", { className: "e04-result__list" });
for (const [key, value, color] of rows) {
const dd = el("dd", { text: value, attrs: { "data-field": key } });
if (color) {
const chip = el("i", { className: "e04-section__chip" });
chip.style.background = color;
dd.prepend(chip);
}
list.append(el("dt", { text: t(key) }), dd);
}
const notes: HTMLElement[] = [];
if (result.stale) notes.push(el("p", { className: "e04-warn", text: t("E04_Result_Stale") }));
if (result.preview)
notes.push(el("p", { className: "e04-note", text: t("E04_Result_Preview") }));
resultBody.replaceChildren(list, ...notes);
}
async function openResult(): Promise<void> {
const { start_m, end_m } = settings.read();
const key = sectionKey(start_m, end_m);
const saved = overview?.saved.some((s) => s.key === key);
result = saved
? await api<Result3d>(`/projects/${projectId}/stab/result3d?key=${key}`).catch(() => null)
: null;
if (key !== sectionKey(settings.read().start_m, settings.read().end_m)) return; // 그새 바뀜
map.setResult(result);
if (mode === "3d") view3d.show(result, overview?.line ?? [], layer);
paintResult();
}
async function refresh(): Promise<void> {
overview = await api<Overview>(`/projects/${projectId}/stab/result3d`);
paintButtons();
paintSections();
}
async function poll(): Promise<void> {
while (root.isConnected) {
const p = await api<Progress>(`/projects/${projectId}/stab/progress3d`).catch(() => null);
if (!p) break;
running = p.running;
progressBox.hidden = !p.running;
progressBar.value = p.percent;
progressText.textContent = `${t("E04_Running")} ${p.percent}% · ${p.message ?? ""}`;
paintButtons();
if (!p.running) {
if (p.error) showToast(p.error, "error");
else if (p.cancelled) showToast(t("E04_Cancelled"), "info");
else showToast(t("E04_Done"), "success");
await refresh();
await openResult();
return;
}
await new Promise((resolve) => setTimeout(resolve, 700));
}
}
async function analyze(force: boolean): Promise<void> {
const values = settings.read();
const { start_m, end_m, method, ...search } = values;
try {
const body = await api<{ status: string }>(`/projects/${projectId}/stab/analyze3d`, {
method: "POST",
body: JSON.stringify({ start_m, end_m, method, search, force }),
});
if (body.status === "cached") {
showToast(t("E04_Cached"), "info");
await openResult();
return;
}
running = true;
paintButtons();
progressBox.hidden = false;
void poll();
} catch (error) {
showToast(error instanceof Error ? error.message : String(error), "error");
}
}
// ---- 처음 ----
void (async () => {
try {
await refresh();
} catch (error) {
main.replaceChildren(
el("p", {
className: "e04-empty",
text: error instanceof Error ? error.message : String(error),
}),
);
return;
}
const ov = overview!;
settings.setDefaults(ov.defaults, ov.max_section_m);
const cached = JSON.parse(
sessionStorage.getItem(cacheKey(projectId)) ?? "null",
) as Settings | null;
const first = ov.saved[0] ?? ov.sections[0];
settings.write(
cached ?? {
...ov.defaults,
start_m: first?.start_m ?? 0,
end_m: first?.end_m ?? 100,
method: "bishop",
},
);
map.setRoute(ov.line, ov.stations);
const { start_m, end_m } = settings.read();
map.setRange(start_m, end_m);
paintSections();
stations2d = await fetch2d(projectId);
await openResult();
const p = await api<Progress>(`/projects/${projectId}/stab/progress3d`).catch(() => null);
if (p?.running) void poll();
})();
// 떠날 때 — WebGL · 관찰자 정리
const leave = new MutationObserver(() => {
if (root.isConnected && main.isConnected) return;
leave.disconnect();
map.dispose();
view3d.dispose();
});
leave.observe(document.body, { childList: true, subtree: true });
}
+141
View File
@@ -0,0 +1,141 @@
/* =============================================================================
* E04_Stab3D_UI_Settings.ts
* E04 좌측 탐색 설정 칸(PLAN 533-3) — 구간 시작 · 끝 · 기본(모양 · 탐색 폭 · 방법) · 고급(접힘 ·
* 기둥 · 구 중심 격자 · 반지름 증분 · 깊이 · 부피 제약) · [기본값]. 기본값 = 서버 SearchSettings.
* ========================================================================== */
import { createPanelCard } from "@ui/ui_template_collapsible";
import {
createButton,
createInputField,
createSelectField,
el,
type InputFieldHandle,
} from "@ui/ui_template_elements";
import { t, type LocaleKey } from "@ui/ui_template_locale";
export interface Settings {
start_m: number;
end_m: number;
method: string;
shape: string;
ellipsoid_ratio: number;
box_half_width_m: number;
cell_m: number;
spacing_m: number;
h_min_m: number;
h_max_m: number;
h_step_m: number;
dr_m: number;
max_depth_m: number;
v_min_m3: number;
v_max_m3: number;
}
type NumberKey = Exclude<keyof Settings, "method" | "shape">;
const BASIC: [NumberKey, LocaleKey][] = [
["ellipsoid_ratio", "E04_Ratio"],
["box_half_width_m", "E04_BoxWidth"],
];
const ADVANCED: [NumberKey, LocaleKey][] = [
["cell_m", "E04_Cell"],
["spacing_m", "E04_Spacing"],
["h_min_m", "E04_HMin"],
["h_max_m", "E04_HMax"],
["h_step_m", "E04_HStep"],
["dr_m", "E04_Dr"],
["max_depth_m", "E04_MaxDepth"],
["v_min_m3", "E04_VMin"],
["v_max_m3", "E04_VMax"],
];
export function buildSettingsCards() {
const fields = new Map<NumberKey, InputFieldHandle>();
const listeners: (() => void)[] = [];
const changed = (): void => listeners.forEach((fn) => fn());
const number = (key: NumberKey, label: LocaleKey): HTMLElement => {
const field = createInputField({ label: t(label), type: "number", compact: true });
field.input.step = "any";
field.input.dataset.key = key;
field.input.addEventListener("change", changed);
fields.set(key, field);
return field.root;
};
const rangeRow = el("div", { className: "e04-range" });
rangeRow.append(number("start_m", "E04_Start"), number("end_m", "E04_End"));
const shape = createSelectField({
label: t("E04_Shape"),
compact: true,
options: (["both", "sphere", "ellipsoid"] as const).map((v) => ({
value: v,
text: t(`E04_Shape_${v}` as LocaleKey),
})),
onChange: changed,
});
const method = createSelectField({
label: t("E04_Method"),
compact: true,
options: (["bishop", "hovland"] as const).map((v) => ({
value: v,
text: t(`E04_Method_${v}` as LocaleKey),
})),
onChange: changed,
});
const basicCard = createPanelCard(t("E04_Card_Search"), false);
basicCard.append(shape.root, ...BASIC.map(([k, l]) => number(k, l)), method.root);
let defaults: Partial<Settings> = {};
const advancedCard = createPanelCard(t("E04_Card_Advanced"), true);
const grid = el("div", { className: "e04-advanced" });
grid.append(...ADVANCED.map(([k, l]) => number(k, l)));
advancedCard.append(
grid,
createButton({
label: t("E04_Defaults"),
variant: "ghost",
size: "sm",
onClick: () => {
const range = { start_m: read().start_m, end_m: read().end_m };
write({ ...read(), ...defaults, ...range, method: "bishop" });
changed();
},
}),
);
function read(): Settings {
const out = { shape: shape.select.value, method: method.select.value } as Settings;
for (const [key, field] of fields) out[key] = Number(field.input.value);
return out;
}
function write(values: Partial<Settings>): void {
for (const [key, field] of fields) {
const value = values[key];
if (typeof value === "number" && Number.isFinite(value)) field.input.value = String(value);
}
if (values.shape) shape.select.value = values.shape;
if (values.method) method.select.value = values.method;
}
return {
rangeRow,
basicCard,
advancedCard,
read,
write,
setRange(start: number, end: number) {
write({ start_m: start, end_m: end });
changed();
},
setDefaults(next: Partial<Settings>, maxSection: number) {
defaults = next;
fields.get("end_m")!.input.title = `≤ ${maxSection} m`;
},
onChange(fn: () => void) {
listeners.push(fn);
},
};
}
+261
View File
@@ -0,0 +1,261 @@
/* =============================================================================
* E04_Stab3D_UI_Style.css
* E04 3D 해석 — 좌측 구간 · 설정 · 진행 / 가운데 지도 · 3D · 범례 / 우측 결과
* 색은 테마 토큰만(밝음 · 어두움 같이) · 안전율 · 체적 색 단계만 logic 고정색
* ========================================================================== */
.e04-page {
height: 100%;
box-sizing: border-box;
}
.e04-row {
height: 100%;
}
.e04-center {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
height: 100%;
min-height: 0;
padding: var(--spacing-12) 0 var(--spacing-12) var(--spacing-12);
box-sizing: border-box;
}
.e04-toolbar {
display: flex;
gap: var(--spacing-12);
flex-wrap: wrap;
}
.e04-toggle {
display: inline-flex;
gap: var(--spacing-4);
}
.e04-stage {
position: relative;
flex: 1 1 auto;
min-height: 320px;
border-radius: var(--radius-sm);
background: var(--color-surface-raised);
overflow: hidden;
}
.e04-map,
.e04-view3d {
position: absolute;
inset: 0;
}
.e04-map[hidden],
.e04-view3d[hidden] {
display: none;
}
.e04-map__canvas,
.e04-view3d__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.e04-map__canvas {
cursor: grab;
}
.e04-map__tip {
position: absolute;
padding: var(--spacing-4) var(--spacing-8);
border-radius: var(--radius-sm);
background: var(--color-surface);
color: var(--color-text);
border: 1px solid var(--color-border);
font-size: 12px;
white-space: pre;
pointer-events: none;
}
.e04-map__empty {
position: absolute;
inset: auto 0 45% 0;
margin: 0;
text-align: center;
color: var(--color-text-muted);
pointer-events: none;
}
.e04-view3d__compass {
position: absolute;
top: var(--spacing-8);
right: var(--spacing-8);
}
.e04-view3d__tools {
position: absolute;
top: var(--spacing-8);
left: var(--spacing-8);
display: flex;
gap: var(--spacing-12);
padding: var(--spacing-4) var(--spacing-8);
border-radius: var(--radius-sm);
background: var(--color-surface);
color: var(--color-text);
font-size: 12px;
}
.e04-view3d__toggle {
display: inline-flex;
align-items: center;
gap: var(--spacing-4);
}
.e04-legend-slot {
position: absolute;
left: var(--spacing-8);
bottom: var(--spacing-8);
pointer-events: none;
}
.e04-legend {
display: flex;
flex-direction: column;
gap: 2px;
padding: var(--spacing-8);
border-radius: var(--radius-sm);
background: var(--color-surface);
color: var(--color-text);
border: 1px solid var(--color-border);
font-size: 12px;
}
.e04-legend__row {
display: flex;
align-items: center;
gap: var(--spacing-8);
}
.e04-legend__swatch,
.e04-section__chip {
display: inline-block;
width: 14px;
height: 10px;
flex: none;
border-radius: 2px;
border: 1px solid var(--color-border);
}
/* 좌측 */
.e04-hint,
.e04-note {
margin: 0 0 var(--spacing-8);
color: var(--color-text-muted);
font-size: 12px;
line-height: 1.5;
}
.e04-sections {
display: flex;
flex-direction: column;
gap: 2px;
max-height: 240px;
overflow: auto;
}
.e04-section {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: var(--spacing-8);
padding: var(--spacing-4) var(--spacing-8);
border: 1px solid transparent;
border-radius: var(--radius-sm);
background: transparent;
color: var(--color-text);
font: inherit;
font-size: 13px;
text-align: left;
cursor: pointer;
}
.e04-section:hover {
background: var(--color-surface-raised);
}
.e04-section.is-active {
border-color: var(--color-primary);
}
.e04-section__value {
color: var(--color-text-muted);
font-size: 12px;
}
.e04-range,
.e04-advanced {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--spacing-8);
margin-top: var(--spacing-8);
}
.e04-progress-box {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
margin: var(--spacing-8) 0;
}
.e04-progress-box[hidden] {
display: none;
}
.e04-progress {
width: 100%;
}
.e04-progress__text {
margin: 0;
color: var(--color-text);
font-size: 12px;
}
/* 우측 */
.e04-result {
padding: var(--spacing-12);
}
.e04-result__list {
display: grid;
grid-template-columns: auto 1fr;
gap: var(--spacing-8) var(--spacing-12);
margin: 0;
font-size: 13px;
}
.e04-result__list dt {
color: var(--color-text-muted);
}
.e04-result__list dd {
display: flex;
align-items: center;
gap: var(--spacing-8);
margin: 0;
color: var(--color-text);
font-weight: 600;
justify-content: flex-end;
}
.e04-warn {
margin: var(--spacing-12) 0 0;
color: var(--color-danger);
font-size: 12px;
}
.e04-empty {
margin: var(--spacing-16);
color: var(--color-text-muted);
}
+245
View File
@@ -0,0 +1,245 @@
/* =============================================================================
* E04_Stab3D_UI_View3D.ts
* E04 [3D 보기](PLAN 533-3) — 합성 지표(원지형 + 노선면 · 셀 색 = 최소 안전율 · 임계 체적) +
* 노선 중심선 + 임계 활동면(구 · 타원체를 지표 아래만 남긴 잘린 면) · 표시만.
* E 복사본에는 B04 지표면 모델 행 · 미리보기 GLB 가 없어 B05 `createRouteViewer.loadSurface` 대신
* 해석 격자(z)를 그대로 그림 — 좌표 변환은 B05 `modelToScene` 과 같은 꼴(x · z · −y).
* 메쉬 · 색 = logic/E04_Stab3D_Mesh · logic/E04_Stab3D_Raster.
* ========================================================================== */
import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import { createTerrainCompass } from "@ui/ui_template_compass";
import { el } from "@ui/ui_template_elements";
import { t } from "@ui/ui_template_locale";
import { heightAt, slipMesh, terrainMesh, type MeshArrays } from "./logic/E04_Stab3D_Mesh";
import {
FS_STEPS,
VOLUME_STEPS,
hexToRgb,
stepColor,
type Result3d,
} from "./logic/E04_Stab3D_Raster";
import type { MapLayer } from "./E04_Stab3D_UI_Map";
export interface View3D {
root: HTMLElement;
show: (result: Result3d | null, line: number[][], layer: MapLayer) => void;
setActive: (active: boolean) => void;
dispose: () => void;
}
const LIGHT_BG = 0xf5f7fa;
const DARK_BG = 0x251f38;
const SLIP_COLOR = 0x8b1a2b;
export function createView3D(): View3D {
const root = el("div", { className: "e04-view3d" });
const canvas = el("canvas", { className: "e04-view3d__canvas" });
const compass = createTerrainCompass({ sizePx: 88, className: "e04-view3d__compass" });
const translucent = el("input", { attrs: { type: "checkbox" } });
translucent.checked = true;
const slipToggle = el("input", { attrs: { type: "checkbox" } });
slipToggle.checked = true;
const tools = el("div", { className: "e04-view3d__tools" });
for (const [input, key] of [
[translucent, "E04_Translucent"],
[slipToggle, "E04_SlipSurface"],
] as const) {
const label = el("label", { className: "e04-view3d__toggle" });
label.append(input, el("span", { text: t(key) }));
tools.append(label);
}
root.append(canvas, compass.root, tools);
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
renderer.setPixelRatio(window.devicePixelRatio || 1);
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(45, 1, 0.5, 20000);
camera.up.set(0, 1, 0);
const controls = new OrbitControls(camera, canvas);
controls.enableDamping = true;
scene.add(new THREE.HemisphereLight(0xffffff, 0x64748b, 2.2));
const sun = new THREE.DirectionalLight(0xffffff, 1.4);
sun.position.set(-1, 2, 1);
scene.add(sun);
const content = new THREE.Group();
scene.add(content);
const background = (): void => {
const theme = document.documentElement.getAttribute("data-theme");
const dark =
theme === "dark" ||
(theme !== "light" && window.matchMedia("(prefers-color-scheme: dark)").matches);
scene.background = new THREE.Color(dark ? DARK_BG : LIGHT_BG);
};
background();
const themeWatch = new MutationObserver(background);
themeWatch.observe(document.documentElement, {
attributes: true,
attributeFilter: ["data-theme"],
});
let origin = { x: 0, y: 0, z: 0 };
let terrain: THREE.Mesh | null = null;
let slip: THREE.Mesh | null = null;
let counts = { terrain: 0, slip: 0, slipTriangles: 0 };
const toScene = (x: number, y: number, z: number): [number, number, number] => [
x - origin.x,
z - origin.z,
-(y - origin.y),
];
function geometry(mesh: MeshArrays, colors?: Float32Array): THREE.BufferGeometry {
const out = new Float32Array(mesh.positions.length);
for (let i = 0; i < mesh.positions.length; i += 3) {
out.set(toScene(mesh.positions[i], mesh.positions[i + 1], mesh.positions[i + 2]), i);
}
const g = new THREE.BufferGeometry();
g.setAttribute("position", new THREE.BufferAttribute(out, 3));
if (colors) g.setAttribute("color", new THREE.BufferAttribute(colors, 3));
g.setIndex(new THREE.BufferAttribute(mesh.indices, 1));
g.computeVertexNormals();
return g;
}
function clear(): void {
for (const child of [...content.children]) {
content.remove(child);
child.traverse((node) => {
const mesh = node as THREE.Mesh;
mesh.geometry?.dispose();
const material = mesh.material as THREE.Material | THREE.Material[] | undefined;
(Array.isArray(material) ? material : material ? [material] : []).forEach((m) =>
m.dispose(),
);
});
}
terrain = slip = null;
}
function applyToggles(): void {
if (terrain) {
const material = terrain.material as THREE.MeshLambertMaterial;
material.transparent = translucent.checked && slipToggle.checked;
material.opacity = material.transparent ? 0.55 : 1;
material.depthWrite = !material.transparent;
material.needsUpdate = true;
}
if (slip) slip.visible = slipToggle.checked;
}
translucent.addEventListener("change", applyToggles);
slipToggle.addEventListener("change", applyToggles);
function show(result: Result3d | null, line: number[][], layer: MapLayer): void {
clear();
counts = { terrain: 0, slip: 0, slipTriangles: 0 };
if (!result) return;
const { grid, z, best } = result;
const zs = z.filter((v): v is number => v !== null);
origin = {
x: grid.x0 + ((grid.nx - 1) * grid.dx) / 2,
y: grid.y0 + ((grid.ny - 1) * grid.dx) / 2,
z: zs.reduce((a, b) => a + b, 0) / Math.max(zs.length, 1),
};
// 지표 — 셀 색 = 지금 층(안전율 · 체적)
const ground = terrainMesh(grid, z);
const values = layer === "fs" ? result.f : result.v;
const steps = layer === "fs" ? FS_STEPS : VOLUME_STEPS;
const colors = new Float32Array(ground.cells.length * 3);
ground.cells.forEach((cell, i) => colors.set(hexToRgb(stepColor(steps, values[cell])), i * 3));
terrain = new THREE.Mesh(
geometry(ground, colors),
new THREE.MeshLambertMaterial({ vertexColors: true, side: THREE.DoubleSide }),
);
content.add(terrain);
counts.terrain = ground.cells.length;
// 임계 활동면 — 잘린 구 · 타원체(아래에서도 보이게 양면) + 테두리 선
const cut = slipMesh(grid, z, best);
counts.slip = cut.cells.length;
counts.slipTriangles = cut.indices.length / 3;
if (cut.indices.length) {
const g = geometry(cut);
slip = new THREE.Mesh(
g,
new THREE.MeshPhongMaterial({ color: SLIP_COLOR, side: THREE.DoubleSide, shininess: 30 }),
);
slip.add(
new THREE.LineSegments(
new THREE.WireframeGeometry(g),
new THREE.LineBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0.25 }),
),
);
content.add(slip);
}
// 노선 중심선 — 지표 위 0.3 m
const points = line
.map((p) => ({ p, h: heightAt(grid, z, p[0], p[1]) }))
.filter((q): q is { p: number[]; h: number } => q.h !== null)
.map(({ p, h }) => new THREE.Vector3(...toScene(p[0], p[1], h + 0.3)));
if (points.length >= 2) {
content.add(
new THREE.Line(
new THREE.BufferGeometry().setFromPoints(points),
new THREE.LineBasicMaterial({ color: 0x1f2937 }),
),
);
}
applyToggles();
// 사시도 — 남동쪽 위에서 격자 전체가 들어오게
const span = Math.max(grid.nx, grid.ny) * grid.dx;
const focus = new THREE.Vector3(...toScene(best.x, best.y, best.z - best.r));
controls.target.copy(focus);
camera.position.set(focus.x + span * 0.55, focus.y + span * 0.6, focus.z + span * 0.75);
controls.update();
}
let frame = 0;
const loop = (): void => {
frame = requestAnimationFrame(loop);
const w = root.clientWidth;
const h = root.clientHeight;
if (
w &&
h &&
(canvas.width !== Math.round(w * renderer.getPixelRatio()) || camera.aspect !== w / h)
) {
renderer.setSize(w, h, false);
camera.aspect = w / h;
camera.updateProjectionMatrix();
}
controls.update();
compass.update(
camera.position.x - controls.target.x,
camera.position.y - controls.target.y,
camera.position.z - controls.target.z,
);
renderer.render(scene, camera);
};
const probe = { counts: () => ({ ...counts }), scene, camera };
(window as unknown as { __e04View?: typeof probe }).__e04View = probe;
return {
root,
show,
setActive(active) {
if (active && !frame) loop();
if (!active && frame) {
cancelAnimationFrame(frame);
frame = 0;
}
},
dispose() {
cancelAnimationFrame(frame);
frame = 0;
clear();
themeWatch.disconnect();
controls.dispose();
renderer.dispose();
renderer.forceContextLoss();
if ((window as unknown as { __e04View?: unknown }).__e04View === probe)
delete (window as unknown as { __e04View?: unknown }).__e04View;
},
};
}
+3 -4
View File
@@ -1,7 +1,7 @@
/* =============================================================================
* E04_Stab3D_Route.ts
* 길 선언(PLAN 529-1) — 라우터가 페이지 폴더마다 draft/*_Route.ts 를 glob 해 등록.
* 로그인 · 회사 · 현재 프로젝트 필요 · 꼬리말 없음 · 본문 = 빈 단계(틀 · 패널 · 단계 막대)
* 길 선언(PLAN 529-1 · 533-3) — 라우터가 페이지 폴더마다 draft/*_Route.ts 를 glob 해 등록.
* 로그인 · 회사 · 현재 프로젝트 필요 · 꼬리말 없음 · 본문 = E04 3D 해석 페이지
* ========================================================================== */
import { ROUTES } from "@config/config_frontend";
@@ -12,6 +12,5 @@ export const ROUTE: DraftRoute = {
workflow: true,
project: true,
footerless: true,
load: async () =>
(await import("../../A00_Common/group_workflow")).groupStepPage(ROUTES.E04_STAB_3D),
load: async () => (await import("../E04_Stab3D_UI_Page")).renderE04Stab3D,
};
+106
View File
@@ -0,0 +1,106 @@
/* =============================================================================
* E04_Stab3D_Mesh.ts
* E04 3D 보기 메쉬(PLAN 533-3) — 합성 지표(원지형 + 노선면) 격자 메쉬 · 임계 활동면(구 · 타원체를
* 지표 아래만 남긴 잘린 면) · 지표 높이 보간. 좌표 = 모델(x 동 · y 북 · z 위) 그대로 —
* 화면 좌표 변환은 화면 파일 몫. 순수 함수만(DOM · three 금지).
* ========================================================================== */
import type { Grid, SlipBest } from "./E04_Stab3D_Raster";
export interface MeshArrays {
/** 꼭짓점 [x, y, z, …] */
positions: Float32Array;
/** 삼각형 꼭짓점 번호 */
indices: Uint32Array;
/** 꼭짓점마다 래스터 셀 번호(색칠 · 툴팁용) */
cells: Int32Array;
}
/** 격자 위 값이 있는 꼭짓점만 이어 메쉬 — 네 모서리 중 셋 이상 있으면 삼각형 */
function gridMesh(grid: Grid, height: (index: number) => number | null): MeshArrays {
const vertexOf = new Int32Array(grid.nx * grid.ny).fill(-1);
const positions: number[] = [];
const cells: number[] = [];
for (let iy = 0; iy < grid.ny; iy += 1) {
for (let ix = 0; ix < grid.nx; ix += 1) {
const index = iy * grid.nx + ix;
const z = height(index);
if (z === null) continue;
vertexOf[index] = cells.length;
positions.push(grid.x0 + ix * grid.dx, grid.y0 + iy * grid.dx, z);
cells.push(index);
}
}
const indices: number[] = [];
for (let iy = 0; iy + 1 < grid.ny; iy += 1) {
for (let ix = 0; ix + 1 < grid.nx; ix += 1) {
const a = vertexOf[iy * grid.nx + ix];
const b = vertexOf[iy * grid.nx + ix + 1];
const c = vertexOf[(iy + 1) * grid.nx + ix + 1];
const d = vertexOf[(iy + 1) * grid.nx + ix];
// 반시계(위에서 봄) — a b c · a c d
if (a >= 0 && b >= 0 && c >= 0 && d >= 0) indices.push(a, b, c, a, c, d);
else if (a >= 0 && b >= 0 && c >= 0) indices.push(a, b, c);
else if (a >= 0 && c >= 0 && d >= 0) indices.push(a, c, d);
else if (a >= 0 && b >= 0 && d >= 0) indices.push(a, b, d);
else if (b >= 0 && c >= 0 && d >= 0) indices.push(b, c, d);
}
}
return {
positions: Float32Array.from(positions),
indices: Uint32Array.from(indices),
cells: Int32Array.from(cells),
};
}
/** 합성 지표 메쉬 — z 가 null 인 셀은 뺌 */
export function terrainMesh(grid: Grid, z: readonly (number | null)[]): MeshArrays {
return gridMesh(grid, (index) => z[index]);
}
/** 활동면 깊이 — 구 · 타원체 아래 반쪽 높이(밖이면 null) */
export function slipHeight(best: SlipBest, x: number, y: number): number | null {
const az = (best.azimuth_deg * Math.PI) / 180;
const dx = x - best.x;
const dy = y - best.y;
// 활동 방향 성분 · 회전축(수직) 성분 — 축 방향만 ratio 로 나눔
const along = dx * Math.sin(az) + dy * Math.cos(az);
const axis = (dx * Math.cos(az) - dy * Math.sin(az)) / Math.max(best.ratio, 1e-9);
const rest = best.r * best.r - along * along - axis * axis;
return rest < 0 ? null : best.z - Math.sqrt(rest);
}
/** 임계 활동면 = 활동면이 지표보다 낮은 셀만(잘린 면) */
export function slipMesh(grid: Grid, z: readonly (number | null)[], best: SlipBest): MeshArrays {
return gridMesh(grid, (index) => {
const top = z[index];
if (top === null) return null;
const x = grid.x0 + (index % grid.nx) * grid.dx;
const y = grid.y0 + Math.floor(index / grid.nx) * grid.dx;
const slip = slipHeight(best, x, y);
return slip === null || slip >= top ? null : slip;
});
}
/** 지표 높이 쌍선형 보간(격자 밖 · 빈 셀 = null) */
export function heightAt(
grid: Grid,
z: readonly (number | null)[],
x: number,
y: number,
): number | null {
const fx = (x - grid.x0) / grid.dx;
const fy = (y - grid.y0) / grid.dx;
const ix = Math.floor(fx);
const iy = Math.floor(fy);
if (ix < 0 || iy < 0 || ix + 1 >= grid.nx || iy + 1 >= grid.ny) return null;
const at = (i: number, j: number): number | null => z[j * grid.nx + i];
const z00 = at(ix, iy);
const z10 = at(ix + 1, iy);
const z01 = at(ix, iy + 1);
const z11 = at(ix + 1, iy + 1);
if (z00 === null || z10 === null || z01 === null || z11 === null) return null;
const tx = fx - ix;
const ty = fy - iy;
return (z00 * (1 - tx) + z10 * tx) * (1 - ty) + (z01 * (1 - tx) + z11 * tx) * ty;
}
+195
View File
@@ -0,0 +1,195 @@
/* =============================================================================
* E04_Stab3D_Raster.ts
* E04 3D 해석 화면 계산(PLAN 533-3) — 래스터 꼴 · 색 단계 · 범례 · 셀 찾기 · 3D/2D 비.
* 래스터 꼴 = 서버 `E04_Stab3D_Run.py` 머리 주석(grid · z · f · v · m · best).
* 순수 함수만(DOM · three · fetch 금지).
* ========================================================================== */
export interface Grid {
x0: number;
y0: number;
dx: number;
nx: number;
ny: number;
}
/** 전체 최소 활동면 — 구(ratio 1) · 타원체(회전축 방향 반축 = ratio·r · 활동 방향에 수직) */
export interface SlipBest {
x: number;
y: number;
z: number;
r: number;
ratio: number;
azimuth_deg: number; // 활동 방향(북 0° · 시계)
f: number;
volume: number;
method: number;
ix: number;
iy: number;
}
export interface Summary3d {
start_m: number;
end_m: number;
f_min: number;
volume: number;
method: string;
chainage_m: number;
cells: number;
trials: number;
}
export interface Result3d {
key: string;
stale: boolean;
preview: boolean;
computed_at: string;
elapsed_s: number;
settings: Record<string, unknown>;
grid: Grid;
z: (number | null)[];
f: (number | null)[];
v: (number | null)[];
m: (number | null)[];
best: SlipBest;
summary: Summary3d;
}
export interface ColorStep {
/** 이 값 미만이면 이 색(마지막은 Infinity) */
below: number;
color: string;
}
/** 안전율 색 단계 — 1.3(우기) · 1.5(건기) = KDS 11 70 05 기준 경계 · 붉을수록 위험 */
export const FS_STEPS: readonly ColorStep[] = [
{ below: 1.0, color: "#a50026" },
{ below: 1.2, color: "#d73027" },
{ below: 1.3, color: "#f46d43" },
{ below: 1.5, color: "#fdae61" },
{ below: 2.0, color: "#fee08b" },
{ below: 3.0, color: "#a6d96a" },
{ below: Infinity, color: "#1a9850" },
];
/** 임계 체적 색 단계(m³ · 10 배씩) — 짙을수록 큼 */
export const VOLUME_STEPS: readonly ColorStep[] = [
{ below: 10, color: "#fde725" },
{ below: 100, color: "#5ec962" },
{ below: 1000, color: "#21918c" },
{ below: 10000, color: "#3b528b" },
{ below: Infinity, color: "#440154" },
];
export const NO_DATA_COLOR = "#9aa3ad";
/** 값 → 단계 번호(null · NaN = -1) */
export function stepIndex(steps: readonly ColorStep[], value: number | null): number {
if (value === null || !Number.isFinite(value)) return -1;
return steps.findIndex((step) => value < step.below);
}
export function stepColor(steps: readonly ColorStep[], value: number | null): string {
const index = stepIndex(steps, value);
return index < 0 ? NO_DATA_COLOR : steps[index].color;
}
/** "#rrggbb" → [r, g, b] 0~1 */
export function hexToRgb(hex: string): [number, number, number] {
const n = Number.parseInt(hex.slice(1), 16);
return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255];
}
const fmt = (value: number, digits: number): string =>
value >= 1000 ? value.toLocaleString("en-US") : value.toFixed(digits);
/** 범례 줄 — "< 1.0" · "1.0 – 1.2" · … · "≥ 3.0" */
export function legendRows(
steps: readonly ColorStep[],
digits: number,
): { color: string; label: string }[] {
return steps.map((step, index) => {
const low = index === 0 ? null : steps[index - 1].below;
const label =
low === null
? `< ${fmt(step.below, digits)}`
: step.below === Infinity
? `≥ ${fmt(low, digits)}`
: `${fmt(low, digits)} – ${fmt(step.below, digits)}`;
return { color: step.color, label };
});
}
/** 셀 (ix, iy) 중심 모델 좌표 */
export function cellCenter(grid: Grid, ix: number, iy: number): { x: number; y: number } {
return { x: grid.x0 + ix * grid.dx, y: grid.y0 + iy * grid.dx };
}
/** 모델 좌표 → 셀(격자 밖 = null) */
export function cellAt(grid: Grid, x: number, y: number): { ix: number; iy: number } | null {
const ix = Math.round((x - grid.x0) / grid.dx);
const iy = Math.round((y - grid.y0) / grid.dx);
return ix < 0 || iy < 0 || ix >= grid.nx || iy >= grid.ny ? null : { ix, iy };
}
/** 래스터 최소 셀(값 없으면 null) */
export function rasterMin(
grid: Grid,
values: readonly (number | null)[],
): { ix: number; iy: number; value: number } | null {
let best = -1;
values.forEach((value, index) => {
if (value !== null && (best < 0 || value < (values[best] as number))) best = index;
});
return best < 0
? null
: { ix: best % grid.nx, iy: Math.floor(best / grid.nx), value: values[best] as number };
}
/** 값이 있는 셀 수 */
export function countCells(values: readonly (number | null)[]): number {
return values.reduce<number>((n, value) => (value === null ? n : n + 1), 0);
}
/** 래스터 → RGBA(줄 0 = 북쪽 끝 · 캔버스 그림용) · 값 없는 셀 = 투명 */
export function rasterRgba(
grid: Grid,
values: readonly (number | null)[],
steps: readonly ColorStep[],
): Uint8ClampedArray<ArrayBuffer> {
const out = new Uint8ClampedArray(grid.nx * grid.ny * 4);
const rgb = steps.map((step) => hexToRgb(step.color).map((c) => Math.round(c * 255)));
for (let iy = 0; iy < grid.ny; iy += 1) {
const row = grid.ny - 1 - iy;
for (let ix = 0; ix < grid.nx; ix += 1) {
const index = stepIndex(steps, values[iy * grid.nx + ix]);
if (index < 0) continue;
const at = (row * grid.nx + ix) * 4;
out[at] = rgb[index][0];
out[at + 1] = rgb[index][1];
out[at + 2] = rgb[index][2];
out[at + 3] = 255;
}
}
return out;
}
/** 구간 안 2D 측점 최소 안전율(없으면 null) */
export function min2d(
stations: readonly { chainage_m: number; fs_min: number | null }[],
start: number,
end: number,
): number | null {
let best: number | null = null;
for (const s of stations) {
if (s.chainage_m < start - 1e-6 || s.chainage_m > end + 1e-6) continue;
if (s.fs_min === null || !Number.isFinite(s.fs_min)) continue;
if (best === null || s.fs_min < best) best = s.fs_min;
}
return best;
}
/** 3D/2D 비 — 둘 중 하나라도 없으면 null */
export function ratio3d2d(f3d: number | null, f2d: number | null): number | null {
return f3d === null || f2d === null || !(f2d > 0) ? null : f3d / f2d;
}
+170
View File
@@ -0,0 +1,170 @@
"""E04 화면 계산(PLAN 533-3) — `E04_Stab3D/logic/` 색 단계 · 범례 · 래스터 그림 · 최소 셀 · 3D/2D 비 ·
지표 메쉬 · 임계 활동면(잘린 구 · 타원체) 꼭짓점 — 손계산 · 파이썬 전수 대조.
./venv/Scripts/python.exe -m pytest resources/tester/test_533_3_e04_logic.py -q
"""
from __future__ import annotations
import json
import math
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
LOGIC = ROOT / "E04_Stab3D" / "logic"
TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
GRID = {"x0": 100.0, "y0": 200.0, "dx": 1.0, "nx": 21, "ny": 15}
# 북쪽(+y)으로 오르는 비탈 · 한 셀 빔
Z = [
None if (ix, iy) == (3, 4) else 50.0 + 0.5 * iy
for iy in range(GRID["ny"])
for ix in range(GRID["nx"])
]
SPHERE = {"x": 110.0, "y": 207.0, "z": 58.0, "r": 6.0, "ratio": 1.0, "azimuth_deg": 0.0}
ELLIPSOID = {**SPHERE, "ratio": 2.0, "azimuth_deg": 180.0} # 남쪽으로 미끄러짐 · 동서로 긴 면
F = [
None if z is None else round(1.0 + abs(i % 21 - 10) * 0.1 + i // 21 * 0.05, 3)
for i, z in enumerate(Z)
]
F[7 * 21 + 10] = 0.95 # 최소
DRIVER = """
import { readFileSync, writeFileSync } from "node:fs";
import * as R from "./E04_Stab3D_Raster.js";
import * as M from "./E04_Stab3D_Mesh.js";
const c = JSON.parse(readFileSync(process.argv[2], "utf-8"));
const mesh = (m) => ({ n: m.positions.length / 3, tri: m.indices.length / 3,
cells: Array.from(m.cells), zs: Array.from(m.positions.filter((_, i) => i % 3 === 2)) });
const rgba = R.rasterRgba(c.grid, c.f, R.FS_STEPS);
writeFileSync(process.argv[3], JSON.stringify({
steps: [0.5, 1.0, 1.25, 1.3, 1.49, 2.9, 3.0, 99, null].map((v) => R.stepIndex(R.FS_STEPS, v)),
colorNull: R.stepColor(R.FS_STEPS, null),
legend: R.legendRows(R.FS_STEPS, 1).map((r) => r.label),
legendV: R.legendRows(R.VOLUME_STEPS, 0).map((r) => r.label),
rgb: R.hexToRgb("#a50026"),
min: R.rasterMin(c.grid, c.f),
count: R.countCells(c.f),
cellAt: [R.cellAt(c.grid, 110.4, 206.6), R.cellAt(c.grid, 99, 200)],
// 남서 셀(0,0) = 그림 맨 아래 줄 · 최소 셀 픽셀 = 가장 붉은 색
pixel00: Array.from(rgba.slice(((c.grid.ny - 1) * c.grid.nx) * 4, ((c.grid.ny - 1) * c.grid.nx) * 4 + 4)),
pixelMin: Array.from(rgba.slice(((c.grid.ny - 1 - 7) * c.grid.nx + 10) * 4, ((c.grid.ny - 1 - 7) * c.grid.nx + 10) * 4 + 4)),
pixelEmpty: rgba[((c.grid.ny - 1 - 4) * c.grid.nx + 3) * 4 + 3],
min2d: R.min2d([{ chainage_m: 0, fs_min: 1.1 }, { chainage_m: 20, fs_min: 1.4 },
{ chainage_m: 40, fs_min: null }, { chainage_m: 60, fs_min: 0.9 }], 10, 50),
ratio: [R.ratio3d2d(1.68, 1.4), R.ratio3d2d(1.5, null), R.ratio3d2d(1.5, 0)],
terrain: mesh(M.terrainMesh(c.grid, c.z)),
sphere: mesh(M.slipMesh(c.grid, c.z, c.sphere)),
ellipsoid: mesh(M.slipMesh(c.grid, c.z, c.ellipsoid)),
hCenter: M.slipHeight(c.sphere, 110, 207),
hEdge: M.slipHeight(c.sphere, 116.5, 207),
hEll: [M.slipHeight(c.ellipsoid, 121, 207), M.slipHeight(c.ellipsoid, 110, 212)],
height: [M.heightAt(c.grid, c.z, 110.5, 207.5), M.heightAt(c.grid, c.z, 103.5, 204.5),
M.heightAt(c.grid, c.z, 500, 0)],
}));
"""
@pytest.fixture(scope="module")
def ts(tmp_path_factory: pytest.TempPathFactory) -> dict:
out = tmp_path_factory.mktemp("e04_logic")
subprocess.run( # noqa: S603 — 고정 실행 파일
["node", str(TSC), str(LOGIC / "E04_Stab3D_Raster.ts"), str(LOGIC / "E04_Stab3D_Mesh.ts"),
"--outDir", str(out), "--module", "esnext", "--target", "es2022",
"--moduleResolution", "bundler", "--ignoreConfig", "--noCheck", "--noResolve"],
cwd=str(ROOT), check=True, capture_output=True,
) # fmt: skip
(out / "driver.mjs").write_text(DRIVER, encoding="utf-8")
payload = {"grid": GRID, "z": Z, "f": F, "sphere": SPHERE, "ellipsoid": ELLIPSOID}
(out / "in.json").write_text(json.dumps(payload), encoding="utf-8")
run = subprocess.run( # noqa: S603
["node", str(out / "driver.mjs"), str(out / "in.json"), str(out / "out.json")],
capture_output=True, text=True, cwd=str(out),
) # fmt: skip
assert run.returncode == 0, run.stderr
return json.loads((out / "out.json").read_text(encoding="utf-8"))
def test_color_steps_and_legend(ts):
# 경계값은 위 단계(1.0 → 1.0~1.2) · null = -1
assert ts["steps"] == [0, 1, 2, 3, 3, 5, 6, 6, -1]
assert ts["colorNull"] == "#9aa3ad"
assert ts["legend"] == [
"< 1.0",
"1.0 – 1.2",
"1.2 – 1.3",
"1.3 – 1.5",
"1.5 – 2.0",
"2.0 – 3.0",
"≥ 3.0",
]
assert ts["legendV"] == ["< 10", "10 – 100", "100 – 1,000", "1,000 – 10,000", "≥ 10,000"]
assert ts["rgb"] == pytest.approx([165 / 255, 0, 38 / 255])
def test_raster_min_pixels_ratio(ts):
assert ts["min"] == {"ix": 10, "iy": 7, "value": 0.95}
assert ts["count"] == GRID["nx"] * GRID["ny"] - 1
assert ts["cellAt"] == [{"ix": 10, "iy": 7}, None]
assert ts["pixelMin"] == [165, 0, 38, 255] # 최소 셀 = 가장 붉은 단계
assert ts["pixel00"][3] == 255 and ts["pixel00"][:3] != [165, 0, 38]
assert ts["pixelEmpty"] == 0 # 빈 셀 = 투명
assert ts["min2d"] == 1.4 # 10~50 m 안 = 20 m(1.4) · null 뺌
assert ts["ratio"][0] == pytest.approx(1.2) and ts["ratio"][1:] == [None, None]
def _py_slip(best, x, y):
az = math.radians(best["azimuth_deg"])
dx, dy = x - best["x"], y - best["y"]
along = dx * math.sin(az) + dy * math.cos(az)
axis = (dx * math.cos(az) - dy * math.sin(az)) / best["ratio"]
rest = best["r"] ** 2 - along**2 - axis**2
return None if rest < 0 else best["z"] - math.sqrt(rest)
def _py_cut(best):
out = []
for index, top in enumerate(Z):
if top is None:
continue
x = GRID["x0"] + index % GRID["nx"]
y = GRID["y0"] + index // GRID["nx"]
slip = _py_slip(best, x, y)
if slip is not None and slip < top:
out.append((index, slip))
return out
def test_terrain_mesh(ts):
nx, ny = GRID["nx"], GRID["ny"]
assert ts["terrain"]["n"] == nx * ny - 1
# 빈 셀 하나 = 네 칸이 삼각형 하나씩 잃음 → 칸 수 × 2 − 4
assert ts["terrain"]["tri"] == (nx - 1) * (ny - 1) * 2 - 4
assert 4 * nx + 3 not in ts["terrain"]["cells"]
@pytest.mark.parametrize("name", ["sphere", "ellipsoid"])
def test_slip_mesh_vertices(ts, name):
best = SPHERE if name == "sphere" else ELLIPSOID
want = _py_cut(best)
got = ts[name]
assert got["n"] == len(want) > 20
assert got["cells"] == [i for i, _ in want]
assert got["zs"] == pytest.approx([z for _, z in want], abs=1e-3) # Float32
assert got["tri"] > 0
# 활동면 꼭짓점은 모두 지표 아래
assert all(z < Z[i] for i, z in want)
def test_slip_height_and_terrain_height(ts):
assert ts["hCenter"] == pytest.approx(52.0) # 중심 바로 아래 = z − r
assert ts["hEdge"] is None # 반지름 밖
# 타원체: 회전축(동서) 방향 11 m 는 반축 12 m 안 · 활동 방향(남북) 5 m
assert ts["hEll"][0] == pytest.approx(58.0 - math.sqrt(36 - (11 / 2) ** 2))
assert ts["hEll"][1] == pytest.approx(58.0 - math.sqrt(36 - 25))
assert ts["height"][0] == pytest.approx(50.0 + 0.5 * 7.5)
assert ts["height"][1] is None # 빈 셀 이웃
assert ts["height"][2] is None
+2 -1
View File
@@ -64,6 +64,7 @@ def project(tmp_path, monkeypatch):
def client(project):
app = FastAPI()
app.include_router(router_mod.router)
app.dependency_overrides[auth.require_company] = lambda: {"role": "SYSTEM_ADMIN"}
with TestClient(app) as c:
yield c
@@ -133,7 +134,7 @@ def test_analyze_cached_force_and_result(client, project, monkeypatch):
assert doc["stale"] is False
over = client.get(f"/api/projects/{PID}/stab/result3d").json()
assert [s["key"] for s in over["saved"]] == ["0.00_100.00"]
assert [s["key"] for s in over["saved"]] == ["0.00_100.00"] and over["editable"] is True
assert len(over["line"]) == len(STATIONS) and over["sections"][0]["end_m"] == 100.0
# E02 지반을 바꾸면 저장본이 「옛 것」
(project / "E02_StabGround").mkdir()
+3
View File
@@ -15,6 +15,7 @@
* - ui_template_locale_b2.ts : B 그룹 후반부 (B05~B07)
* - ui_template_locale_b5.ts : B 그룹 끝 (B08~B11)
* - ui_template_locale_de.ts : D 예정노선 · E 안정성평가 · 대시보드 [+] 고르기
* - ui_template_locale_e04.ts : E04 안정성평가 3D 해석
*
* 이 파일의 export 시그니처는 분할 이전과 동일하므로 소비처 import 수정은 불필요하다.
* ========================================================================== */
@@ -26,6 +27,7 @@ import { ui_locales_a } from "./ui_template_locale_a";
import { ui_locales_b1 } from "./ui_template_locale_b1";
import { ui_locales_b2 } from "./ui_template_locale_b2";
import { ui_locales_b3 } from "./ui_template_locale_b3";
import { ui_locales_e04 } from "./ui_template_locale_e04";
import { ui_locales_b5 } from "./ui_template_locale_b5";
import { ui_locales_m1 } from "./ui_template_locale_m1";
import { ui_locales_m2 } from "./ui_template_locale_m2";
@@ -64,6 +66,7 @@ export const ui_locales = {
...ui_locales_b1,
...ui_locales_b2,
...ui_locales_b3,
...ui_locales_e04,
...ui_locales_b5,
...ui_locales_m1,
...ui_locales_m2,
+89
View File
@@ -0,0 +1,89 @@
/* =============================================================================
* ui_template_locale_e04.ts
* 다국어 사전 — E04 안정성평가 3D 해석(요구 347-1 · PLAN 533-3)
*
* 신규 문구는 해당 섹션 최하단에 추가.
* 이 파일은 사전 데이터만 담는다. 합성·언어 전환·t() 헬퍼는 ui_template_locale.ts 참조.
* ========================================================================== */
import type { LocaleEntry } from "./ui_template_locale_common";
export const ui_locales_e04 = {
/* --- 좌측 패널 --- */
E04_NoProject: ["현재 프로젝트가 없습니다.", "No current project."],
E04_Hint: [
"탐색 = USGS Scoops3D 방식 구 · 타원체 격자 + 부피 제약 · 해석 = Hungr 3D Bishop(못 뚫는 층에 잘리면 Janbu 보정).",
"Search = Scoops3D-style sphere/ellipsoid grid with volume limits · Hungr 3D Bishop (Janbu corrected when truncated).",
],
E04_Card_Sections: ["구간", "Sections"],
E04_Card_Search: ["탐색 설정", "Search"],
E04_Card_Advanced: ["고급 설정", "Advanced"],
E04_Start: ["시작 (m)", "Start (m)"],
E04_End: ["끝 (m)", "End (m)"],
E04_Shape: ["활동면 모양", "Slip shape"],
E04_Shape_sphere: ["구", "Sphere"],
E04_Shape_ellipsoid: ["타원체", "Ellipsoid"],
E04_Shape_both: ["구 + 타원체", "Sphere + ellipsoid"],
E04_Ratio: ["타원체 길이비", "Ellipsoid ratio"],
E04_BoxWidth: ["탐색 폭 (중심선 ±m)", "Search half-width (m)"],
E04_Cell: ["기둥 크기 (m)", "Column size (m)"],
E04_Spacing: ["구 중심 간격 (m)", "Center spacing (m)"],
E04_HMin: ["중심 높이 최소 (m)", "Center height min (m)"],
E04_HMax: ["중심 높이 최대 (m)", "Center height max (m)"],
E04_HStep: ["중심 높이 간격 (m)", "Center height step (m)"],
E04_Dr: ["반지름 증분 (m)", "Radius increment (m)"],
E04_MaxDepth: ["최대 깊이 (m)", "Max depth (m)"],
E04_VMin: ["최소 부피 (m³)", "Min volume (m³)"],
E04_VMax: ["최대 부피 (m³)", "Max volume (m³)"],
E04_Method: ["해석 방법", "Method"],
E04_Method_bishop: ["3D Bishop (Hungr) + Janbu 보정", "3D Bishop (Hungr) + corrected Janbu"],
E04_Method_hovland: ["Hovland (비교)", "Hovland (comparison)"],
E04_Defaults: ["기본값", "Defaults"],
E04_Analyze: ["해석", "Analyze"],
E04_Reanalyze: ["다시 해석", "Re-analyze"],
E04_Cancel: ["끊기", "Stop"],
E04_Running: ["해석 중", "Analyzing"],
E04_Cancelled: ["해석을 끊었습니다.", "Analysis stopped."],
E04_Cached: ["입력이 같아 저장된 결과를 엽니다.", "Same inputs — opening the saved result."],
E04_Done: ["3D 해석이 끝났습니다.", "3D analysis finished."],
E04_ReadOnly: ["보기만 허용된 프로젝트입니다.", "View-only project."],
E04_Saved: ["결과 있음", "Saved"],
E04_Stale: ["옛 결과", "Outdated"],
/* --- 가운데 지도 · 3D --- */
E04_View_Plan: ["평면 지도", "Plan map"],
E04_View_3D: ["3D 보기", "3D view"],
E04_Layer_FS: ["최소 안전율", "Min. FS"],
E04_Layer_Volume: ["임계 체적", "Critical volume"],
E04_Legend_FS: ["셀별 최소 안전율", "Min. factor of safety per cell"],
E04_Legend_Volume: ["임계 체적 (m³)", "Critical volume (m³)"],
E04_NoData: ["값 없음", "No data"],
E04_Tip_Station: ["가까운 측점", "Nearest station"],
E04_Translucent: ["지표 반투명", "Translucent ground"],
E04_SlipSurface: ["임계 활동면", "Critical slip surface"],
E04_Map_Empty: ["구간을 고르고 [해석]을 누르세요.", "Pick a section and press [Analyze]."],
/* --- 우측 결과 --- */
E04_Result_Title: ["구간 결과", "Section result"],
E04_Result_F3d: ["3D 최소 안전율", "3D min. FS"],
E04_Result_F2d: ["2D 최소 안전율 (같은 구간)", "2D min. FS (same section)"],
E04_Result_Ratio: ["3D / 2D 비", "3D / 2D ratio"],
E04_Result_Volume: ["임계 체적", "Critical volume"],
E04_Result_Method: ["다스린 방법", "Governing method"],
E04_Result_Where: ["위치 (가까운 측점)", "Location (nearest station)"],
E04_Result_Cells: ["셀 수", "Cells"],
E04_Result_Trials: ["시험 활동면 수", "Trial surfaces"],
E04_Result_Time: ["계산 시간", "Run time"],
E04_Result_None: ["이 구간 결과가 없습니다 — [해석]을 누르세요.", "No result for this section."],
E04_Result_Stale: [
"입력(지반 조건 · 탐색 설정 · 엔진)이 바뀌었습니다 — 다시 해석하세요.",
"Inputs changed — re-analyze.",
],
E04_Result_Preview: [
"미리보기 엔진 결과(3D 엔진 연결 전 · 지형 경사로 만든 값)",
"Preview engine (slope-based placeholder)",
],
E04_No2D: ["2D 결과 없음", "No 2D result"],
E04_MethodName_bishop: ["3D Bishop", "3D Bishop"],
E04_MethodName_janbu_corrected: ["3D Janbu 보정", "3D corrected Janbu"],
} as const satisfies Record<string, LocaleEntry>;