From ecd027f028229610265839b0e9e7aadb9bbc6b9c Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 25 Oct 2026 20:35:36 +0900 Subject: [PATCH] =?UTF-8?q?feat(532-3):=20E03=202D=20=EC=B8=A1=EC=A0=90=20?= =?UTF-8?q?=ED=95=B4=EC=84=9D=20=EA=B8=B8=20=C2=B7=20=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=E2=80=94=20=ED=8C=8C=EC=83=9D=20=EC=A0=80=EC=9E=A5=EB=B3=B8(?= =?UTF-8?q?=EC=B8=A1=EC=A0=90=20=EC=A7=80=EB=AC=B8)=20=C2=B7=20=EC=A7=84?= =?UTF-8?q?=ED=96=89=EB=A5=A0=20=C2=B7=20=EC=9E=84=EA=B3=84=EB=A9=B4=20?= =?UTF-8?q?=C2=B7=20=EB=93=B1=EC=95=88=EC=A0=84=EC=9C=A8=20=C2=B7=20?= =?UTF-8?q?=EC=A0=88=ED=8E=B8=20=EC=9E=90=EC=9C=A0=EB=AC=BC=EC=B2=B4?= =?UTF-8?q?=EB=8F=84=20=C2=B7=20=ED=8F=89=EB=A9=B4=20=EB=9D=A0=20=C2=B7=20?= =?UTF-8?q?3D=20=EC=83=89=EC=B9=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 길: POST analyze2d(뒤에서 · 일꾼 프로세스 병렬 · 바뀐 측점만) · GET progress · result2d · section · corridor3d · PUT representative - 입력 = E01 복사본(측점 · 횡단 · DTM 넓힘) + E02 지반 조건 · 엔진 = sub4 analyze_station · 벽 = 다음 일감(532-4) - 화면: 측점 목록(조건 · 정렬 · N.G 만 · 대표 단면) · 횡단(지층 · 지하수선 · 임계면 · 후보 · 등안전율 지도 · 중심 격자 · 절편) · 결과 · 절편표 · 자유물체도 · 절편력 그래프 · 평면 띠 · 3D 보기 - 계산 = logic/E03_StabSection_Logic.ts · 시험 test_532_3_e03_section.py Co-Authored-By: Claude Opus 5.5 --- E03_StabSection/E03_StabSection_Router.py | 426 +++++++++++ E03_StabSection/E03_StabSection_UI_List.ts | 109 +++ E03_StabSection/E03_StabSection_UI_Page.ts | 424 +++++++++++ E03_StabSection/E03_StabSection_UI_Plan.ts | 89 +++ E03_StabSection/E03_StabSection_UI_Result.ts | 285 ++++++++ E03_StabSection/E03_StabSection_UI_Section.ts | 334 +++++++++ E03_StabSection/E03_StabSection_UI_Style.css | 686 ++++++++++++++++++ E03_StabSection/E03_StabSection_UI_View3D.ts | 201 +++++ E03_StabSection/E03_StabSection_View.py | 262 +++++++ E03_StabSection/E03_StabSection_Worker.py | 74 ++ .../draft/E03_StabSection_Route.ts | 7 +- .../logic/E03_StabSection_Logic.ts | 371 ++++++++++ main.py | 2 + resources/tester/test_532_3_e03_section.py | 551 ++++++++++++++ ui_template/ui_template_locale.ts | 2 + ui_template/ui_template_locale_e03.ts | 119 +++ 16 files changed, 3938 insertions(+), 4 deletions(-) create mode 100644 E03_StabSection/E03_StabSection_Router.py create mode 100644 E03_StabSection/E03_StabSection_UI_List.ts create mode 100644 E03_StabSection/E03_StabSection_UI_Page.ts create mode 100644 E03_StabSection/E03_StabSection_UI_Plan.ts create mode 100644 E03_StabSection/E03_StabSection_UI_Result.ts create mode 100644 E03_StabSection/E03_StabSection_UI_Section.ts create mode 100644 E03_StabSection/E03_StabSection_UI_Style.css create mode 100644 E03_StabSection/E03_StabSection_UI_View3D.ts create mode 100644 E03_StabSection/E03_StabSection_View.py create mode 100644 E03_StabSection/E03_StabSection_Worker.py create mode 100644 E03_StabSection/logic/E03_StabSection_Logic.ts create mode 100644 resources/tester/test_532_3_e03_section.py create mode 100644 ui_template/ui_template_locale_e03.ts diff --git a/E03_StabSection/E03_StabSection_Router.py b/E03_StabSection/E03_StabSection_Router.py new file mode 100644 index 000000000..573c922a9 --- /dev/null +++ b/E03_StabSection/E03_StabSection_Router.py @@ -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=" 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)) diff --git a/E03_StabSection/E03_StabSection_UI_List.ts b/E03_StabSection/E03_StabSection_UI_List.ts new file mode 100644 index 000000000..52c86ea45 --- /dev/null +++ b/E03_StabSection/E03_StabSection_UI_List.ts @@ -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; +} diff --git a/E03_StabSection/E03_StabSection_UI_Page.ts b/E03_StabSection/E03_StabSection_UI_Page.ts new file mode 100644 index 000000000..4287bfa09 --- /dev/null +++ b/E03_StabSection/E03_StabSection_UI_Page.ts @@ -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; + stations: StationRow[]; +} + +interface Progress { + running: boolean; + percent: number; + message: string; + error: string | null; +} + +async function api(path: string, init?: RequestInit): Promise { + const response = await fetch(`${API_BASE_URL}${path}`, { + credentials: "include", + headers: { "Content-Type": "application/json" }, + ...init, + }); + const body = (await response.json().catch(() => ({}))) as T & { detail?: unknown }; + if (!response.ok) { + 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 => 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(); + 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 = 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 { + if (active !== key) picked = null; + active = key; + paintList(); + paintPlan(); + if (result?.status === "ok" && !sections.has(key) && !currentRow()?.error) { + try { + sections.set(key, await api(`${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 { + result = await api(`${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 { + showLoadingOverlay(); + try { + for (;;) { + const p = await api(`${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 { + 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 { + try { + const overrides: Record = {}; + 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 { + if (!result) return; + try { + const data = await api[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"); + }); +} diff --git a/E03_StabSection/E03_StabSection_UI_Plan.ts b/E03_StabSection/E03_StabSection_UI_Plan.ts new file mode 100644 index 000000000..c732c2104 --- /dev/null +++ b/E03_StabSection/E03_StabSection_UI_Plan.ts @@ -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; +} diff --git a/E03_StabSection/E03_StabSection_UI_Result.ts b/E03_StabSection/E03_StabSection_UI_Result.ts new file mode 100644 index 000000000..a9225f2aa --- /dev/null +++ b/E03_StabSection/E03_StabSection_UI_Result.ts @@ -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; +} diff --git a/E03_StabSection/E03_StabSection_UI_Section.ts b/E03_StabSection/E03_StabSection_UI_Section.ts new file mode 100644 index 000000000..7960d5042 --- /dev/null +++ b/E03_StabSection/E03_StabSection_UI_Section.ts @@ -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( + tag: K, + attrs: Record = {}, + 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(); + }, + }; +} diff --git a/E03_StabSection/E03_StabSection_UI_Style.css b/E03_StabSection/E03_StabSection_UI_Style.css new file mode 100644 index 000000000..95b8a2665 --- /dev/null +++ b/E03_StabSection/E03_StabSection_UI_Style.css @@ -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); +} diff --git a/E03_StabSection/E03_StabSection_UI_View3D.ts b/E03_StabSection/E03_StabSection_UI_View3D.ts new file mode 100644 index 000000000..4f69b9229 --- /dev/null +++ b/E03_StabSection/E03_StabSection_UI_View3D.ts @@ -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 { + 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); +} diff --git a/E03_StabSection/E03_StabSection_View.py b/E03_StabSection/E03_StabSection_View.py new file mode 100644 index 000000000..a52d20470 --- /dev/null +++ b/E03_StabSection/E03_StabSection_View.py @@ -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, + } diff --git a/E03_StabSection/E03_StabSection_Worker.py b/E03_StabSection/E03_StabSection_Worker.py new file mode 100644 index 000000000..9dcc4b2d4 --- /dev/null +++ b/E03_StabSection/E03_StabSection_Worker.py @@ -0,0 +1,74 @@ +"""E03 측점 해석 일꾼 — 서버 밖 프로세스(PLAN 532-3). + +`python -m E03_StabSection.E03_StabSection_Worker ` — 서버(`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() diff --git a/E03_StabSection/draft/E03_StabSection_Route.ts b/E03_StabSection/draft/E03_StabSection_Route.ts index 4b3805d51..e94e8ae84 100644 --- a/E03_StabSection/draft/E03_StabSection_Route.ts +++ b/E03_StabSection/draft/E03_StabSection_Route.ts @@ -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, }; diff --git a/E03_StabSection/logic/E03_StabSection_Logic.ts b/E03_StabSection/logic/E03_StabSection_Logic.ts new file mode 100644 index 000000000..9bf1903dd --- /dev/null +++ b/E03_StabSection/logic/E03_StabSection_Logic.ts @@ -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>>; + min: Partial>; + verdict: Partial>; + 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; + 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>; + 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; +} diff --git a/main.py b/main.py index 067aa0391..dff99b472 100644 --- a/main.py +++ b/main.py @@ -81,6 +81,7 @@ from D04_PlanConfirm.D04_PlanConfirm_Link import router as d04_plan_link_router from D04_PlanConfirm.D04_PlanConfirm_Router import router as d04_plan_confirm_router from E01_StabRegister.E01_StabRegister_Router import router as e01_stab_register_router from E02_StabGround.E02_StabGround_Router import router as e02_stab_ground_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 @@ -488,6 +489,7 @@ app.include_router(d03_plan_analysis_router, dependencies=protected_with_company app.include_router(d04_plan_confirm_router, dependencies=protected_with_company) 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(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) diff --git a/resources/tester/test_532_3_e03_section.py b/resources/tester/test_532_3_e03_section.py new file mode 100644 index 000000000..7f1a08727 --- /dev/null +++ b/resources/tester/test_532_3_e03_section.py @@ -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 diff --git a/ui_template/ui_template_locale.ts b/ui_template/ui_template_locale.ts index e0ade76ab..783c7c69e 100644 --- a/ui_template/ui_template_locale.ts +++ b/ui_template/ui_template_locale.ts @@ -23,6 +23,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"; @@ -62,6 +63,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, diff --git a/ui_template/ui_template_locale_e03.ts b/ui_template/ui_template_locale_e03.ts new file mode 100644 index 000000000..40f2fae3c --- /dev/null +++ b/ui_template/ui_template_locale_e03.ts @@ -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;