- [해석] 끝 · [대표 단면 저장] 때 E03_StabSection/_derived/result2d.json(측점 줄 · 지배 비탈 · 조건별 최소 · 판정) · results/<대표 측점>.json · representative.json stations 덧붙임(overrides 그대로) - 측점 지문에 E02 지하수 갈래 · 일꾼 job 에 지반 조건(sub4 analyze_station 고리 뒤 측점 수위로 이음) - 측점 보기 water_rule — r_u 갈래면 범례에 r_u 값(수위선 없음) - 시험 test_532_5_e03_seam — 벽 측점 임계면 · 벽 측점만 지문 · 지하수 갈래 지문 · 수위선 갈래 · 빠진 횡단 · E05 가 E03 저장본으로 빌더 돎 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
510 lines
21 KiB
Python
510 lines
21 KiB
Python
"""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_Walls as walls_mod
|
|
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"
|
|
#: E05 보고서가 읽는 꼴(`E05_StabReport_Build` · result2d.json · results/<측점>.json)
|
|
REPORT_DIR = Path("E03_StabSection") / "_derived"
|
|
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 지반 조건 — 작업본 · 없으면 초기값(옛 정본 = 빠진 지하수 칸 기본값 · E02 화면과 같음)."""
|
|
ground = e02._read_working(root) or e02.ensure_initial(root)
|
|
return ground_model.upgrade(ground, ground_model.load_defaults())
|
|
|
|
|
|
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]]
|
|
|
|
|
|
@lru_cache(maxsize=4)
|
|
def _walls(source: str, _copy_fp: str) -> dict[str, dict[str, Any]]:
|
|
"""측점별 벽(532-4 · Node 한 번 약 2 s) — 복사본은 동결이라 복사 지문으로 기억."""
|
|
return walls_mod.walls_by_station(source)
|
|
|
|
|
|
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),
|
|
"groundwater": ground.get("groundwater"),
|
|
}
|
|
resolved = {chainage_key(r["chainage_m"]): r for r in ground_model.resolve_stations(ground)}
|
|
try:
|
|
found = _walls(str(source), copy_fp)
|
|
except RuntimeError as error:
|
|
raise HTTPException(status_code=500, detail=str(error)) from error
|
|
items = []
|
|
for station in stations:
|
|
key = chainage_key(station["chainage_m"])
|
|
layers = _layers(resolved.get(key))
|
|
entry = found.get(key)
|
|
parts = [engine_hash(), copy_fp, shared, key, layers] + ([entry] if entry else [])
|
|
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,
|
|
"walls": entry,
|
|
"fp": derived_key(*parts), # 벽 없는 측점 = 종전 지문 꼴
|
|
}
|
|
)
|
|
factors = ground.get("safety_factors") or {}
|
|
overall = derived_key([item["fp"] for item in items], factors)
|
|
return {
|
|
"source": source,
|
|
"ground": ground,
|
|
"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": walls_mod.design_with_walls(cross["design"], item.get("walls")),
|
|
"walls": walls_mod.walls_of(item.get("walls")),
|
|
"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")
|
|
walls = len(walls_mod.walls_of(item.get("walls")) or [])
|
|
return {
|
|
**{k: item[k] for k in keys},
|
|
"section_mode": design.get("section_mode"),
|
|
"walls": walls,
|
|
}
|
|
|
|
|
|
def _run_worker(job: dict[str, Any], emit: Any) -> None:
|
|
"""일꾼 프로세스(`E03_StabSection_Worker`) — 한 줄마다 emit · 실패면 RuntimeError.
|
|
|
|
stderr 는 파일로(파이프로 두면 경고가 쌓여 일꾼이 멈춤)."""
|
|
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")
|
|
log = Path(folder) / "stderr.txt"
|
|
with log.open("w", encoding="utf-8") as errors:
|
|
process = subprocess.Popen(
|
|
[sys.executable, "-m", "E03_StabSection.E03_StabSection_Worker", str(path)],
|
|
cwd=PROJECT_ROOT,
|
|
stdout=subprocess.PIPE,
|
|
stderr=errors,
|
|
text=True,
|
|
encoding="utf-8",
|
|
)
|
|
for line in process.stdout:
|
|
if line.startswith("{"):
|
|
emit(json.loads(line).get("done"))
|
|
code = process.wait()
|
|
if code != 0:
|
|
tail = log.read_text(encoding="utf-8", errors="replace").strip().splitlines()[-1:]
|
|
raise RuntimeError(f"해석 일꾼 실패: {tail}")
|
|
|
|
|
|
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),
|
|
"conditions_ground": plan["ground"],
|
|
"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)
|
|
export_report(root, summary)
|
|
return len(todo)
|
|
|
|
|
|
def export_report(root: Path, summary: dict[str, Any]) -> None:
|
|
"""E05 보고서 입력 — 요약(측점 줄) · 대표 단면 측점 해석 결과 · representative.json `stations`
|
|
(E03 은 `overrides` 만 읽음). [해석] 끝 · [대표 단면 저장] 때 씀."""
|
|
rows = [dict(r) for r in summary["stations"]]
|
|
overrides = _overrides(root)
|
|
view.mark_representative(rows, overrides)
|
|
folder = root / REPORT_DIR
|
|
atomic_write_json(
|
|
folder / "result2d.json",
|
|
{
|
|
"input_fingerprint": summary["fingerprint"],
|
|
"computed_at": summary["computed_at"],
|
|
"conditions": summary["conditions"],
|
|
"stations": [view.report_row(r) for r in rows],
|
|
},
|
|
)
|
|
reps = view.report_representatives(rows)
|
|
results = folder / "results"
|
|
for old in results.glob("*.json"):
|
|
old.unlink()
|
|
by_key = {r["key"]: r for r in rows}
|
|
for rep in reps:
|
|
saved = read_derived(root, KIND_STATION, by_key[rep["station_key"]]["fp"])
|
|
if saved and saved["value"]["out"]:
|
|
atomic_write_json(results / f"{rep['station_key']}.json", saved["value"]["out"])
|
|
atomic_write_json(root / REPRESENTATIVE, {"overrides": overrides, "stations": reps})
|
|
|
|
|
|
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"))
|
|
missing = src.missing_stations(plan["source"]) # 횡단 없어 해석에서 빠진 측점(화면 띠)
|
|
if not summary:
|
|
rows = [view.station_row(_meta(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,
|
|
"missing_stations": missing,
|
|
}
|
|
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,
|
|
"missing_stations": missing,
|
|
}
|
|
|
|
|
|
@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"])
|
|
entry = next((it["walls"] for it in plan["items"] if it["key"] == station_key), None)
|
|
design = walls_mod.design_with_walls(cross["design"], entry)
|
|
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"],
|
|
"walls": walls_mod.walls_of(entry),
|
|
}
|
|
dry = section_model_from_b06(cross, design, **kw)
|
|
water, rules = {}, {}
|
|
for cond in summary["conditions"]:
|
|
# 수위선 = E02 고른 갈래(r_u · 없음 = 선 없음)
|
|
model = section_model_from_b06(cross, design, wet=cond == "wet", **kw)
|
|
gw = ground_model.station_groundwater(ground, cond, model)
|
|
water[cond] = gw.get("line") if gw.get("mode") == "line" else None
|
|
rules[cond] = {"mode": gw.get("mode"), "ru": gw.get("ru")}
|
|
out = view.section_view(row, value["out"], dry, water)
|
|
out["water_rule"] = rules
|
|
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)
|
|
plan = plan_run(root, _ground(root))
|
|
known = {item["key"] for item in plan["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})
|
|
summary, _ = _latest_summary(root, plan["fp"])
|
|
if summary:
|
|
export_report(root, summary)
|
|
return {"overrides": overrides}
|
|
|
|
|
|
@lru_cache(maxsize=2)
|
|
def _corridor3d(path: str, _stamp: tuple[int, int]) -> dict[str, Any]:
|
|
data = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
ribbons = []
|
|
origin = None
|
|
for ribbon in data.get("ribbons") or []:
|
|
cols = int(ribbon["colCount"])
|
|
chain = np.asarray(ribbon["chainages"], dtype=float)
|
|
pos = np.frombuffer(base64.b64decode(ribbon["positionsBase64"]), dtype="<f8")
|
|
pos = pos.reshape(len(chain), cols, 3)
|
|
if origin is None and len(pos):
|
|
origin = pos[0, 0].tolist()
|
|
# 솎음 — 1 m 마다 첫 줄 + 끝 줄
|
|
bins = np.floor(np.round(chain, 6) / CORRIDOR_STEP_M)
|
|
keep = np.flatnonzero(np.r_[True, np.diff(bins) > 0])
|
|
if keep[-1] != len(chain) - 1:
|
|
keep = np.r_[keep, len(chain) - 1]
|
|
local = pos[keep] - np.asarray(origin)
|
|
ribbons.append(
|
|
{
|
|
"kind": ribbon["kind"],
|
|
"side": ribbon["side"],
|
|
"colCount": cols,
|
|
"chainages": np.round(chain[keep], 3).tolist(),
|
|
"positions": np.round(local, 3).reshape(-1).tolist(),
|
|
}
|
|
)
|
|
return {"origin": origin, "ribbons": ribbons}
|
|
|
|
|
|
@router.get("/{project_id}/stab/corridor3d", response_model=None)
|
|
async def corridor3d(project_id: UUID) -> dict[str, Any]:
|
|
"""복사본 노선면 — 원점 빼고 1 m 솎음(모델 좌표 x · y · z)."""
|
|
root, _ = await e02._project(project_id)
|
|
path = src.corridor_path(src.source_dir(root))
|
|
if not path:
|
|
raise HTTPException(status_code=404, detail="복사본에 노선면이 없습니다.")
|
|
stat = path.stat()
|
|
return await asyncio.to_thread(_corridor3d, str(path), (stat.st_mtime_ns, stat.st_size))
|