Merge remote-tracking branch 'origin/sub_laptop_7' into sub_laptop_1
This commit is contained in:
@@ -21,6 +21,7 @@ import base64
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
@@ -32,8 +33,10 @@ from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
import numpy as np
|
||||
import shapely
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from scipy.spatial import cKDTree
|
||||
|
||||
from common_util.common_util_auth import verify_session
|
||||
from common_util.common_util_derived_store import (
|
||||
@@ -73,6 +76,9 @@ ENGINE_FILES = (
|
||||
)
|
||||
#: 3D 보기 노선면 솎음 간격(m)
|
||||
CORRIDOR_STEP_M = 1.0
|
||||
TERRAIN_MARGIN_M = 80.0 # 노선면 둘레 지형 띠 폭(B05 지형 띠 여유와 같음)
|
||||
TERRAIN_STEP_M = 2.0 # 지형 칸(이보다 촘촘히 안 함)
|
||||
TERRAIN_MAX_CELLS = 250_000 # 응답 크기 상한 — 넘으면 칸을 키움
|
||||
#: 프로세스 수 — None = 코어 − 2 · 1 = 서버 안에서 차례로(시험)
|
||||
WORKERS: int | None = None
|
||||
|
||||
@@ -435,10 +441,49 @@ async def put_representative(project_id: UUID, payload: RepresentativeRequest) -
|
||||
return {"overrides": overrides}
|
||||
|
||||
|
||||
def _terrain(dtm: str, origin: list[float], strips: list[np.ndarray]) -> dict[str, Any] | None:
|
||||
"""설계 지형 격자(E 복사본 DTM) — 노선면 둘레 띠만 · 노면 발자국 안 = None(깎기 지형이 노면을
|
||||
덮지 않게 · 가장자리 틈 한 칸 허용). 꼴 = E04 Grid + z(행 우선 iy·nx+ix) · 리본과 같은 원점."""
|
||||
try:
|
||||
sampler = DtmGridSampler.open_cached(dtm)
|
||||
except (OSError, ValueError, KeyError):
|
||||
logger.warning("E03 3D 지형 격자 못 엶: %s", dtm)
|
||||
return None
|
||||
pts = np.vstack([p.reshape(-1, 3)[:, :2] for p in strips])
|
||||
lo = pts.min(axis=0) - TERRAIN_MARGIN_M
|
||||
hi = pts.max(axis=0) + TERRAIN_MARGIN_M
|
||||
area = float(np.prod(hi - lo))
|
||||
dx = max(TERRAIN_STEP_M, math.ceil(math.sqrt(area / TERRAIN_MAX_CELLS) * 10) / 10)
|
||||
nx, ny = (np.floor((hi - lo) / dx).astype(int) + 1).tolist()
|
||||
xx, yy = np.meshgrid(lo[0] + np.arange(nx) * dx, lo[1] + np.arange(ny) * dx)
|
||||
flat = np.column_stack([xx.ravel(), yy.ravel()])
|
||||
near = np.isfinite(cKDTree(pts).query(flat, distance_upper_bound=TERRAIN_MARGIN_M)[0])
|
||||
# 발자국 = 리본마다 첫 열 + 끝 열(거꾸로) 다각형의 합집합
|
||||
rings = [np.vstack([p[:, 0, :2], p[::-1, -1, :2]]) for p in strips if len(p) >= 2]
|
||||
foot = shapely.union_all([shapely.make_valid(shapely.Polygon(r)) for r in rings])
|
||||
hole = shapely.contains_xy(foot, flat[:, 0], flat[:, 1])
|
||||
z, valid = sampler.sample_xy(flat + np.asarray(origin[:2]))
|
||||
keep = near & valid & ~hole & np.isfinite(z)
|
||||
local = np.round(z - origin[2], 2)
|
||||
return {
|
||||
"grid": {
|
||||
"x0": round(float(lo[0]), 3),
|
||||
"y0": round(float(lo[1]), 3),
|
||||
"dx": dx,
|
||||
"nx": nx,
|
||||
"ny": ny,
|
||||
},
|
||||
"z": [float(v) if k else None for v, k in zip(local.tolist(), keep.tolist())],
|
||||
}
|
||||
|
||||
|
||||
@lru_cache(maxsize=2)
|
||||
def _corridor3d(path: str, _stamp: tuple[int, int]) -> dict[str, Any]:
|
||||
def _corridor3d(
|
||||
path: str, _stamp: tuple[int, int], dtm: str | None, _dtm_stamp: tuple[int, int] | None
|
||||
) -> dict[str, Any]:
|
||||
data = json.loads(Path(path).read_text(encoding="utf-8"))
|
||||
ribbons = []
|
||||
strips = []
|
||||
origin = None
|
||||
for ribbon in data.get("ribbons") or []:
|
||||
cols = int(ribbon["colCount"])
|
||||
@@ -453,6 +498,7 @@ def _corridor3d(path: str, _stamp: tuple[int, int]) -> dict[str, Any]:
|
||||
if keep[-1] != len(chain) - 1:
|
||||
keep = np.r_[keep, len(chain) - 1]
|
||||
local = pos[keep] - np.asarray(origin)
|
||||
strips.append(local)
|
||||
ribbons.append(
|
||||
{
|
||||
"kind": ribbon["kind"],
|
||||
@@ -462,15 +508,25 @@ def _corridor3d(path: str, _stamp: tuple[int, int]) -> dict[str, Any]:
|
||||
"positions": np.round(local, 3).reshape(-1).tolist(),
|
||||
}
|
||||
)
|
||||
return {"origin": origin, "ribbons": ribbons}
|
||||
terrain = _terrain(dtm, origin, strips) if dtm and origin and strips else None
|
||||
return {"origin": origin, "ribbons": ribbons, "terrain": terrain}
|
||||
|
||||
|
||||
@router.get("/{project_id}/stab/corridor3d", response_model=None)
|
||||
async def corridor3d(project_id: UUID) -> dict[str, Any]:
|
||||
"""복사본 노선면 — 원점 빼고 1 m 솎음(모델 좌표 x · y · z)."""
|
||||
"""복사본 노선면 — 원점 빼고 1 m 솎음(모델 x · y · z) + 설계 지형 격자(노면 구멍)."""
|
||||
root, _ = await e02._project(project_id)
|
||||
path = src.corridor_path(src.source_dir(root))
|
||||
source = src.source_dir(root)
|
||||
path = src.corridor_path(source)
|
||||
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))
|
||||
try:
|
||||
dtm = src.terrain_grid_path(source)
|
||||
dtm_stat = dtm.stat()
|
||||
dtm_key = (str(dtm), (dtm_stat.st_mtime_ns, dtm_stat.st_size))
|
||||
except (OSError, FileNotFoundError):
|
||||
dtm_key = (None, None)
|
||||
return await asyncio.to_thread(
|
||||
_corridor3d, str(path), (stat.st_mtime_ns, stat.st_size), *dtm_key
|
||||
)
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
--e03-critical: #0b0b0b;
|
||||
--e03-surface-line: #52514e;
|
||||
--e03-plot: #fcfcfb;
|
||||
--e03-terrain: #c9c2a8;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] {
|
||||
@@ -49,6 +50,7 @@
|
||||
--e03-critical: #f0efec;
|
||||
--e03-surface-line: #c3c2b7;
|
||||
--e03-plot: #1a1a19;
|
||||
--e03-terrain: #5b5646;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
@@ -72,6 +74,7 @@
|
||||
--e03-critical: #f0efec;
|
||||
--e03-surface-line: #c3c2b7;
|
||||
--e03-plot: #1a1a19;
|
||||
--e03-terrain: #5b5646;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
* E03 [3D 보기](PLAN 532-3) — 복사본 노선면(GET …/stab/corridor3d · 1 m 솎음)에 측점 안전율 색칠(표시만).
|
||||
* 색 = 가장 가까운 측점의 그 조건 최소 (안전율 ÷ 기준) 띠(횡단 등안전율과 같은 띠 · logic `bandIndex`) ·
|
||||
* 해석 없는 측점 = 회색. createRouteViewer 는 DB 지표면 · 색 고리가 없어 쓰지 않고 three 를 바로 씀.
|
||||
* 설계 지형 = 응답 `terrain`(E 복사본 DTM · 노면 자리 구멍 · 요구 390) → E04 `terrainMesh` · 한 색 · 켬끔.
|
||||
* ========================================================================== */
|
||||
|
||||
import * as THREE from "three";
|
||||
@@ -10,7 +11,9 @@ import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
import { t } from "@ui/ui_template_locale";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import { legend, muted } from "./E03_StabSection_UI_Parts";
|
||||
import { terrainMesh } from "../E04_Stab3D/logic/E04_Stab3D_Mesh";
|
||||
import type { Grid } from "../E04_Stab3D/logic/E04_Stab3D_Raster";
|
||||
import { check, legend, muted } from "./E03_StabSection_UI_Parts";
|
||||
import {
|
||||
bandIndex,
|
||||
RATIO_BREAKS,
|
||||
@@ -27,6 +30,8 @@ interface Corridor3d {
|
||||
chainages: number[];
|
||||
positions: number[];
|
||||
}[];
|
||||
/** 설계 지형 — z 행 우선(iy·nx+ix) · null = 노면 구멍 · 띠 밖 · 복사본에 격자 없으면 null */
|
||||
terrain?: { grid: Grid; z: (number | null)[] } | null;
|
||||
}
|
||||
|
||||
/** 측점 하나 그 조건 최소 비(안전율 ÷ 기준)의 띠 */
|
||||
@@ -61,9 +66,16 @@ export async function openView3d(
|
||||
color: `var(--e03-band-${i})`,
|
||||
}));
|
||||
items.push({ label: t("E03_Verdict_none"), color: "var(--e03-none)" });
|
||||
let ground: THREE.Mesh | null = null;
|
||||
const legendRow = el("div", {
|
||||
className: "e03-legend",
|
||||
children: [muted(`${conditionLabel} · ${t("E03_View3d_Legend")}`), legend(items)],
|
||||
children: [
|
||||
muted(`${conditionLabel} · ${t("E03_View3d_Legend")}`),
|
||||
legend(items),
|
||||
...(data.terrain
|
||||
? [check(t("E03_View3d_Terrain"), true, (v) => ground && (ground.visible = v))]
|
||||
: []),
|
||||
],
|
||||
});
|
||||
const host = el("div", { className: "e03-view3d__canvas" });
|
||||
let teardown = (): void => undefined;
|
||||
@@ -145,6 +157,30 @@ export async function openView3d(
|
||||
}
|
||||
scene.add(group);
|
||||
|
||||
// 설계 지형 — 모델 [x, y, z] → three [x, z, −y](리본과 같음) · 노면과 겹치는 곳은 뒤로 밀어 둠
|
||||
if (data.terrain) {
|
||||
const mesh = terrainMesh(data.terrain.grid, data.terrain.z);
|
||||
const pos = new Float32Array(mesh.positions.length);
|
||||
for (let i = 0; i < pos.length; i += 3) {
|
||||
pos.set([mesh.positions[i], mesh.positions[i + 2], -mesh.positions[i + 1]], i);
|
||||
}
|
||||
const geometry = new THREE.BufferGeometry();
|
||||
geometry.setAttribute("position", new THREE.BufferAttribute(pos, 3));
|
||||
geometry.setIndex(new THREE.BufferAttribute(mesh.indices, 1));
|
||||
geometry.computeVertexNormals();
|
||||
ground = new THREE.Mesh(
|
||||
geometry,
|
||||
new THREE.MeshLambertMaterial({
|
||||
color: cssColor("--e03-terrain"),
|
||||
side: THREE.DoubleSide,
|
||||
polygonOffset: true,
|
||||
polygonOffsetFactor: 1,
|
||||
polygonOffsetUnits: 1,
|
||||
}),
|
||||
);
|
||||
scene.add(ground);
|
||||
}
|
||||
|
||||
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));
|
||||
@@ -178,12 +214,15 @@ export async function openView3d(
|
||||
(n, m) => n + ((m as THREE.Mesh).geometry.getAttribute("position").count ?? 0),
|
||||
0,
|
||||
),
|
||||
terrain: ground?.geometry.getAttribute("position").count ?? 0,
|
||||
};
|
||||
|
||||
const dispose = (): void => {
|
||||
cancelAnimationFrame(frame);
|
||||
observer.disconnect();
|
||||
controls.dispose();
|
||||
ground?.geometry.dispose();
|
||||
(ground?.material as THREE.Material | undefined)?.dispose();
|
||||
group.traverse((o) => {
|
||||
if (o instanceof THREE.Mesh) {
|
||||
o.geometry.dispose();
|
||||
|
||||
@@ -271,6 +271,39 @@ def test_노선면_솎음(project):
|
||||
assert max(abs(v) for v in ribbon["positions"]) < 5000 # 원점 뺀 자리
|
||||
|
||||
|
||||
def test_노선면_설계_지형(project):
|
||||
"""지형 = E04 Grid 꼴 · 리본과 같은 원점 · 노면 발자국 안 = None · 노면 옆 표고 ≈ 비탈 끝."""
|
||||
body = asyncio.run(router.corridor3d(UUID(PID)))
|
||||
terrain = body["terrain"]
|
||||
g, z = terrain["grid"], terrain["z"]
|
||||
assert len(z) == g["nx"] * g["ny"] and g["dx"] >= router.TERRAIN_STEP_M
|
||||
assert g["nx"] * g["ny"] <= router.TERRAIN_MAX_CELLS * 1.05
|
||||
have = [v for v in z if v is not None]
|
||||
assert have and max(abs(v) for v in have) < 500 # 원점 표고 뺀 값
|
||||
|
||||
def cell(x, y):
|
||||
ix = round((x - g["x0"]) / g["dx"])
|
||||
iy = round((y - g["y0"]) / g["dx"])
|
||||
return z[iy * g["nx"] + ix]
|
||||
|
||||
# 노면 리본 가운데 열 = 발자국 안 → 구멍
|
||||
road = next(r for r in body["ribbons"] if r["colCount"] >= 3)
|
||||
cols = road["colCount"]
|
||||
mid = [
|
||||
road["positions"][(i * cols + cols // 2) * 3 : (i * cols + cols // 2) * 3 + 2]
|
||||
for i in range(len(road["chainages"]))
|
||||
]
|
||||
holes = [cell(*p) is None for p in mid]
|
||||
assert sum(holes) / len(holes) > 0.9
|
||||
# 지형 있는 칸의 원래 표고 = DTM 표본 − 원점 표고
|
||||
sampler = router.DtmGridSampler.from_npz(src.terrain_grid_path(src.source_dir(project)))
|
||||
i = next(k for k, v in enumerate(z) if v is not None)
|
||||
x = g["x0"] + (i % g["nx"]) * g["dx"] + body["origin"][0]
|
||||
y = g["y0"] + (i // g["nx"]) * g["dx"] + body["origin"][1]
|
||||
want, ok = sampler.sample_xy([[x, y]])
|
||||
assert ok[0] and z[i] == pytest.approx(want[0] - body["origin"][2], abs=0.02)
|
||||
|
||||
|
||||
class _Cursor:
|
||||
def __init__(self, rows):
|
||||
self.rows = rows
|
||||
|
||||
@@ -25,6 +25,7 @@ export const ui_locales_e03 = {
|
||||
E03_Fresh: ["입력이 같아 다시 셀 것이 없습니다.", "Inputs unchanged — nothing to recompute."],
|
||||
E03_View3d: ["3D 보기", "3D View"],
|
||||
E03_View3d_Legend: ["최소 안전율 ÷ 기준안전율", "Min FS ÷ required FS"],
|
||||
E03_View3d_Terrain: ["설계 지형", "Design terrain"],
|
||||
E03_Loading: ["불러오는 중…", "Loading…"],
|
||||
E03_NotYet: [
|
||||
"아직 해석하지 않았습니다 — [해석]을 누르세요.",
|
||||
|
||||
Reference in New Issue
Block a user