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:
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>;
|
||||
Reference in New Issue
Block a user