diff --git a/E04_Stab3D/E04_Stab3D_Router.py b/E04_Stab3D/E04_Stab3D_Router.py index 30a1b8e82..56a21b515 100644 --- a/E04_Stab3D/E04_Stab3D_Router.py +++ b/E04_Stab3D/E04_Stab3D_Router.py @@ -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) diff --git a/E04_Stab3D/E04_Stab3D_UI_Map.ts b/E04_Stab3D/E04_Stab3D_UI_Map.ts new file mode 100644 index 000000000..144db2268 --- /dev/null +++ b/E04_Stab3D/E04_Stab3D_UI_Map.ts @@ -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 = 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( + (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; + }, + }; +} diff --git a/E04_Stab3D/E04_Stab3D_UI_Page.ts b/E04_Stab3D/E04_Stab3D_UI_Page.ts new file mode 100644 index 000000000..5e6b07a1c --- /dev/null +++ b/E04_Stab3D/E04_Stab3D_UI_Page.ts @@ -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(path: string, init?: RequestInit): Promise { + 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[] }>( + `/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( + (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 { + 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(`/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 { + overview = await api(`/projects/${projectId}/stab/result3d`); + paintButtons(); + paintSections(); + } + + async function poll(): Promise { + while (root.isConnected) { + const p = await api(`/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 { + 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(`/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 }); +} diff --git a/E04_Stab3D/E04_Stab3D_UI_Settings.ts b/E04_Stab3D/E04_Stab3D_UI_Settings.ts new file mode 100644 index 000000000..69377dded --- /dev/null +++ b/E04_Stab3D/E04_Stab3D_UI_Settings.ts @@ -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; + +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(); + 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 = {}; + 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): 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, maxSection: number) { + defaults = next; + fields.get("end_m")!.input.title = `≤ ${maxSection} m`; + }, + onChange(fn: () => void) { + listeners.push(fn); + }, + }; +} diff --git a/E04_Stab3D/E04_Stab3D_UI_Style.css b/E04_Stab3D/E04_Stab3D_UI_Style.css new file mode 100644 index 000000000..54a4a56ea --- /dev/null +++ b/E04_Stab3D/E04_Stab3D_UI_Style.css @@ -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); +} diff --git a/E04_Stab3D/E04_Stab3D_UI_View3D.ts b/E04_Stab3D/E04_Stab3D_UI_View3D.ts new file mode 100644 index 000000000..962d54ed3 --- /dev/null +++ b/E04_Stab3D/E04_Stab3D_UI_View3D.ts @@ -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; + }, + }; +} diff --git a/E04_Stab3D/draft/E04_Stab3D_Route.ts b/E04_Stab3D/draft/E04_Stab3D_Route.ts index 0f7aa9118..34dd043be 100644 --- a/E04_Stab3D/draft/E04_Stab3D_Route.ts +++ b/E04_Stab3D/draft/E04_Stab3D_Route.ts @@ -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, }; diff --git a/E04_Stab3D/logic/E04_Stab3D_Mesh.ts b/E04_Stab3D/logic/E04_Stab3D_Mesh.ts new file mode 100644 index 000000000..94f7ab968 --- /dev/null +++ b/E04_Stab3D/logic/E04_Stab3D_Mesh.ts @@ -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; +} diff --git a/E04_Stab3D/logic/E04_Stab3D_Raster.ts b/E04_Stab3D/logic/E04_Stab3D_Raster.ts new file mode 100644 index 000000000..dd73bdaac --- /dev/null +++ b/E04_Stab3D/logic/E04_Stab3D_Raster.ts @@ -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; + 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((n, value) => (value === null ? n : n + 1), 0); +} + +/** 래스터 → RGBA(줄 0 = 북쪽 끝 · 캔버스 그림용) · 값 없는 셀 = 투명 */ +export function rasterRgba( + grid: Grid, + values: readonly (number | null)[], + steps: readonly ColorStep[], +): Uint8ClampedArray { + 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; +} diff --git a/resources/tester/test_533_3_e04_logic.py b/resources/tester/test_533_3_e04_logic.py new file mode 100644 index 000000000..4071f4ec2 --- /dev/null +++ b/resources/tester/test_533_3_e04_logic.py @@ -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 diff --git a/resources/tester/test_533_3_e04_router.py b/resources/tester/test_533_3_e04_router.py index 93be5dea0..31a69f097 100644 --- a/resources/tester/test_533_3_e04_router.py +++ b/resources/tester/test_533_3_e04_router.py @@ -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() diff --git a/ui_template/ui_template_locale.ts b/ui_template/ui_template_locale.ts index e0ade76ab..3ca69f502 100644 --- a/ui_template/ui_template_locale.ts +++ b/ui_template/ui_template_locale.ts @@ -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"; @@ -65,6 +67,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, diff --git a/ui_template/ui_template_locale_e04.ts b/ui_template/ui_template_locale_e04.ts new file mode 100644 index 000000000..49aef3f42 --- /dev/null +++ b/ui_template/ui_template_locale_e04.ts @@ -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;