This commit is contained in:
2026-07-17 17:50:16 +09:00
parent 33815e0855
commit 177dde45fe
5 changed files with 139 additions and 254 deletions
@@ -1,211 +0,0 @@
import json
import logging
from pathlib import Path
from typing import Any
from uuid import UUID
from fastapi import APIRouter, HTTPException, Response
from fastapi.responses import FileResponse, JSONResponse
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
from common_util.common_util_storage import resolve_stored_project_path
from config.config_db import get_db_pool
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/projects", tags=["B04 Surface GIS"])
tiles_router = APIRouter(tags=["B04 MVT Tiles"])
# VWorld 메타 API
@router.get("/{project_id}/vworld-meta", response_model=None)
async def get_vworld_meta(
project_id: UUID, layer_name: str = "satellite"
) -> dict[str, Any] | JSONResponse:
"""VWorld 위성 맵 이미지 매핑 좌표 메타데이터를 반환합니다."""
pool = get_db_pool()
try:
async with pool.acquire() as connection:
stored_path = await get_project_storage_relative_path(connection, project_id)
project_root = Path(resolve_stored_project_path(stored_path))
target_dir = project_root / "B04_wf1_Surface" / "processed"
target_layer = "white" if layer_name.lower() in ["gray", "white"] else layer_name.lower()
meta_name = f"vworld_{target_layer}_meta.json"
meta_path = target_dir / meta_name
if not meta_path.exists() and target_layer == "satellite":
meta_path = target_dir / "vworld_meta.json"
if not meta_path.exists():
return JSONResponse(
status_code=404,
content={
"status": "error",
"message": f"VWorld {layer_name} 메타데이터를 찾을 수 없습니다.",
},
)
return json.loads(meta_path.read_text(encoding="utf-8"))
except Exception as exc:
return JSONResponse(status_code=500, content={"status": "error", "message": str(exc)})
# VWorld 맵 API
@router.get("/{project_id}/vworld-map", response_model=None)
async def get_vworld_map(
project_id: UUID, layer_name: str = "satellite"
) -> FileResponse | JSONResponse:
"""배경 지도 레이어 PNG 이미지를 반환합니다."""
pool = get_db_pool()
try:
async with pool.acquire() as connection:
stored_path = await get_project_storage_relative_path(connection, project_id)
project_root = Path(resolve_stored_project_path(stored_path))
target_dir = project_root / "B04_wf1_Surface" / "processed"
target_layer = layer_name.lower()
if target_layer in ["gray", "white"]:
target_layer = "white"
map_name = f"vworld_{target_layer}.png"
map_path = target_dir / map_name
if not map_path.exists() and target_layer == "satellite":
map_path = target_dir / "vworld_map.png"
if not map_path.exists():
return JSONResponse(
status_code=404,
content={
"status": "error",
"message": f"VWorld {layer_name} 지도가 존재하지 않습니다.",
},
)
return FileResponse(map_path, media_type="image/png")
except Exception as exc:
return JSONResponse(status_code=500, content={"status": "error", "message": str(exc)})
# GeoJSON 조회 API
@router.get("/{project_id}/geojson", response_model=None)
async def get_project_geojson(project_id: UUID, layer: str) -> dict[str, Any] | JSONResponse:
"""저장된 프로젝트의 특정 GeoJSON 레이어 데이터를 반환합니다."""
pool = get_db_pool()
try:
async with pool.acquire() as connection:
stored_path = await get_project_storage_relative_path(connection, project_id)
project_root = Path(resolve_stored_project_path(stored_path))
target_dir = project_root / "B04_wf1_Surface" / "processed"
layer_mapping = {
"지적도": "연속지적도_bounds.geojson",
"용도지역": "용도지역도_bounds.geojson",
"행정구역_시군구": "행정구역_시군구_bounds.geojson",
"행정구역_읍면동": "행정구역_읍면동_bounds.geojson",
"수계망": "수계망_물줄기_bounds.geojson",
"등고선": "등고선_bounds.geojson",
"산사태": "산사태위험등급_bounds.geojson",
}
filename = layer_mapping.get(layer)
if not filename:
return JSONResponse(
status_code=400,
content={"status": "error", "message": "유효하지 않은 레이어명입니다."},
)
filepath = target_dir / filename
if not filepath.exists():
return JSONResponse(
status_code=404,
content={
"status": "error",
"message": f"요청한 레이어({layer}) 파일이 존재하지 않습니다.",
},
)
if layer == "등고선":
simplified_filepath = target_dir / "등고선_bounds_simplified.geojson"
if simplified_filepath.exists():
try:
return json.loads(simplified_filepath.read_text(encoding="utf-8"))
except Exception:
pass
try:
import geopandas as gpd
gdf = gpd.read_file(filepath)
gdf["geometry"] = gdf["geometry"].simplify(
tolerance=0.00003, preserve_topology=True
)
simplified_filepath.write_text(gdf.to_json(), encoding="utf-8")
return json.loads(simplified_filepath.read_text(encoding="utf-8"))
except Exception as e:
logger.warning("등고선 단순화 처리 실패 (원본 전송): %s", e)
return json.loads(filepath.read_text(encoding="utf-8"))
except Exception as exc:
return JSONResponse(status_code=500, content={"status": "error", "message": str(exc)})
@tiles_router.get("/tiles/{project_id}/{layer}/{z}/{x}/{y}.pbf", response_model=None)
async def get_vector_tile(project_id: UUID, layer: str, z: int, x: int, y: int) -> Response:
"""프로젝트의 특정 레이어에 대한 정밀 벡터 타일(MVT) 조각을 동적으로 렌더링하여 반환합니다."""
pool = get_db_pool()
try:
async with pool.acquire() as connection:
stored_path = await get_project_storage_relative_path(connection, project_id)
project_root = Path(resolve_stored_project_path(stored_path))
target_dir = project_root / "B04_wf1_Surface" / "processed"
layer_mapping = {
"지적도": "연속지적도_bounds.geojson",
"용도지역": "용도지역도_bounds.geojson",
"행정구역_시군구": "행정구역_시군구_bounds.geojson",
"행정구역_읍면동": "행정구역_읍면동_bounds.geojson",
"수계망": "수계망_물줄기_bounds.geojson",
"등고선": "등고선_bounds.geojson",
"산사태": "산사태위험등급_bounds.geojson",
"임도노선": "임도노선.geojson",
}
filename = layer_mapping.get(layer)
if not filename:
raise HTTPException(status_code=400, detail="유효하지 않은 레이어명입니다.")
filepath = target_dir / filename
if layer == "임도노선" and not filepath.exists():
from config.config_system import PROJECT_ROOT
road_shp_candidates = list(PROJECT_ROOT.glob("samples/**/*_Polyline.shp"))
if road_shp_candidates:
try:
import geopandas as gpd
gdf = gpd.read_file(road_shp_candidates[0])
gdf = gdf.to_crs(epsg=4326)
filepath.write_text(gdf.to_json(), encoding="utf-8")
except Exception:
pass
if not filepath.exists():
import mapbox_vector_tile
empty_tile = mapbox_vector_tile.encode([]) if mapbox_vector_tile else b""
return Response(content=empty_tile, media_type="application/x-protobuf")
cache_key = f"{project_id}_{layer}"
from B04_wf1_Surface.B04_wf1_Surface_Engine_MvtHelper import generate_mvt_tile
mvt_bytes = generate_mvt_tile(filepath, cache_key, z, x, y, layer_name=layer)
return Response(
content=mvt_bytes,
media_type="application/x-protobuf",
headers={"Content-Encoding": "identity"},
)
except Exception:
import mapbox_vector_tile
empty_tile = mapbox_vector_tile.encode([]) if mapbox_vector_tile else b""
return Response(content=empty_tile, media_type="application/x-protobuf")
@@ -114,6 +114,10 @@ export async function renderB04Surface(root: HTMLElement): Promise<void> {
viewer.applyCameraState(state);
syncingCamera = false;
});
terrainViewer.onAxesVisibilityChange((visible) => {
viewer.setAxesVisible(visible);
});
viewer.setAxesVisible(false);
const confirmButton = createButton({
label: "모델 확정",
+86 -4
View File
@@ -286,15 +286,17 @@
}
.viewer-controls {
display: flex;
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: var(--spacing-8);
flex-wrap: wrap;
justify-content: flex-end;
width: 100%;
}
.viewer-controls button {
width: 100%;
min-width: 0;
min-height: 32px;
padding: 0 var(--spacing-12);
padding: 0 var(--spacing-4);
font-size: var(--text-caption);
border: 1px solid var(--color-border);
border-radius: var(--radius-buttons);
@@ -342,6 +344,86 @@
opacity: 0.55;
}
.model-display-options {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: var(--spacing-8);
width: 100%;
}
.model-display-options .toggle-button {
position: relative;
justify-content: center;
width: 100%;
min-width: 0;
min-height: 34px;
padding: 0 var(--spacing-12);
border: 1px solid var(--color-border);
border-radius: var(--radius-buttons);
background: var(--color-canvas);
color: var(--color-text-secondary);
cursor: pointer;
user-select: none;
}
.model-display-options .toggle-button input {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
pointer-events: none;
}
.model-display-options .toggle-button:has(input:checked) {
border-color: var(--color-accent);
background: var(--color-mist-violet);
color: var(--color-accent);
font-weight: var(--font-weight-semibold);
}
.model-display-options .toggle-button:has(input:focus-visible) {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
.model-display-options .toggle-button.is-disabled {
cursor: not-allowed;
}
.contour-interval-form {
display: grid;
grid-column: 1 / -1;
grid-template-columns: auto minmax(64px, 1fr) auto auto;
align-items: center;
gap: var(--spacing-8);
width: 100%;
}
.contour-interval-input,
.contour-interval-submit {
min-height: 34px;
border: 1px solid var(--color-border);
border-radius: var(--radius-buttons);
background: var(--color-canvas);
color: var(--color-text);
}
.contour-interval-input {
width: 100%;
min-width: 0;
padding: 0 var(--spacing-8);
}
.contour-interval-submit {
padding: 0 var(--spacing-12);
cursor: pointer;
}
.contour-interval-submit:disabled {
cursor: wait;
opacity: 0.6;
}
.viewer-option-val {
font-variant-numeric: tabular-nums;
min-width: 38px;
@@ -20,6 +20,7 @@ export interface SurfaceTerrainViewer {
setSelection: (sourceFilter: string, method: string) => void;
applyCameraState: (state: SurfaceCameraState) => void;
onCameraChange: (listener: (state: SurfaceCameraState) => void) => void;
onAxesVisibilityChange: (listener: (visible: boolean) => void) => void;
isSmoothingEnabled: () => boolean;
resetOptions: () => void;
dispose: () => void;
@@ -39,14 +40,14 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
const axesCheck = document.createElement("input");
axesCheck.type = "checkbox";
axesCheck.checked = true;
axesCheck.checked = false;
const rightControls = document.createElement("div");
rightControls.className = "viewer-options";
rightControls.className = "viewer-options model-display-options";
// Surface Toggle
const surfLabel = document.createElement("label");
surfLabel.className = "toggle-label";
surfLabel.className = "toggle-label toggle-button";
const surfCheck = document.createElement("input");
surfCheck.type = "checkbox";
surfCheck.checked = true;
@@ -54,7 +55,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
// Smooth Toggle (for tin/dtm)
const smoothLabel = document.createElement("label");
smoothLabel.className = "toggle-label";
smoothLabel.className = "toggle-label toggle-button";
const smoothCheck = document.createElement("input");
smoothCheck.type = "checkbox";
smoothCheck.checked = true;
@@ -62,7 +63,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
// Contour Toggle
const contourLabel = document.createElement("label");
contourLabel.className = "toggle-label";
contourLabel.className = "toggle-label toggle-button";
const contourCheck = document.createElement("input");
contourCheck.type = "checkbox";
contourCheck.checked = true;
@@ -70,24 +71,19 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
// Contour Interval input form
const intervalForm = document.createElement("form");
intervalForm.style.display = "flex";
intervalForm.style.alignItems = "center";
intervalForm.style.gap = "var(--spacing-4)";
intervalForm.className = "contour-interval-form";
const intervalInput = document.createElement("input");
intervalInput.type = "number";
intervalInput.value = "5.0";
intervalInput.value = "1.0";
intervalInput.step = "0.5";
intervalInput.min = "0.5";
intervalInput.style.width = "60px";
intervalInput.style.padding = "var(--spacing-4) var(--spacing-8)";
intervalInput.style.border = "1px solid var(--color-border)";
intervalInput.style.borderRadius = "var(--radius-normal)";
intervalInput.className = "contour-interval-input";
const intervalSubmit = document.createElement("button");
intervalSubmit.type = "submit";
intervalSubmit.textContent = "적용";
intervalSubmit.style.padding = "var(--spacing-4) var(--spacing-12)";
intervalSubmit.className = "contour-interval-submit";
intervalForm.append(
document.createTextNode("간격 "),
@@ -97,7 +93,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
);
const axesLabel = document.createElement("label");
axesLabel.className = "toggle-label";
axesLabel.className = "toggle-label toggle-button";
axesLabel.append(axesCheck, document.createTextNode(" 축"));
rightControls.append(axesLabel, surfLabel, smoothLabel, contourLabel, intervalForm);
@@ -174,8 +170,11 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
let currentModelsList: readonly SurfaceModelSummary[] = [];
let referenceBounds: SurfaceBounds | null = null;
let cameraListener: ((state: SurfaceCameraState) => void) | null = null;
let axesVisibilityListener: ((visible: boolean) => void) | null = null;
let suppressCameraEvent = false;
let smoothPreferred = true;
let currentModelId: number | null = null;
let currentModelSmooth = false;
const scene = new THREE.Scene();
scene.background = new THREE.Color(0xf5f7f9);
@@ -295,6 +294,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
clearMesh();
clearContours();
currentModelId = null;
scaleBar.hidden = true;
statusSpan.textContent = "모델 조회 중...";
@@ -318,6 +318,8 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
const modelId = match.id;
const isSmooth = (activeMethod === "tin" || activeMethod === "dtm") && smoothCheck.checked;
currentModelId = modelId;
currentModelSmooth = isSmooth;
statusSpan.textContent = "3D 메쉬 파일 다운로드 중...";
const previewUrl = `${API_BASE_URL}/projects/${currentProjectId}/surface/models/${modelId}/preview?smooth=${isSmooth}`;
@@ -339,7 +341,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
scene.add(points);
fitCamera(points);
statusSpan.textContent = `${activeFilter.toUpperCase()} · ${activeMethod.toUpperCase()} 표시 중`;
loadContourLines(modelId, isSmooth);
void loadContourLines(modelId, isSmooth);
},
undefined,
() => {
@@ -361,7 +363,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
scene.add(gltf.scene);
fitCamera(gltf.scene);
statusSpan.textContent = `${activeFilter.toUpperCase()} · ${activeMethod.toUpperCase()} 표시 중`;
loadContourLines(modelId, isSmooth);
void loadContourLines(modelId, isSmooth);
},
undefined,
() => {
@@ -374,8 +376,8 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
}
}
async function loadContourLines(modelId: number, isSmooth: boolean) {
const interval = parseFloat(intervalInput.value) || 5.0;
async function loadContourLines(modelId: number, isSmooth: boolean): Promise<boolean> {
const interval = parseFloat(intervalInput.value) || 1.0;
const contourUrl = `${API_BASE_URL}/projects/${currentProjectId}/surface/models/${modelId}/contour?interval=${interval}&smooth=${isSmooth}`;
try {
@@ -386,7 +388,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
clearContours();
const bounds = data.bounds;
if (!bounds) return;
if (!bounds) return false;
const cx = (bounds.x[0] + bounds.x[1]) / 2;
const cy = (bounds.y[0] + bounds.y[1]) / 2;
@@ -477,8 +479,10 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
} else {
legendBar.style.display = "none";
}
return true;
} catch (e) {
legendBar.style.display = "none";
return false;
}
}
@@ -529,6 +533,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
// Event Listeners
axesCheck.addEventListener("change", () => {
axes.visible = axesCheck.checked;
axesVisibilityListener?.(axesCheck.checked);
});
surfCheck.addEventListener("change", () => {
@@ -549,9 +554,17 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
});
});
intervalForm.addEventListener("submit", (e) => {
intervalForm.addEventListener("submit", async (e) => {
e.preventDefault();
updateSelectedModel();
const interval = Number(intervalInput.value);
if (!Number.isFinite(interval) || interval < 0.5 || currentModelId === null) return;
intervalSubmit.disabled = true;
statusSpan.textContent = `등고선 ${interval}m 계산 중...`;
const loaded = await loadContourLines(currentModelId, currentModelSmooth);
statusSpan.textContent = loaded
? `${activeFilter.toUpperCase()} · ${activeMethod.toUpperCase()} · 등고선 ${interval}m`
: "등고선 계산 또는 조회에 실패했습니다.";
intervalSubmit.disabled = false;
});
controls.addEventListener("change", emitCameraState);
@@ -590,18 +603,22 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
onCameraChange(listener) {
cameraListener = listener;
},
onAxesVisibilityChange(listener) {
axesVisibilityListener = listener;
},
isSmoothingEnabled() {
return !smoothCheck.disabled && smoothCheck.checked;
},
resetOptions() {
axesCheck.checked = true;
axes.visible = true;
axesCheck.checked = false;
axes.visible = false;
axesVisibilityListener?.(false);
surfCheck.checked = true;
smoothPreferred = true;
syncSmoothingSupport();
contourCheck.checked = true;
contourGroup.visible = true;
intervalInput.value = "5.0";
intervalInput.value = "1.0";
if (terrainMesh) terrainMesh.visible = true;
void updateSelectedModel();
},
+7 -14
View File
@@ -19,6 +19,7 @@ export interface SurfacePointCloudViewer {
optionsGroup: HTMLElement;
statusSpan: HTMLElement;
render: (data: SurfacePointCloudSampleResponse | null) => void;
setAxesVisible: (visible: boolean) => void;
applyCameraState: (state: SurfaceCameraState) => void;
onCameraChange: (listener: (state: SurfaceCameraState) => void) => void;
resetOptions: () => void;
@@ -50,14 +51,7 @@ export function createSurfacePointCloudViewer(): SurfacePointCloudViewer {
["front", makeButton("정면")],
["side", makeButton("측면")],
];
const resetViewButton = makeButton("리셋");
const axesLabel = document.createElement("label");
axesLabel.className = "toggle-label";
const axesCheck = document.createElement("input");
axesCheck.type = "checkbox";
axesCheck.checked = true;
axesLabel.append(axesCheck, document.createTextNode(" 축"));
controls.append(...viewButtons.map(([, button]) => button), resetViewButton, axesLabel);
controls.append(...viewButtons.map(([, button]) => button));
const controlsGroup = document.createElement("section");
controlsGroup.className = "b04-surface__group";
@@ -119,6 +113,7 @@ export function createSurfacePointCloudViewer(): SurfacePointCloudViewer {
orbit.dampingFactor = 0.08;
orbit.screenSpacePanning = true;
const axes = new THREE.AxesHelper(45);
axes.visible = false;
scene.add(axes);
let pointsObject: THREE.Points | null = null;
@@ -281,10 +276,6 @@ export function createSurfacePointCloudViewer(): SurfacePointCloudViewer {
viewButtons.forEach(([view, button]) => {
button.addEventListener("click", () => setCameraView(view));
});
resetViewButton.addEventListener("click", () => setCameraView("iso"));
axesCheck.addEventListener("change", () => {
axes.visible = axesCheck.checked;
});
sizeInput.addEventListener("input", () => {
if (pointsObject)
(pointsObject.material as THREE.PointsMaterial).size = Number(sizeInput.value);
@@ -316,9 +307,11 @@ export function createSurfacePointCloudViewer(): SurfacePointCloudViewer {
onCameraChange(listener) {
cameraListener = listener;
},
setAxesVisible(visible) {
axes.visible = visible;
},
resetOptions() {
axesCheck.checked = true;
axes.visible = true;
axes.visible = false;
sizeInput.value = "0.42";
densityInput.value = "10";
const label = densityLabel.querySelector(".viewer-option-val");