Merge origin/sub_laptop_2 — E03 2D 해석 길 · 화면 · main.py 라우터 충돌 풂(E03 · E04 · E05 모두 등록)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-25 20:45:21 +09:00
co-authored by Claude Opus 5.5
16 changed files with 3938 additions and 4 deletions
+426
View File
@@ -0,0 +1,426 @@
"""E03 2D 측점 해석 API (요구 345 · 347 · 351 · PLAN 532-3).
- `POST /api/projects/{id}/stab/analyze2d` — 전 측점 해석(뒤에서 · 바뀐 측점만) · 같은 지문 = 안 셈
- `GET …/stab/progress` — 진행률
- `GET …/stab/result2d` — 요약 + 측점 목록(판정 · 대표 단면)
- `GET …/stab/section/{station_key}` — 그 측점 지층 폴리곤 · 지하수선 · 임계면 · 후보 · 절편 · 격자
- `PUT …/stab/representative` — 대표 단면 켬/끔 → 작업본 `E03_StabSection/representative.json`
- `GET …/stab/corridor3d` — 복사본 노선면(1 m 솎음 · 3D 색칠 보기용)
입력 = E01 복사본(측점 · 횡단 · DTM) + E02 지반 조건(작업본 · 없으면 초기값)
계산 자리 = ③ 서버 단독.
결과 = 파생 저장본 `_derived/stab2d_station/<측점 지문>` · `_derived/stab2d_summary/<전체 지문>` —
측점 지문 = 엔진 코드 · 복사 지문 · 물성 · 조건 · 하중 · 그 측점 지층. 공유 회사(보기만)는
`require_project_access` 가 POST · PUT 를 막음.
"""
from __future__ import annotations
import asyncio
import base64
import hashlib
import json
import logging
import os
import subprocess
import sys
import tempfile
import time
from functools import lru_cache
from pathlib import Path
from typing import Any
from uuid import UUID
import numpy as np
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from common_util.common_util_auth import verify_session
from common_util.common_util_derived_store import (
derived_dir,
derived_key,
read_derived,
write_derived,
)
from common_util.common_util_json import atomic_write_json
from common_util.common_util_station_key import chainage_key
from common_util.common_util_surface_sampler import DtmGridSampler
from E01_StabRegister import E01_StabRegister_Source as src
from E02_StabGround import E02_StabGround_Model as ground_model
from E02_StabGround import E02_StabGround_Router as e02
from E03_StabSection import E03_StabSection_View as view
from E03_StabSection import E03_StabSection_Worker as worker
from E03_StabSection.E03_StabSection_Engine_Model import (
ground_from_cross,
section_model_from_b06,
widen_ground,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/projects", tags=["E03 Stab Section"])
PROJECT_ROOT = Path(__file__).resolve().parents[1]
KIND_STATION = worker.KIND_STATION
KIND_SUMMARY = "stab2d_summary"
REPRESENTATIVE = Path("E03_StabSection") / "representative.json"
ENGINE_FILES = (
"E03_StabSection_Types.py",
"E03_StabSection_Engine_Model.py",
"E03_StabSection_Engine_LEM.py",
"E03_StabSection_Engine_Search.py",
"E03_StabSection_Engine_Station.py",
)
#: 3D 보기 노선면 솎음 간격(m)
CORRIDOR_STEP_M = 1.0
#: 프로세스 수 — None = 코어 − 2 · 1 = 서버 안에서 차례로(시험)
WORKERS: int | None = None
# 진행 — 프로젝트 → {running, percent, message, error, done, total} · 프로세스 메모리
_JOBS: dict[str, dict[str, Any]] = {}
_TASKS: set[asyncio.Task] = set()
class RepresentativeRequest(BaseModel):
overrides: dict[str, bool | None]
@lru_cache(maxsize=1)
def engine_hash() -> str:
"""엔진 코드 지문 — 코드가 바뀌면 모든 측점을 다시 셈."""
digest = hashlib.sha1()
for name in ENGINE_FILES:
digest.update((Path(__file__).parent / name).read_bytes())
return digest.hexdigest()[:12]
def _ground(root: Path) -> dict[str, Any]:
"""E02 지반 조건 — 작업본 · 없으면 초기값."""
return e02._read_working(root) or e02.ensure_initial(root)
def _layers(resolved: dict[str, Any] | None) -> list | None:
"""E02 측점 적용 지층 → 엔진 지층(위부터 [물성 id, 원지반 아래 바닥 깊이 | None]).
없으면 None(엔진 기본 = B06 라벨)."""
if not resolved or not resolved.get("upper"):
return None
if resolved.get("lower"):
return [[resolved["upper"], float(resolved["soil_thickness_m"])], [resolved["lower"], None]]
return [[resolved["upper"], None]]
def plan_run(root: Path, ground: dict[str, Any]) -> dict[str, Any]:
"""측점마다 입력 · 지문 + 전체 지문(판정 기준 포함) — 복사본이 없으면 400."""
try:
source = src.source_dir(root)
copy_fp = src.manifest(source)["source_fingerprint"]
stations = src.stations(source)
except (OSError, KeyError) as error:
raise HTTPException(status_code=400, detail="원본 복사본이 없습니다.") from error
seismic = ground.get("seismic") or {}
conditions = ["dry", "wet"] + (["seismic"] if seismic.get("enabled") else [])
shared = {
"materials": ground.get("materials"),
"conditions": conditions,
"seismic_kh": float(seismic.get("kh") or 0.0) if seismic.get("enabled") else 0.0,
"road_surcharge_kpa": float(ground.get("road_surcharge_kpa") or 0.0),
}
resolved = {chainage_key(r["chainage_m"]): r for r in ground_model.resolve_stations(ground)}
items = []
for station in stations:
key = chainage_key(station["chainage_m"])
layers = _layers(resolved.get(key))
items.append(
{
"key": key,
"chainage_m": station["chainage_m"],
"label": station.get("label"),
"ground_type": station.get("ground_type"),
"center": [station.get("center_x"), station.get("center_y")],
"layers": layers,
"fp": derived_key(engine_hash(), copy_fp, shared, key, layers),
}
)
factors = ground.get("safety_factors") or {}
overall = derived_key([item["fp"] for item in items], factors)
return {"source": source, "shared": shared, "items": items, "factors": factors, "fp": overall}
def _station_input(source: Path, item: dict[str, Any], sampler: Any) -> dict[str, Any]:
"""측점 하나 엔진 입력 — 횡단 · 설계 · 넓힌 지반선(DTM 밖은 표본 그대로)."""
cross = src.cross_section(source, item["chainage_m"])
try:
ground = widen_ground(cross, sampler) if sampler else ground_from_cross(cross)
except (KeyError, TypeError, ValueError):
ground = ground_from_cross(cross)
return {"cross": cross, "design": cross["design"], "ground": [list(p) for p in ground]}
def _sampler(source: Path) -> Any:
try:
return DtmGridSampler.from_npz(src.terrain_grid_path(source))
except (OSError, FileNotFoundError, ValueError):
logger.warning("E03 지형 격자 없음 — 횡단 표본 폭으로 해석: %s", source)
return None
def _meta(item: dict[str, Any], design: dict[str, Any]) -> dict[str, Any]:
keys = ("key", "chainage_m", "label", "ground_type", "center", "fp")
return {**{k: item[k] for k in keys}, "section_mode": design.get("section_mode")}
def _run_worker(job: dict[str, Any], emit: Any) -> None:
"""일꾼 프로세스(`E03_StabSection_Worker`) — 한 줄마다 emit · 실패면 RuntimeError."""
with tempfile.TemporaryDirectory(prefix="aislo_e03_") as folder:
path = Path(folder) / "job.json"
path.write_text(json.dumps(job, ensure_ascii=False), encoding="utf-8")
process = subprocess.Popen(
[sys.executable, "-m", "E03_StabSection.E03_StabSection_Worker", str(path)],
cwd=PROJECT_ROOT,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
encoding="utf-8",
)
for line in process.stdout:
if line.startswith("{"):
emit(json.loads(line).get("done"))
error = process.stderr.read()
if process.wait() != 0:
raise RuntimeError(f"해석 일꾼 실패: {error.strip().splitlines()[-1:]}")
def run_all(root: Path, plan: dict[str, Any], report: Any) -> int:
"""지문이 없는 측점만 셈(일꾼 프로세스 병렬) → 측점 저장본 · 요약 저장본. 센 측점 수."""
source, shared, items = plan["source"], plan["shared"], plan["items"]
todo = [it for it in items if read_derived(root, KIND_STATION, it["fp"]) is None]
if todo:
sampler = _sampler(source)
job_items = []
for it in todo:
data = _station_input(source, it, sampler)
job_items.append({"fp": it["fp"], "meta": _meta(it, data["design"]), **data})
job_items[-1]["layers"] = it["layers"]
job = {
"root": str(root),
"keep": max(3 * len(items), 6),
"workers": WORKERS or max(1, (os.cpu_count() or 2) - 2),
"kwargs": {
"materials": shared["materials"],
"conditions": list(shared["conditions"]),
"seismic_kh": shared["seismic_kh"],
"road_surcharge_kpa": shared["road_surcharge_kpa"],
},
"items": job_items,
}
report(2, f"측점 {len(todo)} 개 해석 시작")
done = [0]
def emit(_key: str | None) -> None:
done[0] += 1
report(2 + 95 * done[0] // len(todo), f"측점 {done[0]} / {len(todo)}")
if WORKERS == 1:
worker.run_job(job, emit)
else:
_run_worker(job, emit)
rows = []
for it in items:
saved = read_derived(root, KIND_STATION, it["fp"])
value = saved["value"] if saved else {"meta": _meta(it, {}), "out": None, "error": "없음"}
rows.append(view.station_row(value["meta"], value["out"], plan["factors"], value["error"]))
summary = {
"fingerprint": plan["fp"],
"computed_at": time.strftime("%Y-%m-%dT%H:%M:%S"),
"conditions": shared["conditions"],
"method": view.PRIMARY,
"factors": plan["factors"],
"summary": view.summarize(rows, shared["conditions"]),
"stations": rows,
}
write_derived(root, KIND_SUMMARY, plan["fp"], summary)
return len(todo)
def _latest_summary(root: Path, fp: str) -> tuple[dict[str, Any] | None, bool]:
"""(요약, 지금 입력과 같은지) — 같은 지문이 없으면 가장 최근 것(옛 결과)."""
saved = read_derived(root, KIND_SUMMARY, fp)
if saved:
return saved["value"], True
files = sorted(
derived_dir(root, KIND_SUMMARY).glob("*.json"),
key=lambda p: p.stat().st_mtime_ns,
reverse=True,
)
for path in files:
old = read_derived(root, KIND_SUMMARY, path.stem)
if old:
return old["value"], False
return None, False
def _overrides(root: Path) -> dict[str, bool]:
path = root / REPRESENTATIVE
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return {}
return {k: bool(v) for k, v in (data.get("overrides") or {}).items() if v is not None}
@router.post("/{project_id}/stab/analyze2d", response_model=None)
async def analyze2d(project_id: UUID) -> dict[str, Any]:
"""전 측점 해석(뒤에서) — 지문이 같으면 다시 안 셈(`fresh`) · 진행은 `GET …/stab/progress`."""
root, _ = await e02._project(project_id)
plan = plan_run(root, _ground(root))
if read_derived(root, KIND_SUMMARY, plan["fp"]):
return {"status": "fresh", "fingerprint": plan["fp"]}
key = str(project_id)
if (_JOBS.get(key) or {}).get("running"):
raise HTTPException(status_code=409, detail="이미 해석 중입니다.")
job = {"running": True, "percent": 0, "message": "시작", "error": None, "computed": 0}
_JOBS[key] = job
def report(percent: int, message: str) -> None:
job.update(percent=int(percent), message=message)
async def work() -> None:
try:
job["computed"] = await asyncio.to_thread(run_all, root, plan, report)
job.update(percent=100, message="끝")
except Exception as error: # noqa: BLE001 — 진행률에 까닭을 그대로 올림
logger.exception("E03 2D 해석 실패: project_id=%s", project_id)
job["error"] = str(getattr(error, "detail", None) or error)
finally:
job["running"] = False
task = asyncio.create_task(work(), name=f"E03 analyze2d {project_id}")
_TASKS.add(task)
task.add_done_callback(_TASKS.discard)
return {"status": "accepted", "fingerprint": plan["fp"], **job}
@router.get("/{project_id}/stab/progress", response_model=None)
async def progress(project_id: UUID) -> dict[str, Any]:
await e02._project(project_id)
job = _JOBS.get(str(project_id))
return dict(job) if job else {"running": False, "percent": 0, "message": "", "error": None}
@router.get("/{project_id}/stab/result2d", response_model=None)
async def result2d(
project_id: UUID, session: dict[str, Any] = Depends(verify_session)
) -> dict[str, Any]:
"""요약 + 측점 목록 — 결과가 없으면 복사본 측점만(status none)."""
root, project = await e02._project(project_id)
ground = _ground(root)
plan = plan_run(root, ground)
summary, fresh = _latest_summary(root, plan["fp"])
editable = e02._editable(session, project)
running = bool((_JOBS.get(str(project_id)) or {}).get("running"))
if not summary:
rows = [view.station_row(item, None, plan["factors"]) for item in plan["items"]]
view.mark_representative(rows, _overrides(root))
return {
"status": "none",
"fresh": False,
"editable": editable,
"running": running,
"conditions": plan["shared"]["conditions"],
"factors": plan["factors"],
"stations": rows,
}
rows = summary["stations"]
view.mark_representative(rows, _overrides(root))
return {
"status": "ok",
"fresh": fresh,
"editable": editable,
"running": running,
**{k: v for k, v in summary.items() if k != "stations"},
"stations": rows,
}
@router.get("/{project_id}/stab/section/{station_key}", response_model=None)
async def section(project_id: UUID, station_key: str) -> dict[str, Any]:
"""측점 하나 — 해석에 쓴 지반선 · 지층으로 모형을 다시 세워(빠름) 결과와 함께."""
root, _ = await e02._project(project_id)
ground = _ground(root)
plan = plan_run(root, ground)
summary, fresh = _latest_summary(root, plan["fp"])
row = next((r for r in (summary or {}).get("stations", []) if r["key"] == station_key), None)
saved = read_derived(root, KIND_STATION, row["fp"]) if row else None
if not saved or not saved["value"]["out"]:
raise HTTPException(status_code=404, detail="그 측점 해석 결과가 없습니다.")
value = saved["value"]
cross = src.cross_section(plan["source"], row["chainage_m"])
kw = {
"materials": plan["shared"]["materials"],
"layers": value["inputs"]["layers"],
"ground": [tuple(p) for p in value["inputs"]["ground"]],
"road_surcharge_kpa": plan["shared"]["road_surcharge_kpa"],
}
dry = section_model_from_b06(cross, cross["design"], **kw)
wet = section_model_from_b06(cross, cross["design"], wet=True, **kw)
water = {
cond: (wet["groundwater"].get("line") if cond == "wet" else None)
for cond in summary["conditions"]
}
out = view.section_view(row, value["out"], dry, water)
out["fresh"] = fresh
return out
@router.put("/{project_id}/stab/representative", response_model=None)
async def put_representative(project_id: UUID, payload: RepresentativeRequest) -> dict[str, Any]:
"""대표 단면 켬/끔(None = 자동) — 아는 측점 열쇠만."""
root, _ = await e02._project(project_id)
known = {item["key"] for item in plan_run(root, _ground(root))["items"]}
unknown = sorted(set(payload.overrides) - known)
if unknown:
raise HTTPException(status_code=422, detail=f"모르는 측점: {unknown[:5]}")
overrides = {k: v for k, v in sorted(payload.overrides.items()) if v is not None}
atomic_write_json(root / REPRESENTATIVE, {"overrides": overrides})
return {"overrides": overrides}
@lru_cache(maxsize=2)
def _corridor3d(path: str, _stamp: tuple[int, int]) -> dict[str, Any]:
data = json.loads(Path(path).read_text(encoding="utf-8"))
ribbons = []
origin = None
for ribbon in data.get("ribbons") or []:
cols = int(ribbon["colCount"])
chain = np.asarray(ribbon["chainages"], dtype=float)
pos = np.frombuffer(base64.b64decode(ribbon["positionsBase64"]), dtype="<f8")
pos = pos.reshape(len(chain), cols, 3)
if origin is None and len(pos):
origin = pos[0, 0].tolist()
# 솎음 — 1 m 마다 첫 줄 + 끝 줄
bins = np.floor(np.round(chain, 6) / CORRIDOR_STEP_M)
keep = np.flatnonzero(np.r_[True, np.diff(bins) > 0])
if keep[-1] != len(chain) - 1:
keep = np.r_[keep, len(chain) - 1]
local = pos[keep] - np.asarray(origin)
ribbons.append(
{
"kind": ribbon["kind"],
"side": ribbon["side"],
"colCount": cols,
"chainages": np.round(chain[keep], 3).tolist(),
"positions": np.round(local, 3).reshape(-1).tolist(),
}
)
return {"origin": origin, "ribbons": ribbons}
@router.get("/{project_id}/stab/corridor3d", response_model=None)
async def corridor3d(project_id: UUID) -> dict[str, Any]:
"""복사본 노선면 — 원점 빼고 1 m 솎음(모델 좌표 x · y · z)."""
root, _ = await e02._project(project_id)
path = src.corridor_path(src.source_dir(root))
if not path:
raise HTTPException(status_code=404, detail="복사본에 노선면이 없습니다.")
stat = path.stat()
return await asyncio.to_thread(_corridor3d, str(path), (stat.st_mtime_ns, stat.st_size))
+109
View File
@@ -0,0 +1,109 @@
/* =============================================================================
* E03_StabSection_UI_List.ts
* E03 좌측 측점 목록(PLAN 532-3) — 측점 · 깎기/쌓기 · 조건별 최소 안전율 · 판정(아이콘 + 글) ·
* 대표 단면 별(자동 = 채움 · 사용자 켬/끔 = 밑줄 · 누르면 바꿈). 정렬 · 거름은 logic `listRows`.
* ========================================================================== */
import { el } from "@ui/ui_template_elements";
import { t, type LocaleKey } from "@ui/ui_template_locale";
import {
listRows,
roleText,
verdictOf,
type Condition,
type SortMode,
type StationRow,
} from "./logic/E03_StabSection_Logic";
const fmt = (v: number | null | undefined): string =>
v === null || v === undefined || !Number.isFinite(v) ? "-" : v.toFixed(2);
export interface ListDraw {
rows: StationRow[];
conditions: Condition[];
condition: Condition;
sort: SortMode;
ngOnly: boolean;
active: string | null;
/** 사용자 켬/끔 — 저장 전 캐시 포함 · 없으면 서버 값 */
representative: (row: StationRow) => { on: boolean; user: boolean | null };
editable: boolean;
}
export function buildList(
draw: ListDraw,
onSelect: (key: string) => void,
onToggle: (key: string) => void,
): HTMLElement {
const box = el("div", { className: "e03-list" });
const table = el("table", { attrs: { "data-role": "station-list" } });
const head = el("tr");
head.append(
el("th", { text: t("E03_Col_Station") }),
el("th", { text: t("E03_Col_Role") }),
...draw.conditions.map((c) =>
el("th", {
className: "is-num",
text: t(`E03_Cond_${c}` as LocaleKey),
attrs: c === draw.condition ? { "aria-sort": "other" } : {},
}),
),
el("th", { text: t("E03_Verdict") }),
el("th", { text: t("E03_Col_Rep"), attrs: { title: t("E03_Rep_Hint") } }),
);
table.append(el("thead", { children: [head] }));
const body = el("tbody");
const rows = listRows(draw.rows, draw.condition, draw.sort, draw.ngOnly);
for (const row of rows) {
const tr = el("tr", {
className: row.key === draw.active ? "is-active" : "",
attrs: { "data-key": row.key, "data-verdict": verdictOf(row, draw.condition) },
});
const v = verdictOf(row, draw.condition);
const rep = draw.representative(row);
const star = el("button", {
className: `e03-star${rep.on ? " is-on" : ""}${rep.user !== null ? " is-user" : ""}`,
text: rep.on ? "★" : "☆",
attrs: {
type: "button",
title:
[
...(row.representative?.auto ?? []).map((r) => t(`E03_Rep_${r}` as LocaleKey)),
rep.user !== null ? t("E03_Rep_User") : "",
]
.filter(Boolean)
.join(" · ") || t("E03_Rep_Hint"),
"aria-pressed": String(rep.on),
},
});
star.disabled = !draw.editable;
star.addEventListener("click", (ev) => {
ev.stopPropagation();
onToggle(row.key);
});
tr.append(
el("td", { text: row.label?.replace(/^STA\./, "") ?? row.key }),
el("td", {
text: row.sides.length ? roleText(row, { cut: t("E03_Cut"), fill: t("E03_Fill") }) : "-",
}),
...draw.conditions.map((c) => el("td", { className: "is-num", text: fmt(row.min[c]) })),
el("td", {
children: [
el("span", {
className: `e03-verdict e03-verdict--${v}`,
text: row.error ? t("E03_Error") : t(`E03_Verdict_${v}` as LocaleKey),
}),
],
}),
el("td", { children: [star] }),
);
if (row.error) tr.title = row.error;
tr.addEventListener("click", () => onSelect(row.key));
body.append(tr);
}
table.append(body);
box.append(table);
if (!rows.length) box.append(el("p", { className: "e03-empty", text: t("E03_ListEmpty") }));
queueMicrotask(() => box.querySelector("tr.is-active")?.scrollIntoView({ block: "nearest" }));
return box;
}
+424
View File
@@ -0,0 +1,424 @@
/* =============================================================================
* E03_StabSection_UI_Page.ts
* E03 2D 측점 해석(요구서 345 4-3 · 요구 347 · 351 · PLAN 532-3) — B05~B09 틀(group_workflow).
* 좌 = 측점 목록(조건 · 정렬 · N.G 만 · 대표 단면 별) · [해석] · [3D 보기] · [대표 단면 저장]
* 위 = 평면 위험 띠 · 가운데 = 횡단(임계면 · 등안전율 · 후보 · 절편) · 우 = 결과 · 절편표 · 자유물체도 · 그래프
* 계산 = 서버 단독([해석] → POST …/stab/analyze2d · 잠금판 진행률 = GET …/stab/progress) ·
* 대표 단면 켬/끔 = sessionStorage 캐시 → [대표 단면 저장] = PUT(자동저장 없음).
* ========================================================================== */
import "./E03_StabSection_UI_Style.css";
import { API_BASE_URL, CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend";
import {
createButton,
createSelectField,
el,
hideLoadingOverlay,
setLoadingOverlayProgress,
showLoadingOverlay,
showToast,
} from "@ui/ui_template_elements";
import { t, type LocaleKey } from "@ui/ui_template_locale";
import { mountGroupStep } from "../A00_Common/group_workflow";
import { buildList } from "./E03_StabSection_UI_List";
import { buildPlan } from "./E03_StabSection_UI_Plan";
import { buildResult } from "./E03_StabSection_UI_Result";
import {
buildLegends,
createSectionCanvas,
type SectionToggles,
} from "./E03_StabSection_UI_Section";
import { openView3d } from "./E03_StabSection_UI_View3D";
import type {
Analysis,
Condition,
SectionView,
SortMode,
StationRow,
} from "./logic/E03_StabSection_Logic";
interface Result2d {
status: "ok" | "none";
fresh: boolean;
editable: boolean;
running: boolean;
conditions: Condition[];
computed_at?: string;
summary?: Record<string, unknown>;
stations: StationRow[];
}
interface Progress {
running: boolean;
percent: number;
message: string;
error: string | null;
}
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) {
throw new Error(typeof body.detail === "string" ? body.detail : `HTTP ${response.status}`);
}
return body;
}
const repKey = (projectId: string): string => `e03_representative_cache:${projectId}`;
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
function checkbox(label: string, checked: boolean, onChange: (v: boolean) => void): HTMLElement {
const input = el("input", { attrs: { type: "checkbox" } });
input.checked = checked;
input.addEventListener("change", () => onChange(input.checked));
return el("label", { className: "e03-check", children: [input, label] });
}
export function renderE03StabSection(root: HTMLElement): void {
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
const main = el("div", { className: "e03-page" });
const panel = el("div", { className: "ui-sidebar-panel e03-panel" });
mountGroupStep(root, ROUTES.E03_STAB_SECTION, { mainContent: main, leftPanel: panel });
if (!projectId)
return void main.append(el("p", { className: "e03-empty", text: t("E03_NoProject") }));
const pid: string = projectId;
const base = `/projects/${pid}/stab`;
let result: Result2d | null = null;
const sections = new Map<string, SectionView>();
let condition: Condition = "dry";
let sort: SortMode = "chainage";
let ngOnly = false;
let active: string | null = null;
let side: string | null = null;
let picked: number | null = null;
const toggles: SectionToggles = { map: true, centers: false, candidates: true };
let repCache: Record<string, boolean | null> = JSON.parse(
sessionStorage.getItem(repKey(pid)) ?? "{}",
);
// ---- 좌측 ----
const condField = createSelectField({
label: t("E03_Condition"),
options: [],
compact: true,
onChange: (v) => {
condition = v as Condition;
side = null;
picked = null;
paint();
},
});
const sortField = createSelectField({
label: t("E03_Sort"),
options: [
{ value: "chainage", text: t("E03_Sort_Chainage") },
{ value: "fs", text: t("E03_Sort_Fs") },
],
compact: true,
onChange: (v) => {
sort = v as SortMode;
paintList();
},
});
const ngBox = checkbox(t("E03_NgOnly"), false, (v) => {
ngOnly = v;
paintList();
});
const status = el("p", { className: "e03-muted", attrs: { "data-role": "status" } });
const listHost = el("div", {
className: "e03-panel",
attrs: { style: "flex: 1 1 auto; min-height: 0" },
});
const analyzeButton = createButton({
label: t("E03_Analyze"),
variant: "filled",
onClick: () => void analyze(),
});
const view3dButton = createButton({
label: t("E03_View3d"),
variant: "ghost",
onClick: () => void show3d(),
});
const repButton = createButton({
label: t("E03_RepSave"),
variant: "ghost",
onClick: () => void saveRep(),
});
const controls = el("div", { className: "e03-panel__row" });
controls.append(condField.root, sortField.root);
const actions = el("div", { className: "ui-sidebar-actions" });
actions.append(repButton, view3dButton, analyzeButton);
panel.append(controls, ngBox, status, listHost, actions);
// ---- 본문 ----
const planHost = el("div");
const toolbar = el("div", { className: "e03-card__head" });
const sectionCard = el("div", { className: "e03-card" });
const legendHost = el("div");
const canvas = createSectionCanvas((i) => {
picked = i;
paintSection();
});
sectionCard.append(toolbar, canvas.root, legendHost);
const resultHost = el("div", { className: "e03-result", attrs: { style: "min-height: 0" } });
const body = el("div", { className: "e03-body" });
body.append(sectionCard, resultHost);
main.append(planHost, body);
const repOf = (row: StationRow): { on: boolean; user: boolean | null } => {
const server = row.representative ?? { auto: [], user: null, on: false };
if (!(row.key in repCache)) return { on: server.on, user: server.user };
const user = repCache[row.key];
return { on: user === null ? server.auto.length > 0 : user, user };
};
const repDirty = (): boolean => Object.keys(repCache).length > 0;
const currentRow = (): StationRow | undefined => result?.stations.find((r) => r.key === active);
/** 고른 측점 · 조건의 비탈 — 고른 게 없으면 안전율 가장 낮은 쪽 */
const currentAnalysis = (view: SectionView): Analysis | null => {
const list = view.analyses.filter((a) => a.condition === condition);
if (!list.length) return null;
const found = side ? list.find((a) => a.side === side) : null;
if (found) return found;
const worst = list
.slice()
.sort((a, b) => (a.critical?.fs ?? Infinity) - (b.critical?.fs ?? Infinity))[0];
side = worst.side;
return worst;
};
const paintStatus = (): void => {
const lines: string[] = [];
if (!result) lines.push(t("E03_Loading"));
else if (result.status === "none") lines.push(t("E03_NotYet"));
else {
lines.push(`${t("E03_Computed")} ${result.computed_at ?? ""}`);
if (!result.fresh) lines.push(t("E03_Stale"));
}
if (result && !result.editable) lines.push(t("E03_ReadOnly"));
if (repDirty()) lines.push(t("E03_RepUnsaved"));
status.textContent = lines.join("\n");
analyzeButton.disabled = !result?.editable;
repButton.disabled = !result?.editable || !repDirty();
view3dButton.disabled = result?.status !== "ok";
};
const paintList = (): void => {
if (!result) return;
listHost.replaceChildren(
buildList(
{
rows: result.stations,
conditions: result.conditions,
condition,
sort,
ngOnly,
active,
representative: repOf,
editable: result.editable,
},
(key) => void select(key),
(key) => {
const row = result!.stations.find((r) => r.key === key)!;
const now = repOf(row).on;
const auto = (row.representative?.auto.length ?? 0) > 0;
// 자동과 같아지면 사용자 값 지움(null) — 서버 값과 같으면 캐시에서 뺌
const next = !now === auto ? null : !now;
if (next === (row.representative?.user ?? null)) delete repCache[key];
else repCache[key] = next;
sessionStorage.setItem(repKey(pid), JSON.stringify(repCache));
paintList();
paintStatus();
},
),
);
};
const paintSection = (): void => {
const view = active ? sections.get(active) : undefined;
toolbar.replaceChildren();
resultHost.replaceChildren();
legendHost.replaceChildren();
if (!view) {
canvas.render(null);
const text = result?.status === "ok" ? t("E03_PickStation") : t("E03_NotYet");
resultHost.append(
el("div", { className: "e03-card", children: [el("p", { className: "e03-empty", text })] }),
);
return;
}
const analysis = currentAnalysis(view);
const sides = view.analyses.filter((a) => a.condition === condition);
const sideField = createSelectField({
options: sides.map((a) => ({
value: a.side,
text: `${t(a.side === "left" ? "E03_Left" : "E03_Right")} · ${t(a.role === "cut" ? "E03_Cut" : "E03_Fill")}`,
})),
value: analysis?.side,
compact: true,
ariaLabel: t("E03_Side"),
onChange: (v) => {
side = v;
picked = null;
paintSection();
},
});
const title = el("h3", {
className: "e03-card__title",
text: `${view.label ?? view.key} · ${t(`E03_Cond_${condition}` as LocaleKey)}`,
});
const flags = el("div", { className: "e03-panel__row" });
flags.append(
checkbox(t("E03_Show_Map"), toggles.map, (v) => ((toggles.map = v), paintSection())),
checkbox(
t("E03_Show_Centers"),
toggles.centers,
(v) => ((toggles.centers = v), paintSection()),
),
checkbox(
t("E03_Show_Candidates"),
toggles.candidates,
(v) => ((toggles.candidates = v), paintSection()),
),
);
toolbar.append(title, sideField.root, flags);
const draw = { view, analysis, toggles, picked };
canvas.render(draw);
legendHost.append(
buildLegends(draw),
el("p", { className: "e03-muted", text: t("E03_NoWalls") }),
);
resultHost.append(
buildResult(view, analysis, picked, (i) => {
picked = i;
paintSection();
}),
);
};
const paintPlan = (): void => {
planHost.replaceChildren(
result ? buildPlan(result.stations, condition, active, (key) => void select(key)) : el("div"),
);
};
function paint(): void {
paintStatus();
paintList();
paintPlan();
paintSection();
}
async function select(key: string): Promise<void> {
if (active !== key) picked = null;
active = key;
paintList();
paintPlan();
if (result?.status === "ok" && !sections.has(key) && !currentRow()?.error) {
try {
sections.set(key, await api<SectionView>(`${base}/section/${encodeURIComponent(key)}`));
} catch (error) {
showToast(error instanceof Error ? error.message : t("Common_Status_Error"), "error");
}
}
if (active === key) paintSection();
}
async function load(): Promise<void> {
result = await api<Result2d>(`${base}/result2d`);
sections.clear();
const conds = result.conditions;
if (!conds.includes(condition)) condition = conds[0] ?? "dry";
condField.setOptions(
conds.map((c) => ({ value: c, text: t(`E03_Cond_${c}` as LocaleKey) })),
condition,
);
const keys = result.stations.map((r) => r.key);
repCache = Object.fromEntries(Object.entries(repCache).filter(([k]) => keys.includes(k)));
if (!active || !keys.includes(active)) {
// 처음 = 그 조건 가장 낮은 안전율 측점
const worst = result.stations
.filter((r) => r.min[condition] !== null && r.min[condition] !== undefined)
.sort((a, b) => (a.min[condition] as number) - (b.min[condition] as number))[0];
active = worst?.key ?? keys[0] ?? null;
}
paint();
if (active) await select(active);
if (result.running) await watch();
}
/** 잠금판 + 진행률 — 끝나면 다시 읽음 */
async function watch(): Promise<void> {
showLoadingOverlay();
try {
for (;;) {
const p = await api<Progress>(`${base}/progress`);
setLoadingOverlayProgress(
p.percent / 100,
`${t("E03_Analyzing")} ${p.percent}% · ${p.message}`,
);
if (!p.running) {
if (p.error) showToast(p.error, "error");
else showToast(t("E03_Done"), "success");
break;
}
await sleep(1000);
}
} finally {
hideLoadingOverlay();
}
await load();
}
async function analyze(): Promise<void> {
try {
const response = await api<{ status: string }>(`${base}/analyze2d`, { method: "POST" });
if (response.status === "fresh") {
showToast(t("E03_Fresh"), "info");
await load();
return;
}
await watch();
} catch (error) {
showToast(error instanceof Error ? error.message : t("Common_Status_Error"), "error");
}
}
async function saveRep(): Promise<void> {
try {
const overrides: Record<string, boolean | null> = {};
for (const row of result?.stations ?? []) {
const user = row.key in repCache ? repCache[row.key] : (row.representative?.user ?? null);
if (user !== null) overrides[row.key] = user;
}
await api(`${base}/representative`, { method: "PUT", body: JSON.stringify({ overrides }) });
repCache = {};
sessionStorage.removeItem(repKey(pid));
showToast(t("E03_RepSaved"), "success");
await load();
} catch (error) {
showToast(error instanceof Error ? error.message : t("Common_Status_Error"), "error");
}
}
async function show3d(): Promise<void> {
if (!result) return;
try {
const data = await api<Parameters<typeof openView3d>[0]>(`${base}/corridor3d`);
await openView3d(data, result.stations, condition, t(`E03_Cond_${condition}` as LocaleKey));
} catch (error) {
showToast(error instanceof Error ? error.message : t("Common_Status_Error"), "error");
}
}
paintStatus();
load().catch((error) => {
status.textContent = error instanceof Error ? error.message : t("Common_Status_Error");
});
}
@@ -0,0 +1,89 @@
/* =============================================================================
* E03_StabSection_UI_Plan.ts
* E03 위 평면 위험 띠(PLAN 532-3) — 측점 중심을 이은 노선을 그 조건 판정 색으로(이웃 측점 사이
* 가운데서 끊음 · 북쪽 위 · 같은 축척) · 고른 측점 표시 · 누르면 가장 가까운 측점.
* 범례 = 판정마다 아이콘 + 글 + 측점 수. 계산은 logic `bandSegments` · `planFit`.
* ========================================================================== */
import { el } from "@ui/ui_template_elements";
import { t, type LocaleKey } from "@ui/ui_template_locale";
import {
bandSegments,
planFit,
verdictOf,
type Condition,
type Point,
type StationRow,
} from "./logic/E03_StabSection_Logic";
import { svgEl } from "./E03_StabSection_UI_Section";
const H = 96;
const PAD = 10;
export function buildPlan(
rows: StationRow[],
condition: Condition,
active: string | null,
onSelect: (key: string) => void,
): HTMLElement {
const box = el("div", { className: "e03-plan e03-card" });
const head = el("div", { className: "e03-card__head" });
head.append(el("h3", { className: "e03-card__title", text: t("E03_Plan") }));
const legend = el("div", { className: "e03-legend", attrs: { "data-role": "plan-legend" } });
const counts = { ok: 0, ng: 0, none: 0 };
rows.forEach((r) => (counts[verdictOf(r, condition)] += 1));
for (const v of ["ng", "ok", "none"] as const) {
if (!counts[v]) continue;
const item = el("span", { className: `e03-verdict e03-verdict--${v}` });
item.append(`${t(`E03_Verdict_${v}` as LocaleKey)} ${counts[v]}`);
legend.append(item);
}
head.append(legend);
box.append(head);
const host = el("div");
box.append(host);
const draw = (): void => {
const W = Math.max(host.clientWidth, 300);
const segs = bandSegments(rows, condition);
const all: Point[] = segs.flatMap((s) => s.points);
const svg = svgEl("svg", { viewBox: `0 0 ${W} ${H}`, "data-role": "plan" });
if (all.length) {
const fit = planFit(all, W, H, PAD);
const scr = (p: Point): Point => [fit.k * p[0] + fit.tx, -fit.k * p[1] + fit.ty];
for (const seg of segs) {
svg.append(
svgEl("polyline", {
points: seg.points.map((p) => scr(p).join(",")).join(" "),
class: `e03-plan__seg e03-plan__seg--${seg.verdict}`,
"data-verdict": seg.verdict,
}),
);
}
const centers = rows.filter((r) => r.center && r.center[0] !== null && r.center[1] !== null);
const picked = centers.find((r) => r.key === active);
if (picked) {
const [x, y] = scr([picked.center![0]!, picked.center![1]!]);
svg.append(
svgEl("circle", { cx: x, cy: y, r: 6, class: "e03-plan__pick", "data-key": picked.key }),
);
}
svg.addEventListener("click", (ev) => {
const box2 = svg.getBoundingClientRect();
const px = ((ev.clientX - box2.left) / box2.width) * W;
const py = ((ev.clientY - box2.top) / box2.height) * H;
let best: StationRow | null = null;
let dist = Infinity;
for (const r of centers) {
const [x, y] = scr([r.center![0]!, r.center![1]!]);
const d = Math.hypot(x - px, y - py);
if (d < dist) [best, dist] = [r, d];
}
if (best && dist < 30) onSelect(best.key);
});
}
host.replaceChildren(svg);
};
new ResizeObserver(draw).observe(host);
return box;
}
@@ -0,0 +1,285 @@
/* =============================================================================
* E03_StabSection_UI_Result.ts
* E03 우측 결과(PLAN 532-3) — 안전율 · 기준 · 판정 · 방법 · 수렴 · 중심/반경 · 방법별 최소 안전율 ·
* 절편표(누르면 고름) · 절편 자유물체도(고른 절편 힘 화살) · 절편력 그래프(바닥 유효 수직응력 ·
* 간극수압 · 동원 전단응력 — kPa 한 축). 계산은 logic.
* ========================================================================== */
import { el } from "@ui/ui_template_elements";
import { t, type LocaleKey } from "@ui/ui_template_locale";
import {
forceArrows,
niceTicks,
pointsText,
sliceShape,
sliceStress,
type Analysis,
type Point,
type SectionView,
} from "./logic/E03_StabSection_Logic";
import { materialClass, svgEl } from "./E03_StabSection_UI_Section";
const fmt = (v: number | null | undefined, d = 2): string =>
v === null || v === undefined || !Number.isFinite(v) ? "-" : v.toFixed(d);
const METHODS = ["ordinary", "bishop", "janbu", "spencer"] as const;
export function verdictChip(ok: boolean | null): HTMLElement {
const v = ok === null ? "none" : ok ? "ok" : "ng";
return el("span", {
className: `e03-verdict e03-verdict--${v}`,
text: t(`E03_Verdict_${v}` as LocaleKey),
});
}
function kpi(analysis: Analysis): HTMLElement {
const c = analysis.critical;
const dl = el("dl", { className: "e03-kpi" });
const row = (label: string, value: HTMLElement | string, big = false): void => {
const dd = el("dd", { className: big ? "is-big" : "" });
dd.append(value);
dl.append(el("dt", { text: label }), dd);
};
row(t("E03_Fs"), fmt(c?.fs, 3), true);
row(t("E03_Verdict"), verdictChip(analysis.ok));
row(t("E03_Required"), fmt(analysis.required));
row(
t("E03_Method"),
`${t("E03_Method_bishop")} · ${c?.kind === "circle" ? t("E03_Circle") : t("E03_NonCircle")}`,
);
row(
t("E03_Converged"),
c
? `${c.converged ? t("E03_Yes") : t("E03_No")} · ${c.iterations ?? "-"}${t("E03_Iter")}`
: "-",
);
row(t("E03_Height"), `${fmt(analysis.height_m)} m`);
if (c?.center) {
row(t("E03_Center"), `${fmt(c.center[0])}, ${fmt(c.center[1])}`);
row(t("E03_Radius"), `${fmt(c.radius)} m`);
}
return dl;
}
function methodTable(analysis: Analysis): HTMLElement {
const table = el("table", { className: "e03-table", attrs: { "data-role": "methods" } });
const head = el("tr");
const body = el("tr");
for (const m of METHODS) {
head.append(el("th", { className: "is-num", text: t(`E03_Method_${m}` as LocaleKey) }));
body.append(el("td", { className: "is-num", text: fmt(analysis.min_by_method[m], 3) }));
}
table.append(el("thead", { children: [head] }), el("tbody", { children: [body] }));
return table;
}
function sliceTable(
analysis: Analysis,
picked: number | null,
onPick: (i: number | null) => void,
): HTMLElement {
const wrap = el("div", { className: "e03-slices" });
const table = el("table", { className: "e03-table", attrs: { "data-role": "slice-table" } });
const cols: [LocaleKey, (s: Analysis["slices"][number]) => string][] = [
["E03_Col_No", (s) => String(s.i)],
["E03_Col_Width", (s) => fmt(s.width)],
["E03_Col_Height", (s) => fmt(s.height)],
["E03_Col_Alpha", (s) => fmt(s.alpha_deg, 1)],
["E03_Col_W", (s) => fmt(s.weight + s.surcharge, 1)],
["E03_Col_U", (s) => fmt(s.pore_pressure, 1)],
["E03_Col_N", (s) => fmt(s.N_eff, 1)],
["E03_Col_S", (s) => fmt(s.S, 1)],
["E03_Col_C", (s) => fmt(s.c, 0)],
["E03_Col_Phi", (s) => fmt(s.phi_deg, 0)],
];
table.append(
el("thead", {
children: [
el("tr", { children: cols.map(([k]) => el("th", { className: "is-num", text: t(k) })) }),
],
}),
);
const body = el("tbody");
analysis.slices.forEach((s, i) => {
const tr = el("tr", {
className: picked === i ? "is-picked" : "",
attrs: { "data-i": String(i) },
});
for (const [, f] of cols) tr.append(el("td", { className: "is-num", text: f(s) }));
tr.addEventListener("click", () => onPick(picked === i ? null : i));
body.append(tr);
});
table.append(body);
wrap.append(table);
if (picked !== null)
queueMicrotask(() => body.children[picked]?.scrollIntoView({ block: "nearest" }));
return wrap;
}
/** 자유물체도 — 고른 절편을 판에 맞춰 키우고 힘 화살(길이 = 힘 비례) · 값 글 */
function freeBody(view: SectionView, analysis: Analysis, picked: number | null): HTMLElement {
const box = el("div", { className: "e03-fbd" });
const slice = picked !== null ? analysis.slices[picked] : null;
if (!slice || !analysis.critical) {
box.append(el("p", { className: "e03-muted", text: t("E03_PickSlice") }));
return box;
}
const W = 380;
const H = 260;
const shape = sliceShape(slice, view.surface, analysis.critical.points);
const xs = shape.map((p) => p[0]);
const zs = shape.map((p) => p[1]);
const span = Math.max(Math.max(...xs) - Math.min(...xs), Math.max(...zs) - Math.min(...zs), 0.01);
const k = 90 / span; // 절편 = 약 90 px
const cx = (Math.min(...xs) + Math.max(...xs)) / 2;
const cz = (Math.min(...zs) + Math.max(...zs)) / 2;
// 화면 = 횡단과 같은 방향(좌측 +offset 이 왼쪽)
const scr = (p: Point): Point => [W / 2 - k * (p[0] - cx), H / 2 - k * (p[1] - cz)];
const forces = [slice.weight, slice.surcharge, slice.N_eff, slice.U, slice.S].concat(
[slice.E_left, slice.E_right, slice.X_left, slice.X_right].map(Math.abs),
);
const maxF = Math.max(...forces, 1e-6);
const lengthPerKn = 80 / k / maxF; // 가장 큰 힘 = 80 px
const svg = svgEl("svg", { viewBox: `0 0 ${W} ${H}`, "data-role": "fbd" });
const defs = svgEl("defs");
const marker = svgEl("marker", {
id: "e03-head",
viewBox: "0 0 10 10",
refX: 9,
refY: 5,
markerWidth: 7,
markerHeight: 7,
orient: "auto-start-reverse",
});
marker.append(svgEl("path", { d: "M0,0 L10,5 L0,10 z", fill: "context-stroke" }));
defs.append(marker);
svg.append(defs);
svg.append(
svgEl("polygon", {
points: shape.map((p) => scr(p).join(",")).join(" "),
class: `e03-region ${materialClass(view, slice.material ?? "")}`,
}),
);
for (const a of forceArrows(slice, shape, analysis.direction, lengthPerKn)) {
const [p0, p1] = [scr(a.from), scr(a.to)];
svg.append(
svgEl("line", {
x1: p0[0],
y1: p0[1],
x2: p1[0],
y2: p1[1],
class: `e03-arrow e03-arrow--${a.name}`,
"marker-end": "url(#e03-head)",
"data-force": a.name,
"data-value": String(a.value),
}),
);
const tip = a.name.startsWith("E") || a.name.startsWith("X") || a.name === "Q" ? p0 : p1;
svg.append(
svgEl(
"text",
{ x: tip[0] + 4, y: tip[1] - 4, class: "e03-label" },
`${a.name} ${fmt(a.value, 1)}`,
),
);
}
box.append(svg);
box.append(
el("p", {
className: "e03-muted",
text: `#${slice.i} · α ${fmt(slice.alpha_deg, 1)}° · b ${fmt(slice.width)} m · ${t("E03_FbdUnit")}`,
}),
);
return box;
}
/** 절편력 그래프 — x = 절편 가운데(횡단과 같은 방향) · y = kPa(한 축) · 고른 절편 세로선 */
function stressGraph(analysis: Analysis, picked: number | null): HTMLElement {
const box = el("div", { className: "e03-graph" });
const rows = sliceStress(analysis.slices);
if (!rows.length) return box;
const W = 380;
const H = 200;
const pad = { l: 44, r: 10, t: 10, b: 26 };
const xs = rows.map((r) => r.x);
const values = rows.flatMap((r) => [r.sigma, r.u, r.tau]);
const ticks = niceTicks(Math.min(...values), Math.max(...values), 4);
const [y0, y1] = [ticks[0], ticks[ticks.length - 1]];
const [x0, x1] = [Math.min(...xs), Math.max(...xs)];
const sx = (x: number): number =>
pad.l + ((x1 - x) / Math.max(x1 - x0, 1e-6)) * (W - pad.l - pad.r);
const sy = (y: number): number =>
H - pad.b - ((y - y0) / Math.max(y1 - y0, 1e-6)) * (H - pad.t - pad.b);
const svg = svgEl("svg", { viewBox: `0 0 ${W} ${H}`, "data-role": "stress-graph" });
const axis = svgEl("g", { class: "e03-axis" });
for (const v of ticks) {
axis.append(svgEl("line", { x1: pad.l, x2: W - pad.r, y1: sy(v), y2: sy(v) }));
axis.append(svgEl("text", { x: pad.l - 4, y: sy(v) + 4, "text-anchor": "end" }, String(v)));
}
axis.append(svgEl("text", { x: pad.l, y: H - 6 }, `${fmt(x1, 1)} m`));
axis.append(svgEl("text", { x: W - pad.r, y: H - 6, "text-anchor": "end" }, `${fmt(x0, 1)} m`));
axis.append(svgEl("text", { x: W / 2, y: H - 6, "text-anchor": "middle" }, t("E03_Offset")));
svg.append(axis);
if (picked !== null && rows[picked]) {
const x = sx(rows[picked].x);
svg.append(svgEl("line", { x1: x, x2: x, y1: pad.t, y2: H - pad.b, class: "e03-radius" }));
}
for (const key of ["sigma", "u", "tau"] as const) {
const pts: Point[] = rows.map((r) => [sx(r.x), sy(r[key])]);
svg.append(svgEl("polyline", { points: pointsText(pts), class: `e03-line e03-line--${key}` }));
}
// 가는 기둥마다 툴팁(절편 번호 · 세 값)
rows.forEach((r, i) => {
const w = (W - pad.l - pad.r) / rows.length;
const hit = svgEl("rect", {
x: sx(r.x) - w / 2,
y: pad.t,
width: w,
height: H - pad.t - pad.b,
fill: "transparent",
});
hit.append(
svgEl(
"title",
{},
`#${i + 1} · σ' ${fmt(r.sigma, 1)} · u ${fmt(r.u, 1)} · τ ${fmt(r.tau, 1)} kPa`,
),
);
svg.append(hit);
});
box.append(svg);
const legend = el("div", { className: "e03-legend" });
for (const [key, label] of [
["sigma", "E03_Graph_Sigma"],
["u", "E03_Graph_U"],
["tau", "E03_Graph_Tau"],
] as const) {
const item = el("span", { className: "e03-legend__item" });
const sw = svgEl("svg", { width: 18, height: 8 });
sw.append(svgEl("line", { x1: 0, x2: 18, y1: 4, y2: 4, class: `e03-line e03-line--${key}` }));
item.append(sw as unknown as HTMLElement, t(label as LocaleKey));
legend.append(item);
}
box.append(legend);
return box;
}
export function buildResult(
view: SectionView,
analysis: Analysis | null,
picked: number | null,
onPick: (i: number | null) => void,
): HTMLElement {
const root = el("div", { className: "e03-result e03-card" });
if (!analysis || !analysis.critical) {
root.append(el("p", { className: "e03-empty", text: t("E03_NoSurface") }));
return root;
}
const head = (key: LocaleKey): HTMLElement =>
el("h3", { className: "e03-card__title", text: t(key) });
root.append(head("E03_Result"), kpi(analysis), methodTable(analysis));
root.append(head("E03_Slices"), sliceTable(analysis, picked, onPick));
root.append(head("E03_Fbd"), freeBody(view, analysis, picked));
root.append(head("E03_Graph"), stressGraph(analysis, picked));
return root;
}
@@ -0,0 +1,334 @@
/* =============================================================================
* E03_StabSection_UI_Section.ts
* E03 가운데 횡단(PLAN 532-3) — 지층 색 · 지표 · 지하수선 · 노면 하중 · 등안전율 색 지도(원호 중심 격자
* 최소 안전율) · 원호 중심 점 · 다른 후보(옅게) · 임계 활동면(굵게 · 중심 · 반경) · 절편(누르면 고름).
* 도형 좌표 = 결과 좌표 그대로 · 묶음 transform(`viewMatrix`) 하나 — 계산은 logic.
* 줌 · 팬 = 공용 `attachSvgZoomPan`(휠 = 줌 · 가운데 끌기 = 팬 · 두 번 누름 = 원배율).
* ========================================================================== */
import { t } from "@ui/ui_template_locale";
import { el } from "@ui/ui_template_elements";
import { attachSvgZoomPan, type ZoomPanState } from "../A00_Common/b_svg_zoom_pan";
import {
bandIndex,
bandLegend,
fitView,
pointsText,
sliceShape,
toModel,
toScreen,
viewMatrix,
type Analysis,
type Point,
type SectionView,
} from "./logic/E03_StabSection_Logic";
const SVG_NS = "http://www.w3.org/2000/svg";
const PAD = 28;
export function svgEl<K extends keyof SVGElementTagNameMap>(
tag: K,
attrs: Record<string, string | number> = {},
text?: string,
): SVGElementTagNameMap[K] {
const node = document.createElementNS(SVG_NS, tag);
for (const [k, v] of Object.entries(attrs)) node.setAttribute(k, String(v));
if (text !== undefined) node.textContent = text;
return node;
}
export interface SectionToggles {
map: boolean;
centers: boolean;
candidates: boolean;
}
export interface SectionDraw {
view: SectionView;
analysis: Analysis | null;
toggles: SectionToggles;
picked: number | null;
}
const fmt = (v: number | null | undefined, d = 3): string =>
v === null || v === undefined || !Number.isFinite(v) ? "-" : v.toFixed(d);
/** 지층 색 번호 — 물성 표 차례(정해진 차례 · 순환 없이 8 넘으면 마지막 색) */
export function materialClass(view: SectionView, id: string): string {
const i = view.materials.findIndex((m) => m.id === id);
return `e03-mat-${Math.min(Math.max(i, 0), 7)}`;
}
/** 범례 줄 — 등안전율 띠(기준안전율 기준 실제 값) · 지층 */
export function buildLegends(draw: SectionDraw): HTMLElement {
const box = el("div", { className: "e03-legend" });
const required = draw.analysis?.required ?? null;
if (draw.toggles.map && draw.analysis?.grid) {
box.append(el("span", { className: "e03-muted", text: t("E03_Legend_Map") }));
for (const band of bandLegend(required)) {
const text =
band.from === null
? `< ${band.to}`
: band.to === null
? `≥ ${band.from}`
: `${band.from}–${band.to}`;
const item = el("span", {
className: "e03-legend__item",
attrs: { "data-band": String(band.index) },
});
item.append(el("span", { className: `e03-legend__swatch e03-band-${band.index}` }), text);
box.append(item);
}
}
const used = new Set(draw.view.regions.map((r) => r.material_id));
box.append(el("span", { className: "e03-muted", text: t("E03_Legend_Layers") }));
for (const m of draw.view.materials.filter((x) => used.has(x.id))) {
const item = el("span", { className: "e03-legend__item" });
item.append(
el("span", { className: `e03-legend__swatch ${materialClass(draw.view, m.id)}` }),
m.name,
);
box.append(item);
}
return box;
}
export interface SectionCanvas {
root: HTMLElement;
render: (draw: SectionDraw | null) => void;
}
export function createSectionCanvas(onPick: (index: number | null) => void): SectionCanvas {
const root = el("div", { className: "e03-section" });
const tip = el("div", { className: "e03-tip" });
let last: SectionDraw | null = null;
let zoom: ZoomPanState | undefined;
let zoomKey = "";
const paint = (): void => {
root.replaceChildren(tip);
const draw = last;
if (!draw) return;
const width = Math.max(root.clientWidth, 320);
const height = Math.max(root.clientHeight, 240);
const { view, analysis, toggles } = draw;
const fit = fitView(view, width, height, PAD);
const svg = svgEl("svg", { viewBox: `0 0 ${width} ${height}`, "data-role": "section" });
const layer = svgEl("g");
const world = svgEl("g", { transform: viewMatrix(fit), "data-role": "world" });
const ns = { "vector-effect": "non-scaling-stroke" };
const unit = 1 / fit.k; // 1 px 의 결과 좌표 길이
for (const region of view.regions) {
const ring = svgEl("polygon", {
points: pointsText(region.polygon),
class: `e03-region ${materialClass(view, region.material_id)}`,
...ns,
});
world.append(ring);
}
// 등안전율 색 지도 — 격자 중심마다 칸(이웃 중심 사이 절반)
const grid = analysis?.grid;
if (grid && toggles.map) {
const half = (arr: number[], i: number): [number, number] => {
const lo =
i > 0 ? (arr[i] - arr[i - 1]) / 2 : i + 1 < arr.length ? (arr[i + 1] - arr[i]) / 2 : 0.5;
const hi = i + 1 < arr.length ? (arr[i + 1] - arr[i]) / 2 : lo;
return [arr[i] - lo, arr[i] + hi];
};
const cells = svgEl("g", { "data-role": "safety-map", opacity: 0.8 });
grid.z.forEach((_z, iz) => {
grid.x.forEach((_x, ix) => {
const band = bandIndex(grid.fs[iz]?.[ix], analysis.required);
if (band === null) return;
const [x0, x1] = half(grid.x, ix).sort((a, b) => a - b);
const [z0, z1] = half(grid.z, iz).sort((a, b) => a - b);
const cell = svgEl("rect", {
x: x0,
y: z0,
width: x1 - x0,
height: z1 - z0,
class: `e03-cell e03-band-${band}`,
"data-fs": String(grid.fs[iz][ix]),
});
cells.append(cell);
});
});
world.append(cells);
}
if (grid && toggles.centers) {
const dots = svgEl("g", { "data-role": "centers" });
grid.z.forEach((z) =>
grid.x.forEach((x) =>
dots.append(svgEl("circle", { cx: x, cy: z, r: 1.6 * unit, class: "e03-gridpt" })),
),
);
world.append(dots);
}
// 노면 하중 — 지표 위 띠 + 아래 화살 눈금
for (const load of view.surcharge) {
const zTop = Math.max(
...view.surface.filter((p) => p[0] >= load.x0 && p[0] <= load.x1).map((p) => p[1]),
);
if (!Number.isFinite(zTop)) continue;
const up = zTop + 14 * unit;
const g = svgEl("g", { class: "e03-load", "data-role": "surcharge" });
g.append(
svgEl("polyline", {
points: pointsText([
[load.x0, up],
[load.x1, up],
]),
...ns,
}),
);
const n = Math.max(2, Math.round((load.x1 - load.x0) / (10 * unit)));
for (let i = 0; i <= n; i += 1) {
const x = load.x0 + ((load.x1 - load.x0) * i) / n;
g.append(
svgEl("polyline", {
points: pointsText([
[x, up],
[x, zTop + 3 * unit],
]),
...ns,
}),
);
}
world.append(g);
}
world.append(
svgEl("polyline", { points: pointsText(view.surface), class: "e03-surface", ...ns }),
);
const water = analysis ? view.water[analysis.condition] : null;
if (water) {
world.append(
svgEl("polyline", {
points: pointsText(water),
class: "e03-water",
"data-role": "water",
...ns,
}),
);
}
const critical = analysis?.critical ?? null;
if (analysis && toggles.candidates) {
for (const c of analysis.candidates) {
if (critical && c.method === critical.method && c.kind === critical.kind) continue;
const line = svgEl("polyline", {
points: pointsText(c.points),
class: "e03-candidate",
"data-role": "candidate",
...ns,
});
line.append(
svgEl(
"title",
{},
`${t(`E03_Method_${c.method}` as never)} · ${c.kind} · Fs ${fmt(c.fs)}`,
),
);
world.append(line);
}
}
const labels = svgEl("g", { "data-role": "labels" });
if (critical && analysis) {
const slices = svgEl("g", { "data-role": "slices" });
analysis.slices.forEach((slice, i) => {
const poly = svgEl("polygon", {
points: pointsText(sliceShape(slice, view.surface, critical.points)),
class: `e03-slice${draw.picked === i ? " is-picked" : ""}`,
"data-i": String(i),
...ns,
});
poly.addEventListener("click", (ev) => {
ev.stopPropagation();
onPick(draw.picked === i ? null : i);
});
slices.append(poly);
});
world.append(slices);
if (critical.center && critical.entry && critical.exit) {
const c = critical.center;
world.append(
svgEl("polyline", {
points: pointsText([critical.entry, c, critical.exit]),
class: "e03-radius",
...ns,
}),
svgEl("circle", { cx: c[0], cy: c[1], r: 3 * unit, class: "e03-gridpt" }),
);
}
world.append(
svgEl("polyline", {
points: pointsText(critical.points),
class: "e03-critical",
"data-role": "critical",
...ns,
}),
);
// 글 — 안전율 · 중심 · 반경(화면 좌표 · 뒤집지 않음)
const anchor: Point =
critical.center ?? critical.points[Math.floor(critical.points.length / 2)];
const [ax, ay] = toScreen(fit, anchor);
labels.append(
svgEl(
"text",
{ x: ax + 8, y: ay - 10, class: "e03-label e03-label--fs", "data-role": "fs-label" },
`Fs = ${fmt(critical.fs)}`,
),
);
if (critical.center) {
labels.append(
svgEl(
"text",
{ x: ax + 8, y: ay + 6, class: "e03-label" },
`Xc ${fmt(critical.center[0], 2)} · Zc ${fmt(critical.center[1], 2)} · R ${fmt(critical.radius, 2)} m`,
),
);
}
}
layer.append(world, labels);
svg.append(layer);
svg.addEventListener("click", () => draw.picked !== null && onPick(null));
root.append(svg);
// 마우스 아래 offset · 표고 (줌 상태 되돌려 셈)
svg.addEventListener("mousemove", (ev) => {
const box = svg.getBoundingClientRect();
const sx = ((ev.clientX - box.left) / box.width) * width;
const sy = ((ev.clientY - box.top) / box.height) * height;
const s = zoom ?? { scale: 1, tx: 0, ty: 0 };
const [x, z] = toModel(fit, [(sx - s.tx) / s.scale, (sy - s.ty) / s.scale]);
tip.textContent = `${t("E03_Offset")} ${x.toFixed(2)} m · EL ${z.toFixed(2)} m`;
tip.style.display = "block";
tip.style.left = `${ev.clientX - box.left + 12}px`;
tip.style.top = `${ev.clientY - box.top + 12}px`;
});
svg.addEventListener("mouseleave", () => (tip.style.display = "none"));
const key = `${view.key}|${width}x${height}`;
if (key !== zoomKey) zoom = undefined;
zoomKey = key;
attachSvgZoomPan({
svg,
layer,
widthPx: width,
heightPx: height,
pad: { left: 0, right: 0, top: 0, bottom: 0 },
wheelZoom: true,
maxScale: 20,
initial: zoom,
onChange: (state) => (zoom = state),
});
};
new ResizeObserver(() => last && paint()).observe(root);
return {
root,
render(draw) {
last = draw;
paint();
},
};
}
@@ -0,0 +1,686 @@
/* =============================================================================
* E03_StabSection_UI_Style.css
* E03 2D 측점 해석 — 판정 색(상태 · 아이콘 + 글과 함께) · 등안전율 띠(빨강 ↔ 회색 ↔ 파랑) ·
* 지층 색(정해진 차례) · 밝음 · 어두움 각각 고른 값
* ========================================================================== */
:root {
--e03-ok: #0ca30c;
--e03-ng: #d03b3b;
--e03-none: #9a9a95;
--e03-band-0: #a31f1f;
--e03-band-1: #e34948;
--e03-band-2: #f29b97;
--e03-band-3: #e6e4df;
--e03-band-4: #9ec5f4;
--e03-band-5: #3987e5;
--e03-band-6: #184f95;
--e03-mat-0: #2a78d6;
--e03-mat-1: #eb6834;
--e03-mat-2: #1baf7a;
--e03-mat-3: #eda100;
--e03-mat-4: #e87ba4;
--e03-mat-5: #008300;
--e03-mat-6: #6250d6;
--e03-mat-7: #e34948;
--e03-water: #1c74d9;
--e03-critical: #0b0b0b;
--e03-surface-line: #52514e;
--e03-plot: #fcfcfb;
}
:root[data-theme="dark"] {
--e03-band-0: #c43c3c;
--e03-band-1: #e66767;
--e03-band-2: #8f4a48;
--e03-band-3: #383835;
--e03-band-4: #1c5cab;
--e03-band-5: #3987e5;
--e03-band-6: #86b6ef;
--e03-mat-0: #3987e5;
--e03-mat-1: #d95926;
--e03-mat-2: #199e70;
--e03-mat-3: #c98500;
--e03-mat-4: #d55181;
--e03-mat-5: #008300;
--e03-mat-6: #9085e9;
--e03-mat-7: #e66767;
--e03-water: #6da7ec;
--e03-critical: #f0efec;
--e03-surface-line: #c3c2b7;
--e03-plot: #1a1a19;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--e03-band-0: #c43c3c;
--e03-band-1: #e66767;
--e03-band-2: #8f4a48;
--e03-band-3: #383835;
--e03-band-4: #1c5cab;
--e03-band-5: #3987e5;
--e03-band-6: #86b6ef;
--e03-mat-0: #3987e5;
--e03-mat-1: #d95926;
--e03-mat-2: #199e70;
--e03-mat-3: #c98500;
--e03-mat-4: #d55181;
--e03-mat-5: #008300;
--e03-mat-6: #9085e9;
--e03-mat-7: #e66767;
--e03-water: #6da7ec;
--e03-critical: #f0efec;
--e03-surface-line: #c3c2b7;
--e03-plot: #1a1a19;
}
}
/* ---- 틀 ---- */
.e03-page {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
gap: var(--spacing-8);
height: 100%;
padding: var(--spacing-8) var(--spacing-12);
box-sizing: border-box;
min-height: 0;
}
.e03-body {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(320px, 420px);
gap: var(--spacing-8);
min-height: 0;
}
.e03-card {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
min-width: 0;
min-height: 0;
padding: var(--spacing-8) var(--spacing-12);
border-radius: var(--radius-sm);
background: var(--color-surface-raised);
}
.e03-card__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-8);
flex-wrap: wrap;
}
.e03-card__title {
margin: 0;
color: var(--color-text);
font-size: var(--text-body);
font-weight: var(--font-weight-medium);
}
.e03-muted {
margin: 0;
color: var(--color-text-secondary);
font-size: var(--text-body-sm);
}
.e03-empty {
margin: auto;
color: var(--color-text-secondary);
font-size: var(--text-body);
text-align: center;
white-space: pre-line;
}
/* ---- 좌측 측점 목록 ---- */
.e03-panel {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
min-height: 0;
height: 100%;
}
.e03-panel__row {
display: flex;
gap: var(--spacing-8);
align-items: center;
flex-wrap: wrap;
}
.e03-panel__row > .ui-field {
flex: 1 1 0;
min-width: 0;
}
.e03-check {
display: inline-flex;
align-items: center;
gap: var(--spacing-4);
color: var(--color-text-body);
font-size: var(--text-body-sm);
cursor: pointer;
user-select: none;
}
.e03-list {
flex: 1 1 auto;
min-height: 0;
overflow: auto;
border-top: 1px solid var(--color-border);
}
.e03-list table {
width: 100%;
border-collapse: collapse;
font-size: var(--text-body-sm);
color: var(--color-text-body);
}
.e03-list th {
position: sticky;
top: 0;
z-index: 1;
padding: var(--spacing-4);
background: var(--color-surface-raised);
border-bottom: 1px solid var(--color-border);
color: var(--color-text-secondary);
font-weight: var(--font-weight-medium);
text-align: left;
white-space: nowrap;
}
.e03-list td {
padding: 3px var(--spacing-4);
border-bottom: 1px solid var(--color-border);
white-space: nowrap;
}
.e03-list td.is-num,
.e03-list th.is-num,
.e03-table td.is-num,
.e03-table th.is-num {
text-align: right;
font-variant-numeric: tabular-nums;
}
.e03-list tbody tr {
cursor: pointer;
}
.e03-list tbody tr:hover {
background: var(--color-surface-sunken);
}
.e03-list tbody tr.is-active {
background: var(--color-lavender-wash);
outline: 1px solid var(--color-accent);
outline-offset: -1px;
}
.e03-verdict {
display: inline-flex;
align-items: center;
gap: 4px;
font-weight: var(--font-weight-medium);
color: var(--color-text);
}
.e03-verdict::before {
content: "";
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--e03-none);
}
.e03-verdict--ok::before {
background: var(--e03-ok);
}
.e03-verdict--ng::before {
background: var(--e03-ng);
border-radius: 2px;
transform: rotate(45deg);
}
.e03-star {
border: none;
background: none;
padding: 0 2px;
color: var(--color-text-muted);
font-size: 15px;
line-height: 1;
cursor: pointer;
}
.e03-star.is-on {
color: var(--color-warning);
}
.e03-star.is-user {
text-decoration: underline;
}
.e03-star:disabled {
cursor: default;
}
/* ---- 평면 띠 ---- */
.e03-plan svg {
display: block;
width: 100%;
height: 96px;
background: var(--e03-plot);
border-radius: var(--radius-sm);
}
.e03-plan__seg {
fill: none;
stroke-width: 7;
stroke-linecap: butt;
stroke-linejoin: round;
cursor: pointer;
}
.e03-plan__seg--ok {
stroke: var(--e03-ok);
}
.e03-plan__seg--ng {
stroke: var(--e03-ng);
}
.e03-plan__seg--none {
stroke: var(--e03-none);
}
.e03-plan__pick {
fill: var(--e03-plot);
stroke: var(--e03-critical);
stroke-width: 2;
}
.e03-legend {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-4) var(--spacing-12);
align-items: center;
color: var(--color-text-body);
font-size: var(--text-caption, 12px);
}
.e03-legend__item {
display: inline-flex;
align-items: center;
gap: 4px;
white-space: nowrap;
}
.e03-legend__swatch {
width: 14px;
height: 10px;
border-radius: 2px;
border: 1px solid var(--color-border);
}
/* ---- 횡단 ---- */
.e03-section {
flex: 1 1 auto;
min-height: 0;
position: relative;
}
.e03-section svg {
display: block;
width: 100%;
height: 100%;
background: var(--e03-plot);
border-radius: var(--radius-sm);
}
.e03-region {
fill-opacity: 0.32;
stroke: var(--e03-surface-line);
stroke-opacity: 0.45;
stroke-width: 0.6;
}
.e03-surface {
fill: none;
stroke: var(--e03-surface-line);
stroke-width: 1.6;
}
.e03-water {
fill: none;
stroke: var(--e03-water);
stroke-width: 1.6;
stroke-dasharray: 8 4;
}
.e03-load {
stroke: var(--color-text-secondary);
stroke-width: 1;
fill: none;
}
.e03-cell {
stroke: none;
}
.e03-gridpt {
fill: var(--color-text-secondary);
}
.e03-candidate {
fill: none;
stroke: var(--color-text-secondary);
stroke-opacity: 0.55;
stroke-width: 1.2;
stroke-dasharray: 4 3;
}
.e03-critical {
fill: none;
stroke: var(--e03-critical);
stroke-width: 3;
}
.e03-radius {
stroke: var(--e03-critical);
stroke-width: 1;
stroke-dasharray: 3 3;
fill: none;
}
.e03-slice {
fill: transparent;
stroke: var(--color-text-secondary);
stroke-opacity: 0.35;
stroke-width: 0.6;
cursor: pointer;
}
.e03-slice:hover {
fill: var(--color-accent);
fill-opacity: 0.18;
}
.e03-slice.is-picked {
fill: var(--color-accent);
fill-opacity: 0.35;
stroke: var(--color-accent);
stroke-opacity: 1;
stroke-width: 1.5;
}
.e03-label {
fill: var(--color-text);
font-size: 12px;
paint-order: stroke;
stroke: var(--e03-plot);
stroke-width: 3px;
}
.e03-label--fs {
font-size: 14px;
font-weight: 700;
}
.e03-axis line {
stroke: var(--color-border);
}
.e03-axis text {
fill: var(--color-text-secondary);
font-size: 11px;
}
.e03-tip {
position: absolute;
pointer-events: none;
padding: 2px 6px;
border-radius: var(--radius-sm);
background: var(--color-surface);
border: 1px solid var(--color-border);
color: var(--color-text);
font-size: var(--text-body-sm);
white-space: nowrap;
display: none;
}
/* ---- 우측 결과 ---- */
.e03-result {
overflow: auto;
}
.e03-kpi {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--spacing-4) var(--spacing-12);
margin: 0;
font-size: var(--text-body-sm);
}
.e03-kpi dt {
color: var(--color-text-secondary);
}
.e03-kpi dd {
margin: 0;
color: var(--color-text);
font-variant-numeric: tabular-nums;
}
.e03-kpi dd.is-big {
font-size: 22px;
font-weight: 700;
}
.e03-table {
width: 100%;
border-collapse: collapse;
font-size: var(--text-body-sm);
color: var(--color-text-body);
}
.e03-table th {
position: sticky;
top: 0;
background: var(--color-surface-raised);
padding: 2px var(--spacing-4);
border-bottom: 1px solid var(--color-border);
color: var(--color-text-secondary);
font-weight: var(--font-weight-medium);
white-space: nowrap;
}
.e03-table td {
padding: 2px var(--spacing-4);
border-bottom: 1px solid var(--color-border);
white-space: nowrap;
}
.e03-table tbody tr {
cursor: pointer;
}
.e03-table tbody tr.is-picked {
background: var(--color-lavender-wash);
}
.e03-slices {
max-height: 220px;
overflow: auto;
}
.e03-fbd svg,
.e03-graph svg {
display: block;
width: 100%;
background: var(--e03-plot);
border-radius: var(--radius-sm);
}
.e03-arrow {
stroke-width: 2;
fill: none;
}
.e03-arrow--W,
.e03-arrow--Q {
stroke: var(--e03-mat-6);
}
.e03-arrow--N {
stroke: var(--e03-mat-0);
}
.e03-arrow--U {
stroke: var(--e03-water);
stroke-dasharray: 4 2;
}
.e03-arrow--S {
stroke: var(--e03-mat-1);
}
.e03-arrow--EL,
.e03-arrow--ER,
.e03-arrow--XL,
.e03-arrow--XR {
stroke: var(--e03-mat-2);
}
.e03-line {
fill: none;
stroke-width: 2;
}
.e03-line--sigma {
stroke: var(--e03-mat-0);
}
.e03-line--u {
stroke: var(--e03-water);
stroke-dasharray: 5 3;
}
.e03-line--tau {
stroke: var(--e03-mat-1);
}
/* ---- 3D 보기 ---- */
.e03-view3d {
position: fixed;
inset: 5vh 5vw;
z-index: 50;
display: flex;
flex-direction: column;
border-radius: var(--radius-sm);
background: var(--color-surface-raised);
box-shadow: var(--shadow-lg);
overflow: hidden;
}
.e03-view3d__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-8);
padding: var(--spacing-8) var(--spacing-12);
}
.e03-view3d__canvas {
flex: 1 1 auto;
min-height: 0;
position: relative;
}
.e03-view3d__canvas canvas {
display: block;
width: 100%;
height: 100%;
}
/* ---- 색 칸(지층 · 등안전율 띠) ---- */
.e03-mat-0 {
fill: var(--e03-mat-0);
background: var(--e03-mat-0);
}
.e03-mat-1 {
fill: var(--e03-mat-1);
background: var(--e03-mat-1);
}
.e03-mat-2 {
fill: var(--e03-mat-2);
background: var(--e03-mat-2);
}
.e03-mat-3 {
fill: var(--e03-mat-3);
background: var(--e03-mat-3);
}
.e03-mat-4 {
fill: var(--e03-mat-4);
background: var(--e03-mat-4);
}
.e03-mat-5 {
fill: var(--e03-mat-5);
background: var(--e03-mat-5);
}
.e03-mat-6 {
fill: var(--e03-mat-6);
background: var(--e03-mat-6);
}
.e03-mat-7 {
fill: var(--e03-mat-7);
background: var(--e03-mat-7);
}
.e03-band-0 {
fill: var(--e03-band-0);
background: var(--e03-band-0);
}
.e03-band-1 {
fill: var(--e03-band-1);
background: var(--e03-band-1);
}
.e03-band-2 {
fill: var(--e03-band-2);
background: var(--e03-band-2);
}
.e03-band-3 {
fill: var(--e03-band-3);
background: var(--e03-band-3);
}
.e03-band-4 {
fill: var(--e03-band-4);
background: var(--e03-band-4);
}
.e03-band-5 {
fill: var(--e03-band-5);
background: var(--e03-band-5);
}
.e03-band-6 {
fill: var(--e03-band-6);
background: var(--e03-band-6);
}
.e03-swatch-none {
background: var(--e03-none);
}
@@ -0,0 +1,201 @@
/* =============================================================================
* E03_StabSection_UI_View3D.ts
* E03 [3D 보기](PLAN 532-3) — 복사본 노선면(GET …/stab/corridor3d · 1 m 솎음)에 측점 안전율 색칠(표시만).
* 색 = 가장 가까운 측점의 그 조건 최소 (안전율 ÷ 기준) 띠(횡단 등안전율과 같은 띠 · logic `bandIndex`) ·
* 해석 없는 측점 = 회색. createRouteViewer 는 DB 지표면 · 색 고리가 없어 쓰지 않고 three 를 바로 씀.
* ========================================================================== */
import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import { createButton, el } from "@ui/ui_template_elements";
import { t } from "@ui/ui_template_locale";
import {
bandIndex,
RATIO_BREAKS,
type Condition,
type StationRow,
} from "./logic/E03_StabSection_Logic";
interface Corridor3d {
origin: [number, number, number];
ribbons: {
kind: string;
side: string;
colCount: number;
chainages: number[];
positions: number[];
}[];
}
/** 측점 하나 그 조건 최소 비(안전율 ÷ 기준)의 띠 */
function stationBand(row: StationRow, condition: Condition): number | null {
let best: number | null = null;
for (const c of Object.values(row.cases[condition] ?? {})) {
const band = bandIndex(c.fs, c.required);
if (band !== null && (best === null || band < best)) best = band;
}
return best;
}
function cssColor(name: string): THREE.Color {
const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
return new THREE.Color(value || "#888888");
}
export async function openView3d(
data: Corridor3d,
rows: StationRow[],
condition: Condition,
conditionLabel: string,
): Promise<void> {
const shell = el("div", { className: "e03-view3d", attrs: { "data-role": "view3d" } });
const head = el("div", { className: "e03-view3d__head" });
const legend = el("div", { className: "e03-legend" });
legend.append(
el("span", { className: "e03-muted", text: `${conditionLabel} · ${t("E03_View3d_Legend")}` }),
);
const edges = [null, ...RATIO_BREAKS, null];
for (let i = 0; i <= RATIO_BREAKS.length; i += 1) {
const item = el("span", { className: "e03-legend__item" });
const text =
edges[i] === null
? `< ${edges[i + 1]}`
: edges[i + 1] === null
? `≥ ${edges[i]}`
: `${edges[i]}–${edges[i + 1]}`;
item.append(el("span", { className: `e03-legend__swatch e03-band-${i}` }), text);
legend.append(item);
}
const noneItem = el("span", { className: "e03-legend__item" });
noneItem.append(
el("span", { className: "e03-legend__swatch e03-swatch-none" }),
t("E03_Verdict_none"),
);
legend.append(noneItem);
const close = createButton({ label: t("Common_Btn_Close"), variant: "ghost", size: "sm" });
head.append(el("h3", { className: "e03-card__title", text: t("E03_View3d") }), legend, close);
const host = el("div", { className: "e03-view3d__canvas" });
shell.append(head, host);
document.body.append(shell);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(window.devicePixelRatio);
host.append(renderer.domElement);
const scene = new THREE.Scene();
scene.background = cssColor("--e03-plot");
scene.add(new THREE.AmbientLight(0xffffff, 0.75));
const sun = new THREE.DirectionalLight(0xffffff, 0.9);
sun.position.set(200, 400, 300);
scene.add(sun);
// 측점 누가 → 색
const sorted = rows.slice().sort((a, b) => a.chainage_m - b.chainage_m);
const bandColors = [...Array(RATIO_BREAKS.length + 1).keys()].map((i) =>
cssColor(`--e03-band-${i}`),
);
const noneColor = cssColor("--e03-none");
const colorAt = (chainage: number): THREE.Color => {
let lo = 0;
let hi = sorted.length - 1;
while (hi - lo > 1) {
const mid = (lo + hi) >> 1;
if (sorted[mid].chainage_m <= chainage) lo = mid;
else hi = mid;
}
const near =
sorted.length &&
Math.abs(sorted[hi].chainage_m - chainage) < Math.abs(sorted[lo].chainage_m - chainage)
? sorted[hi]
: sorted[lo];
const band = near ? stationBand(near, condition) : null;
return band === null ? noneColor : bandColors[band];
};
const group = new THREE.Group();
for (const ribbon of data.ribbons) {
const rowsN = ribbon.chainages.length;
const cols = ribbon.colCount;
if (rowsN < 2 || cols < 2) continue;
const pos = new Float32Array(rowsN * cols * 3);
const col = new Float32Array(rowsN * cols * 3);
for (let r = 0; r < rowsN; r += 1) {
const c3 = colorAt(ribbon.chainages[r]);
for (let c = 0; c < cols; c += 1) {
const i = (r * cols + c) * 3;
const [x, y, z] = ribbon.positions.slice(i, i + 3);
pos.set([x, z, -y], i);
col.set([c3.r, c3.g, c3.b], i);
}
}
const index: number[] = [];
for (let r = 0; r + 1 < rowsN; r += 1) {
for (let c = 0; c + 1 < cols; c += 1) {
const a = r * cols + c;
index.push(a, a + cols, a + 1, a + 1, a + cols, a + cols + 1);
}
}
const geometry = new THREE.BufferGeometry();
geometry.setAttribute("position", new THREE.BufferAttribute(pos, 3));
geometry.setAttribute("color", new THREE.BufferAttribute(col, 3));
geometry.setIndex(index);
geometry.computeVertexNormals();
const material = new THREE.MeshLambertMaterial({ vertexColors: true, side: THREE.DoubleSide });
group.add(new THREE.Mesh(geometry, material));
}
scene.add(group);
const box = new THREE.Box3().setFromObject(group);
const sphere = box.getBoundingSphere(new THREE.Sphere());
const camera = new THREE.PerspectiveCamera(45, 1, 0.5, Math.max(sphere.radius * 20, 1000));
camera.position
.copy(sphere.center)
.add(new THREE.Vector3(0, sphere.radius * 1.4, sphere.radius * 1.2));
const controls = new OrbitControls(camera, renderer.domElement);
controls.target.copy(sphere.center);
controls.update();
let frame = 0;
const resize = (): void => {
const w = Math.max(host.clientWidth, 1);
const h = Math.max(host.clientHeight, 1);
renderer.setSize(w, h, false);
camera.aspect = w / h;
camera.updateProjectionMatrix();
};
const loop = (): void => {
frame = requestAnimationFrame(loop);
controls.update();
renderer.render(scene, camera);
};
const observer = new ResizeObserver(resize);
observer.observe(host);
resize();
loop();
(window as unknown as { __e03View3d?: unknown }).__e03View3d = {
meshes: group.children.length,
vertices: group.children.reduce(
(n, m) => n + ((m as THREE.Mesh).geometry.getAttribute("position").count ?? 0),
0,
),
};
const dispose = (): void => {
cancelAnimationFrame(frame);
observer.disconnect();
controls.dispose();
group.traverse((o) => {
if (o instanceof THREE.Mesh) {
o.geometry.dispose();
(o.material as THREE.Material).dispose();
}
});
renderer.dispose();
shell.remove();
document.removeEventListener("keydown", onKey);
};
const onKey = (ev: KeyboardEvent): void => {
if (ev.key === "Escape") dispose();
};
document.addEventListener("keydown", onKey);
close.addEventListener("click", dispose);
}
+262
View File
@@ -0,0 +1,262 @@
"""E03 2D 해석 결과 → 화면 꼴 (요구 345 · 347 · PLAN 532-3) — storage 를 모르는 순수 함수.
엔진 출력 = `E03_StabSection_Engine_Station.analyze_station` 그대로(cases × methods).
판정 = 대표 방법(Bishop) 최소 안전율 ≥ 기준안전율(E02 `safety_factors[조건][깎기 · 쌓기]`).
대표 단면(보고서 몫) = 자동(쌓기 높이 ≥ 10 m · 깎기 ≥ 15 m · 최소 안전율 하위 5) + 사용자 켬/끔.
좌표 = 엔진 그대로(x = B06 offset · 좌 + · z = 표고) — 화면이 같은 좌표로 그림.
"""
from __future__ import annotations
import math
from typing import Any
PRIMARY = "bishop"
CONDITIONS = ("dry", "wet", "seismic")
REPRESENTATIVE_FILL_M = 10.0
REPRESENTATIVE_CUT_M = 15.0
REPRESENTATIVE_LOWEST = 5
#: 화면에 넘기는 면 꼭짓점 · 절편 자릿수
_ROUND = 3
def _r(value: Any, digits: int = _ROUND) -> Any:
if isinstance(value, float):
return round(value, digits) if math.isfinite(value) else None
if isinstance(value, list):
return [_r(v, digits) for v in value]
return value
def required_factor(factors: dict[str, Any], condition: str, role: str) -> float | None:
"""기준안전율 — 조건 · 비탈 역할(cut · fill). 없으면 None."""
value = (factors.get(condition) or {}).get(role)
return float(value) if isinstance(value, int | float) else None
def _governing(case: dict[str, Any]) -> dict[str, Any] | None:
"""한 비탈 · 조건의 대표 방법 최소 면(원호 · 비원호 중 작은 쪽)."""
return ((case.get("result") or {}).get("methods") or {}).get(PRIMARY, {}).get("min")
def station_row(
meta: dict[str, Any],
out: dict[str, Any] | None,
factors: dict[str, Any],
error: str | None = None,
) -> dict[str, Any]:
"""측점 목록 한 줄 — 조건 × 비탈 안전율 · 기준 · 판정 · 비탈 높이."""
cases: dict[str, dict[str, Any]] = {}
sides: dict[str, dict[str, Any]] = {}
for case in (out or {}).get("cases", []):
cond, side, role = case["condition"], case["side"], case["role"]
sides.setdefault(side, {"side": side, "role": role, "height_m": _r(case.get("height_m"))})
best = _governing(case)
fs = best.get("F") if best else None
need = required_factor(factors, cond, role)
cases.setdefault(cond, {})[side] = {
"fs": _r(fs),
"required": need,
"ok": None if fs is None or need is None else bool(fs >= need),
"kind": best.get("kind") if best else None,
"converged": best.get("converged") if best else None,
}
minimum: dict[str, float | None] = {}
verdict: dict[str, str] = {}
for cond, by_side in cases.items():
values = [v["fs"] for v in by_side.values() if v["fs"] is not None]
minimum[cond] = min(values) if values else None
oks = [v["ok"] for v in by_side.values() if v["ok"] is not None]
verdict[cond] = "none" if not oks else ("ok" if all(oks) else "ng")
return {
"key": meta["key"],
"chainage_m": meta["chainage_m"],
"label": meta.get("label"),
"section_mode": meta.get("section_mode"),
"ground_type": meta.get("ground_type"),
"center": meta.get("center"),
"fp": meta.get("fp"),
"sides": list(sides.values()),
"cases": cases,
"min": minimum,
"verdict": verdict,
"error": error,
"elapsed_s": (out or {}).get("elapsed_s"),
}
def overall_verdict(row: dict[str, Any], condition: str | None = None) -> str:
"""측점 판정 — 조건 하나 또는 전 조건 중 가장 나쁜 것(ng > ok > none)."""
verdict = row.get("verdict") or {}
values = [verdict.get(condition, "none")] if condition else list(verdict.values())
if "ng" in values:
return "ng"
return "ok" if "ok" in values else "none"
def auto_representative(rows: list[dict[str, Any]]) -> dict[str, list[str]]:
"""자동 대표 단면 — 열쇠 → 까닭(fill_height · cut_height · lowest)."""
reasons: dict[str, list[str]] = {}
for row in rows:
for side in row["sides"]:
height = side.get("height_m") or 0.0
if side["role"] == "fill" and height >= REPRESENTATIVE_FILL_M:
reasons.setdefault(row["key"], []).append("fill_height")
if side["role"] == "cut" and height >= REPRESENTATIVE_CUT_M:
reasons.setdefault(row["key"], []).append("cut_height")
ranked = sorted(
(
(min(v for v in row["min"].values() if v is not None), row["key"])
for row in rows
if any(v is not None for v in row["min"].values())
),
)
for _, key in ranked[:REPRESENTATIVE_LOWEST]:
reasons.setdefault(key, []).append("lowest")
return {key: sorted(set(v)) for key, v in reasons.items()}
def mark_representative(rows: list[dict[str, Any]], overrides: dict[str, bool]) -> None:
"""행마다 대표 단면 칸 — auto(까닭) · user(켬/끔 · 없으면 None) · on(최종)."""
auto = auto_representative(rows)
for row in rows:
user = overrides.get(row["key"])
reasons = auto.get(row["key"], [])
row["representative"] = {
"auto": reasons,
"user": user,
"on": bool(reasons) if user is None else bool(user),
}
def summarize(rows: list[dict[str, Any]], conditions: list[str]) -> dict[str, Any]:
"""머리 요약 — 조건별 최소 안전율 · N.G 측점 수."""
out: dict[str, Any] = {"stations": len(rows), "errors": sum(1 for r in rows if r["error"])}
for cond in conditions:
values = [r["min"].get(cond) for r in rows if r["min"].get(cond) is not None]
out[cond] = {
"min_fs": min(values) if values else None,
"ng": sum(1 for r in rows if r["verdict"].get(cond) == "ng"),
}
return out
def _slices(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""절편표 + 자유물체도 힘 — N(전 수직력) = N' + u·l · U = u·l · 오른쪽 = 다음 절편의 왼쪽."""
out = []
for i, s in enumerate(rows):
nxt = rows[i + 1] if i + 1 < len(rows) else None
U = s["pore_pressure"] * s["base_length"]
out.append(
{
"i": i + 1,
**{k: _r(s.get(k)) for k in ("x_left", "x_right", "width", "height", "alpha_deg")},
**{k: _r(s.get(k)) for k in ("weight", "surcharge", "pore_pressure")},
**{k: _r(s.get(k)) for k in ("base_length", "c", "phi_deg")},
"material": s.get("material"),
"N": _r(s["normal_eff"] + U),
"N_eff": _r(s["normal_eff"]),
"U": _r(U),
"S": _r(s["shear_mobilized"]),
"E_left": _r(s["E_left"]),
"E_right": _r(nxt["E_left"]) if nxt else 0.0,
"X_left": _r(s["X_left"]),
"X_right": _r(nxt["X_left"]) if nxt else 0.0,
}
)
return out
def _surface(found: dict[str, Any] | None, method: str) -> dict[str, Any] | None:
if not found:
return None
out = {
"method": method,
"kind": found.get("kind"),
"fs": _r(found.get("F")),
"points": _r(found.get("points") or []),
"entry": _r(found.get("entry")),
"exit": _r(found.get("exit")),
"converged": found.get("converged"),
"iterations": found.get("iterations"),
}
if found.get("center") is not None:
out["center"] = _r(found["center"])
out["radius"] = _r(found.get("radius"))
for key in ("lambda", "theta_deg", "F0", "f0"):
if key in found:
out[key] = _r(found[key])
return out
def section_view(
row: dict[str, Any],
out: dict[str, Any],
model: dict[str, Any],
water: dict[str, list[list[float]] | None],
) -> dict[str, Any]:
"""측점 하나 화면 — 지층 · 지표 · 지하수선 · 하중 · 비탈 × 조건 임계면 · 후보 · 절편 · 격자."""
analyses = []
for case in out.get("cases", []):
result = case.get("result") or {}
methods = result.get("methods") or {}
best = (methods.get(PRIMARY) or {}).get("min")
candidates = []
for method, found in methods.items():
for kind in ("circular", "noncircular"):
surface = _surface(found.get(kind), method)
if surface and surface["fs"] is not None:
candidates.append(surface)
side_row = (row["cases"].get(case["condition"]) or {}).get(case["side"]) or {}
contour = result.get("contour")
analyses.append(
{
"condition": case["condition"],
"side": case["side"],
"role": case["role"],
"height_m": _r(case.get("height_m")),
"limits": _r(list(case.get("limits") or [])),
"direction": case.get("direction"),
"required": side_row.get("required"),
"ok": side_row.get("ok"),
"min_by_method": {m: _r(v) for m, v in (case.get("min_F") or {}).items()},
"critical": _surface(best, PRIMARY),
"candidates": candidates,
"slices": _slices(best.get("slices") or []) if best else [],
"grid": (
{
"method": contour.get("method"),
"x": _r(contour["x"]),
"z": _r(contour["z"]),
"fs": _r(contour["F"]),
}
if contour
else None
),
"counts": result.get("counts"),
}
)
return {
"key": row["key"],
"chainage_m": row["chainage_m"],
"label": row.get("label"),
"materials": [
{k: m.get(k) for k in ("id", "name", "gamma_t", "gamma_sat", "c", "phi")}
for m in model["materials"]
],
"regions": [
{
"material_id": r["material_id"],
"polygon": _r(r["polygon"]),
"holes": _r(r.get("holes") or []),
}
for r in model["regions"]
],
"surface": _r(model["surface"]),
"bottom_z": _r(model.get("bottom_z")),
"surcharge": model.get("surcharge") or [],
"water": {cond: _r(line) if line else None for cond, line in water.items()},
"areas": {k: _r(v) for k, v in (out.get("areas") or {}).items()},
"analyses": analyses,
"row": row,
}
+74
View File
@@ -0,0 +1,74 @@
"""E03 측점 해석 일꾼 — 서버 밖 프로세스(PLAN 532-3).
`python -m E03_StabSection.E03_StabSection_Worker <job.json>` — 서버(`Router.run_all`)가 띄움.
측점마다 프로세스로 나눠 돌리고 끝날 때마다 파생 저장본을 쓰고 한 줄(JSON)을 찍음 → 서버 진행률.
서버 프로세스에서 바로 프로세스 풀을 열면 자식마다 `main.py` 를 다시 읽어 이 모듈을 입구로 둠.
job = {root, keep, kwargs, workers, items: [{fp, meta, layers, cross, design, ground}]}.
"""
from __future__ import annotations
import json
import logging
import sys
from concurrent.futures import ProcessPoolExecutor, as_completed
from pathlib import Path
from typing import Any, Callable
from common_util.common_util_derived_store import write_derived
from E03_StabSection.E03_StabSection_Engine_Station import analyze_station
KIND_STATION = "stab2d_station"
logger = logging.getLogger(__name__)
def _analyze(item: dict[str, Any], kwargs: dict[str, Any]) -> dict[str, Any]:
kw = {**kwargs, "layers": item["layers"], "ground": [tuple(p) for p in item["ground"]]}
return analyze_station(item["cross"], item["design"], **kw)
def _value(item: dict[str, Any], out: dict | None, error: str | None) -> dict[str, Any]:
return {
"meta": item["meta"],
"inputs": {"layers": item["layers"], "ground": item["ground"]},
"out": out,
"error": error,
}
def run_job(job: dict[str, Any], emit: Callable[[str], None]) -> None:
"""측점마다 해석 → 저장본 · emit(열쇠). 한 측점 실패는 그 줄에 까닭만 남기고 이어 감."""
root, keep, kwargs = Path(job["root"]), int(job["keep"]), job["kwargs"]
items = job["items"]
def save(item: dict[str, Any], out: dict | None, error: str | None) -> None:
write_derived(root, KIND_STATION, item["fp"], _value(item, out, error), keep=keep)
emit(item["meta"]["key"])
workers = max(1, min(int(job.get("workers") or 1), len(items)))
if workers == 1:
for item in items:
try:
save(item, _analyze(item, kwargs), None)
except Exception as error: # noqa: BLE001
logger.exception("E03 측점 해석 실패: %s", item["meta"]["key"])
save(item, None, str(error))
return
with ProcessPoolExecutor(max_workers=workers) as pool:
futures = {pool.submit(_analyze, item, kwargs): item for item in items}
for future in as_completed(futures):
item = futures[future]
try:
save(item, future.result(), None)
except Exception as error: # noqa: BLE001
logger.exception("E03 측점 해석 실패: %s", item["meta"]["key"])
save(item, None, str(error))
def main() -> None:
job = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
run_job(job, lambda key: print(json.dumps({"done": key}), flush=True))
if __name__ == "__main__":
main()
@@ -1,7 +1,7 @@
/* =============================================================================
* E03_StabSection_Route.ts
* 길 선언(PLAN 529-1) — 라우터가 페이지 폴더마다 draft/*_Route.ts 를 glob 해 등록.
* 로그인 · 회사 · 현재 프로젝트 필요 · 꼬리말 없음 · 본문 = 빈 단계(틀 · 패널 · 단계 막대)
* 길 선언(PLAN 529-1 · 532-3) — 라우터가 페이지 폴더마다 draft/*_Route.ts 를 glob 해 등록.
* 로그인 · 회사 · 현재 프로젝트 필요 · 꼬리말 없음 · 본문 = E03 2D 측점 해석 페이지
* ========================================================================== */
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.E03_STAB_SECTION),
load: async () => (await import("../E03_StabSection_UI_Page")).renderE03StabSection,
};
@@ -0,0 +1,371 @@
/* =============================================================================
* E03_StabSection_Logic.ts
* E03 2D 측점 해석 화면 계산(PLAN 532-3) — 화면 · three · fetch · sessionStorage 없음 · 순수 함수만.
* 좌표 = 서버 결과 그대로(x = B06 offset · 좌 + · z = 표고). 화면은 B06 처럼 좌측(+offset)을 왼쪽에 둠
* → 화면 x = −k·x + tx · y = −k·z + ty (`fitView`). SVG 도형 좌표는 결과 좌표를 그대로 쓰고
* 묶음 transform 하나(`viewMatrix`)로 옮김 — 「SVG 좌표 = 결과 좌표」.
* 판정 = 서버 `E03_StabSection_View.overall_verdict` 와 같은 규칙(ng > ok > none).
* ========================================================================== */
export type Condition = "dry" | "wet" | "seismic";
export type Verdict = "ok" | "ng" | "none";
export type Point = [number, number];
export interface SideCase {
fs: number | null;
required: number | null;
ok: boolean | null;
kind: string | null;
converged: boolean | null;
}
export interface StationRow {
key: string;
chainage_m: number;
label: string | null;
section_mode: string | null;
ground_type: string | null;
center: [number | null, number | null] | null;
sides: { side: string; role: "cut" | "fill"; height_m: number | null }[];
cases: Partial<Record<Condition, Record<string, SideCase>>>;
min: Partial<Record<Condition, number | null>>;
verdict: Partial<Record<Condition, Verdict>>;
error: string | null;
representative?: { auto: string[]; user: boolean | null; on: boolean };
}
export interface Surface {
method: string;
kind: string | null;
fs: number | null;
points: Point[];
entry: Point | null;
exit: Point | null;
converged: boolean | null;
iterations: number | null;
center?: Point;
radius?: number;
}
export interface SliceRow {
i: number;
x_left: number;
x_right: number;
width: number;
height: number;
alpha_deg: number;
weight: number;
surcharge: number;
pore_pressure: number;
base_length: number;
c: number;
phi_deg: number;
material: string | null;
N: number;
N_eff: number;
U: number;
S: number;
E_left: number;
E_right: number;
X_left: number;
X_right: number;
}
export interface Analysis {
condition: Condition;
side: string;
role: "cut" | "fill";
height_m: number | null;
limits: [number, number];
direction: number;
required: number | null;
ok: boolean | null;
min_by_method: Record<string, number | null>;
critical: Surface | null;
candidates: Surface[];
slices: SliceRow[];
grid: { method: string; x: number[]; z: number[]; fs: (number | null)[][] } | null;
}
export interface SectionView {
key: string;
chainage_m: number;
label: string | null;
materials: {
id: string;
name: string;
gamma_t: number;
gamma_sat: number;
c: number;
phi: number;
}[];
regions: { material_id: string; polygon: Point[]; holes: Point[][] }[];
surface: Point[];
bottom_z: number | null;
surcharge: { x0: number; x1: number; q: number }[];
water: Partial<Record<Condition, Point[] | null>>;
analyses: Analysis[];
}
/* ---- 판정 · 목록 ---------------------------------------------------------- */
/** 측점 판정 — 조건 하나(또는 전 조건 중 가장 나쁜 것) */
export function verdictOf(row: StationRow, condition?: Condition): Verdict {
const values = condition ? [row.verdict[condition] ?? "none"] : Object.values(row.verdict);
if (values.includes("ng")) return "ng";
return values.includes("ok") ? "ok" : "none";
}
export type SortMode = "chainage" | "fs";
/** 목록 정렬 · 거름 — fs = 그 조건 최소 안전율 낮은 순(없으면 맨 뒤) */
export function listRows(
rows: StationRow[],
condition: Condition,
sort: SortMode,
ngOnly: boolean,
): StationRow[] {
const kept = ngOnly ? rows.filter((r) => verdictOf(r, condition) === "ng") : rows.slice();
if (sort === "fs") {
const fs = (r: StationRow): number => r.min[condition] ?? Number.POSITIVE_INFINITY;
kept.sort((a, b) => fs(a) - fs(b) || a.chainage_m - b.chainage_m);
} else kept.sort((a, b) => a.chainage_m - b.chainage_m);
return kept;
}
/** 깎기 · 쌓기 줄임 — 비탈 역할 좌/우(예 「쌓기 · 깎기」) */
export function roleText(row: StationRow, names: { cut: string; fill: string }): string {
const order = ["left", "right"];
return row.sides
.slice()
.sort((a, b) => order.indexOf(a.side) - order.indexOf(b.side))
.map((s) => names[s.role])
.join(" · ");
}
/** 평면 띠 — 측점 중심을 이은 선을 측점 판정으로 나눈 구간(이웃 측점 사이 중간에서 끊음) */
export function bandSegments(
rows: StationRow[],
condition: Condition | undefined,
): { verdict: Verdict; points: Point[]; keys: string[] }[] {
const pts = rows
.filter((r) => r.center && r.center[0] !== null && r.center[1] !== null)
.sort((a, b) => a.chainage_m - b.chainage_m)
.map((r) => ({ row: r, p: [r.center![0]!, r.center![1]!] as Point }));
const out: { verdict: Verdict; points: Point[]; keys: string[] }[] = [];
const mid = (a: Point, b: Point): Point => [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
pts.forEach((item, i) => {
const v = verdictOf(item.row, condition);
const start = i > 0 ? mid(pts[i - 1].p, item.p) : item.p;
const end = i < pts.length - 1 ? mid(item.p, pts[i + 1].p) : item.p;
const last = out[out.length - 1];
if (last && last.verdict === v) {
last.points.push(item.p, end);
last.keys.push(item.row.key);
} else out.push({ verdict: v, points: [start, item.p, end], keys: [item.row.key] });
});
return out;
}
/** 평면 띠 보기 틀 — 북쪽 위 · 같은 축척 · 여백 pad(px) */
export function planFit(
points: Point[],
width: number,
height: number,
pad: number,
): { k: number; tx: number; ty: number } {
const xs = points.map((p) => p[0]);
const ys = points.map((p) => p[1]);
const [x0, x1, y0, y1] = [Math.min(...xs), Math.max(...xs), Math.min(...ys), Math.max(...ys)];
const k = Math.min(
(width - 2 * pad) / Math.max(x1 - x0, 1),
(height - 2 * pad) / Math.max(y1 - y0, 1),
);
return { k, tx: width / 2 - (k * (x0 + x1)) / 2, ty: height / 2 + (k * (y0 + y1)) / 2 };
}
/* ---- 안전율 색 ------------------------------------------------------------ */
/** 등안전율 띠 — 안전율 ÷ 기준안전율 경계(빨강 셋 · 회색 하나 · 파랑 셋 · 가운데 = 기준 통과 직후) */
export const RATIO_BREAKS = [0.8, 0.9, 1.0, 1.1, 1.25, 1.5] as const;
/** 띠 번호 0~6 — 0 = 가장 위험 · 3 = 기준 바로 위 · null = 값 없음 */
export function bandIndex(fs: number | null | undefined, required: number | null): number | null {
if (fs === null || fs === undefined || !Number.isFinite(fs)) return null;
const ratio = Math.round((fs / (required && required > 0 ? required : 1)) * 1e9) / 1e9;
const i = RATIO_BREAKS.findIndex((b) => ratio < b);
return i < 0 ? RATIO_BREAKS.length : i;
}
/** 범례 — 띠마다 안전율 구간(기준안전율을 곱한 실제 값) */
export function bandLegend(
required: number | null,
): { index: number; from: number | null; to: number | null }[] {
const base = required && required > 0 ? required : 1;
const edges = RATIO_BREAKS.map((b) => Math.round(b * base * 100) / 100);
return [...Array(RATIO_BREAKS.length + 1).keys()].map((i) => ({
index: i,
from: i === 0 ? null : edges[i - 1],
to: i === RATIO_BREAKS.length ? null : edges[i],
}));
}
/* ---- 횡단 보기 틀 --------------------------------------------------------- */
export interface ViewFit {
k: number; // px / m (가로 · 세로 같음 — 참 모양)
tx: number;
ty: number;
}
/** 범위(지표 · 바닥 · 격자 중심 · 임계면) 를 판에 맞춤 — 좌측(+offset) 이 왼쪽 */
export function fitView(view: SectionView, width: number, height: number, pad: number): ViewFit {
const xs: number[] = [];
const zs: number[] = [];
const add = (p: Point): void => {
xs.push(p[0]);
zs.push(p[1]);
};
view.surface.forEach(add);
if (view.bottom_z !== null) zs.push(view.bottom_z);
for (const a of view.analyses) {
if (a.grid) {
a.grid.x.forEach((x) => xs.push(x));
a.grid.z.forEach((z) => zs.push(z));
}
a.critical?.points.forEach(add);
}
const [x0, x1, z0, z1] = [Math.min(...xs), Math.max(...xs), Math.min(...zs), Math.max(...zs)];
const k = Math.min(
(width - 2 * pad) / Math.max(x1 - x0, 1),
(height - 2 * pad) / Math.max(z1 - z0, 1),
);
return { k, tx: width / 2 + (k * (x0 + x1)) / 2, ty: height / 2 + (k * (z0 + z1)) / 2 };
}
/** 결과 좌표 → 화면 px */
export function toScreen(fit: ViewFit, p: Point): Point {
return [-fit.k * p[0] + fit.tx, -fit.k * p[1] + fit.ty];
}
/** 화면 px → 결과 좌표(마우스 아래 값) */
export function toModel(fit: ViewFit, p: Point): Point {
return [(fit.tx - p[0]) / fit.k, (fit.ty - p[1]) / fit.k];
}
/** 결과 좌표 도형 묶음에 거는 transform — 도형 points 는 결과 좌표 그대로 */
export function viewMatrix(fit: ViewFit): string {
return `matrix(${-fit.k} 0 0 ${-fit.k} ${fit.tx} ${fit.ty})`;
}
/** 폴리곤 · 선 → SVG points 글(결과 좌표 그대로) */
export function pointsText(points: Point[]): string {
return points.map((p) => `${p[0]},${p[1]}`).join(" ");
}
/** 꺾은선 위 x 의 z(선형 · 범위 밖은 끝값) — 선은 x 오름 · 내림 무관 */
export function zAt(line: Point[], x: number): number | null {
if (!line.length) return null;
const pts = line.slice().sort((a, b) => a[0] - b[0]);
if (x <= pts[0][0]) return pts[0][1];
for (let i = 1; i < pts.length; i += 1) {
if (x <= pts[i][0]) {
const [a, b] = [pts[i - 1], pts[i]];
const t = b[0] === a[0] ? 0 : (x - a[0]) / (b[0] - a[0]);
return a[1] + t * (b[1] - a[1]);
}
}
return pts[pts.length - 1][1];
}
/** 절편 네 꼭짓점 — 바닥 = 임계면 · 위 = 지표 */
export function sliceShape(slice: SliceRow, surface: Point[], base: Point[]): Point[] {
const at = (line: Point[], x: number): number => zAt(line, x) ?? 0;
return [
[slice.x_left, at(base, slice.x_left)],
[slice.x_right, at(base, slice.x_right)],
[slice.x_right, at(surface, slice.x_right)],
[slice.x_left, at(surface, slice.x_left)],
];
}
/* ---- 자유물체도 --------------------------------------------------------- */
export interface ForceArrow {
name: "W" | "Q" | "N" | "U" | "S" | "EL" | "ER" | "XL" | "XR";
value: number;
/** 화살 시작 · 끝(결과 좌표 · 길이 = 값 × scale) */
from: Point;
to: Point;
}
/**
* 절편 하나 힘 화살 — W · Q 아래 · N · U 바닥 법선(절편 안쪽) · S 바닥 따라 미끄럼 반대 ·
* E 좌우 면 수평 · X 좌우 면 연직. 엔진 절편표는 활동 방향(direction)으로 뒤집은 틀의 차례라
* 그 틀의 「왼쪽 면」은 direction < 0 이면 결과 좌표의 오른쪽 면. 길이 = 값 × lengthPerKn.
*/
export function forceArrows(
slice: SliceRow,
shape: Point[],
direction: number,
lengthPerKn: number,
): ForceArrow[] {
const s = direction < 0 ? -1 : 1;
const [bl, br, tr, tl] = shape;
const midBase: Point = [(bl[0] + br[0]) / 2, (bl[1] + br[1]) / 2];
const midTop: Point = [(tl[0] + tr[0]) / 2, (tl[1] + tr[1]) / 2];
const centroid: Point = [(midBase[0] + midTop[0]) / 2, (midBase[1] + midTop[1]) / 2];
const len = Math.hypot(br[0] - bl[0], br[1] - bl[1]) || 1;
const t: Point = [(br[0] - bl[0]) / len, (br[1] - bl[1]) / len]; // 바닥 왼→오
const n: Point = [-t[1], t[0]]; // 바닥 법선 위쪽
const faceMid = (a: Point, b: Point): Point => [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
const [nearFace, farFace] =
s > 0 ? [faceMid(bl, tl), faceMid(br, tr)] : [faceMid(br, tr), faceMid(bl, tl)];
const arrow = (
name: ForceArrow["name"],
value: number,
at: Point,
dir: Point,
ending = false,
): ForceArrow => {
const d: Point = [dir[0] * value * lengthPerKn, dir[1] * value * lengthPerKn];
return ending
? { name, value, from: [at[0] - d[0], at[1] - d[1]], to: at }
: { name, value, from: at, to: [at[0] + d[0], at[1] + d[1]] };
};
return [
arrow("W", slice.weight, centroid, [0, -1]),
arrow("Q", slice.surcharge, midTop, [0, -1], true),
arrow("N", slice.N_eff, midBase, n),
arrow("U", slice.U, midBase, n),
arrow("S", slice.S, midBase, [-s * t[0], -s * t[1]]),
arrow("EL", slice.E_left, nearFace, [s, 0], true),
arrow("ER", slice.E_right, farFace, [-s, 0], true),
arrow("XL", slice.X_left, nearFace, [0, 1], true),
arrow("XR", slice.X_right, farFace, [0, -1], true),
].filter((a) => Math.abs(a.value) > 1e-9);
}
/** 절편력 그래프 꼴 — 절편 가운데 x 마다 바닥 유효 수직응력 · 간극수압 · 동원 전단응력(kPa · 한 축) */
export function sliceStress(
slices: SliceRow[],
): { x: number; sigma: number; u: number; tau: number }[] {
return slices.map((s) => {
const l = s.base_length > 0 ? s.base_length : 1;
return { x: (s.x_left + s.x_right) / 2, sigma: s.N_eff / l, u: s.pore_pressure, tau: s.S / l };
});
}
/** 그래프 눈금 — 0 포함 · 1 · 2 · 5 × 10ⁿ 간격 · 대략 count 칸 */
export function niceTicks(min: number, max: number, count = 5): number[] {
const lo = Math.min(0, min);
const hi = Math.max(lo + 1e-9, max);
const raw = (hi - lo) / Math.max(count, 1);
const mag = 10 ** Math.floor(Math.log10(raw));
const step = [1, 2, 5, 10].map((m) => m * mag).find((v) => v >= raw) ?? raw;
const out: number[] = [];
for (let v = Math.floor(lo / step) * step; v <= hi + step * 1e-6; v += step)
out.push(Math.round(v * 1e6) / 1e6);
if (out[out.length - 1] < hi) out.push(out[out.length - 1] + step);
return out;
}
+2
View File
@@ -83,6 +83,7 @@ from E01_StabRegister.E01_StabRegister_Router import router as e01_stab_register
from E02_StabGround.E02_StabGround_Router import router as e02_stab_ground_router
from E04_Stab3D.E04_Stab3D_Router import router as e04_stab_3d_router
from E05_StabReport.E05_StabReport_Router import router as e05_stab_report_router
from E03_StabSection.E03_StabSection_Router import router as e03_stab_section_router
from M01_MasterData.M01_MasterData_Router import router as m01_master_data_router
from M02_MasterTemplete.M02_Labor_Router import router as m02_labor_router
from M02_MasterTemplete.M02_MasterTemplete_Router import router as m02_master_template_router
@@ -492,6 +493,7 @@ app.include_router(d04_plan_link_router, dependencies=protected_with_company)
app.include_router(e02_stab_ground_router, dependencies=protected_with_company)
app.include_router(e04_stab_3d_router, dependencies=protected_with_company)
app.include_router(e05_stab_report_router, dependencies=protected_with_company)
app.include_router(e03_stab_section_router, dependencies=protected_with_company)
app.include_router(b05_corridor_router, dependencies=protected_with_company)
app.include_router(b05_route_replan_router, dependencies=protected_with_company)
app.include_router(b05_route_terrain_router, dependencies=protected_with_company)
+551
View File
@@ -0,0 +1,551 @@
"""532-3 E03 2D 측점 해석 길 · 화면 계산(요구 345 · 347 · 351).
- 길: 지문이 같으면 다시 안 셈 · 바뀐 측점만 다시 셈 · 진행률 · 결과 꼴 · 대표 단면 저장 ·
공유 회사 POST 403(공용 접근 판정) · 노선면 솎음
- 실제 엔진 한 측점 → 화면 꼴(임계면 · 절편 · 격자)
- logic(TS) — 판정 규칙 = 파이썬 · 정렬 · 거름 · 등안전율 띠 · 화면 좌표 · 평면 띠 · 힘 화살
입력 = 검증_sub2 원본을 임시 폴더로 동결 복사(DB 읽기만) · 엔진 = 가짜(길) / 진짜(꼴).
"""
import asyncio
import copy
import json
import math
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
from types import SimpleNamespace
from uuid import UUID
import aiomysql
import pytest
from fastapi import HTTPException
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
from common_util import common_util_auth as auth # noqa: E402
from common_util import common_util_project_copy as cp # noqa: E402
from config.config_db import DB_HOST, DB_NAME, DB_PASSWORD, DB_PORT, DB_USER # noqa: E402
from E01_StabRegister import E01_StabRegister_Source as src # noqa: E402
from E02_StabGround import E02_StabGround_Model as ground_model # noqa: E402
from E03_StabSection import E03_StabSection_Router as router # noqa: E402
from E03_StabSection import E03_StabSection_View as view # noqa: E402
from E03_StabSection import E03_StabSection_Worker as worker # noqa: E402
from E03_StabSection.E03_StabSection_Engine_Model import section_model_from_b06 # noqa: E402
from E03_StabSection.E03_StabSection_Engine_Station import analyze_station # noqa: E402
SOURCE_ID = "cb15892a-5788-4a26-9316-20a15421f68b" # 검증_sub2(읽기만)
PID = "e" * 8 + "-" + "e" * 4 + "-" + "e" * 4 + "-" + "e" * 4 + "-" + "e" * 12
N_STATIONS = 3
TYPESCRIPT = ROOT / "config" / "node_modules" / "typescript"
TS = ROOT / "E03_StabSection" / "logic" / "E03_StabSection_Logic.ts"
async def _copy(target: Path) -> None:
connection = await aiomysql.connect(
host=DB_HOST, port=DB_PORT, user=DB_USER, password=DB_PASSWORD, db=DB_NAME, autocommit=True
)
try:
await cp.copy_project(
connection, SOURCE_ID, scope="stability", new_id=PID, project_root=target
)
finally:
connection.close()
@pytest.fixture(scope="module")
def frozen():
folder = Path(tempfile.mkdtemp(prefix="aislo_e03_"))
try:
asyncio.run(_copy(folder / "copy"))
except Exception as error: # noqa: BLE001
shutil.rmtree(folder, ignore_errors=True)
pytest.skip(f"검증 원본 복사 실패: {error}")
yield folder / "copy"
shutil.rmtree(folder, ignore_errors=True)
CALLS: list[float] = []
def _fake_analyze(cross, design, *, layers=None, ground=None, conditions=("dry", "wet"), **kw):
"""엔진 출력 꼴 그대로 · 값은 누가거리로 정함(1.0 + ch/100 · 건기 +0.3)."""
CALLS.append(float(cross["chainage_m"]))
model = section_model_from_b06(
cross, design, layers=layers, ground=ground, materials=kw.get("materials")
)
cases = []
for cond in conditions:
for side in model["sides"]:
F = round(1.0 + float(cross["chainage_m"]) / 100 + (0.3 if cond == "dry" else 0.0), 3)
x0, x1 = side["limits"]
row = {
"x_left": x0,
"x_right": x1,
"width": x1 - x0,
"height": 1.0,
"alpha_deg": 30.0,
"weight": 20.0,
"surcharge": 0.0,
"pore_pressure": 0.0,
"base_length": 2.0,
"material": "weathered_soil",
"c": 15.0,
"phi_deg": 30.0,
"normal_eff": 15.0,
"shear_mobilized": 9.0,
"E_left": 0.0,
"X_left": 0.0,
}
found = {
"F": F,
"kind": "circle",
"points": [[x0, 800.0], [x1, 799.0]],
"entry": [x0, 800.0],
"exit": [x1, 799.0],
"iterations": 4,
"converged": True,
"slices": [row],
"center": [0.0, 820.0],
"radius": 20.0,
}
result = {
"direction": side["direction"],
"limits": list(side["limits"]),
"methods": {"bishop": {"circular": found, "min": found}},
"contour": {
"method": "bishop",
"x": [0.0, 1.0],
"z": [810.0, 811.0],
"F": [[F, None], [2.0, 3.0]],
},
"counts": {"circles": 1},
}
cases.append({"condition": cond, **side, "min_F": {"bishop": F}, "result": result})
return {
"chainage_m": cross["chainage_m"],
"label": cross.get("label"),
"cases": cases,
"areas": model["areas"],
"elapsed_s": 0.0,
}
@pytest.fixture
def project(frozen, monkeypatch):
"""E 프로젝트 루트(복사본 + 비운 결과) · 측점 셋 · 가짜 엔진 · 서버 안에서 차례로."""
for name in ("_derived", "E02_StabGround", "E03_StabSection", "initial_snapshot"):
shutil.rmtree(frozen / name, ignore_errors=True)
original = src.stations
monkeypatch.setattr(router.src, "stations", lambda root: original(root)[:N_STATIONS])
async def fake_project(_pid):
return frozen, {"company_id": 1, "project_kind": "STABILITY"}
monkeypatch.setattr(router.e02, "_project", fake_project)
monkeypatch.setattr(worker, "analyze_station", _fake_analyze)
monkeypatch.setattr(router, "WORKERS", 1)
router._JOBS.clear()
CALLS.clear()
return frozen
def _analyze() -> dict:
"""POST → 뒤 일이 끝날 때까지 기다림."""
async def go():
first = await router.analyze2d(UUID(PID))
while router._TASKS:
await asyncio.gather(*list(router._TASKS))
return first
return asyncio.run(go())
USER = {"role": "USER", "company_id": 1}
def test_지문이_같으면_다시_안셈(project):
first = _analyze()
assert first["status"] == "accepted" and len(CALLS) == N_STATIONS
job = asyncio.run(router.progress(UUID(PID)))
assert job == {**job, "running": False, "percent": 100, "error": None, "computed": N_STATIONS}
again = _analyze()
assert again == {"status": "fresh", "fingerprint": first["fingerprint"]}
assert len(CALLS) == N_STATIONS # 다시 안 셈
# 둘째 측점만 구간 덮어쓰기 → 그 측점만 다시 셈 · 다른 지문
ground = router._ground(project)
second = router.plan_run(project, ground)["items"][1]["chainage_m"]
ground["zones"] = [
{
"from_m": second,
"to_m": second,
"soil": "colluvium",
"rock": None,
"soil_thickness_m": None,
}
]
(project / "E02_StabGround").mkdir(exist_ok=True)
ground_model.ground_path(project).write_text(json.dumps(ground), encoding="utf-8")
CALLS.clear()
third = _analyze()
assert third["status"] == "accepted" and third["fingerprint"] != first["fingerprint"]
assert CALLS == [second]
# 기준안전율만 바꿈 → 엔진은 안 돌고 판정만 새로(요약 지문만 바뀜)
ground["safety_factors"]["dry"]["fill"] = 9.0
ground_model.ground_path(project).write_text(json.dumps(ground), encoding="utf-8")
CALLS.clear()
assert _analyze()["status"] == "accepted" and CALLS == []
body = asyncio.run(router.result2d(UUID(PID), USER))
fill_dry = [
c["dry"]["left"] for c in (r["cases"] for r in body["stations"]) if "left" in c["dry"]
]
assert body["fresh"] and any(v["required"] == 9.0 and v["ok"] is False for v in fill_dry)
def test_진행률_올라감(project, monkeypatch):
seen = []
def spy(*args, **kw):
seen.append(router._JOBS[PID]["percent"])
return _fake_analyze(*args, **kw)
monkeypatch.setattr(worker, "analyze_station", spy)
_analyze()
assert seen[0] == 2 and seen == sorted(seen) and len(set(seen)) == N_STATIONS
assert asyncio.run(router.progress(UUID(PID)))["percent"] == 100
def test_결과_꼴_대표단면(project):
none = asyncio.run(router.result2d(UUID(PID), USER))
assert none["status"] == "none" and len(none["stations"]) == N_STATIONS
_analyze()
body = asyncio.run(router.result2d(UUID(PID), USER))
assert body["status"] == "ok" and body["fresh"] and body["editable"]
assert [r["key"] for r in body["stations"]] == [r["key"] for r in none["stations"]]
row = body["stations"][0]
assert set(row["verdict"]) == {"dry", "wet"} and row["representative"]["on"] is True # 하위 5
other = asyncio.run(router.result2d(UUID(PID), {"role": "USER", "company_id": 2}))
assert other["editable"] is False
sec = asyncio.run(router.section(UUID(PID), row["key"]))
assert sec["regions"] and sec["surface"] and sec["surcharge"][0]["q"] == 10.0
assert sec["water"]["dry"] is None and sec["water"]["wet"]
a = sec["analyses"][0]
assert a["critical"]["points"] and a["grid"]["fs"][0][1] is None and a["slices"][0]["N"] == 15.0
with pytest.raises(HTTPException) as missing:
asyncio.run(router.section(UUID(PID), "99999.00"))
assert missing.value.status_code == 404
saved = asyncio.run(
router.put_representative(
UUID(PID), router.RepresentativeRequest(overrides={row["key"]: False})
)
)
assert saved == {"overrides": {row["key"]: False}}
after = asyncio.run(router.result2d(UUID(PID), USER))["stations"][0]["representative"]
assert after == {"auto": ["lowest"], "user": False, "on": False}
with pytest.raises(HTTPException) as bad:
asyncio.run(
router.put_representative(
UUID(PID), router.RepresentativeRequest(overrides={"x": True})
)
)
assert bad.value.status_code == 422
def test_노선면_솎음(project):
body = asyncio.run(router.corridor3d(UUID(PID)))
assert len(body["origin"]) == 3 and body["ribbons"]
for ribbon in body["ribbons"]:
chain = ribbon["chainages"]
assert len(ribbon["positions"]) == len(chain) * ribbon["colCount"] * 3
assert chain == sorted(set(chain)) # 1 m 칸마다 한 줄 + 끝 줄
assert len(chain) <= math.ceil((chain[-1] - chain[0]) / router.CORRIDOR_STEP_M) + 2
assert max(abs(v) for v in ribbon["positions"]) < 5000 # 원점 뺀 자리
class _Cursor:
def __init__(self, rows):
self.rows = rows
async def __aenter__(self):
return self
async def __aexit__(self, *exc):
return False
async def execute(self, sql, args=()):
self.last = sql
async def fetchone(self):
return self.rows["shared"] if "plan_route_assignees" in self.last else self.rows["project"]
class _Pool:
def __init__(self, rows):
self.rows = rows
def acquire(self):
pool = self
class _Conn:
async def __aenter__(self):
return self
async def __aexit__(self, *exc):
return False
def cursor(self):
return _Cursor(pool.rows)
return _Conn()
@pytest.mark.parametrize("method,ok", [("GET", True), ("POST", False), ("PUT", False)])
def test_공유_회사는_GET_만(monkeypatch, method, ok):
monkeypatch.setattr(auth, "get_db_pool", lambda: _Pool({"project": (1,), "shared": (1,)}))
request = SimpleNamespace(path_params={"project_id": PID}, method=method)
session = {"role": "USER", "company_id": 2}
if ok:
assert asyncio.run(auth.require_project_access(request, session)) is session
else:
with pytest.raises(HTTPException) as error:
asyncio.run(auth.require_project_access(request, session))
assert error.value.status_code == 403
def test_E03_길에_공용_접근_판정이_걸림():
import main
paths = {
"/api/projects/{project_id}/stab/" + p
for p in ("analyze2d", "result2d", "progress", "representative")
}
found = set()
for route in main.app.routes:
if getattr(route, "path", None) in paths:
calls = {d.call for d in route.dependant.dependencies}
assert auth.require_project_access in calls and auth.require_company in calls
found.add(route.path)
assert found == paths
def test_실제_엔진_한_측점_화면_꼴(frozen):
source = src.source_dir(frozen)
station = src.stations(source)[5]
cross = src.cross_section(source, station["chainage_m"])
out = analyze_station(cross, cross["design"], methods=("ordinary", "bishop"), noncircular=False)
meta = {
"key": "k",
"chainage_m": station["chainage_m"],
"label": cross["label"],
"section_mode": cross["design"]["section_mode"],
"center": [0, 0],
"fp": "f",
}
factors = {"dry": {"cut": 1.5, "fill": 1.5}, "wet": {"cut": 1.2, "fill": 1.3}}
row = view.station_row(meta, out, factors)
model = section_model_from_b06(cross, cross["design"])
sec = view.section_view(row, out, model, {"dry": None, "wet": model["surface"]})
assert {a["condition"] for a in sec["analyses"]} == {"dry", "wet"}
for a in sec["analyses"]:
c = a["critical"]
assert c and c["kind"] == "circle" and c["center"] and c["radius"] > 0
# 임계면 꼭짓점 = 원 위(반경 ±0.01 m)
for x, z in c["points"]:
assert abs(math.hypot(x - c["center"][0], z - c["center"][1]) - c["radius"]) < 0.02
assert a["slices"] and a["slices"][0]["x_left"] == pytest.approx(
min(p[0] for p in c["points"]), abs=0.01
)
assert a["min_by_method"]["bishop"] == pytest.approx(c["fs"], abs=1e-3)
assert row["cases"][a["condition"]][a["side"]]["ok"] == (c["fs"] >= a["required"])
assert len(a["grid"]["fs"]) == len(a["grid"]["z"])
def test_대표단면_자동_규칙():
rows = [
{"key": str(i), "sides": [{"role": "fill", "height_m": h}], "min": {"dry": 3.0 - i * 0.1}}
for i, h in enumerate([10.0, 9.99, 0, 0, 0, 0, 0, 0])
]
rows[3]["sides"] = [{"role": "cut", "height_m": 15.0}]
auto = view.auto_representative(rows)
assert (
auto["0"] == ["fill_height"] and "1" not in auto and auto["3"] == ["cut_height", "lowest"]
)
assert sorted(k for k, v in auto.items() if "lowest" in v) == ["3", "4", "5", "6", "7"]
# ---- logic(TS) --------------------------------------------------------------
SCRIPT = r"""
const ts = require(process.argv[1]);
const fs = require("fs");
const src = fs.readFileSync(process.argv[2], "utf8");
const opts = { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 };
const js = ts.transpileModule(src, { compilerOptions: opts }).outputText;
const box = { exports: {} };
new Function("exports", "module", js)(box.exports, box);
const m = box.exports;
const input = JSON.parse(fs.readFileSync(0, "utf8"));
const fit = m.fitView(input.view, 800, 500, 20);
const shape = input.shape;
process.stdout.write(JSON.stringify({
verdict: input.rows.map((r) => [m.verdictOf(r), m.verdictOf(r, "dry"), m.verdictOf(r, "wet")]),
sortFs: m.listRows(input.rows, "wet", "fs", false).map((r) => r.key),
ngOnly: m.listRows(input.rows, "wet", "chainage", true).map((r) => r.key),
bands: input.fsList.map((f) => m.bandIndex(f, 1.5)),
legend: m.bandLegend(1.5),
fit,
matrix: m.viewMatrix(fit),
screen: input.view.surface.map((p) => m.toScreen(fit, p)),
back: input.view.surface.map((p) => m.toModel(fit, m.toScreen(fit, p))),
segments: m.bandSegments(input.rows, "wet").map((s) => [s.verdict, s.keys]),
arrows: m.forceArrows(input.slice, shape, -1, 0.01),
arrowsPos: m.forceArrows(input.slice, shape, 1, 0.01),
stress: m.sliceStress([input.slice]),
ticks: m.niceTicks(-3, 47, 5),
zAt: [m.zAt([[0, 0], [10, 10]], 2.5), m.zAt([[10, 10], [0, 0]], -5)],
}));
"""
def _row(key, ch, dry, wet, center):
cases = {
"dry": {"left": {"fs": dry, "required": 1.5, "ok": None if dry is None else dry >= 1.5}},
"wet": {"left": {"fs": wet, "required": 1.3, "ok": None if wet is None else wet >= 1.3}},
}
verdict = {}
for cond, by_side in cases.items():
oks = [v["ok"] for v in by_side.values() if v["ok"] is not None]
verdict[cond] = "none" if not oks else ("ok" if all(oks) else "ng")
return {
"key": key,
"chainage_m": ch,
"label": key,
"center": center,
"cases": cases,
"sides": [{"side": "left", "role": "fill", "height_m": 1.0}],
"min": {"dry": dry, "wet": wet},
"verdict": verdict,
"error": None,
}
def test_logic_TS():
rows = [
_row("a", 0, 2.0, 1.4, [0, 0]),
_row("b", 20, 1.6, 1.1, [20, 0]),
_row("c", 40, None, None, [40, 5]),
_row("d", 60, 1.2, 1.0, [60, 10]),
_row("e", 80, 2.0, 1.5, [80, 10]),
]
surface = [[-20.0, 810.0], [-2.0, 800.0], [2.0, 800.0], [20.0, 790.0]]
sec = {"surface": surface, "bottom_z": 780.0, "analyses": []}
shape = [[0.0, 0.0], [2.0, 1.0], [2.0, 4.0], [0.0, 3.0]]
slice_row = {
"x_left": 0,
"x_right": 2,
"weight": 50,
"surcharge": 5,
"N_eff": 40,
"U": 10,
"S": 20,
"E_left": 3,
"E_right": 4,
"X_left": 0,
"X_right": 0,
"base_length": 2.236,
"pore_pressure": 4.47,
}
fs_list = [None, 1.0, 1.2, 1.35, 1.5, 1.65, 1.875, 2.25, 9.0]
payload = {"rows": rows, "view": sec, "fsList": fs_list, "shape": shape, "slice": slice_row}
proc = subprocess.run(
["node", "-e", SCRIPT, str(TYPESCRIPT), str(TS)],
input=json.dumps(payload),
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
)
assert proc.returncode == 0, proc.stderr
out = json.loads(proc.stdout)
# 판정 규칙 = 파이썬 overall_verdict
for r, got in zip(rows, out["verdict"]):
assert got == [
view.overall_verdict(r),
view.overall_verdict(r, "dry"),
view.overall_verdict(r, "wet"),
]
assert out["sortFs"] == ["d", "b", "a", "e", "c"] # 없음은 맨 뒤
assert out["ngOnly"] == ["b", "d"]
# 띠 = 안전율 ÷ 기준(1.5) 경계 0.8 · 0.9 · 1.0 · 1.1 · 1.25 · 1.5
assert out["bands"] == [None, 0, 1, 2, 3, 4, 5, 6, 6]
assert [(b["from"], b["to"]) for b in out["legend"]] == [
(None, 1.2),
(1.2, 1.35),
(1.35, 1.5),
(1.5, 1.65),
(1.65, 1.88),
(1.88, 2.25),
(2.25, None),
]
# 화면 좌표 — 좌측(+offset) 이 왼쪽 · 위 = 높은 표고 · 같은 축척 · 왕복 = 원래 값 · 판 안
k, tx, ty = out["fit"]["k"], out["fit"]["tx"], out["fit"]["ty"]
assert out["matrix"] == f"matrix({-k} 0 0 {-k} {tx} {ty})"
xs = [p[0] for p in out["screen"]]
assert xs == sorted(xs, reverse=True)
for (x, z), (sx, sy), back in zip(surface, out["screen"], out["back"]):
assert (sx, sy) == pytest.approx((-k * x + tx, -k * z + ty))
assert back == pytest.approx([x, z]) and 0 <= sx <= 800 and 0 <= sy <= 500
# 평면 띠 — 판정이 바뀔 때만 끊음 · 색 수 = 판정 수
assert out["segments"] == [
["ok", ["a"]],
["ng", ["b"]],
["none", ["c"]],
["ng", ["d"]],
["ok", ["e"]],
]
assert len({s[0] for s in out["segments"]}) == len(
{view.overall_verdict(r, "wet") for r in rows}
)
def vec(a):
return (a["to"][0] - a["from"][0], a["to"][1] - a["from"][1])
arrows = {a["name"]: a for a in out["arrows"]}
assert set(arrows) == {"W", "Q", "N", "U", "S", "EL", "ER"} # X = 0 은 뺌
assert vec(arrows["W"]) == pytest.approx((0, -0.5)) # 50 × 0.01 아래
base = (2 / math.sqrt(5), 1 / math.sqrt(5))
nx, nz = vec(arrows["N"])
assert nx * base[0] + nz * base[1] == pytest.approx(0, abs=1e-9) and nz > 0 # 바닥에 수직 · 위
sx, sz = vec(arrows["S"])
assert (sx, sz) == pytest.approx(
(0.2 * base[0], 0.2 * base[1])
) # 방향 −1 → 미끄럼 반대 = +바닥
assert vec({a["name"]: a for a in out["arrowsPos"]}["S"]) == pytest.approx(
(-0.2 * base[0], -0.2 * base[1])
)
assert arrows["EL"]["to"] == pytest.approx(
[2.0, 2.5]
) # 방향 −1 → 틀의 왼쪽 면 = 결과 오른쪽 면
assert vec(arrows["EL"]) == pytest.approx((-0.03, 0))
assert out["stress"][0] == pytest.approx(
{"x": 1, "sigma": 40 / 2.236, "u": 4.47, "tau": 20 / 2.236}
)
assert out["ticks"][0] <= -3 and out["ticks"][-1] >= 47 and 0 in out["ticks"]
assert out["zAt"] == [2.5, 0]
def test_엔진_지문은_코드_내용():
assert len(router.engine_hash()) == 12
item = copy.deepcopy({"upper": "a", "lower": "b", "soil_thickness_m": 0.5})
assert router._layers(item) == [["a", 0.5], ["b", None]]
assert router._layers({"upper": "a", "lower": None}) == [["a", None]]
assert router._layers(None) is None
+2
View File
@@ -24,6 +24,7 @@
import { ui_locales_b4 } from "./ui_template_locale_b4";
import { ui_locales_common } from "./ui_template_locale_common";
import { ui_locales_a } from "./ui_template_locale_a";
import { ui_locales_e03 } from "./ui_template_locale_e03";
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";
@@ -65,6 +66,7 @@ export const ui_locales = {
...ui_locales_b4,
...ui_locales_common,
...ui_locales_a,
...ui_locales_e03,
...ui_locales_b1,
...ui_locales_b2,
...ui_locales_b3,
+119
View File
@@ -0,0 +1,119 @@
/* =============================================================================
* ui_template_locale_e03.ts
* 다국어 사전 — E03 2D 측점 해석(요구 345 · 347 · PLAN 532-3)
*
* 신규 문구는 해당 섹션 최하단에 추가.
* 이 파일은 사전 데이터만 담는다. 합성·언어 전환·t() 헬퍼는 ui_template_locale.ts 참조.
* ========================================================================== */
import type { LocaleEntry } from "./ui_template_locale_common";
export const ui_locales_e03 = {
/* --- 좌측 목록 · 단추 --- */
E03_NoProject: ["현재 프로젝트가 없습니다.", "No current project."],
E03_Condition: ["조건", "Condition"],
E03_Cond_dry: ["건기", "Dry"],
E03_Cond_wet: ["우기", "Wet"],
E03_Cond_seismic: ["지진", "Seismic"],
E03_Sort: ["정렬", "Sort"],
E03_Sort_Chainage: ["측점 순", "By station"],
E03_Sort_Fs: ["안전율 낮은 순", "Lowest FS first"],
E03_NgOnly: ["N.G 만", "N.G only"],
E03_Analyze: ["해석", "Analyze"],
E03_Analyzing: ["해석 중", "Analyzing"],
E03_Done: ["해석이 끝났습니다.", "Analysis finished."],
E03_Fresh: ["입력이 같아 다시 셀 것이 없습니다.", "Inputs unchanged — nothing to recompute."],
E03_View3d: ["3D 보기", "3D View"],
E03_View3d_Legend: ["최소 안전율 ÷ 기준안전율", "Min FS ÷ required FS"],
E03_RepSave: ["대표 단면 저장", "Save Representative"],
E03_RepSaved: ["대표 단면을 저장했습니다.", "Representative sections saved."],
E03_RepUnsaved: ["대표 단면 바뀜 — 저장 안 됨", "Representative changes not saved"],
E03_Loading: ["불러오는 중…", "Loading…"],
E03_NotYet: [
"아직 해석하지 않았습니다 — [해석]을 누르세요.",
"Not analyzed yet — press [Analyze].",
],
E03_Computed: ["해석 시각", "Analyzed at"],
E03_Stale: [
"지반 조건이 바뀌었습니다 — [해석]을 다시 누르세요(바뀐 측점만 다시 셈).",
"Ground conditions changed — press [Analyze] again (only changed stations).",
],
E03_ReadOnly: ["보기만 허용된 프로젝트입니다.", "View-only project."],
E03_ListEmpty: ["보일 측점이 없습니다.", "No stations to show."],
E03_Col_Station: ["측점", "Station"],
E03_Col_Role: ["좌 · 우", "L · R"],
E03_Col_Rep: ["대표", "Rep."],
E03_Verdict: ["판정", "Verdict"],
E03_Verdict_ok: ["O.K", "O.K"],
E03_Verdict_ng: ["N.G", "N.G"],
E03_Verdict_none: ["해석 없음", "No result"],
E03_Error: ["오류", "Error"],
E03_Cut: ["깎기", "Cut"],
E03_Fill: ["쌓기", "Fill"],
E03_Left: ["좌 비탈", "Left slope"],
E03_Right: ["우 비탈", "Right slope"],
E03_Side: ["비탈", "Slope"],
E03_Rep_Hint: [
"대표 단면(보고서) — 자동: 쌓기 10 m · 깎기 15 m 이상 · 최소 안전율 하위 5 · 누르면 켬/끔",
"Representative section (report) — auto: fill ≥ 10 m · cut ≥ 15 m · lowest 5 FS · click to toggle",
],
E03_Rep_fill_height: ["쌓기 10 m 이상", "Fill ≥ 10 m"],
E03_Rep_cut_height: ["깎기 15 m 이상", "Cut ≥ 15 m"],
E03_Rep_lowest: ["최소 안전율 하위 5", "Lowest 5 FS"],
E03_Rep_User: ["사용자 지정", "User set"],
/* --- 평면 · 횡단 --- */
E03_Plan: ["평면 위험 띠", "Plan Risk Band"],
E03_PickStation: ["측점을 고르세요.", "Pick a station."],
E03_Show_Map: ["등안전율 지도", "Safety map"],
E03_Show_Centers: ["원호 중심 격자", "Center grid"],
E03_Show_Candidates: ["다른 후보", "Other surfaces"],
E03_Legend_Map: ["등안전율(Fs)", "Safety factor"],
E03_Legend_Layers: ["지층", "Layers"],
E03_Offset: ["거리", "Offset"],
E03_NoWalls: [
"옹벽 · 석축은 아직 해석에 반영 전입니다(벽 없는 단면으로 셈).",
"Retaining walls and masonry are not yet reflected (analyzed without walls).",
],
/* --- 우측 결과 --- */
E03_Result: ["결과", "Result"],
E03_NoSurface: ["이 비탈은 해석된 활동면이 없습니다.", "No slip surface for this slope."],
E03_Fs: ["최소 안전율", "Min FS"],
E03_Required: ["기준안전율", "Required FS"],
E03_Method: ["방법", "Method"],
E03_Method_ordinary: ["Fellenius", "Fellenius"],
E03_Method_bishop: ["Bishop", "Bishop"],
E03_Method_janbu: ["Janbu 보정", "Janbu corr."],
E03_Method_spencer: ["Spencer", "Spencer"],
E03_Circle: ["원호", "Circular"],
E03_NonCircle: ["비원호", "Non-circular"],
E03_Converged: ["수렴", "Converged"],
E03_Yes: ["예", "Yes"],
E03_No: ["아니오", "No"],
E03_Iter: ["회", " iter."],
E03_Height: ["비탈 높이", "Slope height"],
E03_Center: ["원호 중심", "Center"],
E03_Radius: ["반경", "Radius"],
E03_Slices: ["절편표", "Slice Data"],
E03_Col_No: ["#", "#"],
E03_Col_Width: ["폭 b", "b"],
E03_Col_Height: ["높이 h", "h"],
E03_Col_Alpha: ["α°", "α°"],
E03_Col_W: ["W+Q", "W+Q"],
E03_Col_U: ["u", "u"],
E03_Col_N: ["N'", "N'"],
E03_Col_S: ["S", "S"],
E03_Col_C: ["c", "c"],
E03_Col_Phi: ["φ°", "φ°"],
E03_Fbd: ["절편 자유물체도", "Slice Free Body"],
E03_PickSlice: [
"절편표 줄이나 횡단의 절편을 누르면 힘이 보입니다.",
"Click a slice row or a slice in the section to see its forces.",
],
E03_FbdUnit: ["힘 kN/m · 길이 = 힘 비례", "forces kN/m · length ∝ force"],
E03_Graph: ["절편력 그래프", "Slice Force Graph"],
E03_Graph_Sigma: ["유효 수직응력 σ'", "Effective normal σ'"],
E03_Graph_U: ["간극수압 u", "Pore pressure u"],
E03_Graph_Tau: ["동원 전단응력 τ", "Mobilized shear τ"],
} as const satisfies Record<string, LocaleEntry>;