260715_0
This commit is contained in:
@@ -163,3 +163,34 @@ export async function fetchSurfaceStatus(projectId: string): Promise<SurfaceStat
|
||||
method: "GET",
|
||||
});
|
||||
}
|
||||
|
||||
export interface VWorldMeta {
|
||||
x_min: number;
|
||||
x_max: number;
|
||||
y_min: number;
|
||||
y_max: number;
|
||||
width_meters: number;
|
||||
height_meters: number;
|
||||
center_x: number;
|
||||
center_y: number;
|
||||
lon_min: number;
|
||||
lon_max: number;
|
||||
lat_min: number;
|
||||
lat_max: number;
|
||||
}
|
||||
|
||||
export function getVWorldMapUrl(projectId: string, layerName: string): string {
|
||||
return `${API_BASE_URL}/projects/${projectId}/vworld-map?layer_name=${layerName}`;
|
||||
}
|
||||
|
||||
export async function fetchVWorldMeta(projectId: string, layerName: string): Promise<VWorldMeta> {
|
||||
return requestJson<VWorldMeta>(`/projects/${projectId}/vworld-meta?layer_name=${layerName}`, {
|
||||
method: "GET",
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchGisGeoJson(projectId: string, layer: string): Promise<any> {
|
||||
return requestJson<any>(`/projects/${projectId}/geojson?layer=${encodeURIComponent(layer)}`, {
|
||||
method: "GET",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -84,6 +84,52 @@ def run_surface_analysis(
|
||||
config["precompute"] = list(methods)
|
||||
manifest = build_all_terrain_models(data, masks, models_dir, config, force=force)
|
||||
|
||||
# 3-2. VWorld 지도 및 국가 GIS 벡터 다운로드
|
||||
_report(90, "download_maps", "VWorld 지도 및 GIS 벡터 데이터 다운로드 중")
|
||||
try:
|
||||
# project_root 내의 .prj 파일 탐색 (B03_FileInput/input 또는 root 내 존재할 수 있음)
|
||||
prj_files = (
|
||||
list(project_root.glob("**/B03_FileInput/input/*.prj"))
|
||||
+ list(project_root.glob("*.prj"))
|
||||
+ list(project_root.glob("**/*.prj"))
|
||||
)
|
||||
prj_path = prj_files[0] if prj_files else project_root / "result.prj"
|
||||
|
||||
bounds_dict_for_download = {
|
||||
"x": [float(bounds[0, 0]), float(bounds[0, 1])],
|
||||
"y": [float(bounds[1, 0]), float(bounds[1, 1])],
|
||||
"z": [float(bounds[2, 0]), float(bounds[2, 1])],
|
||||
}
|
||||
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Engine_GisVector import download_all_gis_vectors
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Engine_VWorld import download_vworld_satellite_map
|
||||
|
||||
# VWorld 지도 및 GIS 데이터 저장 위치는 B04_wf1_Surface/processed에 보관.
|
||||
layers = [
|
||||
{"layer": "Satellite", "ext": "jpeg"},
|
||||
{"layer": "Hybrid", "ext": "png"},
|
||||
{"layer": "white", "ext": "png"},
|
||||
]
|
||||
for item in layers:
|
||||
try:
|
||||
download_vworld_satellite_map(
|
||||
prj_path,
|
||||
bounds_dict_for_download,
|
||||
processed_dir,
|
||||
layer_name=item["layer"],
|
||||
ext=item["ext"],
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
download_all_gis_vectors(prj_path, bounds_dict_for_download, processed_dir)
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
_report(95, "saving", "결과 저장 중")
|
||||
|
||||
processed = {
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
"""국가 GIS 벡터 데이터 다운로드 엔진."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
from pyproj import Transformer
|
||||
|
||||
# 설정 불러오기
|
||||
try:
|
||||
from config import config_system
|
||||
|
||||
VWORLD_API_KEY = getattr(
|
||||
config_system, "VWORLD_API_KEY", "3DBD7306-7DBD-38BB-B292-267C5ED7AC6B"
|
||||
)
|
||||
PROJECT_ROOT = getattr(config_system, "PROJECT_ROOT", Path(__file__).resolve().parent.parent)
|
||||
except ImportError:
|
||||
VWORLD_API_KEY = "3DBD7306-7DBD-38BB-B292-267C5ED7AC6B"
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Engine_VWorld import get_epsg_from_prj
|
||||
|
||||
|
||||
def request_vworld_wfs(typename: str, filename: str, bounds_wgs84: dict, output_dir: Path) -> None:
|
||||
"""브이월드 WFS API를 사용하여 지정된 Bounds 영역 내의
|
||||
공간 벡터 데이터를 GeoJSON으로 다운로드합니다.
|
||||
"""
|
||||
# 4326(위경도) bbox 파라미터 구성 (BBOX=minx,miny,maxx,maxy)
|
||||
bbox_str = (
|
||||
f"{bounds_wgs84['min_lon']},{bounds_wgs84['min_lat']},"
|
||||
f"{bounds_wgs84['max_lon']},{bounds_wgs84['max_lat']}"
|
||||
)
|
||||
|
||||
# WFS 파라미터 조립
|
||||
params = {
|
||||
"key": VWORLD_API_KEY,
|
||||
"domain": "localhost",
|
||||
"service": "WFS",
|
||||
"version": "1.1.0",
|
||||
"request": "GetFeature",
|
||||
"typename": typename,
|
||||
"bbox": bbox_str,
|
||||
"output": "application/json", # GeoJSON 포맷 요청
|
||||
"srsName": "EPSG:4326",
|
||||
}
|
||||
|
||||
url = "http://api.vworld.kr/req/wfs?" + urllib.parse.urlencode(params)
|
||||
headers = {"User-Agent": "Mozilla/5.0"}
|
||||
|
||||
req = urllib.request.Request(url, headers=headers)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=15) as res:
|
||||
content = res.read()
|
||||
|
||||
data = json.loads(content.decode("utf-8"))
|
||||
|
||||
output_path = output_dir / filename
|
||||
output_path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def download_all_gis_vectors(prj_path: Path, bounds_meter: dict, output_dir: Path) -> None:
|
||||
"""지형의 로컬 미터단위 bounds 정보를 위경도로 변환 후 5대 국가 GIS 데이터를 다운로드합니다."""
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 1. vworld_white_meta.json이 생성되어 있다면 직접 위경도 경계 획득하여 pyproj 축왜곡 방지
|
||||
meta_path = prj_path.parent / "vworld_white_meta.json"
|
||||
if not meta_path.exists():
|
||||
meta_path = prj_path.parent / "vworld_satellite_meta.json"
|
||||
|
||||
if meta_path.exists():
|
||||
try:
|
||||
with open(meta_path, "r", encoding="utf-8") as f:
|
||||
meta_data = json.load(f)
|
||||
bounds_wgs84 = {
|
||||
"min_lon": meta_data["lon_min"] - 0.010,
|
||||
"max_lon": meta_data["lon_max"] + 0.010,
|
||||
"min_lat": meta_data["lat_min"] - 0.010,
|
||||
"max_lat": meta_data["lat_max"] + 0.010,
|
||||
}
|
||||
except Exception:
|
||||
# 폴백용 pyproj 변환
|
||||
src_epsg = "EPSG:5186"
|
||||
if prj_path.exists():
|
||||
src_epsg = get_epsg_from_prj(prj_path.read_text(encoding="utf-8", errors="ignore"))
|
||||
transformer = Transformer.from_crs(src_epsg, "EPSG:4326", always_xy=True)
|
||||
x_min, x_max = bounds_meter["x"][0], bounds_meter["x"][1]
|
||||
y_min, y_max = bounds_meter["y"][0], bounds_meter["y"][1]
|
||||
lon_min, lat_min = transformer.transform(x_min, y_min)
|
||||
lon_max, lat_max = transformer.transform(x_max, y_max)
|
||||
bounds_wgs84 = {
|
||||
"min_lon": min(lon_min, lon_max) - 0.002,
|
||||
"max_lon": max(lon_min, lon_max) + 0.002,
|
||||
"min_lat": min(lat_min, lat_max) - 0.002,
|
||||
"max_lat": max(lat_min, lat_max) + 0.002,
|
||||
}
|
||||
else:
|
||||
# 폴백용 pyproj 변환
|
||||
src_epsg = "EPSG:5186"
|
||||
if prj_path.exists():
|
||||
src_epsg = get_epsg_from_prj(prj_path.read_text(encoding="utf-8", errors="ignore"))
|
||||
transformer = Transformer.from_crs(src_epsg, "EPSG:4326", always_xy=True)
|
||||
x_min, x_max = bounds_meter["x"][0], bounds_meter["x"][1]
|
||||
y_min, y_max = bounds_meter["y"][0], bounds_meter["y"][1]
|
||||
lon_min, lat_min = transformer.transform(x_min, y_min)
|
||||
lon_max, lat_max = transformer.transform(x_max, y_max)
|
||||
bounds_wgs84 = {
|
||||
"min_lon": min(lon_min, lon_max) - 0.010,
|
||||
"max_lon": max(lon_min, lon_max) + 0.010,
|
||||
"min_lat": min(lat_min, lat_max) - 0.010,
|
||||
"max_lat": max(lat_min, lat_max) + 0.010,
|
||||
}
|
||||
|
||||
# 5대 레이어 다운로드
|
||||
request_vworld_wfs(
|
||||
"lt_c_landinfobasemap", "연속지적도_bounds.geojson", bounds_wgs84, output_dir
|
||||
)
|
||||
request_vworld_wfs("lt_c_uq111", "용도지역도_bounds.geojson", bounds_wgs84, output_dir)
|
||||
request_vworld_wfs("lt_c_adsigg", "행정구역_시군구_bounds.geojson", bounds_wgs84, output_dir)
|
||||
request_vworld_wfs("lt_c_adri", "행정구역_읍면동_bounds.geojson", bounds_wgs84, output_dir)
|
||||
request_vworld_wfs("lt_c_gimshydro", "수계망_물줄기_bounds.geojson", bounds_wgs84, output_dir)
|
||||
request_vworld_wfs(
|
||||
"lt_c_kfdrssigugrade", "산사태위험등급_bounds.geojson", bounds_wgs84, output_dir
|
||||
)
|
||||
|
||||
# 6. gpkg 등고선(national_contours.gpkg) 데이터에서
|
||||
# 기준 영역 크롭하여 등고선_bounds.geojson 생성
|
||||
|
||||
gpkg_path = PROJECT_ROOT / "source" / "grobal_contours" / "national_contours.gpkg"
|
||||
if gpkg_path.exists():
|
||||
try:
|
||||
import geopandas as gpd
|
||||
from shapely.geometry import box
|
||||
|
||||
t_to_5179 = Transformer.from_crs("EPSG:4326", "EPSG:5179", always_xy=True)
|
||||
min_x_5179, min_y_5179 = t_to_5179.transform(
|
||||
bounds_wgs84["min_lon"], bounds_wgs84["min_lat"]
|
||||
)
|
||||
max_x_5179, max_y_5179 = t_to_5179.transform(
|
||||
bounds_wgs84["max_lon"], bounds_wgs84["max_lat"]
|
||||
)
|
||||
|
||||
bbox = box(min_x_5179, min_y_5179, max_x_5179, max_y_5179)
|
||||
|
||||
gdf = gpd.read_file(gpkg_path, bbox=bbox)
|
||||
if not gdf.empty:
|
||||
gdf_wgs84 = gdf.to_crs("EPSG:4326")
|
||||
geojson_out = output_dir / "등고선_bounds.geojson"
|
||||
gdf_wgs84.to_file(geojson_out, driver="GeoJSON")
|
||||
except Exception:
|
||||
pass
|
||||
@@ -0,0 +1,113 @@
|
||||
"""MVT 벡터 타일 생성 유틸리티."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
import geopandas as gpd
|
||||
from shapely.geometry import box
|
||||
except ImportError:
|
||||
gpd = None
|
||||
box = None
|
||||
|
||||
try:
|
||||
import mapbox_vector_tile
|
||||
except ImportError:
|
||||
mapbox_vector_tile = None
|
||||
|
||||
# 메모리 캐시: {project_id}_{layer_name}: GeoDataFrame
|
||||
_gdf_cache: dict[str, Any] = {}
|
||||
|
||||
|
||||
def num2deg(xtile: int, ytile: int, zoom: int) -> tuple[float, float]:
|
||||
"""Z/X/Y 타일 인덱스를 경도/위도(WGS84) 좌표로 변환합니다."""
|
||||
n = 1.0 << zoom
|
||||
lon_deg = xtile / n * 360.0 - 180.0
|
||||
lat_rad = math.atan(math.sinh(math.pi * (1 - 2 * ytile / n)))
|
||||
lat_deg = math.degrees(lat_rad)
|
||||
return lon_deg, lat_deg
|
||||
|
||||
|
||||
def get_tile_bbox(xtile: int, ytile: int, zoom: int) -> tuple[float, float, float, float]:
|
||||
"""Z/X/Y 타일의 경위도 Bounding Box (min_lon, min_lat, max_lon, max_lat)를 구합니다."""
|
||||
left, top = num2deg(xtile, ytile, zoom)
|
||||
right, bottom = num2deg(xtile + 1, ytile + 1, zoom)
|
||||
return min(left, right), min(top, bottom), max(left, right), max(top, bottom)
|
||||
|
||||
|
||||
def load_geojson_cached(filepath: Path, cache_key: str) -> Any:
|
||||
"""GeoJSON 파일을 한 번 읽으면 메모리에 GeoDataFrame으로 캐싱합니다."""
|
||||
global _gdf_cache
|
||||
if cache_key not in _gdf_cache:
|
||||
if not filepath.exists():
|
||||
raise FileNotFoundError(f"File not found: {filepath}")
|
||||
if gpd is None:
|
||||
raise ImportError("geopandas가 설치되어 있지 않습니다.")
|
||||
gdf = gpd.read_file(filepath)
|
||||
if gdf.crs is None:
|
||||
gdf.set_crs(epsg=4326, inplace=True)
|
||||
if gdf.crs.to_epsg() != 4326:
|
||||
gdf = gdf.to_crs(epsg=4326)
|
||||
_gdf_cache[cache_key] = gdf
|
||||
return _gdf_cache[cache_key]
|
||||
|
||||
|
||||
def generate_mvt_tile(
|
||||
filepath: Path, cache_key: str, z: int, x: int, y: int, layer_name: str = "contour"
|
||||
) -> bytes:
|
||||
"""주어진 GeoJSON 파일에서 Z/X/Y 영역에 해당하는 데이터를
|
||||
필터링하고 MVT 타일 바이너리로 인코딩합니다.
|
||||
"""
|
||||
if mapbox_vector_tile is None:
|
||||
# mapbox_vector_tile 패키지가 없을 경우 빈 바이트 리턴하여 다운 방지
|
||||
return b""
|
||||
|
||||
gdf = load_geojson_cached(filepath, cache_key)
|
||||
|
||||
# 1. 타일 경계 구하기
|
||||
min_lon, min_lat, max_lon, max_lat = get_tile_bbox(x, y, z)
|
||||
tile_geom = box(min_lon, min_lat, max_lon, max_lat)
|
||||
|
||||
# 2. 해당 영역 내에 공간적으로 접하는(Intersects) 데이터 신속 조회
|
||||
possible_matches_index = list(gdf.sindex.intersection((min_lon, min_lat, max_lon, max_lat)))
|
||||
possible_matches = gdf.iloc[possible_matches_index]
|
||||
|
||||
# 정확하게 겹치는 피처만 정밀 필터링 및 타일 경계로 클리핑(자르기)
|
||||
clipped_gdf = possible_matches.clip(tile_geom)
|
||||
|
||||
if clipped_gdf.empty:
|
||||
return mapbox_vector_tile.encode([])
|
||||
|
||||
# 3. GeoJSON 스타일 피처 목록 생성
|
||||
features = []
|
||||
extent = 4096
|
||||
|
||||
for idx, row in clipped_gdf.iterrows():
|
||||
geom = row.geometry
|
||||
if geom.is_empty:
|
||||
continue
|
||||
|
||||
properties = {}
|
||||
for col in clipped_gdf.columns:
|
||||
if col != "geometry":
|
||||
val = row[col]
|
||||
if isinstance(val, (int, float, str, bool)):
|
||||
properties[col] = val
|
||||
else:
|
||||
properties[col] = str(val) if val is not None else ""
|
||||
|
||||
features.append({"geometry": geom, "properties": properties})
|
||||
|
||||
tile_data = [{"name": layer_name, "features": features}]
|
||||
|
||||
mvt_bytes = mapbox_vector_tile.encode(
|
||||
tile_data,
|
||||
quantize_bounds=(min_lon, min_lat, max_lon, max_lat),
|
||||
y_coord_down=True,
|
||||
extents=extent,
|
||||
)
|
||||
|
||||
return mvt_bytes
|
||||
@@ -0,0 +1,199 @@
|
||||
"""VWorld 위성 맵 및 타일 다운로드 엔진."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image
|
||||
from pyproj import Transformer
|
||||
|
||||
# 설정 불러오기
|
||||
try:
|
||||
from config import config_system
|
||||
|
||||
VWORLD_API_KEY = getattr(
|
||||
config_system, "VWORLD_API_KEY", "3DBD7306-7DBD-38BB-B292-267C5ED7AC6B"
|
||||
)
|
||||
except ImportError:
|
||||
VWORLD_API_KEY = "3DBD7306-7DBD-38BB-B292-267C5ED7AC6B"
|
||||
|
||||
|
||||
def get_epsg_from_prj(prj_content: str) -> str:
|
||||
"""PRJ 파일 텍스트 내용을 해석하여 적합한 EPSG 좌표계 코드를 추정합니다.
|
||||
|
||||
기본 한반도 주요 좌표계 매핑:
|
||||
- Korea Central Belt (중부원점 20만, 60만) -> EPSG:5186 (Korea 2000 Central)
|
||||
- Korea East Belt (동부원점) -> EPSG:5187
|
||||
- Korea West Belt (서부원점) -> EPSG:5185
|
||||
- WGS84 UTM 52N -> EPSG:32652
|
||||
"""
|
||||
prj_upper = prj_content.upper()
|
||||
if "EAST" in prj_upper:
|
||||
return "EPSG:5187"
|
||||
elif "WEST" in prj_upper:
|
||||
return "EPSG:5185"
|
||||
elif "CENTRAL" in prj_upper:
|
||||
return "EPSG:5186"
|
||||
elif "UTM" in prj_upper and "52N" in prj_upper:
|
||||
return "EPSG:32652"
|
||||
|
||||
# 디폴트는 한국 중부 2000 좌표계 적용
|
||||
return "EPSG:5186"
|
||||
|
||||
|
||||
def latlon_to_tile(lat: float, lon: float, zoom: int) -> tuple[int, int]:
|
||||
"""위도, 경도를 OSM/VWorld 표준 지도 타일 좌표 (X, Y)로 변환합니다."""
|
||||
lat_rad = math.radians(lat)
|
||||
n = 2.0**zoom
|
||||
xtile = int((lon + 180.0) / 360.0 * n)
|
||||
ytile = int((1.0 - math.log(math.tan(lat_rad) + (1 / math.cos(lat_rad))) / math.pi) / 2.0 * n)
|
||||
return xtile, ytile
|
||||
|
||||
|
||||
def tile_to_latlon(xtile: int, ytile: int, zoom: int) -> tuple[float, float]:
|
||||
"""OSM/VWorld 표준 타일 좌표 (X, Y)를 위도, 경도 좌표로 역변환합니다."""
|
||||
n = 2.0**zoom
|
||||
lon_deg = xtile / n * 360.0 - 180.0
|
||||
lat_rad = math.atan(math.sinh(math.pi * (1 - 2 * ytile / n)))
|
||||
lat_deg = math.degrees(lat_rad)
|
||||
return lat_deg, lon_deg
|
||||
|
||||
|
||||
def download_vworld_satellite_map(
|
||||
prj_path: Path, bounds: dict, output_dir: Path, layer_name: str = "Satellite", ext: str = "jpeg"
|
||||
) -> Path | None:
|
||||
"""PRJ 좌표 정보와 지형 범위(bounds)를 바탕으로,
|
||||
해당 임도 산림 지역을 감싸는 VWorld 지도 타일을 다운로드하여 하나의 PNG 텍스처로 병합합니다.
|
||||
"""
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
file_name = f"vworld_{layer_name.lower()}.png"
|
||||
map_png_path = output_dir / file_name
|
||||
meta_json_path = output_dir / f"vworld_{layer_name.lower()}_meta.json"
|
||||
|
||||
# 1. PRJ 내용 분석
|
||||
if not prj_path.exists():
|
||||
return None
|
||||
prj_content = prj_path.read_text(encoding="utf-8", errors="ignore")
|
||||
src_epsg = get_epsg_from_prj(prj_content)
|
||||
|
||||
# 2. 중심점 및 경계 좌표 위경도 변환
|
||||
# bounds: {'x': [x_min, x_max], 'y': [y_min, y_max], 'z': [z_min, z_max]}
|
||||
x_min, x_max = bounds["x"][0], bounds["x"][1]
|
||||
y_min, y_max = bounds["y"][0], bounds["y"][1]
|
||||
|
||||
# 좌표 변환기 생성 (지정 좌표계 -> 경위도 EPSG:4326)
|
||||
try:
|
||||
transformer = Transformer.from_crs(src_epsg, "EPSG:4326", always_xy=True)
|
||||
except Exception:
|
||||
# 실패 시 강제로 Korea Central -> WGS84 생성
|
||||
transformer = Transformer.from_crs("EPSG:5186", "EPSG:4326", always_xy=True)
|
||||
|
||||
lon_min, lat_min = transformer.transform(x_min, y_min)
|
||||
lon_max, lat_max = transformer.transform(x_max, y_max)
|
||||
|
||||
# 3. 지도 타일 크기 결정 (ZOOM 18 초고해상도 적용)
|
||||
zoom = 18
|
||||
|
||||
# 영역을 포괄하는 좌상단 타일, 우하단 타일 인덱스 산출
|
||||
x1, y1 = latlon_to_tile(lat_max, lon_min, zoom)
|
||||
x2, y2 = latlon_to_tile(lat_min, lon_max, zoom)
|
||||
|
||||
# 타일 경계 마진 패딩
|
||||
x_start = min(x1, x2) - 1
|
||||
x_end = max(x1, x2) + 1
|
||||
y_start = min(y1, y2) - 1
|
||||
y_end = max(y1, y2) + 1
|
||||
|
||||
tile_w = x_end - x_start + 1
|
||||
tile_h = y_end - y_start + 1
|
||||
|
||||
# 너무 많은 타일을 다운로드하여 IP 차단되는 것을 방지
|
||||
if tile_w > 15:
|
||||
tile_w = 15
|
||||
x_end = x_start + 14
|
||||
if tile_h > 15:
|
||||
tile_h = 15
|
||||
y_end = y_start + 14
|
||||
|
||||
# 4. 개별 타일 다운로드 및 이미지 병합
|
||||
map_img = Image.new("RGBA", (tile_w * 256, tile_h * 256))
|
||||
headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)"}
|
||||
|
||||
for ty in range(y_start, y_end + 1):
|
||||
for tx in range(x_start, x_end + 1):
|
||||
try:
|
||||
normalized_layer = layer_name.lower()
|
||||
if normalized_layer == "satellite":
|
||||
sat_url = f"http://api.vworld.kr/req/wmts/1.0.0/{VWORLD_API_KEY}/Satellite/{zoom}/{ty}/{tx}.jpeg"
|
||||
sat_req = urllib.request.Request(sat_url, headers=headers)
|
||||
with urllib.request.urlopen(sat_req, timeout=5) as sat_res:
|
||||
sat_data = sat_res.read()
|
||||
base_img = Image.open(BytesIO(sat_data)).convert("RGBA")
|
||||
elif normalized_layer == "hybrid":
|
||||
hy_url = f"http://api.vworld.kr/req/wmts/1.0.0/{VWORLD_API_KEY}/Hybrid/{zoom}/{ty}/{tx}.png"
|
||||
hy_req = urllib.request.Request(hy_url, headers=headers)
|
||||
with urllib.request.urlopen(hy_req, timeout=5) as hy_res:
|
||||
hy_data = hy_res.read()
|
||||
base_img = Image.open(BytesIO(hy_data)).convert("RGBA")
|
||||
elif normalized_layer == "white":
|
||||
white_url = f"http://api.vworld.kr/req/wmts/1.0.0/{VWORLD_API_KEY}/white/{zoom}/{ty}/{tx}.png"
|
||||
white_req = urllib.request.Request(white_url, headers=headers)
|
||||
with urllib.request.urlopen(white_req, timeout=5) as white_res:
|
||||
white_data = white_res.read()
|
||||
base_img = Image.open(BytesIO(white_data)).convert("RGBA")
|
||||
else:
|
||||
layer_url = f"http://api.vworld.kr/req/wmts/1.0.0/{VWORLD_API_KEY}/{layer_name}/{zoom}/{ty}/{tx}.{ext}"
|
||||
layer_req = urllib.request.Request(layer_url, headers=headers)
|
||||
with urllib.request.urlopen(layer_req, timeout=5) as layer_res:
|
||||
layer_data = layer_res.read()
|
||||
base_img = Image.open(BytesIO(layer_data)).convert("RGBA")
|
||||
|
||||
# 병합 좌표 계산
|
||||
px = (tx - x_start) * 256
|
||||
py = (ty - y_start) * 256
|
||||
map_img.paste(base_img, (px, py))
|
||||
except Exception:
|
||||
# 다운로드 실패 시 회색 격자 텍스처로 대체
|
||||
fallback_tile = Image.new("RGBA", (256, 256), (200, 200, 200, 255))
|
||||
px = (tx - x_start) * 256
|
||||
py = (ty - y_start) * 256
|
||||
map_img.paste(fallback_tile, (px, py))
|
||||
|
||||
# 5. 병합된 이미지의 실제 위경도 바운더리 계산
|
||||
lat_top, lon_left = tile_to_latlon(x_start, y_start, zoom)
|
||||
lat_bottom, lon_right = tile_to_latlon(x_end + 1, y_end + 1, zoom)
|
||||
|
||||
# 위경도를 다시 원본 Local 3D 미터 좌표계로 변환
|
||||
try:
|
||||
rev_transformer = Transformer.from_crs("EPSG:4326", src_epsg, always_xy=True)
|
||||
except Exception:
|
||||
rev_transformer = Transformer.from_crs("EPSG:4326", "EPSG:5186", always_xy=True)
|
||||
|
||||
img_x_min, img_y_max = rev_transformer.transform(lon_left, lat_top)
|
||||
img_x_max, img_y_min = rev_transformer.transform(lon_right, lat_bottom)
|
||||
|
||||
# 6. 이미지 저장 및 메타데이터 작성
|
||||
map_img.save(map_png_path, "PNG")
|
||||
|
||||
meta = {
|
||||
"x_min": img_x_min,
|
||||
"x_max": img_x_max,
|
||||
"y_min": img_y_min,
|
||||
"y_max": img_y_max,
|
||||
"width_meters": img_x_max - img_x_min,
|
||||
"height_meters": img_y_max - img_y_min,
|
||||
"center_x": (img_x_min + img_x_max) / 2.0,
|
||||
"center_y": (img_y_min + img_y_max) / 2.0,
|
||||
"lon_min": lon_left,
|
||||
"lon_max": lon_right,
|
||||
"lat_min": lat_bottom,
|
||||
"lat_max": lat_top,
|
||||
}
|
||||
meta_json_path.write_text(json.dumps(meta, indent=2), encoding="utf-8")
|
||||
|
||||
return map_png_path
|
||||
@@ -9,7 +9,7 @@ from uuid import UUID
|
||||
|
||||
import aiomysql
|
||||
import numpy as np
|
||||
from fastapi import APIRouter
|
||||
from fastapi import APIRouter, HTTPException, Response
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
|
||||
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
|
||||
@@ -637,3 +637,202 @@ async def get_surface_model_contour(
|
||||
status_code=500,
|
||||
content={"status": "error", "message": "등고선 파일 조회 중 오류가 발생했습니다."},
|
||||
)
|
||||
|
||||
|
||||
# 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 정의 (prefix 없음)
|
||||
tiles_router = APIRouter(tags=["B04 MVT Tiles"])
|
||||
|
||||
|
||||
@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")
|
||||
|
||||
@@ -11,8 +11,9 @@
|
||||
* 텍스트는 ui_template_locale에 선(先) 등록 후 참조 (frontend.md §3).
|
||||
* ========================================================================== */
|
||||
|
||||
import { CURRENT_PROJECT_ID_KEY } from "@config/config_frontend";
|
||||
import { CURRENT_PROJECT_ID_KEY, ROUTES, type RoutePath } from "@config/config_frontend";
|
||||
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
|
||||
import { navigateTo } from "../A00_Common/router";
|
||||
import {
|
||||
createButton,
|
||||
createTag,
|
||||
@@ -32,7 +33,7 @@ import {
|
||||
type SurfaceModelSummary,
|
||||
type SurfaceStatusResponse,
|
||||
} from "./B04_wf1_Surface_Api_Fetch";
|
||||
import { createWorkflowLayout } from "@ui/ui_template_workflow_layout";
|
||||
import { createWorkflowLayout, type WorkflowStage } from "@ui/ui_template_workflow_layout";
|
||||
import { createSurfacePointCloudViewer } from "./B04_wf1_Surface_UI_Viewer";
|
||||
import { createSurfaceTerrainViewer } from "./B04_wf1_Surface_UI_TerrainViewer";
|
||||
import "./B04_wf1_Surface_UI_Style.css";
|
||||
@@ -91,7 +92,7 @@ function buildInfoLine(label: string, value: unknown): HTMLElement {
|
||||
return row;
|
||||
}
|
||||
|
||||
export function renderB04Surface(root: HTMLElement): void {
|
||||
export async function renderB04Surface(root: HTMLElement): Promise<void> {
|
||||
let selectedInputFile: SurfaceInputFileSummary | null = null;
|
||||
let inputFiles: SurfaceInputFileSummary[] = [];
|
||||
|
||||
@@ -190,12 +191,44 @@ export function renderB04Surface(root: HTMLElement): void {
|
||||
bottom.append(statsSection, modelsSection);
|
||||
workspace.append(topbar, viewer.root, terrainViewer.root, bottom);
|
||||
|
||||
const workflowRoutes = [
|
||||
ROUTES.B03_FILE_INPUT,
|
||||
ROUTES.B04_WF1_SURFACE,
|
||||
ROUTES.B05_WF2_ROUTE,
|
||||
ROUTES.B06_WF3_PROFILE_CROSS,
|
||||
ROUTES.B07_WF4_DESIGN_DETAIL,
|
||||
ROUTES.B08_WF5_QUANTITY,
|
||||
ROUTES.B09_WF6_ESTIMATION,
|
||||
];
|
||||
|
||||
let workflowStages: WorkflowStage[] = [];
|
||||
const layoutProjectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
|
||||
if (layoutProjectId) {
|
||||
try {
|
||||
const res = await fetch(`/api/projects/${layoutProjectId}/workflow-state`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
workflowStages = data.workflow_state?.stages ?? data.stages ?? [];
|
||||
}
|
||||
} catch {
|
||||
/* 실패 시 빈 배열 → activeStep 기준 폴백으로 이동 허용 */
|
||||
}
|
||||
}
|
||||
|
||||
const layout = createWorkflowLayout({
|
||||
title: L("B04_Surface_Title"),
|
||||
steps: workflowSteps(),
|
||||
activeStep: 1,
|
||||
leftPanel: panel,
|
||||
mainContent: workspace,
|
||||
stages: workflowStages,
|
||||
routes: workflowRoutes,
|
||||
onStepClick: (_stepIndex, route) => {
|
||||
if (layoutProjectId) {
|
||||
localStorage.setItem(CURRENT_PROJECT_ID_KEY, layoutProjectId);
|
||||
navigateTo(route as RoutePath);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
function getProjectId(): string | null {
|
||||
|
||||
@@ -3,11 +3,13 @@ import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
|
||||
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
|
||||
import { PLYLoader } from "three/examples/jsm/loaders/PLYLoader.js";
|
||||
import type { SurfaceModelSummary } from "./B04_wf1_Surface_Api_Fetch";
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import { fetchVWorldMeta, getVWorldMapUrl, fetchGisGeoJson } from "./B04_wf1_Surface_Api_Fetch";
|
||||
|
||||
export interface SurfaceTerrainViewer {
|
||||
root: HTMLElement;
|
||||
render: (projectId: string, models: readonly SurfaceModelSummary[]) => void;
|
||||
updateBgMap: (projectId: string, bgLayer: string) => void;
|
||||
updateGisLayer: (projectId: string, gisLayer: string) => void;
|
||||
dispose: () => void;
|
||||
}
|
||||
|
||||
@@ -263,6 +265,46 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
|
||||
scaleBar.append(scaleLabel);
|
||||
viewerArea.append(scaleBar);
|
||||
|
||||
// Elevation bounds legend bar overlay (I-403)
|
||||
const legendBar = document.createElement("div");
|
||||
legendBar.style.position = "absolute";
|
||||
legendBar.style.top = "16px";
|
||||
legendBar.style.right = "16px";
|
||||
legendBar.style.background = "rgba(255, 255, 255, 0.9)";
|
||||
legendBar.style.border = "1px solid #cbd5e1";
|
||||
legendBar.style.borderRadius = "6px";
|
||||
legendBar.style.padding = "8px";
|
||||
legendBar.style.width = "50px";
|
||||
legendBar.style.display = "none"; // hidden until contours are loaded
|
||||
legendBar.style.flexDirection = "column";
|
||||
legendBar.style.alignItems = "center";
|
||||
legendBar.style.zIndex = "10";
|
||||
legendBar.style.boxShadow = "0 2px 6px rgba(0,0,0,0.08)";
|
||||
legendBar.style.pointerEvents = "none";
|
||||
|
||||
const maxValSpan = document.createElement("span");
|
||||
maxValSpan.style.fontSize = "10px";
|
||||
maxValSpan.style.fontWeight = "bold";
|
||||
maxValSpan.style.color = "#b91c1c";
|
||||
maxValSpan.style.marginBottom = "4px";
|
||||
|
||||
const gradientDiv = document.createElement("div");
|
||||
gradientDiv.style.width = "12px";
|
||||
gradientDiv.style.height = "120px";
|
||||
gradientDiv.style.background =
|
||||
"linear-gradient(to bottom, #d60000 0%, #ff5100 25%, #e6a100 50%, #228b22 75%, #3a85ff 100%)";
|
||||
gradientDiv.style.borderRadius = "2px";
|
||||
gradientDiv.style.border = "1px solid #94a3b8";
|
||||
|
||||
const minValSpan = document.createElement("span");
|
||||
minValSpan.style.fontSize = "10px";
|
||||
minValSpan.style.fontWeight = "bold";
|
||||
minValSpan.style.color = "#1d4ed8";
|
||||
minValSpan.style.marginTop = "4px";
|
||||
|
||||
legendBar.append(maxValSpan, gradientDiv, minValSpan);
|
||||
viewerArea.append(legendBar);
|
||||
|
||||
// Three.js context variables
|
||||
let currentProjectId = "";
|
||||
let currentModelsList: readonly SurfaceModelSummary[] = [];
|
||||
@@ -324,6 +366,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
|
||||
}
|
||||
labelElements.forEach((el) => el.remove());
|
||||
labelElements.length = 0;
|
||||
legendBar.style.display = "none";
|
||||
}
|
||||
|
||||
const getFitParams = (object: THREE.Object3D) => {
|
||||
@@ -376,8 +419,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
|
||||
const match = currentModelsList.find((m) => {
|
||||
const typeMatches = m.model_type.toLowerCase() === activeMethod.toLowerCase();
|
||||
const pathContainsFilter =
|
||||
m.model_file_path &&
|
||||
m.model_file_path.toLowerCase().includes(activeFilter.toLowerCase());
|
||||
m.model_file_path && m.model_file_path.toLowerCase().includes(activeFilter.toLowerCase());
|
||||
return typeMatches && pathContainsFilter;
|
||||
});
|
||||
|
||||
@@ -471,7 +513,13 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
|
||||
});
|
||||
};
|
||||
|
||||
let minH = Infinity;
|
||||
let maxH = -Infinity;
|
||||
|
||||
data.contours.forEach((c: any) => {
|
||||
if (c.level < minH) minH = c.level;
|
||||
if (c.level > maxH) maxH = c.level;
|
||||
|
||||
const points = transform(c.coordinates);
|
||||
if (points.length < 2) return;
|
||||
|
||||
@@ -531,8 +579,18 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
|
||||
labelElements.push(labelDiv);
|
||||
}
|
||||
});
|
||||
|
||||
if (minH !== Infinity && maxH !== -Infinity) {
|
||||
const nearestMin10 = Math.round(minH / 10) * 10;
|
||||
const nearestMax10 = Math.round(maxH / 10) * 10;
|
||||
maxValSpan.textContent = `${nearestMax10}m`;
|
||||
minValSpan.textContent = `${nearestMin10}m`;
|
||||
legendBar.style.display = "flex";
|
||||
} else {
|
||||
legendBar.style.display = "none";
|
||||
}
|
||||
} catch (e) {
|
||||
// Contour loading failed or silent fail
|
||||
legendBar.style.display = "none";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -652,6 +710,12 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
|
||||
currentModelsList = models;
|
||||
updateSelectedModel();
|
||||
},
|
||||
updateBgMap(projectId, bgLayer) {
|
||||
// 포인트클라우드 뷰어와 인터페이스 조화를 위해 빈 껍데기 함수 정의
|
||||
},
|
||||
updateGisLayer(projectId, gisLayer) {
|
||||
// 포인트클라우드 뷰어와 인터페이스 조화를 위해 빈 껍데기 함수 정의
|
||||
},
|
||||
dispose() {
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
resizeObserver.disconnect();
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import * as THREE from "three";
|
||||
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
|
||||
import { RENDER_OPTIONS } from "@config/config_frontend";
|
||||
import type { SurfacePointCloudSampleResponse } from "./B04_wf1_Surface_Api_Fetch";
|
||||
import {
|
||||
fetchVWorldMeta,
|
||||
getVWorldMapUrl,
|
||||
fetchGisGeoJson,
|
||||
type SurfacePointCloudSampleResponse,
|
||||
} from "./B04_wf1_Surface_Api_Fetch";
|
||||
|
||||
export interface SurfacePointCloudViewer {
|
||||
root: HTMLElement;
|
||||
@@ -9,6 +14,8 @@ export interface SurfacePointCloudViewer {
|
||||
optionsGroup: HTMLElement;
|
||||
statusSpan: HTMLElement;
|
||||
render: (data: SurfacePointCloudSampleResponse | null) => void;
|
||||
updateBgMap: (projectId: string, bgLayer: string) => void;
|
||||
updateGisLayer: (projectId: string, gisLayer: string) => void;
|
||||
dispose: () => void;
|
||||
}
|
||||
|
||||
@@ -85,7 +92,53 @@ export function createSurfacePointCloudViewer(): SurfacePointCloudViewer {
|
||||
densityInput.value = "10";
|
||||
densityLabel.append(densityInput);
|
||||
|
||||
optionsDiv.append(sizeLabel, densityLabel);
|
||||
const bgLabel = document.createElement("label");
|
||||
bgLabel.textContent = "배경 지도 ";
|
||||
bgLabel.style.display = "flex";
|
||||
bgLabel.style.flexDirection = "column";
|
||||
bgLabel.style.gap = "var(--spacing-4)";
|
||||
bgLabel.style.marginTop = "var(--spacing-8)";
|
||||
const bgSelect = document.createElement("select");
|
||||
bgSelect.className = "b04-surface__select";
|
||||
const bgOptions = [
|
||||
{ value: "none", label: "없음" },
|
||||
{ value: "satellite", label: "위성사진" },
|
||||
{ value: "hybrid", label: "하이브리드 지도" },
|
||||
{ value: "white", label: "백지도" },
|
||||
];
|
||||
bgOptions.forEach((opt) => {
|
||||
const o = document.createElement("option");
|
||||
o.value = opt.value;
|
||||
o.textContent = opt.label;
|
||||
bgSelect.append(o);
|
||||
});
|
||||
bgLabel.append(bgSelect);
|
||||
|
||||
const gisLabel = document.createElement("label");
|
||||
gisLabel.textContent = "국가 GIS 레이어 ";
|
||||
gisLabel.style.display = "flex";
|
||||
gisLabel.style.flexDirection = "column";
|
||||
gisLabel.style.gap = "var(--spacing-4)";
|
||||
gisLabel.style.marginTop = "var(--spacing-8)";
|
||||
const gisSelect = document.createElement("select");
|
||||
gisSelect.className = "b04-surface__select";
|
||||
const gisOptions = [
|
||||
{ value: "none", label: "없음" },
|
||||
{ value: "지적도", label: "연속지적도" },
|
||||
{ value: "수계망", label: "수계망" },
|
||||
{ value: "산사태", label: "산사태위험등급" },
|
||||
{ value: "행정구역_시군구", label: "시군구 경계" },
|
||||
{ value: "행정구역_읍면동", label: "읍면동 경계" },
|
||||
];
|
||||
gisOptions.forEach((opt) => {
|
||||
const o = document.createElement("option");
|
||||
o.value = opt.value;
|
||||
o.textContent = opt.label;
|
||||
gisSelect.append(o);
|
||||
});
|
||||
gisLabel.append(gisSelect);
|
||||
|
||||
optionsDiv.append(sizeLabel, densityLabel, bgLabel, gisLabel);
|
||||
|
||||
const optionsGroup = document.createElement("section");
|
||||
optionsGroup.className = "b04-surface__group";
|
||||
@@ -151,6 +204,8 @@ export function createSurfacePointCloudViewer(): SurfacePointCloudViewer {
|
||||
scene.add(axes);
|
||||
|
||||
let pointsObject: THREE.Points | null = null;
|
||||
let bgPlane: THREE.Mesh | null = null;
|
||||
let gisObjects: THREE.Object3D[] = [];
|
||||
let animationFrame = 0;
|
||||
let currentData: SurfacePointCloudSampleResponse | null = null;
|
||||
|
||||
@@ -343,15 +398,204 @@ export function createSurfacePointCloudViewer(): SurfacePointCloudViewer {
|
||||
statusSpan.textContent = ` ${renderPoints.length.toLocaleString()}개 점 표시 중 [높이범위: ~${nearestMin10}m ... ${nearestMax10}m~]`;
|
||||
}
|
||||
|
||||
function clearBgMap(): void {
|
||||
if (bgPlane) {
|
||||
bgPlane.geometry.dispose();
|
||||
if (Array.isArray(bgPlane.material)) {
|
||||
bgPlane.material.forEach((mat) => mat.dispose());
|
||||
} else {
|
||||
bgPlane.material.dispose();
|
||||
}
|
||||
scene.remove(bgPlane);
|
||||
bgPlane = null;
|
||||
}
|
||||
}
|
||||
|
||||
function clearGisLayers(): void {
|
||||
gisObjects.forEach((obj) => {
|
||||
if (obj instanceof THREE.Line) {
|
||||
obj.geometry.dispose();
|
||||
if (Array.isArray(obj.material)) {
|
||||
obj.material.forEach((mat) => mat.dispose());
|
||||
} else {
|
||||
obj.material.dispose();
|
||||
}
|
||||
}
|
||||
scene.remove(obj);
|
||||
});
|
||||
gisObjects = [];
|
||||
}
|
||||
|
||||
function updateBgMap(projectId: string, bgLayer: string): void {
|
||||
clearBgMap();
|
||||
if (bgLayer === "none" || !currentData) return;
|
||||
|
||||
const bounds = currentData.bounds;
|
||||
const xMin = bounds.x_min ?? 0;
|
||||
const xMax = bounds.x_max ?? 0;
|
||||
const yMin = bounds.y_min ?? 0;
|
||||
const yMax = bounds.y_max ?? 0;
|
||||
const zMin = bounds.z_min ?? 0;
|
||||
const zMax = bounds.z_max ?? 0;
|
||||
|
||||
const xMid = (xMin + xMax) / 2;
|
||||
const yMid = (yMin + yMax) / 2;
|
||||
const zMid = (zMin + zMax) / 2;
|
||||
|
||||
const xSpan = Math.max(xMax - xMin, 1e-9);
|
||||
const ySpan = Math.max(yMax - yMin, 1e-9);
|
||||
const zSpan = Math.max(zMax - zMin, 1e-9);
|
||||
const scale = 180 / Math.max(xSpan, ySpan, zSpan);
|
||||
|
||||
const targetLayer = bgLayer === "gray" ? "white" : bgLayer;
|
||||
|
||||
fetchVWorldMeta(projectId, targetLayer)
|
||||
.then((meta) => {
|
||||
const planeW = meta.width_meters * scale;
|
||||
const planeH = meta.height_meters * scale;
|
||||
const planeGeo = new THREE.PlaneGeometry(planeW, planeH);
|
||||
|
||||
const textureLoader = new THREE.TextureLoader();
|
||||
textureLoader.load(getVWorldMapUrl(projectId, targetLayer), (texture) => {
|
||||
const planeMat = new THREE.MeshBasicMaterial({
|
||||
map: texture,
|
||||
side: THREE.DoubleSide,
|
||||
transparent: true,
|
||||
opacity: 0.85,
|
||||
});
|
||||
bgPlane = new THREE.Mesh(planeGeo, planeMat);
|
||||
bgPlane.rotation.x = -Math.PI / 2;
|
||||
|
||||
const planeCenterX = (meta.center_x - xMid) * scale;
|
||||
const planeCenterY = -(meta.center_y - yMid) * scale;
|
||||
const planeCenterZ = (zMin - zMid) * scale - 0.5;
|
||||
|
||||
bgPlane.position.set(planeCenterX, planeCenterZ, planeCenterY);
|
||||
scene.add(bgPlane);
|
||||
});
|
||||
})
|
||||
.catch((e) => console.log("VWorld 배경 로드 실패:", e.message));
|
||||
}
|
||||
|
||||
function updateGisLayer(projectId: string, gisLayer: string): void {
|
||||
clearGisLayers();
|
||||
if (gisLayer === "none" || !currentData) return;
|
||||
|
||||
const bounds = currentData.bounds;
|
||||
const xMin = bounds.x_min ?? 0;
|
||||
const xMax = bounds.x_max ?? 0;
|
||||
const yMin = bounds.y_min ?? 0;
|
||||
const yMax = bounds.y_max ?? 0;
|
||||
const zMin = bounds.z_min ?? 0;
|
||||
const zMax = bounds.z_max ?? 0;
|
||||
|
||||
const xMid = (xMin + xMax) / 2;
|
||||
const yMid = (yMin + yMax) / 2;
|
||||
const zMid = (zMin + zMax) / 2;
|
||||
|
||||
const xSpan = Math.max(xMax - xMin, 1e-9);
|
||||
const ySpan = Math.max(yMax - yMin, 1e-9);
|
||||
const zSpan = Math.max(zMax - zMin, 1e-9);
|
||||
const scale = 180 / Math.max(xSpan, ySpan, zSpan);
|
||||
|
||||
Promise.all([fetchGisGeoJson(projectId, gisLayer), fetchVWorldMeta(projectId, "white")])
|
||||
.then(([geoData, meta]) => {
|
||||
const lonRange = meta.lon_max - meta.lon_min;
|
||||
const latRange = meta.lat_max - meta.lat_min;
|
||||
const xRange = meta.x_max - meta.x_min;
|
||||
const yRange = meta.y_max - meta.y_min;
|
||||
|
||||
const toLocal = (lon: number, lat: number): [number, number] => {
|
||||
const localX_m = meta.x_min + ((lon - meta.lon_min) / lonRange) * xRange;
|
||||
const localY_m = meta.y_min + ((lat - meta.lat_min) / latRange) * yRange;
|
||||
return [(localX_m - xMid) * scale, -(localY_m - yMid) * scale];
|
||||
};
|
||||
|
||||
const features = geoData.features || [];
|
||||
const colors: Record<string, number> = {
|
||||
지적도: 0xff5500,
|
||||
수계망: 0x00aaff,
|
||||
산사태: 0xff0055,
|
||||
행정구역_시군구: 0x333333,
|
||||
행정구역_읍면동: 0x666666,
|
||||
};
|
||||
const layerColor = colors[gisLayer] || 0x00aa00;
|
||||
const groundZ = (zMin - zMid) * scale + 0.1;
|
||||
|
||||
features.forEach((feat: any) => {
|
||||
const geom = feat.geometry;
|
||||
if (!geom) return;
|
||||
|
||||
const drawRing = (ring: number[][]) => {
|
||||
const pts: THREE.Vector3[] = ring.map((pt) => {
|
||||
const [lx, ly] = toLocal(pt[0], pt[1]);
|
||||
return new THREE.Vector3(lx, groundZ, ly);
|
||||
});
|
||||
if (pts.length === 0) return;
|
||||
const lineGeo = new THREE.BufferGeometry().setFromPoints(pts);
|
||||
const lineMat = new THREE.LineBasicMaterial({ color: layerColor });
|
||||
const line = new THREE.Line(lineGeo, lineMat);
|
||||
scene.add(line);
|
||||
gisObjects.push(line);
|
||||
};
|
||||
|
||||
const drawCoordinates = (coords: any[], type: string) => {
|
||||
if (type === "Polygon") {
|
||||
coords.forEach((ring: number[][]) => drawRing(ring));
|
||||
} else if (type === "MultiPolygon") {
|
||||
coords.forEach((poly: any[]) => drawCoordinates(poly, "Polygon"));
|
||||
} else if (type === "LineString") {
|
||||
drawRing(coords as number[][]);
|
||||
} else if (type === "MultiLineString") {
|
||||
(coords as number[][][]).forEach((line) => drawRing(line));
|
||||
}
|
||||
};
|
||||
|
||||
drawCoordinates(geom.coordinates, geom.type);
|
||||
});
|
||||
})
|
||||
.catch((err) => console.error("GIS 벡터 로드 실패:", err));
|
||||
}
|
||||
|
||||
const getProjectIdLocal = () => {
|
||||
return localStorage.getItem("current_project_id");
|
||||
};
|
||||
|
||||
bgSelect.addEventListener("change", () => {
|
||||
const projId = getProjectIdLocal();
|
||||
if (projId) {
|
||||
updateBgMap(projId, bgSelect.value);
|
||||
}
|
||||
});
|
||||
|
||||
gisSelect.addEventListener("change", () => {
|
||||
const projId = getProjectIdLocal();
|
||||
if (projId) {
|
||||
updateGisLayer(projId, gisSelect.value);
|
||||
}
|
||||
});
|
||||
|
||||
function render(data: SurfacePointCloudSampleResponse | null): void {
|
||||
currentData = data;
|
||||
clearPoints();
|
||||
clearBgMap();
|
||||
clearGisLayers();
|
||||
if (!data) {
|
||||
statusSpan.textContent = "데이터가 존재하지 않습니다.";
|
||||
return;
|
||||
}
|
||||
renderPointCloudInternal(data);
|
||||
setCameraView("top");
|
||||
|
||||
const projId = getProjectIdLocal();
|
||||
if (projId) {
|
||||
if (bgSelect.value !== "none") {
|
||||
updateBgMap(projId, bgSelect.value);
|
||||
}
|
||||
if (gisSelect.value !== "none") {
|
||||
updateGisLayer(projId, gisSelect.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Event Listeners
|
||||
@@ -377,6 +621,11 @@ export function createSurfacePointCloudViewer(): SurfacePointCloudViewer {
|
||||
if (valSpan) valSpan.textContent = `${val * 10}%`;
|
||||
if (currentData) {
|
||||
renderPointCloudInternal(currentData);
|
||||
const projId = getProjectIdLocal();
|
||||
if (projId) {
|
||||
if (bgSelect.value !== "none") updateBgMap(projId, bgSelect.value);
|
||||
if (gisSelect.value !== "none") updateGisLayer(projId, gisSelect.value);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -388,9 +637,13 @@ export function createSurfacePointCloudViewer(): SurfacePointCloudViewer {
|
||||
optionsGroup,
|
||||
statusSpan,
|
||||
render,
|
||||
updateBgMap,
|
||||
updateGisLayer,
|
||||
dispose: () => {
|
||||
cancelAnimationFrame(animationFrame);
|
||||
clearPoints();
|
||||
clearBgMap();
|
||||
clearGisLayers();
|
||||
controls.dispose();
|
||||
renderer.dispose();
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user