feat(E03): 3D 보기 설계 지형 + 노면 구멍 (PLAN 566-3)

- corridor3d 응답에 terrain 칸 — E 복사본 DTM 을 노선면 둘레 80 m 띠로 · 2 m 칸(25만 칸 넘으면 키움) · 리본 발자국 안 = None(깎기 지형이 노면을 덮지 않게) · 리본과 같은 원점 · 캐시 열쇠에 DTM 수정시각
- 3D 보기 = E04 terrainMesh 로 지형 메쉬 · 한 색(--e03-terrain 밝음 · 어두움) · [설계 지형] 켬끔 · 노면 쪽으로 겹침 밀어 둠
- 시험 532_3 노선면 설계 지형(꼴 · 구멍 · 표고 = DTM − 원점)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-27 20:02:45 +09:00
co-authored by Claude Opus 5.5
parent 7d5f516b84
commit 0ea6b032a4
5 changed files with 139 additions and 7 deletions
+61 -5
View File
@@ -21,6 +21,7 @@ import base64
import hashlib import hashlib
import json import json
import logging import logging
import math
import os import os
import subprocess import subprocess
import sys import sys
@@ -32,8 +33,10 @@ from typing import Any
from uuid import UUID from uuid import UUID
import numpy as np import numpy as np
import shapely
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel from pydantic import BaseModel
from scipy.spatial import cKDTree
from common_util.common_util_auth import verify_session from common_util.common_util_auth import verify_session
from common_util.common_util_derived_store import ( from common_util.common_util_derived_store import (
@@ -73,6 +76,9 @@ ENGINE_FILES = (
) )
#: 3D 보기 노선면 솎음 간격(m) #: 3D 보기 노선면 솎음 간격(m)
CORRIDOR_STEP_M = 1.0 CORRIDOR_STEP_M = 1.0
TERRAIN_MARGIN_M = 80.0 # 노선면 둘레 지형 띠 폭(B05 지형 띠 여유와 같음)
TERRAIN_STEP_M = 2.0 # 지형 칸(이보다 촘촘히 안 함)
TERRAIN_MAX_CELLS = 250_000 # 응답 크기 상한 — 넘으면 칸을 키움
#: 프로세스 수 — None = 코어 − 2 · 1 = 서버 안에서 차례로(시험) #: 프로세스 수 — None = 코어 − 2 · 1 = 서버 안에서 차례로(시험)
WORKERS: int | None = None WORKERS: int | None = None
@@ -435,10 +441,49 @@ async def put_representative(project_id: UUID, payload: RepresentativeRequest) -
return {"overrides": overrides} 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) @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")) data = json.loads(Path(path).read_text(encoding="utf-8"))
ribbons = [] ribbons = []
strips = []
origin = None origin = None
for ribbon in data.get("ribbons") or []: for ribbon in data.get("ribbons") or []:
cols = int(ribbon["colCount"]) 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: if keep[-1] != len(chain) - 1:
keep = np.r_[keep, len(chain) - 1] keep = np.r_[keep, len(chain) - 1]
local = pos[keep] - np.asarray(origin) local = pos[keep] - np.asarray(origin)
strips.append(local)
ribbons.append( ribbons.append(
{ {
"kind": ribbon["kind"], "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(), "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) @router.get("/{project_id}/stab/corridor3d", response_model=None)
async def corridor3d(project_id: UUID) -> dict[str, Any]: 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) 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: if not path:
raise HTTPException(status_code=404, detail="복사본에 노선면이 없습니다.") raise HTTPException(status_code=404, detail="복사본에 노선면이 없습니다.")
stat = path.stat() 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-critical: #0b0b0b;
--e03-surface-line: #52514e; --e03-surface-line: #52514e;
--e03-plot: #fcfcfb; --e03-plot: #fcfcfb;
--e03-terrain: #c9c2a8;
} }
:root[data-theme="dark"] { :root[data-theme="dark"] {
@@ -49,6 +50,7 @@
--e03-critical: #f0efec; --e03-critical: #f0efec;
--e03-surface-line: #c3c2b7; --e03-surface-line: #c3c2b7;
--e03-plot: #1a1a19; --e03-plot: #1a1a19;
--e03-terrain: #5b5646;
} }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
@@ -72,6 +74,7 @@
--e03-critical: #f0efec; --e03-critical: #f0efec;
--e03-surface-line: #c3c2b7; --e03-surface-line: #c3c2b7;
--e03-plot: #1a1a19; --e03-plot: #1a1a19;
--e03-terrain: #5b5646;
} }
} }
+41 -2
View File
@@ -3,6 +3,7 @@
* E03 [3D 보기](PLAN 532-3) — 복사본 노선면(GET …/stab/corridor3d · 1 m 솎음)에 측점 안전율 색칠(표시만). * E03 [3D 보기](PLAN 532-3) — 복사본 노선면(GET …/stab/corridor3d · 1 m 솎음)에 측점 안전율 색칠(표시만).
* 색 = 가장 가까운 측점의 그 조건 최소 (안전율 ÷ 기준) 띠(횡단 등안전율과 같은 띠 · logic `bandIndex`) · * 색 = 가장 가까운 측점의 그 조건 최소 (안전율 ÷ 기준) 띠(횡단 등안전율과 같은 띠 · logic `bandIndex`) ·
* 해석 없는 측점 = 회색. createRouteViewer 는 DB 지표면 · 색 고리가 없어 쓰지 않고 three 를 바로 씀. * 해석 없는 측점 = 회색. createRouteViewer 는 DB 지표면 · 색 고리가 없어 쓰지 않고 three 를 바로 씀.
* 설계 지형 = 응답 `terrain`(E 복사본 DTM · 노면 자리 구멍 · 요구 390) → E04 `terrainMesh` · 한 색 · 켬끔.
* ========================================================================== */ * ========================================================================== */
import * as THREE from "three"; 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 { el } from "@ui/ui_template_elements";
import { t } from "@ui/ui_template_locale"; import { t } from "@ui/ui_template_locale";
import { openModal } from "@ui/ui_template_modal"; 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 { import {
bandIndex, bandIndex,
RATIO_BREAKS, RATIO_BREAKS,
@@ -27,6 +30,8 @@ interface Corridor3d {
chainages: number[]; chainages: number[];
positions: 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})`, color: `var(--e03-band-${i})`,
})); }));
items.push({ label: t("E03_Verdict_none"), color: "var(--e03-none)" }); items.push({ label: t("E03_Verdict_none"), color: "var(--e03-none)" });
let ground: THREE.Mesh | null = null;
const legendRow = el("div", { const legendRow = el("div", {
className: "e03-legend", 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" }); const host = el("div", { className: "e03-view3d__canvas" });
let teardown = (): void => undefined; let teardown = (): void => undefined;
@@ -145,6 +157,30 @@ export async function openView3d(
} }
scene.add(group); 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 box = new THREE.Box3().setFromObject(group);
const sphere = box.getBoundingSphere(new THREE.Sphere()); const sphere = box.getBoundingSphere(new THREE.Sphere());
const camera = new THREE.PerspectiveCamera(45, 1, 0.5, Math.max(sphere.radius * 20, 1000)); 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), (n, m) => n + ((m as THREE.Mesh).geometry.getAttribute("position").count ?? 0),
0, 0,
), ),
terrain: ground?.geometry.getAttribute("position").count ?? 0,
}; };
const dispose = (): void => { const dispose = (): void => {
cancelAnimationFrame(frame); cancelAnimationFrame(frame);
observer.disconnect(); observer.disconnect();
controls.dispose(); controls.dispose();
ground?.geometry.dispose();
(ground?.material as THREE.Material | undefined)?.dispose();
group.traverse((o) => { group.traverse((o) => {
if (o instanceof THREE.Mesh) { if (o instanceof THREE.Mesh) {
o.geometry.dispose(); o.geometry.dispose();
@@ -271,6 +271,39 @@ def test_노선면_솎음(project):
assert max(abs(v) for v in ribbon["positions"]) < 5000 # 원점 뺀 자리 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: class _Cursor:
def __init__(self, rows): def __init__(self, rows):
self.rows = rows self.rows = rows
+1
View File
@@ -25,6 +25,7 @@ export const ui_locales_e03 = {
E03_Fresh: ["입력이 같아 다시 셀 것이 없습니다.", "Inputs unchanged — nothing to recompute."], E03_Fresh: ["입력이 같아 다시 셀 것이 없습니다.", "Inputs unchanged — nothing to recompute."],
E03_View3d: ["3D 보기", "3D View"], E03_View3d: ["3D 보기", "3D View"],
E03_View3d_Legend: ["최소 안전율 ÷ 기준안전율", "Min FS ÷ required FS"], E03_View3d_Legend: ["최소 안전율 ÷ 기준안전율", "Min FS ÷ required FS"],
E03_View3d_Terrain: ["설계 지형", "Design terrain"],
E03_Loading: ["불러오는 중…", "Loading…"], E03_Loading: ["불러오는 중…", "Loading…"],
E03_NotYet: [ E03_NotYet: [
"아직 해석하지 않았습니다 — [해석]을 누르세요.", "아직 해석하지 않았습니다 — [해석]을 누르세요.",