Merge pull request 'Feature/b03 페이지 개선' (#1) from feature/B03-페이지-개선 into main
Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
SERVER_HOST=0.0.0.0
|
||||
SERVER_PORT=8000
|
||||
DEBUG=True
|
||||
# 분석(WF1) 실행 중 .py 저장 시 uvicorn 자동 리로드가 백그라운드 분석을 죽이므로 False 유지.
|
||||
# 개발 중 자동 리로드가 필요할 때만 일시적으로 True로 변경할 것.
|
||||
DEBUG=False
|
||||
ENVIRONMENT=development
|
||||
|
||||
DB_HOST=dsm.chemifactory.com
|
||||
|
||||
@@ -34,6 +34,7 @@ dist/
|
||||
storage/
|
||||
0_old/
|
||||
docs/
|
||||
graphify-out/
|
||||
|
||||
# 로그 파일
|
||||
*.log
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
"""B03 원본 입력 파일 메타데이터 분석."""
|
||||
|
||||
import logging
|
||||
import math
|
||||
import re
|
||||
from pathlib import Path
|
||||
from threading import get_ident
|
||||
from typing import Any
|
||||
|
||||
import laspy
|
||||
@@ -9,6 +12,117 @@ import numpy as np
|
||||
import rasterio
|
||||
from pyproj import CRS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_CUSTOM_VERTICAL_AUTHORITY_PATTERN = re.compile(
|
||||
r',?\s*AUTHORITY\["EPSG","(9995|99999)"\]',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_CUSTOM_VERTICAL_WARNING_PATTERN = re.compile(
|
||||
r"proj_create_from_database: crs not found: EPSG:(9995|99999)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
class _CustomVerticalCrsWarningFilter(logging.Filter):
|
||||
"""알려진 사설 수직 CRS 경고만 B03 안내 로그로 변환한다."""
|
||||
|
||||
def __init__(self, source: Path) -> None:
|
||||
super().__init__()
|
||||
self.source = source
|
||||
self.thread_id = get_ident()
|
||||
self.logged_codes: set[str] = set()
|
||||
|
||||
def filter(self, record: logging.LogRecord) -> bool:
|
||||
if get_ident() != self.thread_id:
|
||||
return True
|
||||
match = _CUSTOM_VERTICAL_WARNING_PATTERN.search(record.getMessage())
|
||||
if match is None:
|
||||
return True
|
||||
code = f"EPSG:{match.group(1)}"
|
||||
if code not in self.logged_codes:
|
||||
logger.info(
|
||||
"사용자 정의 수직 CRS 코드를 보존합니다: file=%s code=%s",
|
||||
self.source.name,
|
||||
code,
|
||||
)
|
||||
self.logged_codes.add(code)
|
||||
return False
|
||||
|
||||
|
||||
def _component_metadata(crs: CRS) -> dict[str, Any]:
|
||||
"""CRS 구성 요소를 JSON 저장 가능한 메타데이터로 변환한다."""
|
||||
authority = crs.to_authority()
|
||||
epsg = crs.to_epsg()
|
||||
return {
|
||||
"name": crs.name,
|
||||
"type": crs.type_name,
|
||||
"epsg": epsg,
|
||||
"authority": (
|
||||
{"name": authority[0], "code": authority[1]} if authority is not None else None
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def normalize_crs_metadata(crs: Any | None) -> dict[str, Any]:
|
||||
"""복합 CRS를 수평·수직 구성 요소로 분리해 일관된 상태를 반환한다."""
|
||||
if crs is None:
|
||||
return {
|
||||
"crs": None,
|
||||
"epsg": None,
|
||||
"horizontal_crs": None,
|
||||
"vertical_crs": None,
|
||||
"crs_status": "missing_crs",
|
||||
}
|
||||
|
||||
parsed = crs if isinstance(crs, CRS) else CRS.from_user_input(crs)
|
||||
components = parsed.sub_crs_list or [parsed]
|
||||
horizontal = next(
|
||||
(item for item in components if item.is_projected or item.is_geographic),
|
||||
None,
|
||||
)
|
||||
vertical = next((item for item in components if item.is_vertical), None)
|
||||
|
||||
horizontal_metadata = _component_metadata(horizontal) if horizontal is not None else None
|
||||
vertical_metadata = _component_metadata(vertical) if vertical is not None else None
|
||||
horizontal_epsg = horizontal_metadata["epsg"] if horizontal_metadata is not None else None
|
||||
|
||||
if horizontal_epsg is None:
|
||||
status = "unknown_horizontal_crs"
|
||||
elif vertical_metadata is not None and vertical_metadata["epsg"] is None:
|
||||
status = "custom_vertical_crs"
|
||||
else:
|
||||
status = "identified"
|
||||
|
||||
return {
|
||||
"crs": crs.to_string(),
|
||||
"epsg": horizontal_epsg,
|
||||
"horizontal_crs": horizontal_metadata,
|
||||
"vertical_crs": vertical_metadata,
|
||||
"crs_status": status,
|
||||
}
|
||||
|
||||
|
||||
def _log_crs_status(source: Path, metadata: dict[str, Any]) -> None:
|
||||
"""정상화된 CRS 상태를 B03 도메인 로그로 남긴다."""
|
||||
if metadata["crs_status"] == "custom_vertical_crs":
|
||||
logger.info(
|
||||
"사용자 정의 수직 CRS를 보존합니다: file=%s horizontal_epsg=%s vertical=%s",
|
||||
source.name,
|
||||
metadata["epsg"],
|
||||
metadata["vertical_crs"]["name"],
|
||||
)
|
||||
elif metadata["crs_status"] == "unknown_horizontal_crs":
|
||||
logger.warning("수평 CRS를 EPSG로 식별하지 못했습니다: file=%s", source.name)
|
||||
|
||||
|
||||
def _prepare_prj_wkt(text: str) -> tuple[str, list[str]]:
|
||||
"""KNGeoid24 사설 EPSG 표식만 파싱용 WKT에서 분리한다."""
|
||||
if "KNGeoid24" not in text:
|
||||
return text, []
|
||||
codes = sorted({f"EPSG:{code}" for code in _CUSTOM_VERTICAL_AUTHORITY_PATTERN.findall(text)})
|
||||
return _CUSTOM_VERTICAL_AUTHORITY_PATTERN.sub("", text), codes
|
||||
|
||||
|
||||
def analyze_las_metadata(path: str | Path) -> dict[str, Any]:
|
||||
"""LAS/LAZ 헤더와 분류 통계를 메모리에 전체 적재하지 않고 분석한다."""
|
||||
@@ -19,6 +133,8 @@ def analyze_las_metadata(path: str | Path) -> dict[str, Any]:
|
||||
dimension_names = list(point_format.dimension_names)
|
||||
point_count = int(header.point_count)
|
||||
crs = header.parse_crs()
|
||||
crs_metadata = normalize_crs_metadata(crs)
|
||||
_log_crs_status(source, crs_metadata)
|
||||
metadata: dict[str, Any] = {
|
||||
"file": source.name,
|
||||
"version": f"{header.version.major}.{header.version.minor}",
|
||||
@@ -35,8 +151,7 @@ def analyze_las_metadata(path: str | Path) -> dict[str, Any]:
|
||||
"scale": [float(value) for value in header.scales],
|
||||
"offset": [float(value) for value in header.offsets],
|
||||
"has_crs": crs is not None,
|
||||
"crs": crs.to_string() if crs else None,
|
||||
"epsg": crs.to_epsg() if crs else None,
|
||||
**crs_metadata,
|
||||
"has_classification": "classification" in dimension_names,
|
||||
"has_rgb": all(name in dimension_names for name in ("red", "green", "blue")),
|
||||
"has_intensity": "intensity" in dimension_names,
|
||||
@@ -75,17 +190,21 @@ def analyze_prj_metadata(path: str | Path) -> dict[str, Any]:
|
||||
metadata["error"] = "PRJ 파일이 비어 있습니다."
|
||||
return metadata
|
||||
|
||||
parse_text, custom_authority_codes = _prepare_prj_wkt(text)
|
||||
try:
|
||||
crs = CRS.from_wkt(text)
|
||||
crs = CRS.from_wkt(parse_text)
|
||||
except Exception as exc:
|
||||
metadata["error"] = str(exc)
|
||||
return metadata
|
||||
|
||||
crs_metadata = normalize_crs_metadata(crs)
|
||||
_log_crs_status(source, crs_metadata)
|
||||
metadata.update(
|
||||
{
|
||||
"epsg": crs.to_epsg(),
|
||||
**crs_metadata,
|
||||
"name": crs.name,
|
||||
"authority": crs.to_authority(),
|
||||
"custom_authority_codes": custom_authority_codes,
|
||||
"is_valid": True,
|
||||
}
|
||||
)
|
||||
@@ -119,28 +238,35 @@ def analyze_tfw_metadata(path: str | Path) -> dict[str, Any]:
|
||||
def analyze_tif_metadata(path: str | Path) -> dict[str, Any]:
|
||||
"""TIF/GeoTIFF 데이터셋의 공간 및 밴드 메타데이터를 분석한다."""
|
||||
source = Path(path)
|
||||
with rasterio.open(source) as dataset:
|
||||
crs = dataset.crs
|
||||
bounds = dataset.bounds
|
||||
return {
|
||||
"file": source.name,
|
||||
"width": int(dataset.width),
|
||||
"height": int(dataset.height),
|
||||
"count": int(dataset.count),
|
||||
"dtypes": list(dataset.dtypes),
|
||||
"nodata": float(dataset.nodata) if dataset.nodata is not None else None,
|
||||
"crs": crs.to_string() if crs else None,
|
||||
"epsg": crs.to_epsg() if crs else None,
|
||||
"bounds": {
|
||||
"left": float(bounds.left),
|
||||
"bottom": float(bounds.bottom),
|
||||
"right": float(bounds.right),
|
||||
"top": float(bounds.top),
|
||||
},
|
||||
"transform": [float(value) for value in list(dataset.transform)[:6]],
|
||||
"resolution": [float(value) for value in dataset.res],
|
||||
"likely_type": "dem" if dataset.count == 1 else "image",
|
||||
}
|
||||
rasterio_logger = logging.getLogger("rasterio._env")
|
||||
warning_filter = _CustomVerticalCrsWarningFilter(source)
|
||||
rasterio_logger.addFilter(warning_filter)
|
||||
try:
|
||||
with rasterio.open(source) as dataset:
|
||||
crs = dataset.crs
|
||||
crs_metadata = normalize_crs_metadata(crs)
|
||||
_log_crs_status(source, crs_metadata)
|
||||
bounds = dataset.bounds
|
||||
return {
|
||||
"file": source.name,
|
||||
"width": int(dataset.width),
|
||||
"height": int(dataset.height),
|
||||
"count": int(dataset.count),
|
||||
"dtypes": list(dataset.dtypes),
|
||||
"nodata": float(dataset.nodata) if dataset.nodata is not None else None,
|
||||
**crs_metadata,
|
||||
"bounds": {
|
||||
"left": float(bounds.left),
|
||||
"bottom": float(bounds.bottom),
|
||||
"right": float(bounds.right),
|
||||
"top": float(bounds.top),
|
||||
},
|
||||
"transform": [float(value) for value in list(dataset.transform)[:6]],
|
||||
"resolution": [float(value) for value in dataset.res],
|
||||
"likely_type": "dem" if dataset.count == 1 else "image",
|
||||
}
|
||||
finally:
|
||||
rasterio_logger.removeFilter(warning_filter)
|
||||
|
||||
|
||||
def analyze_input_metadata(path: str | Path) -> dict[str, Any]:
|
||||
|
||||
@@ -196,7 +196,16 @@ async def trigger_wf1_analysis_and_email(
|
||||
)
|
||||
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Engine import run_surface_analysis
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Router import save_surface_analysis_to_db
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Router import (
|
||||
save_surface_analysis_to_db,
|
||||
write_surface_progress,
|
||||
)
|
||||
|
||||
# 업로드 자동 분석도 progress.json에 진행률을 기록한다 (PLAN C-4)
|
||||
write_surface_progress(project_root, 5, "analyzing", "WF1 분석을 시작합니다.")
|
||||
|
||||
def _on_progress(percent: int, stage: str, message: str) -> None:
|
||||
write_surface_progress(project_root, percent, stage, message)
|
||||
|
||||
logger.info("WF1 분석 엔진 시작: las_path=%s", las_path)
|
||||
analysis_result = await asyncio.to_thread(
|
||||
@@ -206,6 +215,7 @@ async def trigger_wf1_analysis_and_email(
|
||||
source_filters=source_filters,
|
||||
methods=methods,
|
||||
force=False,
|
||||
on_progress=_on_progress,
|
||||
)
|
||||
logger.info("WF1 분석 엔진 완료: 모델 %d개 생성됨", len(analysis_result.get("models", [])))
|
||||
|
||||
@@ -229,6 +239,13 @@ async def trigger_wf1_analysis_and_email(
|
||||
await connection.rollback()
|
||||
raise
|
||||
|
||||
write_surface_progress(
|
||||
project_root,
|
||||
100,
|
||||
"awaiting_confirmation",
|
||||
"WF1 분석이 완료되었습니다. 사용할 모델을 확정하세요.",
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"SEND_ANALYSIS_COMPLETION_EMAIL=%s, project_info=%s",
|
||||
SEND_ANALYSIS_COMPLETION_EMAIL,
|
||||
|
||||
@@ -65,12 +65,21 @@ export interface SurfaceInputFileListResponse {
|
||||
files: SurfaceInputFileSummary[];
|
||||
}
|
||||
|
||||
export interface SurfaceBounds {
|
||||
x_min: number;
|
||||
x_max: number;
|
||||
y_min: number;
|
||||
y_max: number;
|
||||
z_min: number;
|
||||
z_max: number;
|
||||
}
|
||||
|
||||
export interface SurfacePointCloudSampleResponse {
|
||||
status: string;
|
||||
project_id: string;
|
||||
point_count: number;
|
||||
sampled_count: number;
|
||||
bounds: Record<string, number>;
|
||||
bounds: SurfaceBounds;
|
||||
points: [number, number, number][];
|
||||
rgb?: [number, number, number][];
|
||||
}
|
||||
@@ -160,9 +169,11 @@ export async function listSurfaceInputFiles(
|
||||
|
||||
export async function fetchSurfacePointCloud(
|
||||
projectId: string,
|
||||
filter?: string,
|
||||
): Promise<SurfacePointCloudSampleResponse> {
|
||||
const query = filter ? `?filter=${encodeURIComponent(filter)}` : "";
|
||||
return requestJson<SurfacePointCloudSampleResponse>(
|
||||
`/projects/${projectId}/surface/point-cloud`,
|
||||
`/projects/${projectId}/surface/point-cloud${query}`,
|
||||
{
|
||||
method: "GET",
|
||||
},
|
||||
|
||||
@@ -4,19 +4,42 @@
|
||||
동기 계산 파이프라인. 라우터에서 asyncio.to_thread로 호출한다.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Engine_Ground import build_ground_masks, summarize_masks
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Engine_Ground import (
|
||||
build_ground_masks,
|
||||
run_ground_filter,
|
||||
summarize_masks,
|
||||
)
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Engine_Pipeline import build_all_terrain_models
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Engine_Structurize import structurize_las
|
||||
from common_util.common_util_atomic import atomic_write_npz
|
||||
from common_util.common_util_json import atomic_write_json
|
||||
from config.config_system import build_surface_model_config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 진행 콜백 시그니처: (진행률 0~100, 현재 단계 키, 메시지)
|
||||
ProgressCallback = Callable[[int, str, str], None]
|
||||
GROUND_POINT_SAMPLE_LIMIT = 500_000
|
||||
GROUND_POINT_CACHE_VERSION = 2
|
||||
|
||||
|
||||
def _source_identity(las_path: Path) -> dict[str, Any]:
|
||||
"""입력 LAS의 정체성(이름·크기·수정시각)으로 캐시 세대를 식별한다 (PLAN B-2)."""
|
||||
stat = las_path.stat()
|
||||
return {
|
||||
"filename": las_path.name,
|
||||
"size_bytes": int(stat.st_size),
|
||||
"mtime": float(stat.st_mtime),
|
||||
}
|
||||
|
||||
|
||||
def _relative_to_project(project_root: Path, path: Path) -> str:
|
||||
@@ -24,6 +47,60 @@ def _relative_to_project(project_root: Path, path: Path) -> str:
|
||||
return path.relative_to(project_root).as_posix()
|
||||
|
||||
|
||||
def cache_ground_points(
|
||||
structured_path: Path,
|
||||
filter_key: str,
|
||||
mask: np.ndarray | None = None,
|
||||
) -> Path:
|
||||
"""필터링된 지면 포인트 미리보기 캐시를 생성하고 경로를 반환한다.
|
||||
|
||||
mask 미전달 시 영구 저장된 mask_{filter}.npy를 우선 재사용한다 (PLAN A-2).
|
||||
"""
|
||||
with np.load(structured_path) as structured:
|
||||
xyz = np.asarray(structured["xyz"], dtype=np.float32)
|
||||
mask_path = structured_path.parent / f"mask_{filter_key}.npy"
|
||||
if mask is None and mask_path.is_file():
|
||||
stored_mask = np.load(mask_path)
|
||||
if len(stored_mask) == len(xyz):
|
||||
mask = np.asarray(stored_mask, dtype=bool)
|
||||
if mask is None:
|
||||
mask = build_ground_masks(structured, [filter_key])[filter_key]
|
||||
np.save(mask_path, np.asarray(mask, dtype=bool))
|
||||
ground_indexes = np.flatnonzero(mask)
|
||||
ground_points = xyz[ground_indexes]
|
||||
ground_point_count = int(len(ground_points))
|
||||
if ground_point_count:
|
||||
data_bounds = np.column_stack((ground_points.min(axis=0), ground_points.max(axis=0)))
|
||||
else:
|
||||
data_bounds = np.zeros((3, 2), dtype=np.float64)
|
||||
if ground_point_count > GROUND_POINT_SAMPLE_LIMIT:
|
||||
rng = np.random.default_rng(20260717)
|
||||
sample_indexes = rng.choice(
|
||||
ground_point_count, GROUND_POINT_SAMPLE_LIMIT, replace=False
|
||||
)
|
||||
ground_indexes = ground_indexes[sample_indexes]
|
||||
points = ground_points[sample_indexes]
|
||||
else:
|
||||
points = ground_points
|
||||
|
||||
arrays = {
|
||||
"xyz": points,
|
||||
"bounds": np.asarray(structured["bounds"], dtype=np.float64),
|
||||
"data_bounds": np.asarray(data_bounds, dtype=np.float64),
|
||||
"cache_version": np.asarray(GROUND_POINT_CACHE_VERSION, dtype=np.int16),
|
||||
"point_count": np.asarray(ground_point_count, dtype=np.int64),
|
||||
"sampled_count": np.asarray(len(points), dtype=np.int64),
|
||||
}
|
||||
if "rgb" in structured:
|
||||
rgb = np.asarray(structured["rgb"])
|
||||
if rgb.ndim > 0 and len(rgb) == len(xyz):
|
||||
arrays["rgb"] = rgb[ground_indexes]
|
||||
|
||||
cache_path = structured_path.parent / f"ground_points_{filter_key}.npz"
|
||||
atomic_write_npz(cache_path, **arrays)
|
||||
return cache_path
|
||||
|
||||
|
||||
def run_surface_analysis(
|
||||
project_root: Path,
|
||||
las_path: Path,
|
||||
@@ -46,15 +123,37 @@ def run_surface_analysis(
|
||||
if on_progress is not None:
|
||||
on_progress(percent, stage, message)
|
||||
|
||||
total_started = time.monotonic()
|
||||
stage_root = project_root / "B04_wf1_Surface"
|
||||
processed_dir = stage_root / "processed"
|
||||
models_dir = stage_root / "models"
|
||||
processed_dir.mkdir(parents=True, exist_ok=True)
|
||||
models_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 1. LAS 구조화 (structured.npz)
|
||||
_report(10, "structurize", "LAS 구조화 중")
|
||||
structured_path = structurize_las(las_path, processed_dir)
|
||||
# 0. 입력 세대 검증: LAS가 바뀌었으면 모든 캐시를 재계산한다 (PLAN B-2)
|
||||
identity_path = processed_dir / "source_identity.json"
|
||||
current_identity = _source_identity(las_path)
|
||||
stored_identity: dict[str, Any] | None = None
|
||||
if identity_path.is_file():
|
||||
try:
|
||||
stored_identity = json.loads(identity_path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
stored_identity = None
|
||||
rebuild = force or stored_identity != current_identity
|
||||
|
||||
# 1. LAS 구조화 (structured.npz) — 캐시가 유효하면 스킵 (PLAN B-1)
|
||||
structured_path = processed_dir / "structured.npz"
|
||||
if rebuild or not structured_path.is_file():
|
||||
_report(10, "structurize", "LAS 구조화 중")
|
||||
step_started = time.monotonic()
|
||||
structured_path = structurize_las(las_path, processed_dir)
|
||||
atomic_write_json(identity_path, current_identity)
|
||||
logger.info(
|
||||
"B04 LAS 구조화 완료: %s (%.1fs)", las_path.name, time.monotonic() - step_started
|
||||
)
|
||||
else:
|
||||
_report(10, "structurize", "구조화 캐시 재사용")
|
||||
logger.info("B04 구조화 캐시 재사용: %s", structured_path.name)
|
||||
with np.load(structured_path) as structured:
|
||||
xyz = structured["xyz"]
|
||||
bounds = structured["bounds"]
|
||||
@@ -72,28 +171,66 @@ def run_surface_analysis(
|
||||
}
|
||||
data = {"xyz": xyz, "bounds": bounds}
|
||||
|
||||
# 2. 지면 필터 실행
|
||||
# 2. 지면 필터 실행 — mask_{filter}.npy 영구 캐시 우선 재사용 (PLAN A-1)
|
||||
_report(40, "ground_filter", "지면 필터 적용 중")
|
||||
masks = build_ground_masks(data, source_filters)
|
||||
masks: dict[str, np.ndarray] = {}
|
||||
for filter_key in source_filters:
|
||||
mask_path = processed_dir / f"mask_{filter_key}.npy"
|
||||
mask: np.ndarray | None = None
|
||||
if not rebuild and mask_path.is_file():
|
||||
stored_mask = np.load(mask_path)
|
||||
if len(stored_mask) == total_points:
|
||||
mask = np.asarray(stored_mask, dtype=bool)
|
||||
logger.info("B04 지면 필터 캐시 재사용: %s", mask_path.name)
|
||||
if mask is None:
|
||||
step_started = time.monotonic()
|
||||
mask = np.asarray(run_ground_filter(filter_key, data), dtype=bool)
|
||||
np.save(mask_path, mask)
|
||||
logger.info(
|
||||
"B04 지면 필터 계산 완료: %s (%.1fs)",
|
||||
filter_key,
|
||||
time.monotonic() - step_started,
|
||||
)
|
||||
masks[filter_key] = mask
|
||||
ground_summary = summarize_masks(data, masks)
|
||||
for filter_key, mask in masks.items():
|
||||
cache_path = processed_dir / f"ground_points_{filter_key}.npz"
|
||||
if not rebuild and cache_path.is_file():
|
||||
with np.load(cache_path) as cached:
|
||||
if (
|
||||
"cache_version" in cached
|
||||
and int(cached["cache_version"]) == GROUND_POINT_CACHE_VERSION
|
||||
):
|
||||
continue
|
||||
cache_ground_points(structured_path, filter_key, mask)
|
||||
|
||||
# 3. 지표면 5종 모델 빌드
|
||||
_report(70, "surface_model", "지표면 모델 생성 중")
|
||||
config = build_surface_model_config()
|
||||
config["source_filters"] = list(source_filters)
|
||||
config["precompute"] = list(methods)
|
||||
manifest = build_all_terrain_models(data, masks, models_dir, config, force=force)
|
||||
step_started = time.monotonic()
|
||||
|
||||
# 3-2. VWorld 지도 및 국가 GIS 벡터 다운로드
|
||||
def _model_progress(percent: int, detail: str) -> None:
|
||||
_report(70 + int(percent * 0.2), "surface_model", detail)
|
||||
|
||||
manifest = build_all_terrain_models(
|
||||
data, masks, models_dir, config, force=rebuild, progress=_model_progress
|
||||
)
|
||||
logger.info(
|
||||
"B04 지표면 모델 빌드 완료: status=%s (%.1fs)",
|
||||
manifest.get("status"),
|
||||
time.monotonic() - step_started,
|
||||
)
|
||||
|
||||
# 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"))
|
||||
# 입력 LAS와 같은 폴더의 .prj를 우선 사용 (PLAN E-1: 전체 재귀 glob 제거)
|
||||
prj_candidates = sorted(las_path.parent.glob("*.prj")) or sorted(
|
||||
project_root.glob("B03_FileInput/**/*.prj")
|
||||
)
|
||||
prj_path = prj_files[0] if prj_files else project_root / "result.prj"
|
||||
prj_path = prj_candidates[0] if prj_candidates else project_root / "result.prj"
|
||||
|
||||
bounds_dict_for_download = {
|
||||
"x": [float(bounds[0, 0]), float(bounds[0, 1])],
|
||||
@@ -111,7 +248,11 @@ def run_surface_analysis(
|
||||
{"layer": "white", "ext": "png"},
|
||||
]
|
||||
for item in layers:
|
||||
meta_path = processed_dir / f"vworld_{item['layer'].lower()}_meta.json"
|
||||
if not rebuild and meta_path.is_file():
|
||||
continue
|
||||
try:
|
||||
step_started = time.monotonic()
|
||||
download_vworld_satellite_map(
|
||||
prj_path,
|
||||
bounds_dict_for_download,
|
||||
@@ -119,16 +260,25 @@ def run_surface_analysis(
|
||||
layer_name=item["layer"],
|
||||
ext=item["ext"],
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
logger.info(
|
||||
"B04 VWorld %s 지도 다운로드 완료 (%.1fs)",
|
||||
item["layer"],
|
||||
time.monotonic() - step_started,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("B04 VWorld %s 지도 다운로드 실패: %s", item["layer"], exc)
|
||||
|
||||
try:
|
||||
download_all_gis_vectors(prj_path, bounds_dict_for_download, processed_dir)
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
if rebuild or not any(processed_dir.glob("*_bounds.geojson")):
|
||||
try:
|
||||
step_started = time.monotonic()
|
||||
download_all_gis_vectors(prj_path, bounds_dict_for_download, processed_dir)
|
||||
logger.info(
|
||||
"B04 국가 GIS 벡터 다운로드 완료 (%.1fs)", time.monotonic() - step_started
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("B04 국가 GIS 벡터 다운로드 실패: %s", exc)
|
||||
except Exception as exc:
|
||||
logger.warning("B04 지도·GIS 다운로드 단계 실패: %s", exc)
|
||||
|
||||
_report(95, "saving", "결과 저장 중")
|
||||
|
||||
@@ -178,6 +328,9 @@ def run_surface_analysis(
|
||||
}
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"B04 WF1 분석 완료: 모델 %d개, 총 %.1fs", len(models), time.monotonic() - total_started
|
||||
)
|
||||
return {
|
||||
"processed": processed,
|
||||
"ground_summary": ground_summary,
|
||||
|
||||
@@ -14,7 +14,7 @@ from scipy.interpolate import RBFInterpolator, RectBivariateSpline
|
||||
from skimage import measure
|
||||
|
||||
# 등고선 캐시 형식/추출 규칙이 바뀔 때 증가시킨다.
|
||||
CONTOUR_EXTRACTOR_VERSION = 3
|
||||
CONTOUR_EXTRACTOR_VERSION = 4
|
||||
|
||||
|
||||
def extract_contours_from_grid(
|
||||
@@ -189,17 +189,26 @@ def _tin_face_coverage_mask(
|
||||
if not len(boundary_edges):
|
||||
return np.zeros(xx.shape, dtype=bool)
|
||||
|
||||
from shapely import get_parts, intersects_xy, linestrings, polygonize, union_all
|
||||
import affine
|
||||
import rasterio.features
|
||||
from shapely import get_parts, linestrings, polygonize
|
||||
|
||||
boundary_lines = linestrings(vertices[boundary_edges, :2])
|
||||
polygons = list(get_parts(polygonize(boundary_lines)))
|
||||
if not polygons:
|
||||
return np.zeros(xx.shape, dtype=bool)
|
||||
coverage = union_all(polygons)
|
||||
xx_flat = np.asarray(xx, dtype=np.float64).ravel()
|
||||
yy_flat = np.asarray(yy, dtype=np.float64).ravel()
|
||||
res_flat = np.asarray(intersects_xy(coverage, xx_flat, yy_flat), dtype=bool)
|
||||
return res_flat.reshape(xx.shape)
|
||||
|
||||
x_coords = xx[0, :]
|
||||
y_coords = yy[:, 0]
|
||||
dx = float(x_coords[1] - x_coords[0]) if len(x_coords) > 1 else 1.0
|
||||
dy = float(y_coords[1] - y_coords[0]) if len(y_coords) > 1 else 1.0
|
||||
|
||||
transform = affine.Affine(dx, 0.0, x_coords[0] - dx / 2.0, 0.0, dy, y_coords[0] - dy / 2.0)
|
||||
|
||||
mask = rasterio.features.rasterize(
|
||||
polygons, out_shape=xx.shape, transform=transform, fill=0, default_value=1, dtype="uint8"
|
||||
)
|
||||
return mask.astype(bool)
|
||||
|
||||
|
||||
def _grid_axes(x_min: float, x_max: float, y_min: float, y_max: float, target_grid_m: float):
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
@@ -33,6 +34,7 @@ from common_util.common_util_json import atomic_write_json
|
||||
|
||||
# 진행률 콜백: (overall_percent, detail_message)
|
||||
ProgressReporter = Callable[[int, str], None]
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 같은 프로세스에서 동일 프로젝트 계산 요청이 겹치면 두 번째 요청을 즉시 취소.
|
||||
_ACTIVE_TERRAIN_BUILDS: set[str] = set()
|
||||
@@ -54,7 +56,7 @@ def _cache_contours(
|
||||
metadata: dict[str, Any],
|
||||
) -> None:
|
||||
"""빌드 완료 직후 기본 간격 등고선을 사전 추출·캐싱한다 (원본 + 스무딩)."""
|
||||
interval = float(config.get("contour_interval_meters", 5.0))
|
||||
interval = float(config.get("contour_interval_meters", 1.0))
|
||||
target_grid_m = float(config.get("contour_grid_resolution_meters", 1.0))
|
||||
model_path = output_dir / f"{stem}.npz"
|
||||
|
||||
@@ -106,6 +108,37 @@ def _cache_contours(
|
||||
)
|
||||
|
||||
|
||||
def _cache_contours_safely(
|
||||
output_dir: Path,
|
||||
stem: str,
|
||||
filter_key: str,
|
||||
method: str,
|
||||
representation: str,
|
||||
config: dict[str, Any],
|
||||
bounds_info: dict[str, Any],
|
||||
metadata: dict[str, Any],
|
||||
) -> None:
|
||||
"""등고선 사전 캐시 실패를 기록하되 모델 빌드는 유지한다."""
|
||||
try:
|
||||
_cache_contours(
|
||||
output_dir,
|
||||
stem,
|
||||
filter_key,
|
||||
method,
|
||||
representation,
|
||||
config,
|
||||
bounds_info,
|
||||
metadata,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"등고선 사전 캐시 실패: filter=%s, method=%s, error=%s",
|
||||
filter_key,
|
||||
method,
|
||||
exc,
|
||||
)
|
||||
|
||||
|
||||
_REPRESENTATIONS = {
|
||||
"meshfree": "meshfree_surfels",
|
||||
"dtm": "regular_grid",
|
||||
@@ -152,7 +185,11 @@ def _build_all_terrain_models(
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
manifest_path = output_dir / "manifest.json"
|
||||
filters = tuple(key for key in config["source_filters"] if key in ground_masks)
|
||||
methods = tuple(key for key in config["precompute"] if key in BUILDERS)
|
||||
methods_list = [key for key in config["precompute"] if key in BUILDERS]
|
||||
if "dtm" in methods_list:
|
||||
methods_list.remove("dtm")
|
||||
methods_list.insert(0, "dtm")
|
||||
methods = tuple(methods_list)
|
||||
signature = config_signature(config)
|
||||
bounds = np.asarray(structured_data["bounds"], dtype=np.float64)
|
||||
xyz = structured_data["xyz"]
|
||||
@@ -213,6 +250,25 @@ def _build_all_terrain_models(
|
||||
)
|
||||
filter_entry["methods"][method] = entry
|
||||
_write_json_file(manifest_path, manifest)
|
||||
interval = float(config.get("contour_interval_meters", 1.0))
|
||||
contour_path = output_dir / f"contour_{filter_key}_{method}_{interval}m.json"
|
||||
needs_contours = not contour_path.exists()
|
||||
if method in config.get("smoothing_methods", ("dtm", "tin")):
|
||||
smooth_contour_path = (
|
||||
output_dir / f"contour_{filter_key}_{method}_smooth_{interval}m.json"
|
||||
)
|
||||
needs_contours = needs_contours or not smooth_contour_path.exists()
|
||||
if needs_contours:
|
||||
_cache_contours_safely(
|
||||
output_dir,
|
||||
stem,
|
||||
filter_key,
|
||||
method,
|
||||
entry.get("representation", _REPRESENTATIONS[method]),
|
||||
config,
|
||||
manifest.get("bounds", {}),
|
||||
entry,
|
||||
)
|
||||
done_units += 1
|
||||
_report(f"{filter_key}-{method} 캐시 재사용")
|
||||
continue
|
||||
@@ -231,6 +287,9 @@ def _build_all_terrain_models(
|
||||
method_started = time.monotonic()
|
||||
filter_entry["methods"][method] = {"status": "running", "error": None}
|
||||
_write_json_file(manifest_path, manifest)
|
||||
# 모델을 새로 만들기 전에 구세대 등고선 캐시를 제거해 모델-등고선 세대 불일치를 막는다.
|
||||
for stale_contour in output_dir.glob(f"contour_{filter_key}_{method}_*.json"):
|
||||
stale_contour.unlink(missing_ok=True)
|
||||
try:
|
||||
metadata = BUILDERS[method](
|
||||
context,
|
||||
@@ -258,19 +317,16 @@ def _build_all_terrain_models(
|
||||
metadata["smooth"] = {"status": "failed", "error": str(smooth_exc)}
|
||||
|
||||
filter_entry["methods"][method] = metadata
|
||||
try:
|
||||
_cache_contours(
|
||||
output_dir,
|
||||
stem,
|
||||
filter_key,
|
||||
method,
|
||||
metadata.get("representation", "regular_grid"),
|
||||
config,
|
||||
manifest.get("bounds", {}),
|
||||
metadata,
|
||||
)
|
||||
except Exception:
|
||||
pass # 등고선 사전 캐시는 실패해도 모델 빌드를 무효화하지 않는다.
|
||||
_cache_contours_safely(
|
||||
output_dir,
|
||||
stem,
|
||||
filter_key,
|
||||
method,
|
||||
metadata.get("representation", "regular_grid"),
|
||||
config,
|
||||
manifest.get("bounds", {}),
|
||||
metadata,
|
||||
)
|
||||
except Exception as exc:
|
||||
failures += 1
|
||||
filter_entry["methods"][method] = {
|
||||
|
||||
@@ -302,3 +302,90 @@ async def confirm_surface_model(
|
||||
)
|
||||
if cursor.rowcount != 1:
|
||||
raise LookupError("확정할 지표면 모델을 찾을 수 없습니다.")
|
||||
|
||||
|
||||
async def update_project_status(
|
||||
connection: aiomysql.Connection, project_id: UUID, status: str
|
||||
) -> None:
|
||||
async with connection.cursor() as cursor:
|
||||
await cursor.execute(
|
||||
"""
|
||||
UPDATE projects
|
||||
SET status = %s, updated_at = NOW()
|
||||
WHERE id = %s AND deleted_at IS NULL
|
||||
""",
|
||||
(status, str(project_id)),
|
||||
)
|
||||
|
||||
|
||||
async def delete_project_surface_models(connection: aiomysql.Connection, project_id: UUID) -> int:
|
||||
"""프로젝트의 기존 지표면 모델 행을 모두 제거한다.
|
||||
|
||||
모든 분석 세대가 동일 파일 경로를 재사용하므로 재분석 시 구세대 행을
|
||||
남기면 존재하지 않는 파일을 가리키게 된다. terrain_layers는 FK CASCADE,
|
||||
routes.surface_model_id는 FK SET NULL로 함께 정리된다.
|
||||
"""
|
||||
async with connection.cursor() as cursor:
|
||||
await cursor.execute(
|
||||
"DELETE FROM surface_models WHERE project_id = %s",
|
||||
(str(project_id),),
|
||||
)
|
||||
return int(cursor.rowcount)
|
||||
|
||||
|
||||
async def save_surface_analysis_to_db(
|
||||
connection: aiomysql.Connection,
|
||||
*,
|
||||
project_id: UUID,
|
||||
input_file_id: int,
|
||||
analysis_result: dict[str, Any],
|
||||
source_filters: list[str],
|
||||
) -> list[int]:
|
||||
"""WF1 분석 결과를 DB에 저장한다 (기존 모델 행은 교체).
|
||||
|
||||
이 함수는 트랜잭션을 시작하거나 종료하지 않는다. 호출자는 같은 커넥션에서
|
||||
begin/commit/rollback을 한 번만 수행해야 한다.
|
||||
"""
|
||||
input_file = await get_input_file(connection, project_id, input_file_id)
|
||||
await delete_project_surface_models(connection, project_id)
|
||||
processed = analysis_result["processed"]
|
||||
processed_cloud_id = await create_processed_point_cloud(
|
||||
connection,
|
||||
input_file_id=input_file_id,
|
||||
project_id=project_id,
|
||||
process_type="structured",
|
||||
processed_file_path=processed["processed_file_path"],
|
||||
converted_format=None,
|
||||
converted_file_path=processed["converted_file_path"],
|
||||
point_count=processed["point_count"],
|
||||
bounds=processed["bounds"],
|
||||
statistics=processed["statistics"],
|
||||
classification_summary=None,
|
||||
processing_params={"filters": source_filters},
|
||||
)
|
||||
surface_model_ids: list[int] = []
|
||||
for model in analysis_result["models"]:
|
||||
model_id = await create_surface_model(
|
||||
connection,
|
||||
project_id=project_id,
|
||||
model_type=model["model_type"],
|
||||
source_file_id=input_file_id,
|
||||
processed_cloud_id=processed_cloud_id,
|
||||
crs_epsg=input_file["crs_epsg"],
|
||||
resolution_m=model["resolution_m"],
|
||||
model_file_path=model["model_file_path"],
|
||||
generation_params=model["generation_params"],
|
||||
)
|
||||
surface_model_ids.append(model_id)
|
||||
for layer in model["layers"]:
|
||||
await create_terrain_layer(
|
||||
connection,
|
||||
surface_model_id=model_id,
|
||||
layer_name=layer["layer_name"],
|
||||
geometry_type=layer["geometry_type"],
|
||||
layer_file_path=layer["file_path"],
|
||||
file_format=layer["file_format"],
|
||||
file_size_mb=None,
|
||||
statistics=None,
|
||||
)
|
||||
return surface_model_ids
|
||||
|
||||
@@ -9,20 +9,22 @@ from uuid import UUID
|
||||
|
||||
import aiomysql
|
||||
import numpy as np
|
||||
from fastapi import APIRouter, HTTPException, Response
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
|
||||
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Engine import run_surface_analysis
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Engine import (
|
||||
GROUND_POINT_CACHE_VERSION,
|
||||
cache_ground_points,
|
||||
run_surface_analysis,
|
||||
)
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Repository import (
|
||||
clear_confirmed_surface_models,
|
||||
confirm_surface_model,
|
||||
create_processed_point_cloud,
|
||||
create_surface_model,
|
||||
create_terrain_layer,
|
||||
get_input_file,
|
||||
list_project_point_cloud_inputs,
|
||||
list_surface_models,
|
||||
save_surface_analysis_to_db,
|
||||
)
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Schema import (
|
||||
SurfaceAnalyzeRequest,
|
||||
@@ -48,7 +50,7 @@ from config.config_db import get_db_pool
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/projects", tags=["B04 Surface Analysis"])
|
||||
POINT_CLOUD_SAMPLE_LIMIT = 100_000
|
||||
POINT_CLOUD_SAMPLE_LIMIT = 500_000
|
||||
# 분석 진행률 파일: B04 산출 폴더 아래에 원자적으로 기록/조회한다.
|
||||
PROGRESS_FILE_RELATIVE = ("B04_wf1_Surface", "processed", "progress.json")
|
||||
|
||||
@@ -82,77 +84,6 @@ def read_surface_progress(project_root: Path) -> dict[str, Any] | None:
|
||||
return None
|
||||
|
||||
|
||||
async def update_project_status(
|
||||
connection: aiomysql.Connection, project_id: UUID, status: str
|
||||
) -> None:
|
||||
async with connection.cursor() as cursor:
|
||||
await cursor.execute(
|
||||
"""
|
||||
UPDATE projects
|
||||
SET status = %s, updated_at = NOW()
|
||||
WHERE id = %s AND deleted_at IS NULL
|
||||
""",
|
||||
(status, str(project_id)),
|
||||
)
|
||||
|
||||
|
||||
async def save_surface_analysis_to_db(
|
||||
connection: aiomysql.Connection,
|
||||
*,
|
||||
project_id: UUID,
|
||||
input_file_id: int,
|
||||
analysis_result: dict[str, Any],
|
||||
source_filters: list[str],
|
||||
) -> list[int]:
|
||||
"""WF1 분석 결과를 DB에 저장한다.
|
||||
|
||||
이 함수는 트랜잭션을 시작하거나 종료하지 않는다. 호출자는 같은 커넥션에서
|
||||
begin/commit/rollback을 한 번만 수행해야 한다.
|
||||
"""
|
||||
input_file = await get_input_file(connection, project_id, input_file_id)
|
||||
processed = analysis_result["processed"]
|
||||
processed_cloud_id = await create_processed_point_cloud(
|
||||
connection,
|
||||
input_file_id=input_file_id,
|
||||
project_id=project_id,
|
||||
process_type="structured",
|
||||
processed_file_path=processed["processed_file_path"],
|
||||
converted_format=None,
|
||||
converted_file_path=processed["converted_file_path"],
|
||||
point_count=processed["point_count"],
|
||||
bounds=processed["bounds"],
|
||||
statistics=processed["statistics"],
|
||||
classification_summary=None,
|
||||
processing_params={"filters": source_filters},
|
||||
)
|
||||
surface_model_ids: list[int] = []
|
||||
for model in analysis_result["models"]:
|
||||
model_id = await create_surface_model(
|
||||
connection,
|
||||
project_id=project_id,
|
||||
model_type=model["model_type"],
|
||||
source_file_id=input_file_id,
|
||||
processed_cloud_id=processed_cloud_id,
|
||||
crs_epsg=input_file["crs_epsg"],
|
||||
resolution_m=model["resolution_m"],
|
||||
model_file_path=model["model_file_path"],
|
||||
generation_params=model["generation_params"],
|
||||
)
|
||||
surface_model_ids.append(model_id)
|
||||
for layer in model["layers"]:
|
||||
await create_terrain_layer(
|
||||
connection,
|
||||
surface_model_id=model_id,
|
||||
layer_name=layer["layer_name"],
|
||||
geometry_type=layer["geometry_type"],
|
||||
layer_file_path=layer["file_path"],
|
||||
file_format=layer["file_format"],
|
||||
file_size_mb=None,
|
||||
statistics=None,
|
||||
)
|
||||
return surface_model_ids
|
||||
|
||||
|
||||
@router.post("/{project_id}/surface/analyze", response_model=SurfaceAnalyzeResponse)
|
||||
async def analyze_surface(
|
||||
project_id: UUID, request: SurfaceAnalyzeRequest
|
||||
@@ -322,8 +253,9 @@ async def get_surface_input_files(project_id: UUID) -> SurfaceInputFileListRespo
|
||||
@router.get("/{project_id}/surface/point-cloud", response_model=SurfacePointCloudSampleResponse)
|
||||
async def get_surface_point_cloud(
|
||||
project_id: UUID,
|
||||
filter: str | None = None,
|
||||
) -> SurfacePointCloudSampleResponse | JSONResponse:
|
||||
"""구조화된 LAS 결과에서 B04 3D 미리보기용 포인트 샘플을 반환한다."""
|
||||
"""원본 또는 필터링된 B04 3D 미리보기 포인트를 반환한다."""
|
||||
pool = get_db_pool()
|
||||
try:
|
||||
async with pool.acquire() as connection:
|
||||
@@ -336,13 +268,34 @@ async def get_surface_point_cloud(
|
||||
content={"status": "error", "message": "구조화된 포인트클라우드가 없습니다."},
|
||||
)
|
||||
|
||||
with np.load(structured_path) as structured:
|
||||
source_path = structured_path
|
||||
if filter is not None:
|
||||
if filter not in {"grid_min_z", "csf", "pmf"}:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"status": "error", "message": "지원하지 않는 지면 필터입니다."},
|
||||
)
|
||||
source_path = structured_path.parent / f"ground_points_{filter}.npz"
|
||||
cache_is_current = False
|
||||
if source_path.is_file():
|
||||
with np.load(source_path) as cached:
|
||||
cache_is_current = (
|
||||
"cache_version" in cached
|
||||
and int(cached["cache_version"]) == GROUND_POINT_CACHE_VERSION
|
||||
)
|
||||
if not cache_is_current:
|
||||
source_path = await asyncio.to_thread(cache_ground_points, structured_path, filter)
|
||||
|
||||
with np.load(source_path) as structured:
|
||||
xyz = np.asarray(structured["xyz"], dtype=np.float32)
|
||||
bounds = np.asarray(structured["bounds"], dtype=np.float64)
|
||||
point_count = int(len(xyz))
|
||||
if point_count > POINT_CLOUD_SAMPLE_LIMIT:
|
||||
point_count = (
|
||||
int(structured["point_count"]) if "point_count" in structured else int(len(xyz))
|
||||
)
|
||||
source_count = int(len(xyz))
|
||||
if source_count > POINT_CLOUD_SAMPLE_LIMIT:
|
||||
rng = np.random.default_rng(20260710)
|
||||
indexes = rng.choice(point_count, POINT_CLOUD_SAMPLE_LIMIT, replace=False)
|
||||
indexes = rng.choice(source_count, POINT_CLOUD_SAMPLE_LIMIT, replace=False)
|
||||
sample = xyz[indexes]
|
||||
else:
|
||||
sample = xyz
|
||||
@@ -350,8 +303,8 @@ async def get_surface_point_cloud(
|
||||
rgb_sample = None
|
||||
if "rgb" in structured:
|
||||
rgb_arr = np.asarray(structured["rgb"])
|
||||
if rgb_arr.ndim > 0 and len(rgb_arr) == point_count:
|
||||
if point_count > POINT_CLOUD_SAMPLE_LIMIT:
|
||||
if rgb_arr.ndim > 0 and len(rgb_arr) == source_count:
|
||||
if source_count > POINT_CLOUD_SAMPLE_LIMIT:
|
||||
rgb_sample = rgb_arr[indexes]
|
||||
else:
|
||||
rgb_sample = rgb_arr
|
||||
@@ -551,14 +504,12 @@ async def get_surface_model_preview(
|
||||
content={"status": "error", "message": "해당 모델을 찾을 수 없습니다."},
|
||||
)
|
||||
model_type, model_file_path = row[0], row[1]
|
||||
|
||||
project_root = Path(resolve_stored_project_path(stored_path))
|
||||
if not model_file_path:
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={"status": "error", "message": "모델 파일 경로가 없습니다."},
|
||||
)
|
||||
|
||||
model_path = project_root / model_file_path
|
||||
models_dir = model_path.parent
|
||||
stem = model_path.stem
|
||||
@@ -595,287 +546,3 @@ async def get_surface_model_preview(
|
||||
status_code=500,
|
||||
content={"status": "error", "message": "프리뷰 파일 조회 중 오류가 발생했습니다."},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{project_id}/surface/models/{model_id}/contour", response_model=None)
|
||||
async def get_surface_model_contour(
|
||||
project_id: UUID,
|
||||
model_id: int,
|
||||
interval: float = 5.0,
|
||||
smooth: bool = False,
|
||||
) -> FileResponse | JSONResponse:
|
||||
"""지표면 모델의 등고선 JSON 파일을 반환한다."""
|
||||
pool = get_db_pool()
|
||||
try:
|
||||
async with pool.acquire() as connection:
|
||||
stored_path = await get_project_storage_relative_path(connection, project_id)
|
||||
async with connection.cursor() as cursor:
|
||||
await cursor.execute(
|
||||
"""
|
||||
SELECT model_type, model_file_path
|
||||
FROM surface_models
|
||||
WHERE id = %s AND project_id = %s
|
||||
""",
|
||||
(model_id, str(project_id)),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
if not row:
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={"status": "error", "message": "해당 모델을 찾을 수 없습니다."},
|
||||
)
|
||||
model_type, model_file_path = row[0], row[1]
|
||||
|
||||
project_root = Path(resolve_stored_project_path(stored_path))
|
||||
if not model_file_path:
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={"status": "error", "message": "모델 파일 경로가 없습니다."},
|
||||
)
|
||||
|
||||
model_path = project_root / model_file_path
|
||||
models_dir = model_path.parent
|
||||
stem = model_path.stem
|
||||
|
||||
parts = stem.split("_")
|
||||
if len(parts) >= 2:
|
||||
method = parts[0]
|
||||
filter_key = "_".join(parts[1:])
|
||||
else:
|
||||
method = model_type
|
||||
filter_key = "csf"
|
||||
|
||||
if smooth and method in ("dtm", "tin"):
|
||||
contour_filename = f"contour_{filter_key}_{method}_smooth_{interval}m.json"
|
||||
else:
|
||||
contour_filename = f"contour_{filter_key}_{method}_{interval}m.json"
|
||||
|
||||
contour_path = models_dir / contour_filename
|
||||
|
||||
if not contour_path.is_file():
|
||||
fallback_files = list(models_dir.glob(f"contour_{filter_key}_{method}*.json"))
|
||||
if smooth and method in ("dtm", "tin"):
|
||||
fallback_files = list(
|
||||
models_dir.glob(f"contour_{filter_key}_{method}_smooth_*.json")
|
||||
)
|
||||
if fallback_files:
|
||||
contour_path = fallback_files[0]
|
||||
|
||||
if not contour_path.is_file():
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={
|
||||
"status": "error",
|
||||
"message": "등고선 파일이 생성되지 않았거나 존재하지 않습니다.",
|
||||
},
|
||||
)
|
||||
|
||||
return FileResponse(contour_path, media_type="application/json", filename=contour_filename)
|
||||
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"지표면 모델 등고선 조회 실패: project_id=%s, model_id=%s", project_id, model_id
|
||||
)
|
||||
return JSONResponse(
|
||||
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")
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
"""B04 지표면 모델 등고선 FastAPI 라우터 (700줄 규정에 따라 본 라우터에서 분리)."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import re
|
||||
import time
|
||||
from pathlib import Path
|
||||
from uuid import UUID
|
||||
|
||||
import numpy as np
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
|
||||
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Engine_Contour import (
|
||||
CONTOUR_EXTRACTOR_VERSION,
|
||||
extract_contours,
|
||||
)
|
||||
from common_util.common_util_atomic import atomic_write_bytes
|
||||
from common_util.common_util_storage import resolve_stored_project_path
|
||||
from config.config_db import get_db_pool
|
||||
from config.config_system import SURFACE_CONTOUR_GRID_RESOLUTION_M
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/projects", tags=["B04 Surface Contour"])
|
||||
|
||||
MODEL_REPRESENTATIONS = {
|
||||
"meshfree": "meshfree_surfels",
|
||||
"dtm": "regular_grid",
|
||||
"tin": "triangular_mesh",
|
||||
"nurbs": "bspline_surface",
|
||||
"implicit": "local_rbf_height_field",
|
||||
}
|
||||
|
||||
|
||||
def _is_contour_cache_current(contour_path: Path, model_path: Path) -> bool:
|
||||
"""캐시가 현재 추출기 버전이고 기반 모델 npz보다 최신인지 검사한다."""
|
||||
try:
|
||||
if contour_path.stat().st_mtime < model_path.stat().st_mtime:
|
||||
return False
|
||||
with contour_path.open("rb") as cache_file:
|
||||
head = cache_file.read(256).decode("utf-8", errors="ignore")
|
||||
except OSError:
|
||||
return False
|
||||
match = re.search(r'"extractor_version"\s*:\s*(\d+)', head)
|
||||
return bool(match) and int(match.group(1)) == CONTOUR_EXTRACTOR_VERSION
|
||||
|
||||
|
||||
@router.get("/{project_id}/surface/models/{model_id}/contour", response_model=None)
|
||||
async def get_surface_model_contour(
|
||||
project_id: UUID,
|
||||
model_id: int,
|
||||
interval: float = 1.0,
|
||||
smooth: bool = False,
|
||||
recalculate: bool = False,
|
||||
) -> FileResponse | JSONResponse:
|
||||
"""지표면 모델의 등고선 JSON 파일을 반환한다."""
|
||||
pool = get_db_pool()
|
||||
try:
|
||||
async with pool.acquire() as connection:
|
||||
stored_path = await get_project_storage_relative_path(connection, project_id)
|
||||
async with connection.cursor() as cursor:
|
||||
await cursor.execute(
|
||||
"""
|
||||
SELECT model_type, model_file_path
|
||||
FROM surface_models
|
||||
WHERE id = %s AND project_id = %s
|
||||
""",
|
||||
(model_id, str(project_id)),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
if not row:
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={"status": "error", "message": "해당 모델을 찾을 수 없습니다."},
|
||||
)
|
||||
model_type, model_file_path = row[0], row[1]
|
||||
|
||||
project_root = Path(resolve_stored_project_path(stored_path))
|
||||
if not model_file_path:
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={"status": "error", "message": "모델 파일 경로가 없습니다."},
|
||||
)
|
||||
|
||||
model_path = project_root / model_file_path
|
||||
models_dir = model_path.parent
|
||||
structured_path = project_root / "B04_wf1_Surface" / "processed" / "structured.npz"
|
||||
stem = model_path.stem
|
||||
parts = stem.split("_")
|
||||
if len(parts) >= 2:
|
||||
method = parts[0]
|
||||
filter_key = "_".join(parts[1:])
|
||||
else:
|
||||
method = model_type
|
||||
filter_key = "csf"
|
||||
if smooth and method in ("dtm", "tin"):
|
||||
contour_filename = f"contour_{filter_key}_{method}_smooth_{interval}m.json"
|
||||
contour_model_path = models_dir / f"{stem}_smooth.npz"
|
||||
representation = "regular_grid" if method == "dtm" else "triangular_mesh"
|
||||
else:
|
||||
contour_filename = f"contour_{filter_key}_{method}_{interval}m.json"
|
||||
contour_model_path = model_path
|
||||
representation = MODEL_REPRESENTATIONS.get(method)
|
||||
contour_path = models_dir / contour_filename
|
||||
if recalculate or not _is_contour_cache_current(contour_path, contour_model_path):
|
||||
if not math.isfinite(interval) or interval < 0.5:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"status": "error", "message": "등고선 간격은 0.5m 이상이어야 합니다."},
|
||||
)
|
||||
if not contour_model_path.is_file() or representation is None:
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={
|
||||
"status": "error",
|
||||
"message": "등고선 생성에 필요한 모델 파일이 없습니다.",
|
||||
},
|
||||
)
|
||||
generation_started = time.monotonic()
|
||||
contours = await asyncio.to_thread(
|
||||
extract_contours,
|
||||
contour_model_path,
|
||||
representation,
|
||||
interval,
|
||||
SURFACE_CONTOUR_GRID_RESOLUTION_M,
|
||||
None,
|
||||
)
|
||||
logger.info(
|
||||
"등고선 온디맨드 계산: filter=%s, method=%s, smooth=%s, "
|
||||
"interval=%.1f, duration=%.1fs",
|
||||
filter_key,
|
||||
method,
|
||||
smooth,
|
||||
interval,
|
||||
time.monotonic() - generation_started,
|
||||
)
|
||||
with np.load(contour_model_path) as model_data:
|
||||
if "bounds" in model_data:
|
||||
model_bounds = np.asarray(model_data["bounds"], dtype=float)
|
||||
bounds_payload = {
|
||||
"x": model_bounds[0].tolist(),
|
||||
"y": model_bounds[1].tolist(),
|
||||
"z": model_bounds[2].tolist(),
|
||||
}
|
||||
else:
|
||||
with np.load(structured_path) as structured:
|
||||
model_bounds = np.asarray(structured["bounds"], dtype=float)
|
||||
bounds_payload = {
|
||||
"x": model_bounds[0].tolist(),
|
||||
"y": model_bounds[1].tolist(),
|
||||
"z": model_bounds[2].tolist(),
|
||||
}
|
||||
payload = {
|
||||
"extractor_version": CONTOUR_EXTRACTOR_VERSION,
|
||||
"project_id": str(project_id),
|
||||
"source_filter": filter_key,
|
||||
"method": method,
|
||||
"interval": interval,
|
||||
"bounds": bounds_payload,
|
||||
"contours": contours,
|
||||
}
|
||||
atomic_write_bytes(
|
||||
contour_path,
|
||||
json.dumps(payload, ensure_ascii=False).encode("utf-8"),
|
||||
)
|
||||
|
||||
if not contour_path.is_file():
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={
|
||||
"status": "error",
|
||||
"message": "등고선 파일이 생성되지 않았거나 존재하지 않습니다.",
|
||||
},
|
||||
)
|
||||
|
||||
return FileResponse(contour_path, media_type="application/json", filename=contour_filename)
|
||||
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"지표면 모델 등고선 조회 실패: project_id=%s, model_id=%s", project_id, model_id
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"status": "error", "message": "등고선 파일 조회 중 오류가 발생했습니다."},
|
||||
)
|
||||
@@ -0,0 +1,211 @@
|
||||
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")
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { SurfaceBounds } from "./B04_wf1_Surface_Api_Fetch";
|
||||
|
||||
export const SURFACE_CAMERA_FOV = 50;
|
||||
|
||||
export interface SurfaceCameraState {
|
||||
direction: [number, number, number];
|
||||
distanceMeters: number;
|
||||
targetMeters: [number, number, number];
|
||||
}
|
||||
|
||||
export function getReferenceCenter(bounds: SurfaceBounds): [number, number, number] {
|
||||
return [
|
||||
(bounds.x_min + bounds.x_max) / 2,
|
||||
(bounds.y_min + bounds.y_max) / 2,
|
||||
(bounds.z_min + bounds.z_max) / 2,
|
||||
];
|
||||
}
|
||||
|
||||
export function getTopFitDistance(bounds: SurfaceBounds, aspect: number): number {
|
||||
const width = Math.max(bounds.x_max - bounds.x_min, 1);
|
||||
const depth = Math.max(bounds.y_max - bounds.y_min, 1);
|
||||
const verticalFov = (SURFACE_CAMERA_FOV * Math.PI) / 180;
|
||||
const horizontalFov = 2 * Math.atan(Math.tan(verticalFov / 2) * Math.max(aspect, 0.1));
|
||||
const verticalDistance = depth / (2 * Math.tan(verticalFov / 2));
|
||||
const horizontalDistance = width / (2 * Math.tan(horizontalFov / 2));
|
||||
return Math.max(verticalDistance, horizontalDistance, 1) * 1.12;
|
||||
}
|
||||
|
||||
export function targetPlaneMetersPerPixel(distanceMeters: number, viewportHeight: number): number {
|
||||
const verticalFov = (SURFACE_CAMERA_FOV * Math.PI) / 180;
|
||||
return (
|
||||
(2 * Math.tan(verticalFov / 2) * Math.max(distanceMeters, 0.001)) / Math.max(viewportHeight, 1)
|
||||
);
|
||||
}
|
||||
|
||||
export function niceScaleDistance(roughMeters: number): number {
|
||||
const exponent = Math.floor(Math.log10(Math.max(roughMeters, 0.001)));
|
||||
const base = 10 ** exponent;
|
||||
const normalized = roughMeters / base;
|
||||
const step = normalized <= 1 ? 1 : normalized <= 2 ? 2 : normalized <= 5 ? 5 : 10;
|
||||
return step * base;
|
||||
}
|
||||
@@ -3,12 +3,14 @@ import {
|
||||
fetchGisGeoJson,
|
||||
fetchVWorldMeta,
|
||||
getVWorldMapUrl,
|
||||
type SurfaceBounds,
|
||||
type VWorldMeta,
|
||||
} from "./B04_wf1_Surface_Api_Fetch";
|
||||
import { niceScaleDistance } from "./B04_wf1_Surface_UI_Camera";
|
||||
|
||||
export interface SurfaceMapViewer {
|
||||
root: HTMLElement;
|
||||
render: (projectId: string) => void;
|
||||
render: (projectId: string, referenceBounds?: SurfaceBounds) => void;
|
||||
dispose: () => void;
|
||||
}
|
||||
|
||||
@@ -25,22 +27,23 @@ type GeoJsonCollection = {
|
||||
features?: GeoJsonFeature[];
|
||||
};
|
||||
|
||||
const BACKGROUND_LAYERS = ["white", "satellite", "hybrid"] as const;
|
||||
const GIS_LAYERS = ["지적도", "수계망", "산사태", "행정구역_시군구", "행정구역_읍면동"] as const;
|
||||
type BackgroundLayer = (typeof BACKGROUND_LAYERS)[number];
|
||||
type GisLayer = (typeof GIS_LAYERS)[number];
|
||||
|
||||
const GIS_LAYER_COLORS: Record<GisLayer, string> = {
|
||||
지적도: "#f97316",
|
||||
수계망: "#0ea5e9",
|
||||
산사태: "#ef4444",
|
||||
행정구역_시군구: "#7c3aed",
|
||||
행정구역_읍면동: "#22c55e",
|
||||
};
|
||||
|
||||
function L(key: keyof typeof ui_locales): string {
|
||||
return ui_locales[key][currentLanguageIndex];
|
||||
}
|
||||
|
||||
function makeOption(value: string, label: string): HTMLOptionElement {
|
||||
const option = document.createElement("option");
|
||||
option.value = value;
|
||||
option.textContent = label;
|
||||
return option;
|
||||
}
|
||||
|
||||
function prettyScaleDistance(roughMeters: number): number {
|
||||
const candidates = [2, 10, 20, 50, 100, 200, 500, 1000, 2000, 5000, 10000];
|
||||
return candidates.find((value) => value >= roughMeters) ?? candidates[candidates.length - 1];
|
||||
}
|
||||
|
||||
export function createSurfaceMapViewer(): SurfaceMapViewer {
|
||||
const root = document.createElement("section");
|
||||
root.className = "b04-map";
|
||||
@@ -52,42 +55,38 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
|
||||
|
||||
const controls = document.createElement("div");
|
||||
controls.className = "b04-map__controls";
|
||||
const backgroundLabel = document.createElement("label");
|
||||
backgroundLabel.textContent = L("B04_Surface_Map_Background");
|
||||
const backgroundSelect = document.createElement("select");
|
||||
backgroundSelect.append(
|
||||
makeOption("none", L("B04_Surface_Map_None")),
|
||||
makeOption("satellite", L("B04_Surface_Map_Satellite")),
|
||||
makeOption("hybrid", L("B04_Surface_Map_Hybrid")),
|
||||
makeOption("white", L("B04_Surface_Map_White")),
|
||||
);
|
||||
backgroundLabel.append(backgroundSelect);
|
||||
const backgroundGroup = document.createElement("div");
|
||||
backgroundGroup.className = "b04-map__control-group";
|
||||
const backgroundTitle = document.createElement("span");
|
||||
backgroundTitle.textContent = L("B04_Surface_Map_Background");
|
||||
const backgroundButtons = document.createElement("div");
|
||||
backgroundButtons.className = "b04-map__layer-buttons";
|
||||
backgroundGroup.append(backgroundTitle, backgroundButtons);
|
||||
|
||||
const gisLabel = document.createElement("label");
|
||||
gisLabel.textContent = L("B04_Surface_Map_GisLayer");
|
||||
const gisSelect = document.createElement("select");
|
||||
gisSelect.append(
|
||||
makeOption("none", L("B04_Surface_Map_None")),
|
||||
makeOption("지적도", L("B04_Surface_Map_Cadastral")),
|
||||
makeOption("수계망", L("B04_Surface_Map_Water")),
|
||||
makeOption("산사태", L("B04_Surface_Map_Landslide")),
|
||||
makeOption("행정구역_시군구", L("B04_Surface_Map_Sigungu")),
|
||||
makeOption("행정구역_읍면동", L("B04_Surface_Map_Eupmyeondong")),
|
||||
);
|
||||
gisLabel.append(gisSelect);
|
||||
const gisGroup = document.createElement("div");
|
||||
gisGroup.className = "b04-map__control-group";
|
||||
const gisTitle = document.createElement("span");
|
||||
gisTitle.textContent = L("B04_Surface_Map_GisLayer");
|
||||
const gisButtons = document.createElement("div");
|
||||
gisButtons.className = "b04-map__layer-buttons";
|
||||
gisGroup.append(gisTitle, gisButtons);
|
||||
|
||||
const resetButton = document.createElement("button");
|
||||
resetButton.type = "button";
|
||||
resetButton.textContent = L("B04_Surface_Map_Reset");
|
||||
controls.append(backgroundLabel, gisLabel, resetButton);
|
||||
controls.append(backgroundGroup, gisGroup, resetButton);
|
||||
header.append(title, controls);
|
||||
|
||||
const viewport = document.createElement("div");
|
||||
viewport.className = "b04-map__viewport";
|
||||
const image = document.createElement("img");
|
||||
image.className = "b04-map__image";
|
||||
image.alt = L("B04_Surface_Map_ImageAlt");
|
||||
image.draggable = false;
|
||||
const backgroundImages = new Map<BackgroundLayer, HTMLImageElement>();
|
||||
BACKGROUND_LAYERS.forEach((layer) => {
|
||||
const image = document.createElement("img");
|
||||
image.className = "b04-map__image";
|
||||
image.alt = L("B04_Surface_Map_ImageAlt");
|
||||
image.draggable = false;
|
||||
backgroundImages.set(layer, image);
|
||||
});
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.className = "b04-map__canvas";
|
||||
const empty = document.createElement("p");
|
||||
@@ -99,30 +98,125 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
|
||||
scaleBar.className = "b04-map__scale";
|
||||
const scaleText = document.createElement("span");
|
||||
scaleBar.append(scaleText);
|
||||
viewport.append(image, canvas, empty, status, scaleBar);
|
||||
viewport.append(
|
||||
...BACKGROUND_LAYERS.map((layer) => backgroundImages.get(layer)!),
|
||||
canvas,
|
||||
empty,
|
||||
status,
|
||||
scaleBar,
|
||||
);
|
||||
root.append(header, viewport);
|
||||
|
||||
let currentProjectId: string | null = null;
|
||||
let referenceBounds: SurfaceBounds | null = null;
|
||||
let meta: VWorldMeta | null = null;
|
||||
let geoJson: GeoJsonCollection | null = null;
|
||||
const geoJsonLayers = new Map<GisLayer, GeoJsonCollection>();
|
||||
const activeBackgrounds = new Set<BackgroundLayer>(BACKGROUND_LAYERS);
|
||||
const activeGisLayers = new Set<GisLayer>(GIS_LAYERS);
|
||||
let scale = 1;
|
||||
let offsetX = 0;
|
||||
let offsetY = 0;
|
||||
let dragStart: { x: number; y: number; offsetX: number; offsetY: number } | null = null;
|
||||
let loadSequence = 0;
|
||||
|
||||
function makeLayerButton<T extends string>(
|
||||
label: string,
|
||||
activeLayers: Set<T>,
|
||||
layer: T,
|
||||
color?: string,
|
||||
): HTMLButtonElement {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "b04-map__layer-button is-active";
|
||||
button.textContent = label;
|
||||
button.setAttribute("aria-pressed", "true");
|
||||
if (color) {
|
||||
button.classList.add("b04-map__layer-button--gis");
|
||||
button.style.setProperty("--b04-layer-color", color);
|
||||
}
|
||||
button.addEventListener("click", () => {
|
||||
if (activeLayers.has(layer)) activeLayers.delete(layer);
|
||||
else activeLayers.add(layer);
|
||||
const isActive = activeLayers.has(layer);
|
||||
button.classList.toggle("is-active", isActive);
|
||||
button.setAttribute("aria-pressed", String(isActive));
|
||||
syncLayerVisibility();
|
||||
});
|
||||
return button;
|
||||
}
|
||||
|
||||
const backgroundLabels: Record<BackgroundLayer, string> = {
|
||||
white: L("B04_Surface_Map_White"),
|
||||
satellite: L("B04_Surface_Map_Satellite"),
|
||||
hybrid: L("B04_Surface_Map_Hybrid"),
|
||||
};
|
||||
BACKGROUND_LAYERS.forEach((layer) => {
|
||||
backgroundButtons.append(makeLayerButton(backgroundLabels[layer], activeBackgrounds, layer));
|
||||
});
|
||||
|
||||
const gisLabels: Record<GisLayer, string> = {
|
||||
지적도: L("B04_Surface_Map_Cadastral"),
|
||||
수계망: L("B04_Surface_Map_Water"),
|
||||
산사태: L("B04_Surface_Map_Landslide"),
|
||||
행정구역_시군구: L("B04_Surface_Map_Sigungu"),
|
||||
행정구역_읍면동: L("B04_Surface_Map_Eupmyeondong"),
|
||||
};
|
||||
GIS_LAYERS.forEach((layer) => {
|
||||
gisButtons.append(
|
||||
makeLayerButton(gisLabels[layer], activeGisLayers, layer, GIS_LAYER_COLORS[layer]),
|
||||
);
|
||||
});
|
||||
|
||||
function updateImageTransform(): void {
|
||||
image.style.transform = `translate(${offsetX}px, ${offsetY}px) scale(${scale})`;
|
||||
backgroundImages.forEach((image) => {
|
||||
image.style.transform = `translate(${offsetX}px, ${offsetY}px) scale(${scale})`;
|
||||
});
|
||||
}
|
||||
|
||||
function syncLayerVisibility(): void {
|
||||
backgroundImages.forEach((image, layer) => {
|
||||
image.hidden = !activeBackgrounds.has(layer);
|
||||
});
|
||||
empty.hidden = activeBackgrounds.size > 0 || activeGisLayers.size > 0;
|
||||
drawVectorLayer();
|
||||
}
|
||||
|
||||
function fitReferenceBounds(): void {
|
||||
if (!meta || !referenceBounds) return;
|
||||
const rect = viewport.getBoundingClientRect();
|
||||
const width = Math.max(rect.width, 1);
|
||||
const height = Math.max(rect.height, 1);
|
||||
const mapRect = getMapRect(width, height);
|
||||
const referenceWidth = Math.max(referenceBounds.x_max - referenceBounds.x_min, 1);
|
||||
const referenceHeight = Math.max(referenceBounds.y_max - referenceBounds.y_min, 1);
|
||||
scale =
|
||||
Math.min(meta.width_meters / referenceWidth, meta.height_meters / referenceHeight) * 0.9;
|
||||
const centerX = (referenceBounds.x_min + referenceBounds.x_max) / 2;
|
||||
const centerY = (referenceBounds.y_min + referenceBounds.y_max) / 2;
|
||||
const baseX = mapRect.x + ((centerX - meta.x_min) / meta.width_meters) * mapRect.width;
|
||||
const baseY = mapRect.y + (1 - (centerY - meta.y_min) / meta.height_meters) * mapRect.height;
|
||||
offsetX = -(baseX - width / 2) * scale;
|
||||
offsetY = -(baseY - height / 2) * scale;
|
||||
}
|
||||
|
||||
function resetView(): void {
|
||||
scale = 1;
|
||||
offsetX = 0;
|
||||
offsetY = 0;
|
||||
fitReferenceBounds();
|
||||
updateImageTransform();
|
||||
drawVectorLayer();
|
||||
}
|
||||
|
||||
function getMapRect(width: number, height: number): DOMRect {
|
||||
if (!meta) return new DOMRect(0, 0, width, height);
|
||||
const mapRatio = meta.width_meters / Math.max(meta.height_meters, 1);
|
||||
const viewportRatio = width / Math.max(height, 1);
|
||||
const mapWidth = mapRatio > viewportRatio ? width : height * mapRatio;
|
||||
const mapHeight = mapRatio > viewportRatio ? width / mapRatio : height;
|
||||
return new DOMRect((width - mapWidth) / 2, (height - mapHeight) / 2, mapWidth, mapHeight);
|
||||
}
|
||||
|
||||
function toCanvasPoint(
|
||||
lon: number,
|
||||
lat: number,
|
||||
@@ -130,10 +224,11 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
|
||||
height: number,
|
||||
): [number, number] {
|
||||
if (!meta) return [0, 0];
|
||||
const mapRect = getMapRect(width, height);
|
||||
const lonRange = meta.lon_max - meta.lon_min || 1;
|
||||
const latRange = meta.lat_max - meta.lat_min || 1;
|
||||
const baseX = ((lon - meta.lon_min) / lonRange) * width;
|
||||
const baseY = height - ((lat - meta.lat_min) / latRange) * height;
|
||||
const baseX = mapRect.x + ((lon - meta.lon_min) / lonRange) * mapRect.width;
|
||||
const baseY = mapRect.y + mapRect.height * (1 - (lat - meta.lat_min) / latRange);
|
||||
const centerX = width / 2;
|
||||
const centerY = height / 2;
|
||||
return [
|
||||
@@ -147,7 +242,7 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
|
||||
ring: unknown,
|
||||
width: number,
|
||||
height: number,
|
||||
fill: boolean,
|
||||
closed: boolean,
|
||||
): void {
|
||||
if (!Array.isArray(ring) || ring.length === 0) return;
|
||||
const points = ring.filter(
|
||||
@@ -161,13 +256,7 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
|
||||
if (index === 0) context.moveTo(x, y);
|
||||
else context.lineTo(x, y);
|
||||
});
|
||||
if (fill) {
|
||||
context.closePath();
|
||||
context.save();
|
||||
context.globalAlpha = 0.16;
|
||||
context.fill();
|
||||
context.restore();
|
||||
}
|
||||
if (closed) context.closePath();
|
||||
context.stroke();
|
||||
}
|
||||
|
||||
@@ -194,13 +283,13 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
|
||||
}
|
||||
}
|
||||
|
||||
function drawScaleBar(width: number): void {
|
||||
function drawScaleBar(width: number, height: number): void {
|
||||
if (!meta || width <= 0) {
|
||||
scaleBar.hidden = true;
|
||||
return;
|
||||
}
|
||||
const metersPerPixel = meta.width_meters / width / scale;
|
||||
const meters = prettyScaleDistance(100 * metersPerPixel);
|
||||
const metersPerPixel = meta.width_meters / getMapRect(width, height).width / scale;
|
||||
const meters = niceScaleDistance(100 * metersPerPixel);
|
||||
const pixels = meters / metersPerPixel;
|
||||
scaleBar.hidden = false;
|
||||
scaleBar.style.width = `${pixels}px`;
|
||||
@@ -220,57 +309,63 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
|
||||
if (!context) return;
|
||||
context.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
context.clearRect(0, 0, width, height);
|
||||
const styles = getComputedStyle(root);
|
||||
context.strokeStyle = styles.getPropertyValue("--b04-map-vector").trim();
|
||||
context.fillStyle = styles.getPropertyValue("--b04-map-vector").trim();
|
||||
context.lineWidth = 1.5;
|
||||
geoJson?.features?.forEach((feature) => {
|
||||
if (feature.geometry) drawGeometry(context, feature.geometry, width, height);
|
||||
GIS_LAYERS.forEach((layer) => {
|
||||
if (!activeGisLayers.has(layer)) return;
|
||||
context.strokeStyle = GIS_LAYER_COLORS[layer];
|
||||
geoJsonLayers.get(layer)?.features?.forEach((feature) => {
|
||||
if (feature.geometry) drawGeometry(context, feature.geometry, width, height);
|
||||
});
|
||||
});
|
||||
updateImageTransform();
|
||||
drawScaleBar(width);
|
||||
drawScaleBar(width, height);
|
||||
}
|
||||
|
||||
async function loadLayers(): Promise<void> {
|
||||
if (!currentProjectId) return;
|
||||
const projectId = currentProjectId;
|
||||
const sequence = ++loadSequence;
|
||||
const background = backgroundSelect.value;
|
||||
const gisLayer = gisSelect.value;
|
||||
empty.hidden = background !== "none" || gisLayer !== "none";
|
||||
image.hidden = background === "none";
|
||||
image.removeAttribute("src");
|
||||
backgroundImages.forEach((image) => image.removeAttribute("src"));
|
||||
meta = null;
|
||||
geoJson = null;
|
||||
geoJsonLayers.clear();
|
||||
resetView();
|
||||
if (background === "none" && gisLayer === "none") {
|
||||
status.textContent = "";
|
||||
return;
|
||||
}
|
||||
status.textContent = L("B04_Surface_Map_Loading");
|
||||
const mapLayer = background === "none" ? "white" : background;
|
||||
try {
|
||||
const [nextMeta, nextGeoJson] = await Promise.all([
|
||||
fetchVWorldMeta(currentProjectId, mapLayer),
|
||||
gisLayer === "none" ? Promise.resolve(null) : fetchGisGeoJson(currentProjectId, gisLayer),
|
||||
]);
|
||||
const nextMeta = await fetchVWorldMeta(projectId, "satellite");
|
||||
const loadedLayers = await Promise.all(
|
||||
GIS_LAYERS.map(async (layer) => {
|
||||
try {
|
||||
const data = (await fetchGisGeoJson(projectId, layer)) as GeoJsonCollection;
|
||||
return [layer, data] as const;
|
||||
} catch {
|
||||
return [layer, null] as const;
|
||||
}
|
||||
}),
|
||||
);
|
||||
if (sequence !== loadSequence) return;
|
||||
meta = nextMeta;
|
||||
geoJson = nextGeoJson as GeoJsonCollection | null;
|
||||
if (background !== "none") {
|
||||
image.src = `${getVWorldMapUrl(currentProjectId, mapLayer)}&_t=${Date.now()}`;
|
||||
}
|
||||
status.textContent = geoJson?.features
|
||||
? L("B04_Surface_Map_Features").replace("{count}", geoJson.features.length.toLocaleString())
|
||||
: "";
|
||||
drawVectorLayer();
|
||||
loadedLayers.forEach(([layer, data]) => {
|
||||
if (data) geoJsonLayers.set(layer, data);
|
||||
});
|
||||
BACKGROUND_LAYERS.forEach((layer) => {
|
||||
backgroundImages.get(layer)!.src = `${getVWorldMapUrl(projectId, layer)}&_t=${Date.now()}`;
|
||||
});
|
||||
const featureCount = [...geoJsonLayers.values()].reduce(
|
||||
(sum, collection) => sum + (collection.features?.length ?? 0),
|
||||
0,
|
||||
);
|
||||
status.textContent = L("B04_Surface_Map_Features").replace(
|
||||
"{count}",
|
||||
featureCount.toLocaleString(),
|
||||
);
|
||||
resetView();
|
||||
syncLayerVisibility();
|
||||
} catch (error) {
|
||||
if (sequence !== loadSequence) return;
|
||||
status.textContent = error instanceof Error ? error.message : L("B04_Surface_Map_LoadFailed");
|
||||
}
|
||||
}
|
||||
|
||||
backgroundSelect.addEventListener("change", () => void loadLayers());
|
||||
gisSelect.addEventListener("change", () => void loadLayers());
|
||||
resetButton.addEventListener("click", resetView);
|
||||
viewport.addEventListener(
|
||||
"wheel",
|
||||
@@ -302,8 +397,9 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
|
||||
|
||||
return {
|
||||
root,
|
||||
render(projectId) {
|
||||
render(projectId, nextReferenceBounds) {
|
||||
currentProjectId = projectId;
|
||||
referenceBounds = nextReferenceBounds ?? null;
|
||||
void loadLayers();
|
||||
},
|
||||
dispose() {
|
||||
|
||||
@@ -1,17 +1,4 @@
|
||||
/* =============================================================================
|
||||
* B04_wf1_Surface_UI_Page.ts
|
||||
* 로그인 후 04: 1차 워크플로우 (지표면 모델 분석)
|
||||
*
|
||||
* 데스크톱 IDE 스타일 레이아웃 (createWorkflowLayout):
|
||||
* 헤더: 페이지 타이틀 + 진행 단계 스텝바 (숨김/복원 토글)
|
||||
* 좌측 오버레이 패널: 입력 파일 자동 선택 + 지면 필터/지표면 표현 + 실행 옵션
|
||||
* 우측 메인: 3D 포인트클라우드 뷰어 + 지면 통계 + 지표면 모델 카드
|
||||
*
|
||||
* 이벤트 핸들러 명명 (frontend.md §4): onB04_Surface_[기능]_[액션]
|
||||
* 텍스트는 ui_template_locale에 선(先) 등록 후 참조 (frontend.md §3).
|
||||
* ========================================================================== */
|
||||
|
||||
import { CURRENT_PROJECT_ID_KEY } from "@config/config_frontend";
|
||||
import { CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend";
|
||||
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
|
||||
import {
|
||||
createButton,
|
||||
@@ -20,6 +7,7 @@ import {
|
||||
showLoadingOverlay,
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import { createWorkflowLayout } from "@ui/ui_template_workflow_layout";
|
||||
import { workflowSteps } from "../A00_Common/b_page_scaffold";
|
||||
import {
|
||||
fetchWorkflowState,
|
||||
@@ -28,66 +16,31 @@ import {
|
||||
type WorkflowState,
|
||||
} from "../A00_Common/b_workflow_nav";
|
||||
import {
|
||||
analyzeSurface,
|
||||
confirmSurfaceModel,
|
||||
fetchSurfaceGroundStats,
|
||||
fetchSurfacePointCloud,
|
||||
fetchSurfaceStatus,
|
||||
listSurfaceInputFiles,
|
||||
listSurfaceModels,
|
||||
type SurfaceInputFileSummary,
|
||||
type SurfaceModelSummary,
|
||||
type SurfacePointCloudSampleResponse,
|
||||
type SurfaceStatusResponse,
|
||||
} from "./B04_wf1_Surface_Api_Fetch";
|
||||
import { createWorkflowLayout } from "@ui/ui_template_workflow_layout";
|
||||
import { createSurfacePointCloudViewer } from "./B04_wf1_Surface_UI_Viewer";
|
||||
import { createSurfaceMapViewer } from "./B04_wf1_Surface_UI_MapViewer";
|
||||
import { createSurfaceTerrainViewer } from "./B04_wf1_Surface_UI_TerrainViewer";
|
||||
import { createSurfacePointCloudViewer } from "./B04_wf1_Surface_UI_Viewer";
|
||||
import "./B04_wf1_Surface_UI_Style.css";
|
||||
|
||||
/** locale 헬퍼 */
|
||||
const SOURCE_FILTERS = ["grid_min_z", "csf", "pmf"] as const;
|
||||
const MODEL_METHODS = ["tin", "dtm", "nurbs", "implicit", "meshfree"] as const;
|
||||
const DEFAULT_FILTER = "csf";
|
||||
const DEFAULT_METHOD = "dtm";
|
||||
const ROUTE_STAGE = ROUTES.B05_WF2_ROUTE;
|
||||
|
||||
function L(key: keyof typeof ui_locales): string {
|
||||
return ui_locales[key][currentLanguageIndex];
|
||||
}
|
||||
|
||||
/** 선택 가능한 지면 필터 (config_system.SURFACE_MODEL_SOURCE_FILTERS + ransac) */
|
||||
const SOURCE_FILTERS = ["grid_min_z", "csf", "pmf", "ransac"] as const;
|
||||
/** 선택 가능한 지표면 표현 (config_system.SURFACE_MODEL_PRECOMPUTE) */
|
||||
const MODEL_METHODS = ["tin", "dtm", "nurbs", "implicit", "meshfree"] as const;
|
||||
|
||||
/** 체크박스 그룹 하나 생성 (라벨 + 항목들). 선택 값 Set을 반환. */
|
||||
function buildCheckboxGroup(
|
||||
legend: string,
|
||||
values: readonly string[],
|
||||
defaults: readonly string[],
|
||||
): { root: HTMLElement; selected: Set<string> } {
|
||||
const selected = new Set<string>(defaults);
|
||||
const root = document.createElement("fieldset");
|
||||
root.className = "b04-surface__group";
|
||||
const legendEl = document.createElement("legend");
|
||||
legendEl.className = "b04-surface__group-legend";
|
||||
legendEl.textContent = legend;
|
||||
root.append(legendEl);
|
||||
|
||||
for (const value of values) {
|
||||
const item = document.createElement("label");
|
||||
item.className = "b04-surface__check";
|
||||
const box = document.createElement("input");
|
||||
box.type = "checkbox";
|
||||
box.value = value;
|
||||
box.checked = selected.has(value);
|
||||
box.addEventListener("change", () => {
|
||||
if (box.checked) selected.add(value);
|
||||
else selected.delete(value);
|
||||
});
|
||||
const text = document.createElement("span");
|
||||
text.textContent = value;
|
||||
item.append(box, text);
|
||||
root.append(item);
|
||||
}
|
||||
return { root, selected };
|
||||
}
|
||||
|
||||
function buildInfoLine(label: string, value: unknown): HTMLElement {
|
||||
const row = document.createElement("div");
|
||||
row.className = "b04-surface__line";
|
||||
@@ -99,9 +52,41 @@ function buildInfoLine(label: string, value: unknown): HTMLElement {
|
||||
return row;
|
||||
}
|
||||
|
||||
function buildSelectGroup(
|
||||
title: string,
|
||||
values: readonly string[],
|
||||
defaultValue: string,
|
||||
): { root: HTMLElement; select: HTMLSelectElement } {
|
||||
const root = document.createElement("section");
|
||||
root.className = "b04-surface__group";
|
||||
const heading = document.createElement("h3");
|
||||
heading.className = "b04-surface__panel-title";
|
||||
heading.textContent = title;
|
||||
const select = document.createElement("select");
|
||||
select.className = "b04-surface__select";
|
||||
values.forEach((value) => {
|
||||
const option = document.createElement("option");
|
||||
option.value = value;
|
||||
option.textContent = value.replaceAll("_", " ").toUpperCase();
|
||||
select.append(option);
|
||||
});
|
||||
select.value = defaultValue;
|
||||
root.append(heading, select);
|
||||
return { root, select };
|
||||
}
|
||||
|
||||
function getModelFilter(model: SurfaceModelSummary): string {
|
||||
const configured = model.generation_params?.source_filter;
|
||||
if (typeof configured === "string") return configured.toLowerCase();
|
||||
const path = model.model_file_path?.toLowerCase() ?? "";
|
||||
return SOURCE_FILTERS.find((filter) => path.includes(filter)) ?? "";
|
||||
}
|
||||
|
||||
export async function renderB04Surface(root: HTMLElement): Promise<void> {
|
||||
let selectedInputFile: SurfaceInputFileSummary | null = null;
|
||||
let inputFiles: SurfaceInputFileSummary[] = [];
|
||||
let models: SurfaceModelSummary[] = [];
|
||||
let pointCloud: SurfacePointCloudSampleResponse | null = null;
|
||||
|
||||
const inputSelect = document.createElement("select");
|
||||
inputSelect.className = "b04-surface__select";
|
||||
@@ -109,95 +94,68 @@ export async function renderB04Surface(root: HTMLElement): Promise<void> {
|
||||
inputInfo.className = "b04-surface__input-info";
|
||||
const statusBox = document.createElement("div");
|
||||
statusBox.className = "b04-surface__status";
|
||||
const modelList = document.createElement("div");
|
||||
modelList.className = "b04-surface__models";
|
||||
const statsList = document.createElement("div");
|
||||
statsList.className = "b04-surface__stats";
|
||||
|
||||
const filterGroup = buildSelectGroup("지면 필터 선택", SOURCE_FILTERS, DEFAULT_FILTER);
|
||||
const methodGroup = buildSelectGroup("지표면 표현 선택", MODEL_METHODS, DEFAULT_METHOD);
|
||||
const viewer = createSurfacePointCloudViewer();
|
||||
const terrainViewer = createSurfaceTerrainViewer();
|
||||
const mapViewer = createSurfaceMapViewer();
|
||||
|
||||
const filterGroup = buildCheckboxGroup(L("B04_Surface_Group_Filters"), SOURCE_FILTERS, [
|
||||
"grid_min_z",
|
||||
"csf",
|
||||
"pmf",
|
||||
]);
|
||||
const methodGroup = buildCheckboxGroup(L("B04_Surface_Group_Methods"), MODEL_METHODS, [
|
||||
"dtm",
|
||||
"tin",
|
||||
]);
|
||||
let syncingCamera = false;
|
||||
viewer.onCameraChange((state) => {
|
||||
if (syncingCamera) return;
|
||||
syncingCamera = true;
|
||||
terrainViewer.applyCameraState(state);
|
||||
syncingCamera = false;
|
||||
});
|
||||
terrainViewer.onCameraChange((state) => {
|
||||
if (syncingCamera) return;
|
||||
syncingCamera = true;
|
||||
viewer.applyCameraState(state);
|
||||
syncingCamera = false;
|
||||
});
|
||||
terrainViewer.onAxesVisibilityChange((visible) => {
|
||||
viewer.setAxesVisible(visible);
|
||||
});
|
||||
viewer.setAxesVisible(false);
|
||||
|
||||
const forceLabel = document.createElement("label");
|
||||
forceLabel.className = "b04-surface__check";
|
||||
const forceBox = document.createElement("input");
|
||||
forceBox.type = "checkbox";
|
||||
const forceText = document.createElement("span");
|
||||
forceText.textContent = L("B04_Surface_Field_Force");
|
||||
forceLabel.append(forceBox, forceText);
|
||||
|
||||
const analyzeButton = createButton({
|
||||
label: L("B04_Surface_Btn_Analyze"),
|
||||
const confirmButton = createButton({
|
||||
label: "모델 확정",
|
||||
variant: "filled",
|
||||
onClick: () => void onB04_Surface_Analyze_Click(),
|
||||
onClick: () => void onB04_Surface_Confirm_Click(),
|
||||
});
|
||||
const refreshButton = createButton({
|
||||
label: L("B04_Surface_Btn_Refresh"),
|
||||
const resetButton = createButton({
|
||||
label: "초기화",
|
||||
variant: "ghost",
|
||||
onClick: () => void onB04_Surface_Refresh_Click(),
|
||||
onClick: () => void onB04_Surface_Reset_Click(),
|
||||
});
|
||||
|
||||
const panel = document.createElement("div");
|
||||
panel.className = "b04-surface__form";
|
||||
const inputGroup = document.createElement("section");
|
||||
inputGroup.className = "b04-surface__group";
|
||||
const inputTitle = document.createElement("h3");
|
||||
inputTitle.className = "b04-surface__panel-title";
|
||||
inputTitle.textContent = L("B04_Surface_InputFiles");
|
||||
inputGroup.append(inputTitle, inputSelect, inputInfo);
|
||||
|
||||
const panel = document.createElement("div");
|
||||
panel.className = "b04-surface__form";
|
||||
panel.append(
|
||||
inputGroup,
|
||||
filterGroup.root,
|
||||
methodGroup.root,
|
||||
forceLabel,
|
||||
viewer.controlsGroup,
|
||||
viewer.optionsGroup,
|
||||
analyzeButton,
|
||||
refreshButton,
|
||||
terrainViewer.optionsGroup,
|
||||
viewer.controlsGroup,
|
||||
confirmButton,
|
||||
resetButton,
|
||||
);
|
||||
|
||||
const viewers = document.createElement("div");
|
||||
viewers.className = "b04-surface__viewers";
|
||||
viewers.append(viewer.root, terrainViewer.root);
|
||||
const workspace = document.createElement("div");
|
||||
workspace.className = "b04-surface__workspace";
|
||||
const topbar = document.createElement("div");
|
||||
topbar.className = "b04-surface__topbar";
|
||||
|
||||
const titleWrap = document.createElement("div");
|
||||
titleWrap.style.display = "flex";
|
||||
titleWrap.style.alignItems = "baseline";
|
||||
titleWrap.style.gap = "var(--spacing-12)";
|
||||
|
||||
const title = document.createElement("h3");
|
||||
title.textContent = L("B04_Surface_PointCloud_Title");
|
||||
titleWrap.append(title, viewer.statusSpan);
|
||||
|
||||
topbar.append(titleWrap, statusBox);
|
||||
|
||||
const bottom = document.createElement("div");
|
||||
bottom.className = "b04-surface__bottom";
|
||||
|
||||
const statsSection = document.createElement("section");
|
||||
statsSection.className = "b04-surface__section";
|
||||
const statsTitle = document.createElement("h3");
|
||||
statsTitle.textContent = L("B04_Surface_GroundStats_Title");
|
||||
statsSection.append(statsTitle, statsList);
|
||||
|
||||
const modelsSection = document.createElement("section");
|
||||
modelsSection.className = "b04-surface__section";
|
||||
const modelsTitle = document.createElement("h3");
|
||||
modelsTitle.textContent = L("B04_Surface_Result_Title");
|
||||
modelsSection.append(modelsTitle, modelList);
|
||||
|
||||
bottom.append(statsSection, modelsSection);
|
||||
workspace.append(topbar, viewer.root, terrainViewer.root, mapViewer.root, bottom);
|
||||
workspace.append(statusBox, viewers, mapViewer.root);
|
||||
|
||||
let workflowState: WorkflowState | undefined;
|
||||
const layoutProjectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
|
||||
@@ -205,7 +163,7 @@ export async function renderB04Surface(root: HTMLElement): Promise<void> {
|
||||
try {
|
||||
workflowState = await fetchWorkflowState(layoutProjectId);
|
||||
} catch {
|
||||
/* 조회 실패 시 stages 미전달 → 전체 이동 허용 */
|
||||
/* 조회 실패 시 전체 이동 허용 */
|
||||
}
|
||||
}
|
||||
|
||||
@@ -219,9 +177,7 @@ export async function renderB04Surface(root: HTMLElement): Promise<void> {
|
||||
currentStage: workflowState?.current_stage,
|
||||
routes: WORKFLOW_STEP_ROUTES,
|
||||
onStepClick: (stepIndex) => {
|
||||
if (layoutProjectId) {
|
||||
goToWorkflowStage(layoutProjectId, WORKFLOW_STEP_ROUTES[stepIndex]);
|
||||
}
|
||||
if (layoutProjectId) goToWorkflowStage(layoutProjectId, WORKFLOW_STEP_ROUTES[stepIndex]);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -232,18 +188,19 @@ export async function renderB04Surface(root: HTMLElement): Promise<void> {
|
||||
}
|
||||
|
||||
function enableRouteStep(projectId: string): void {
|
||||
const route = ROUTE_STAGE;
|
||||
const routeIndex = WORKFLOW_STEP_ROUTES.indexOf(route);
|
||||
const routeButton = layout.root.querySelectorAll<HTMLButtonElement>(
|
||||
".ui-workflow-layout__step",
|
||||
)[2];
|
||||
)[routeIndex];
|
||||
if (!routeButton) return;
|
||||
routeButton.disabled = false;
|
||||
routeButton.classList.add("is-enabled");
|
||||
routeButton.classList.add("is-enabled", "state-in_progress");
|
||||
routeButton.classList.remove("state-not_started", "state-stale");
|
||||
routeButton.classList.add("state-in_progress");
|
||||
if (routeButton.dataset.b04RouteEnabled === "true") return;
|
||||
routeButton.dataset.b04RouteEnabled = "true";
|
||||
routeButton.addEventListener("click", () => {
|
||||
goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[2]);
|
||||
goToWorkflowStage(projectId, route);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -261,148 +218,131 @@ export async function renderB04Surface(root: HTMLElement): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
function renderInputInfo(): void {
|
||||
inputInfo.replaceChildren();
|
||||
if (!selectedInputFile) return;
|
||||
const bounds = pointCloud?.bounds;
|
||||
const heightRange = bounds
|
||||
? `${bounds.z_min.toFixed(2)} m ~ ${bounds.z_max.toFixed(2)} m`
|
||||
: null;
|
||||
inputInfo.append(
|
||||
buildInfoLine(
|
||||
"좌표계",
|
||||
selectedInputFile.crs_epsg ? `EPSG:${selectedInputFile.crs_epsg}` : null,
|
||||
),
|
||||
buildInfoLine(
|
||||
"크기",
|
||||
selectedInputFile.file_size_mb == null
|
||||
? null
|
||||
: `${selectedInputFile.file_size_mb.toFixed(2)} MB`,
|
||||
),
|
||||
buildInfoLine("포인트 수", pointCloud?.point_count.toLocaleString()),
|
||||
buildInfoLine("표시 포인트 수", pointCloud?.sampled_count.toLocaleString()),
|
||||
buildInfoLine("높이 범위", heightRange),
|
||||
);
|
||||
}
|
||||
|
||||
function renderInputFiles(files: readonly SurfaceInputFileSummary[]): void {
|
||||
inputFiles = [...files];
|
||||
inputSelect.replaceChildren();
|
||||
if (inputFiles.length === 0) {
|
||||
selectedInputFile = null;
|
||||
selectedInputFile = inputFiles[0] ?? null;
|
||||
if (!selectedInputFile) {
|
||||
const option = document.createElement("option");
|
||||
option.value = "";
|
||||
option.textContent = L("B04_Surface_InputFiles_Empty");
|
||||
inputSelect.append(option);
|
||||
inputInfo.replaceChildren();
|
||||
analyzeButton.disabled = true;
|
||||
confirmButton.disabled = true;
|
||||
renderInputInfo();
|
||||
return;
|
||||
}
|
||||
selectedInputFile = inputFiles[0];
|
||||
for (const file of inputFiles) {
|
||||
inputFiles.forEach((file) => {
|
||||
const option = document.createElement("option");
|
||||
option.value = String(file.id);
|
||||
option.textContent = `${file.original_filename} (#${file.id})`;
|
||||
option.textContent = `입력 LAS #${file.id}`;
|
||||
inputSelect.append(option);
|
||||
}
|
||||
});
|
||||
inputSelect.value = String(selectedInputFile.id);
|
||||
analyzeButton.disabled = false;
|
||||
renderInputInfo();
|
||||
}
|
||||
|
||||
function renderInputInfo(): void {
|
||||
inputInfo.replaceChildren();
|
||||
if (!selectedInputFile) return;
|
||||
inputInfo.append(
|
||||
buildInfoLine(L("B04_Surface_Input_FileName"), selectedInputFile.original_filename),
|
||||
buildInfoLine(L("B04_Surface_Input_Crs"), selectedInputFile.crs_epsg),
|
||||
buildInfoLine(L("B04_Surface_Input_Size"), selectedInputFile.file_size_mb?.toFixed(2)),
|
||||
function findSelectedModel(): SurfaceModelSummary | undefined {
|
||||
return models.find(
|
||||
(model) =>
|
||||
model.model_type.toLowerCase() === methodGroup.select.value &&
|
||||
getModelFilter(model) === filterGroup.select.value,
|
||||
);
|
||||
}
|
||||
|
||||
function renderModels(models: readonly SurfaceModelSummary[]): void {
|
||||
modelList.replaceChildren();
|
||||
function updateSelectedModel(): void {
|
||||
const projectId = getProjectId();
|
||||
if (projectId) {
|
||||
terrainViewer.render(projectId, models);
|
||||
}
|
||||
if (models.length === 0) {
|
||||
const empty = document.createElement("p");
|
||||
empty.className = "b04-surface__empty";
|
||||
empty.textContent = L("B04_Surface_Result_Empty");
|
||||
modelList.append(empty);
|
||||
return;
|
||||
}
|
||||
for (const model of models) {
|
||||
const card = document.createElement("article");
|
||||
card.className = "b04-surface__model-card";
|
||||
const head = document.createElement("div");
|
||||
head.className = "b04-surface__model-head";
|
||||
const type = document.createElement("strong");
|
||||
type.textContent = model.model_type;
|
||||
const variant =
|
||||
model.status === "CONFIRMED" || model.status === "COMPLETE" ? "success" : "neutral";
|
||||
head.append(
|
||||
type,
|
||||
createTag(
|
||||
model.status === "CONFIRMED" ? L("B04_Surface_Model_Confirmed") : model.status,
|
||||
variant,
|
||||
),
|
||||
);
|
||||
const meta = document.createElement("div");
|
||||
meta.className = "b04-surface__model-meta";
|
||||
meta.append(
|
||||
buildInfoLine(L("B04_Surface_Model_Filter"), model.generation_params?.source_filter),
|
||||
buildInfoLine(
|
||||
L("B04_Surface_Model_Representation"),
|
||||
model.generation_params?.representation,
|
||||
),
|
||||
buildInfoLine(L("B04_Surface_Model_Resolution"), model.resolution_m),
|
||||
buildInfoLine(L("B04_Surface_Model_Path"), model.model_file_path),
|
||||
);
|
||||
const confirmButton = createButton({
|
||||
label:
|
||||
model.status === "CONFIRMED"
|
||||
? L("B04_Surface_Model_Confirmed")
|
||||
: L("B04_Surface_Btn_Confirm"),
|
||||
variant: model.status === "CONFIRMED" ? "ghost" : "filled",
|
||||
onClick: () => void onB04_Surface_Confirm_Click(model),
|
||||
});
|
||||
confirmButton.disabled = model.status === "CONFIRMED";
|
||||
card.append(head, meta, confirmButton);
|
||||
modelList.append(card);
|
||||
}
|
||||
if (!projectId) return;
|
||||
terrainViewer.setSelection(filterGroup.select.value, methodGroup.select.value);
|
||||
terrainViewer.render(projectId, models);
|
||||
confirmButton.disabled = !findSelectedModel();
|
||||
}
|
||||
|
||||
function renderGroundStats(filters: Record<string, Record<string, unknown>>): void {
|
||||
statsList.replaceChildren();
|
||||
const entries = Object.entries(filters);
|
||||
if (entries.length === 0) {
|
||||
const empty = document.createElement("p");
|
||||
empty.className = "b04-surface__empty";
|
||||
empty.textContent = L("B04_Surface_GroundStats_Empty");
|
||||
statsList.append(empty);
|
||||
return;
|
||||
}
|
||||
for (const [name, value] of entries) {
|
||||
const item = document.createElement("article");
|
||||
item.className = "b04-surface__stat-card";
|
||||
item.append(
|
||||
buildInfoLine(L("B04_Surface_GroundStats_Filter"), name),
|
||||
buildInfoLine(L("B04_Surface_GroundStats_SourcePoints"), value.source_point_count),
|
||||
);
|
||||
statsList.append(item);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadProjectData(projectId: string): Promise<void> {
|
||||
const [inputs, status, models, stats] = await Promise.all([
|
||||
listSurfaceInputFiles(projectId),
|
||||
fetchSurfaceStatus(projectId),
|
||||
listSurfaceModels(projectId),
|
||||
fetchSurfaceGroundStats(projectId),
|
||||
]);
|
||||
renderInputFiles(inputs.files);
|
||||
renderStatus(status);
|
||||
renderModels(models.models);
|
||||
renderGroundStats(stats.filters);
|
||||
mapViewer.render(projectId);
|
||||
try {
|
||||
viewer.render(await fetchSurfacePointCloud(projectId));
|
||||
} catch {
|
||||
viewer.render(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function onB04_Surface_Confirm_Click(model: SurfaceModelSummary): Promise<void> {
|
||||
async function updatePointCloudForFilter(): Promise<void> {
|
||||
const projectId = getProjectId();
|
||||
if (!projectId) return;
|
||||
showLoadingOverlay();
|
||||
try {
|
||||
pointCloud = await fetchSurfacePointCloud(projectId, filterGroup.select.value);
|
||||
terrainViewer.setReferenceBounds(pointCloud.bounds);
|
||||
viewer.render(pointCloud);
|
||||
renderInputInfo();
|
||||
} catch (error) {
|
||||
pointCloud = null;
|
||||
viewer.render(null);
|
||||
const detail = error instanceof Error ? error.message : "지면 포인트 조회에 실패했습니다.";
|
||||
showToast(detail, "error");
|
||||
} finally {
|
||||
hideLoadingOverlay();
|
||||
}
|
||||
}
|
||||
|
||||
async function loadProjectData(projectId: string): Promise<void> {
|
||||
const [inputs, status, modelResponse] = await Promise.all([
|
||||
listSurfaceInputFiles(projectId),
|
||||
fetchSurfaceStatus(projectId),
|
||||
listSurfaceModels(projectId),
|
||||
]);
|
||||
models = modelResponse.models;
|
||||
renderInputFiles(inputs.files);
|
||||
renderStatus(status);
|
||||
try {
|
||||
pointCloud = await fetchSurfacePointCloud(projectId, filterGroup.select.value);
|
||||
terrainViewer.setReferenceBounds(pointCloud.bounds);
|
||||
viewer.render(pointCloud);
|
||||
mapViewer.render(projectId, pointCloud.bounds);
|
||||
} catch {
|
||||
pointCloud = null;
|
||||
viewer.render(null);
|
||||
mapViewer.render(projectId);
|
||||
}
|
||||
renderInputInfo();
|
||||
updateSelectedModel();
|
||||
}
|
||||
|
||||
async function onB04_Surface_Confirm_Click(): Promise<void> {
|
||||
const projectId = getProjectId();
|
||||
const model = findSelectedModel();
|
||||
if (!projectId || !model) {
|
||||
showToast(L("B04_Surface_Error_Selection"), "error");
|
||||
return;
|
||||
}
|
||||
showLoadingOverlay();
|
||||
try {
|
||||
await confirmSurfaceModel(projectId, model.id);
|
||||
const summary = L("B04_Surface_Confirm_Success")
|
||||
.replace("{filter}", String(model.generation_params?.source_filter ?? "-"))
|
||||
.replace("{method}", model.model_type)
|
||||
.replace("{smoothing}", String(model.generation_params?.representation ?? "-"));
|
||||
showToast(summary, "success");
|
||||
showToast(
|
||||
L("B04_Surface_Confirm_Success")
|
||||
.replace("{filter}", filterGroup.select.value)
|
||||
.replace("{method}", methodGroup.select.value)
|
||||
.replace("{smoothing}", terrainViewer.isSmoothingEnabled() ? "ON" : "OFF"),
|
||||
"success",
|
||||
);
|
||||
await loadProjectData(projectId);
|
||||
enableRouteStep(projectId);
|
||||
goToWorkflowStage(projectId, ROUTE_STAGE);
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : L("B04_Surface_Confirm_Failed");
|
||||
showToast(`${L("B04_Surface_Confirm_Failed")} ${detail}`, "error");
|
||||
@@ -411,37 +351,13 @@ export async function renderB04Surface(root: HTMLElement): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function onB04_Surface_Analyze_Click(): Promise<void> {
|
||||
const projectId = getProjectId();
|
||||
if (!projectId || !selectedInputFile) return;
|
||||
if (filterGroup.selected.size === 0 || methodGroup.selected.size === 0) {
|
||||
showToast(L("B04_Surface_Error_Selection"), "error");
|
||||
return;
|
||||
}
|
||||
showLoadingOverlay();
|
||||
try {
|
||||
const response = await analyzeSurface(projectId, {
|
||||
input_file_id: selectedInputFile.id,
|
||||
source_filters: [...filterGroup.selected],
|
||||
methods: [...methodGroup.selected],
|
||||
force: forceBox.checked,
|
||||
});
|
||||
showToast(
|
||||
`${L("B04_Surface_Analyze_Success")} (${response.surface_model_ids.length})`,
|
||||
"success",
|
||||
);
|
||||
await loadProjectData(projectId);
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : L("B04_Surface_Analyze_Failed");
|
||||
showToast(`${L("B04_Surface_Analyze_Failed")} ${detail}`, "error");
|
||||
} finally {
|
||||
hideLoadingOverlay();
|
||||
}
|
||||
}
|
||||
|
||||
async function onB04_Surface_Refresh_Click(): Promise<void> {
|
||||
async function onB04_Surface_Reset_Click(): Promise<void> {
|
||||
const projectId = getProjectId();
|
||||
if (!projectId) return;
|
||||
filterGroup.select.value = DEFAULT_FILTER;
|
||||
methodGroup.select.value = DEFAULT_METHOD;
|
||||
viewer.resetOptions();
|
||||
terrainViewer.resetOptions();
|
||||
showLoadingOverlay();
|
||||
try {
|
||||
await loadProjectData(projectId);
|
||||
@@ -456,8 +372,13 @@ export async function renderB04Surface(root: HTMLElement): Promise<void> {
|
||||
selectedInputFile = inputFiles.find((file) => String(file.id) === inputSelect.value) ?? null;
|
||||
renderInputInfo();
|
||||
});
|
||||
filterGroup.select.addEventListener("change", () => {
|
||||
updateSelectedModel();
|
||||
void updatePointCloudForFilter();
|
||||
});
|
||||
methodGroup.select.addEventListener("change", updateSelectedModel);
|
||||
|
||||
root.replaceChildren(layout.root);
|
||||
const projectId = getProjectId();
|
||||
if (projectId) void onB04_Surface_Refresh_Click();
|
||||
if (projectId) void onB04_Surface_Reset_Click();
|
||||
}
|
||||
|
||||
@@ -88,9 +88,30 @@
|
||||
.b04-surface__workspace {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
min-height: calc(100vh - var(--spacing-64));
|
||||
}
|
||||
|
||||
.b04-surface__workspace > .b04-surface__status {
|
||||
justify-content: flex-end;
|
||||
padding: var(--spacing-12) var(--spacing-24);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.b04-surface__viewers {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--spacing-16);
|
||||
width: 100%;
|
||||
padding: var(--spacing-16) var(--spacing-24);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.b04-surface__viewers > * {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.b04-surface__topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -229,6 +250,14 @@
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.terrain-model-group {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-cards);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.point-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -257,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);
|
||||
@@ -307,6 +338,92 @@
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.viewer-options label.is-disabled {
|
||||
color: var(--color-text-muted);
|
||||
cursor: not-allowed;
|
||||
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;
|
||||
@@ -330,6 +447,41 @@
|
||||
background: var(--color-canvas);
|
||||
}
|
||||
|
||||
.b04-surface__status-info {
|
||||
position: absolute;
|
||||
top: var(--spacing-12);
|
||||
left: var(--spacing-12);
|
||||
z-index: 2;
|
||||
padding: var(--spacing-4) var(--spacing-8);
|
||||
border-radius: var(--radius-inputs);
|
||||
background: var(--color-surface-raised);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-caption);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.b04-surface__scale {
|
||||
position: absolute;
|
||||
bottom: var(--spacing-16);
|
||||
left: var(--spacing-16);
|
||||
z-index: 2;
|
||||
height: var(--spacing-8);
|
||||
border: 2px solid var(--color-text);
|
||||
border-top: 0;
|
||||
color: var(--color-text);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.b04-surface__scale span {
|
||||
position: absolute;
|
||||
bottom: var(--spacing-8);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
white-space: nowrap;
|
||||
font-size: var(--text-caption);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
/* --- 하단 2D 지도 --- */
|
||||
.b04-map {
|
||||
--b04-map-vector: var(--color-accent);
|
||||
@@ -345,7 +497,8 @@
|
||||
|
||||
.b04-map__header,
|
||||
.b04-map__controls,
|
||||
.b04-map__controls label {
|
||||
.b04-map__control-group,
|
||||
.b04-map__layer-buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
@@ -365,13 +518,17 @@
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.b04-map__controls label {
|
||||
.b04-map__control-group {
|
||||
gap: var(--spacing-8);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
.b04-map__controls select,
|
||||
.b04-map__layer-buttons {
|
||||
gap: var(--spacing-4);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.b04-map__controls button {
|
||||
min-height: 34px;
|
||||
padding: 0 var(--spacing-12);
|
||||
@@ -385,6 +542,27 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.b04-map__controls .b04-map__layer-button {
|
||||
padding: var(--spacing-8);
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.b04-map__layer-button.is-active {
|
||||
opacity: 1;
|
||||
box-shadow: inset 0 0 0 1px currentColor;
|
||||
}
|
||||
|
||||
.b04-map__layer-button--gis {
|
||||
border-color: var(--color-border);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.b04-map__layer-button--gis.is-active {
|
||||
border-color: var(--b04-layer-color);
|
||||
color: var(--b04-layer-color);
|
||||
box-shadow: inset 0 0 0 1px var(--b04-layer-color);
|
||||
}
|
||||
|
||||
.b04-map__viewport {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
@@ -410,9 +588,10 @@
|
||||
}
|
||||
|
||||
.b04-map__image {
|
||||
object-fit: fill;
|
||||
object-fit: contain;
|
||||
transform-origin: center;
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.b04-map__canvas {
|
||||
@@ -468,3 +647,9 @@
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.b04-surface__viewers {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,176 +3,51 @@ 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 { API_BASE_URL } from "@config/config_frontend";
|
||||
import type { SurfaceModelSummary } from "./B04_wf1_Surface_Api_Fetch";
|
||||
import type { SurfaceBounds, SurfaceModelSummary } from "./B04_wf1_Surface_Api_Fetch";
|
||||
import {
|
||||
getTopFitDistance,
|
||||
niceScaleDistance,
|
||||
SURFACE_CAMERA_FOV,
|
||||
targetPlaneMetersPerPixel,
|
||||
type SurfaceCameraState,
|
||||
} from "./B04_wf1_Surface_UI_Camera";
|
||||
|
||||
export interface SurfaceTerrainViewer {
|
||||
root: HTMLElement;
|
||||
optionsGroup: HTMLElement;
|
||||
render: (projectId: string, models: readonly SurfaceModelSummary[]) => void;
|
||||
updateBgMap: (projectId: string, bgLayer: string) => void;
|
||||
updateGisLayer: (projectId: string, gisLayer: string) => void;
|
||||
setReferenceBounds: (bounds: SurfaceBounds) => void;
|
||||
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;
|
||||
}
|
||||
|
||||
export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
|
||||
const root = document.createElement("div");
|
||||
root.className = "terrain-model-group panel panel--no-bottom";
|
||||
root.style.marginTop = "var(--spacing-24)";
|
||||
|
||||
// Header Title
|
||||
const header = document.createElement("div");
|
||||
header.className = "panel-title";
|
||||
header.style.display = "flex";
|
||||
header.style.justifyContent = "space-between";
|
||||
header.style.alignItems = "center";
|
||||
|
||||
const titleSpan = document.createElement("span");
|
||||
titleSpan.innerHTML = "🏕️ 지면 필터별 5가지 지표면 모델 비교";
|
||||
root.className = "terrain-model-group";
|
||||
|
||||
const statusSpan = document.createElement("span");
|
||||
statusSpan.className = "terrain-status";
|
||||
statusSpan.style.fontSize = "var(--text-caption)";
|
||||
statusSpan.style.color = "var(--color-text-secondary)";
|
||||
statusSpan.textContent = "모델 선택 대기 중...";
|
||||
|
||||
header.append(titleSpan, statusSpan);
|
||||
root.append(header);
|
||||
|
||||
// Filters and Methods selector bars
|
||||
const selectorBar = document.createElement("div");
|
||||
selectorBar.className = "filter-selector-bar terrain-selector-bar";
|
||||
selectorBar.style.padding = "var(--spacing-12) var(--spacing-16)";
|
||||
selectorBar.style.background = "var(--color-bg-light)";
|
||||
selectorBar.style.borderBottom = "1px solid var(--color-border)";
|
||||
selectorBar.style.display = "flex";
|
||||
selectorBar.style.flexDirection = "column";
|
||||
selectorBar.style.gap = "var(--spacing-12)";
|
||||
|
||||
// 1. Source Filter row
|
||||
const filterRow = document.createElement("div");
|
||||
filterRow.style.display = "flex";
|
||||
filterRow.style.alignItems = "center";
|
||||
filterRow.style.gap = "var(--spacing-12)";
|
||||
|
||||
const filterLabel = document.createElement("span");
|
||||
filterLabel.className = "filter-bar-label";
|
||||
filterLabel.textContent = "서피스 기준 데이터:";
|
||||
filterLabel.style.fontWeight = "bold";
|
||||
filterLabel.style.minWidth = "120px";
|
||||
|
||||
const filterSegmented = document.createElement("div");
|
||||
filterSegmented.className = "segmented";
|
||||
|
||||
const filters = [
|
||||
{ key: "csf", label: "CSF" },
|
||||
{ key: "pmf", label: "PMF" },
|
||||
{ key: "grid_min_z", label: "Grid Min-Z" },
|
||||
];
|
||||
|
||||
let activeFilter = "csf";
|
||||
const filterButtons = filters.map((f) => {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.textContent = f.label;
|
||||
btn.className = f.key === activeFilter ? "active" : "";
|
||||
btn.addEventListener("click", () => {
|
||||
filterButtons.forEach((b) => b.classList.remove("active"));
|
||||
btn.classList.add("active");
|
||||
activeFilter = f.key;
|
||||
updateSelectedModel();
|
||||
});
|
||||
filterSegmented.append(btn);
|
||||
return btn;
|
||||
});
|
||||
filterRow.append(filterLabel, filterSegmented);
|
||||
|
||||
// 2. Method row
|
||||
const methodRow = document.createElement("div");
|
||||
methodRow.style.display = "flex";
|
||||
methodRow.style.alignItems = "center";
|
||||
methodRow.style.gap = "var(--spacing-12)";
|
||||
|
||||
const methodLabel = document.createElement("span");
|
||||
methodLabel.className = "filter-bar-label";
|
||||
methodLabel.textContent = "지표면 표현 방식:";
|
||||
methodLabel.style.fontWeight = "bold";
|
||||
methodLabel.style.minWidth = "120px";
|
||||
|
||||
const methodSegmented = document.createElement("div");
|
||||
methodSegmented.className = "segmented";
|
||||
|
||||
const methods = [
|
||||
{ key: "tin", label: "TIN" },
|
||||
{ key: "dtm", label: "DTM (Grid)" },
|
||||
{ key: "nurbs", label: "NURBS" },
|
||||
{ key: "implicit", label: "Implicit" },
|
||||
{ key: "meshfree", label: "Meshfree" },
|
||||
];
|
||||
|
||||
let activeMethod = "dtm";
|
||||
const methodButtons = methods.map((m) => {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.textContent = m.label;
|
||||
btn.className = m.key === activeMethod ? "active" : "";
|
||||
btn.addEventListener("click", () => {
|
||||
methodButtons.forEach((b) => b.classList.remove("active"));
|
||||
btn.classList.add("active");
|
||||
activeMethod = m.key;
|
||||
updateSelectedModel();
|
||||
});
|
||||
methodSegmented.append(btn);
|
||||
return btn;
|
||||
});
|
||||
methodRow.append(methodLabel, methodSegmented);
|
||||
selectorBar.append(filterRow, methodRow);
|
||||
root.append(selectorBar);
|
||||
|
||||
// Viewer Controls toolbar & checkbox options
|
||||
const toolbar = document.createElement("div");
|
||||
toolbar.className = "point-toolbar";
|
||||
toolbar.style.display = "flex";
|
||||
toolbar.style.justifyContent = "space-between";
|
||||
toolbar.style.alignItems = "center";
|
||||
toolbar.style.padding = "var(--spacing-12) var(--spacing-16)";
|
||||
toolbar.style.background = "var(--color-bg-card)";
|
||||
toolbar.style.borderBottom = "1px solid var(--color-border)";
|
||||
|
||||
const leftControls = document.createElement("div");
|
||||
leftControls.className = "viewer-controls";
|
||||
|
||||
const btnIso = document.createElement("button");
|
||||
btnIso.type = "button";
|
||||
btnIso.innerHTML = "🔍 사시도";
|
||||
const btnTop = document.createElement("button");
|
||||
btnTop.type = "button";
|
||||
btnTop.textContent = "상단";
|
||||
const btnFront = document.createElement("button");
|
||||
btnFront.type = "button";
|
||||
btnFront.textContent = "정면";
|
||||
const btnSide = document.createElement("button");
|
||||
btnSide.type = "button";
|
||||
btnSide.textContent = "측면";
|
||||
const btnReset = document.createElement("button");
|
||||
btnReset.type = "button";
|
||||
btnReset.innerHTML = "🔄 리셋";
|
||||
|
||||
const toggleAxesLabel = document.createElement("label");
|
||||
toggleAxesLabel.className = "toggle-label";
|
||||
const axesCheck = document.createElement("input");
|
||||
axesCheck.type = "checkbox";
|
||||
axesCheck.checked = true;
|
||||
toggleAxesLabel.append(axesCheck, document.createTextNode(" 축"));
|
||||
|
||||
leftControls.append(btnIso, btnTop, btnFront, btnSide, btnReset, toggleAxesLabel);
|
||||
axesCheck.checked = false;
|
||||
|
||||
const rightControls = document.createElement("div");
|
||||
rightControls.style.display = "flex";
|
||||
rightControls.style.alignItems = "center";
|
||||
rightControls.style.gap = "var(--spacing-16)";
|
||||
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;
|
||||
@@ -180,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;
|
||||
@@ -188,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;
|
||||
@@ -196,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("간격 "),
|
||||
@@ -222,14 +92,21 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
|
||||
intervalSubmit,
|
||||
);
|
||||
|
||||
rightControls.append(surfLabel, smoothLabel, contourLabel, intervalForm);
|
||||
toolbar.append(leftControls, rightControls);
|
||||
root.append(toolbar);
|
||||
const axesLabel = document.createElement("label");
|
||||
axesLabel.className = "toggle-label toggle-button";
|
||||
axesLabel.append(axesCheck, document.createTextNode(" 축"));
|
||||
rightControls.append(axesLabel, surfLabel, smoothLabel, contourLabel, intervalForm);
|
||||
|
||||
const optionsGroup = document.createElement("section");
|
||||
optionsGroup.className = "b04-surface__group";
|
||||
const optionsTitle = document.createElement("h3");
|
||||
optionsTitle.className = "b04-surface__panel-title";
|
||||
optionsTitle.textContent = "모델 표시 옵션";
|
||||
optionsGroup.append(optionsTitle, rightControls, statusSpan);
|
||||
|
||||
// 3D View container
|
||||
const viewerArea = document.createElement("div");
|
||||
viewerArea.className = "three-viewer";
|
||||
viewerArea.style.height = "520px";
|
||||
viewerArea.style.position = "relative";
|
||||
viewerArea.style.borderRadius = "0 0 var(--radius-cards) var(--radius-cards)";
|
||||
viewerArea.style.overflow = "hidden";
|
||||
@@ -241,27 +118,10 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
|
||||
|
||||
// Scale bar overlay
|
||||
const scaleBar = document.createElement("div");
|
||||
scaleBar.style.position = "absolute";
|
||||
scaleBar.style.bottom = "16px";
|
||||
scaleBar.style.left = "16px";
|
||||
scaleBar.style.background = "rgba(255, 255, 255, 0.9)";
|
||||
scaleBar.style.border = "1.5px solid #1e293b";
|
||||
scaleBar.style.borderTop = "none";
|
||||
scaleBar.style.height = "8px";
|
||||
scaleBar.style.width = "100px";
|
||||
scaleBar.style.zIndex = "10";
|
||||
scaleBar.style.display = "none";
|
||||
scaleBar.style.flexDirection = "column";
|
||||
scaleBar.style.alignItems = "center";
|
||||
scaleBar.style.justifyContent = "flex-end";
|
||||
scaleBar.className = "b04-surface__scale";
|
||||
scaleBar.hidden = true;
|
||||
|
||||
const scaleLabel = document.createElement("span");
|
||||
scaleLabel.style.fontSize = "10px";
|
||||
scaleLabel.style.fontWeight = "bold";
|
||||
scaleLabel.style.color = "#1e293b";
|
||||
scaleLabel.style.position = "absolute";
|
||||
scaleLabel.style.bottom = "10px";
|
||||
scaleLabel.style.whiteSpace = "nowrap";
|
||||
scaleBar.append(scaleLabel);
|
||||
viewerArea.append(scaleBar);
|
||||
|
||||
@@ -308,11 +168,20 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
|
||||
// Three.js context variables
|
||||
let currentProjectId = "";
|
||||
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;
|
||||
// 선택 변경 후 늦게 도착한 이전 로더 콜백이 장면을 오염시키지 않도록 세대를 추적한다.
|
||||
let loadGeneration = 0;
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
scene.background = new THREE.Color(0xf5f7f9);
|
||||
|
||||
const camera = new THREE.PerspectiveCamera(50, 1, 0.01, 100000);
|
||||
const camera = new THREE.PerspectiveCamera(SURFACE_CAMERA_FOV, 1, 0.01, 100000);
|
||||
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
|
||||
|
||||
@@ -378,30 +247,47 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
|
||||
};
|
||||
|
||||
const fitCamera = (object: THREE.Object3D) => {
|
||||
const { center, span } = getFitParams(object);
|
||||
controls.target.copy(center);
|
||||
camera.position.set(center.x, center.y + span * 1.2, center.z + 0.001);
|
||||
camera.near = Math.max(span / 10000, 0.01);
|
||||
camera.far = span * 100;
|
||||
const { span } = getFitParams(object);
|
||||
const aspect = viewerArea.clientWidth / Math.max(viewerArea.clientHeight, 1);
|
||||
const distance = referenceBounds ? getTopFitDistance(referenceBounds, aspect) : span * 1.2;
|
||||
controls.target.set(0, 0, 0);
|
||||
camera.position.set(0, distance, 0.001);
|
||||
camera.near = Math.max(distance / 10000, 0.01);
|
||||
camera.far = distance * 100;
|
||||
camera.updateProjectionMatrix();
|
||||
controls.update();
|
||||
};
|
||||
|
||||
function setCameraView(view: "iso" | "top" | "front" | "side") {
|
||||
if (!terrainMesh) return;
|
||||
const { center, span } = getFitParams(terrainMesh);
|
||||
controls.target.copy(center);
|
||||
if (view === "iso") {
|
||||
camera.position.set(center.x + span * 0.8, center.y + span * 0.65, center.z + span * 0.9);
|
||||
} else if (view === "top") {
|
||||
camera.position.set(center.x, center.y + span * 1.2, center.z + 0.001);
|
||||
} else if (view === "front") {
|
||||
camera.position.set(center.x, center.y, center.z + span * 1.2);
|
||||
} else if (view === "side") {
|
||||
camera.position.set(center.x + span * 1.2, center.y, center.z);
|
||||
}
|
||||
camera.updateProjectionMatrix();
|
||||
function emitCameraState(): void {
|
||||
if (suppressCameraEvent || !cameraListener) return;
|
||||
const offset = camera.position.clone().sub(controls.target);
|
||||
const distance = Math.max(offset.length(), 0.001);
|
||||
offset.normalize();
|
||||
cameraListener({
|
||||
direction: [offset.x, offset.y, offset.z],
|
||||
distanceMeters: distance,
|
||||
targetMeters: [controls.target.x, controls.target.y, controls.target.z],
|
||||
});
|
||||
}
|
||||
|
||||
function applyCameraState(state: SurfaceCameraState): void {
|
||||
suppressCameraEvent = true;
|
||||
controls.target.set(...state.targetMeters);
|
||||
camera.position
|
||||
.set(...state.direction)
|
||||
.multiplyScalar(Math.max(state.distanceMeters, 0.001))
|
||||
.add(controls.target);
|
||||
camera.lookAt(controls.target);
|
||||
controls.update();
|
||||
suppressCameraEvent = false;
|
||||
}
|
||||
|
||||
function syncSmoothingSupport(): void {
|
||||
const supported = activeMethod === "tin" || activeMethod === "dtm";
|
||||
smoothCheck.disabled = !supported;
|
||||
smoothCheck.checked = supported && smoothPreferred;
|
||||
smoothLabel.classList.toggle("is-disabled", !supported);
|
||||
smoothLabel.title = supported ? "" : "이 지표면 표현은 스무딩을 지원하지 않습니다.";
|
||||
}
|
||||
|
||||
// Load mesh and contours
|
||||
@@ -410,7 +296,8 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
|
||||
|
||||
clearMesh();
|
||||
clearContours();
|
||||
scaleBar.style.display = "none";
|
||||
currentModelId = null;
|
||||
scaleBar.hidden = true;
|
||||
statusSpan.textContent = "모델 조회 중...";
|
||||
|
||||
// 1. Find matching model in list
|
||||
@@ -418,9 +305,12 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
|
||||
// model_file_path contains the activeFilter (e.g. csf, pmf, grid_min_z)
|
||||
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());
|
||||
return typeMatches && pathContainsFilter;
|
||||
const configuredFilter = m.generation_params?.source_filter;
|
||||
const filterMatches =
|
||||
(typeof configuredFilter === "string" &&
|
||||
configuredFilter.toLowerCase() === activeFilter.toLowerCase()) ||
|
||||
Boolean(m.model_file_path?.toLowerCase().includes(activeFilter.toLowerCase()));
|
||||
return typeMatches && filterMatches;
|
||||
});
|
||||
|
||||
if (!match) {
|
||||
@@ -430,6 +320,9 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
|
||||
|
||||
const modelId = match.id;
|
||||
const isSmooth = (activeMethod === "tin" || activeMethod === "dtm") && smoothCheck.checked;
|
||||
currentModelId = modelId;
|
||||
currentModelSmooth = isSmooth;
|
||||
const generation = ++loadGeneration;
|
||||
|
||||
statusSpan.textContent = "3D 메쉬 파일 다운로드 중...";
|
||||
const previewUrl = `${API_BASE_URL}/projects/${currentProjectId}/surface/models/${modelId}/preview?smooth=${isSmooth}`;
|
||||
@@ -438,7 +331,11 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
|
||||
if (activeMethod === "meshfree") {
|
||||
new PLYLoader().load(
|
||||
previewUrl,
|
||||
(geometry) => {
|
||||
async (geometry) => {
|
||||
if (generation !== loadGeneration) {
|
||||
geometry.dispose();
|
||||
return;
|
||||
}
|
||||
geometry.computeBoundingSphere();
|
||||
const material = new THREE.PointsMaterial({
|
||||
size: 0.35,
|
||||
@@ -450,18 +347,22 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
|
||||
terrainMesh = points;
|
||||
scene.add(points);
|
||||
fitCamera(points);
|
||||
statusSpan.textContent = `${activeFilter.toUpperCase()} · ${activeMethod.toUpperCase()} 표시 중`;
|
||||
loadContourLines(modelId, isSmooth);
|
||||
await loadSelectedContours(modelId, isSmooth);
|
||||
},
|
||||
undefined,
|
||||
() => {
|
||||
if (generation !== loadGeneration) return;
|
||||
statusSpan.textContent = "3D 파일 로드에 실패했습니다.";
|
||||
},
|
||||
);
|
||||
} else {
|
||||
new GLTFLoader().load(
|
||||
previewUrl,
|
||||
(gltf) => {
|
||||
async (gltf) => {
|
||||
if (generation !== loadGeneration) {
|
||||
disposeObject(gltf.scene);
|
||||
return;
|
||||
}
|
||||
gltf.scene.traverse((child) => {
|
||||
if (child instanceof THREE.Mesh) {
|
||||
child.material.side = THREE.DoubleSide;
|
||||
@@ -472,11 +373,11 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
|
||||
terrainMesh = gltf.scene;
|
||||
scene.add(gltf.scene);
|
||||
fitCamera(gltf.scene);
|
||||
statusSpan.textContent = `${activeFilter.toUpperCase()} · ${activeMethod.toUpperCase()} 표시 중`;
|
||||
loadContourLines(modelId, isSmooth);
|
||||
await loadSelectedContours(modelId, isSmooth);
|
||||
},
|
||||
undefined,
|
||||
() => {
|
||||
if (generation !== loadGeneration) return;
|
||||
statusSpan.textContent = "3D 메쉬 파일이 없거나 로드할 수 없습니다.";
|
||||
},
|
||||
);
|
||||
@@ -486,19 +387,32 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadContourLines(modelId: number, isSmooth: boolean) {
|
||||
const interval = parseFloat(intervalInput.value) || 5.0;
|
||||
const contourUrl = `${API_BASE_URL}/projects/${currentProjectId}/surface/models/${modelId}/contour?interval=${interval}&smooth=${isSmooth}`;
|
||||
async function loadContourLines(
|
||||
modelId: number,
|
||||
isSmooth: boolean,
|
||||
recalculate = false,
|
||||
): Promise<boolean> {
|
||||
const interval = parseFloat(intervalInput.value) || 1.0;
|
||||
const projectId = currentProjectId;
|
||||
const contourUrl = `${API_BASE_URL}/projects/${projectId}/surface/models/${modelId}/contour?interval=${interval}&smooth=${isSmooth}&recalculate=${recalculate}`;
|
||||
|
||||
try {
|
||||
const res = await fetch(contourUrl);
|
||||
const res = await fetch(contourUrl, { cache: "no-store" });
|
||||
if (!res.ok) throw new Error("등고선 조회 실패");
|
||||
const data = await res.json();
|
||||
if (
|
||||
currentProjectId !== projectId ||
|
||||
currentModelId !== modelId ||
|
||||
currentModelSmooth !== isSmooth ||
|
||||
(parseFloat(intervalInput.value) || 1.0) !== interval
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -589,8 +503,31 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
|
||||
} else {
|
||||
legendBar.style.display = "none";
|
||||
}
|
||||
return true;
|
||||
} catch (e) {
|
||||
legendBar.style.display = "none";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSelectedContours(
|
||||
modelId: number,
|
||||
isSmooth: boolean,
|
||||
recalculate = false,
|
||||
): Promise<void> {
|
||||
const projectId = currentProjectId;
|
||||
const interval = parseFloat(intervalInput.value) || 1.0;
|
||||
statusSpan.textContent = `등고선 ${interval}m 계산 중...`;
|
||||
const loaded = await loadContourLines(modelId, isSmooth, recalculate);
|
||||
if (
|
||||
currentProjectId === projectId &&
|
||||
currentModelId === modelId &&
|
||||
currentModelSmooth === isSmooth &&
|
||||
(parseFloat(intervalInput.value) || 1.0) === interval
|
||||
) {
|
||||
statusSpan.textContent = loaded
|
||||
? `${activeFilter.toUpperCase()} · ${activeMethod.toUpperCase()} · 등고선 ${interval}m`
|
||||
: "등고선 계산 또는 조회에 실패했습니다.";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -615,38 +552,16 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
|
||||
|
||||
// Render scale bar dynamically
|
||||
if (terrainMesh && terrainMesh.visible) {
|
||||
scaleBar.style.display = "flex";
|
||||
scaleBar.hidden = false;
|
||||
const dist = camera.position.distanceTo(controls.target);
|
||||
const metersPerPixel =
|
||||
(2 * Math.tan((camera.fov * Math.PI) / 360) * dist) / viewerArea.clientWidth;
|
||||
const metersPerPixel = targetPlaneMetersPerPixel(dist, viewerArea.clientHeight);
|
||||
const roughMeters = 100 * metersPerPixel;
|
||||
const prettyMeters =
|
||||
roughMeters < 5
|
||||
? 2
|
||||
: roughMeters < 15
|
||||
? 10
|
||||
: roughMeters < 35
|
||||
? 20
|
||||
: roughMeters < 75
|
||||
? 50
|
||||
: roughMeters < 150
|
||||
? 100
|
||||
: roughMeters < 350
|
||||
? 200
|
||||
: roughMeters < 750
|
||||
? 500
|
||||
: roughMeters < 1500
|
||||
? 1000
|
||||
: roughMeters < 3500
|
||||
? 2000
|
||||
: roughMeters < 7500
|
||||
? 5000
|
||||
: 10000;
|
||||
const prettyMeters = niceScaleDistance(roughMeters);
|
||||
scaleBar.style.width = `${prettyMeters / metersPerPixel}px`;
|
||||
scaleLabel.textContent =
|
||||
prettyMeters >= 1000 ? `${(prettyMeters / 1000).toFixed(0)} km` : `${prettyMeters} m`;
|
||||
} else {
|
||||
scaleBar.style.display = "none";
|
||||
scaleBar.hidden = true;
|
||||
}
|
||||
|
||||
// Update labels position
|
||||
@@ -661,16 +576,9 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
|
||||
}
|
||||
|
||||
// Event Listeners
|
||||
btnIso.addEventListener("click", () => setCameraView("iso"));
|
||||
btnTop.addEventListener("click", () => setCameraView("top"));
|
||||
btnFront.addEventListener("click", () => setCameraView("front"));
|
||||
btnSide.addEventListener("click", () => setCameraView("side"));
|
||||
btnReset.addEventListener("click", () => {
|
||||
if (terrainMesh) fitCamera(terrainMesh);
|
||||
});
|
||||
|
||||
axesCheck.addEventListener("change", () => {
|
||||
axes.visible = axesCheck.checked;
|
||||
axesVisibilityListener?.(axesCheck.checked);
|
||||
});
|
||||
|
||||
surfCheck.addEventListener("change", () => {
|
||||
@@ -680,6 +588,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
|
||||
});
|
||||
|
||||
smoothCheck.addEventListener("change", () => {
|
||||
smoothPreferred = smoothCheck.checked;
|
||||
updateSelectedModel();
|
||||
});
|
||||
|
||||
@@ -690,11 +599,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;
|
||||
await loadSelectedContours(currentModelId, currentModelSmooth, true);
|
||||
intervalSubmit.disabled = false;
|
||||
});
|
||||
|
||||
controls.addEventListener("change", emitCameraState);
|
||||
|
||||
// Resize handler
|
||||
const resizeObserver = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
@@ -711,16 +626,42 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
|
||||
|
||||
return {
|
||||
root,
|
||||
optionsGroup,
|
||||
render(projectId, models) {
|
||||
currentProjectId = projectId;
|
||||
currentModelsList = models;
|
||||
updateSelectedModel();
|
||||
},
|
||||
updateBgMap(_projectId, _bgLayer) {
|
||||
// 포인트클라우드 뷰어와 인터페이스 조화를 위해 빈 껍데기 함수 정의
|
||||
setReferenceBounds(bounds) {
|
||||
referenceBounds = bounds;
|
||||
},
|
||||
updateGisLayer(_projectId, _gisLayer) {
|
||||
// 포인트클라우드 뷰어와 인터페이스 조화를 위해 빈 껍데기 함수 정의
|
||||
setSelection(sourceFilter, method) {
|
||||
activeFilter = sourceFilter;
|
||||
activeMethod = method;
|
||||
syncSmoothingSupport();
|
||||
},
|
||||
applyCameraState,
|
||||
onCameraChange(listener) {
|
||||
cameraListener = listener;
|
||||
},
|
||||
onAxesVisibilityChange(listener) {
|
||||
axesVisibilityListener = listener;
|
||||
},
|
||||
isSmoothingEnabled() {
|
||||
return !smoothCheck.disabled && smoothCheck.checked;
|
||||
},
|
||||
resetOptions() {
|
||||
axesCheck.checked = false;
|
||||
axes.visible = false;
|
||||
axesVisibilityListener?.(false);
|
||||
surfCheck.checked = true;
|
||||
smoothPreferred = true;
|
||||
syncSmoothingSupport();
|
||||
contourCheck.checked = true;
|
||||
contourGroup.visible = true;
|
||||
intervalInput.value = "1.0";
|
||||
if (terrainMesh) terrainMesh.visible = true;
|
||||
void updateSelectedModel();
|
||||
},
|
||||
dispose() {
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import { RENDER_OPTIONS } from "@config/config_frontend";
|
||||
import * as THREE from "three";
|
||||
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
|
||||
import { CURRENT_PROJECT_ID_KEY } from "@config/config_frontend";
|
||||
import { RENDER_OPTIONS } from "@config/config_frontend";
|
||||
import type { SurfaceBounds, SurfacePointCloudSampleResponse } from "./B04_wf1_Surface_Api_Fetch";
|
||||
import {
|
||||
fetchVWorldMeta,
|
||||
getVWorldMapUrl,
|
||||
fetchGisGeoJson,
|
||||
type SurfacePointCloudSampleResponse,
|
||||
} from "./B04_wf1_Surface_Api_Fetch";
|
||||
getReferenceCenter,
|
||||
getTopFitDistance,
|
||||
niceScaleDistance,
|
||||
SURFACE_CAMERA_FOV,
|
||||
targetPlaneMetersPerPixel,
|
||||
type SurfaceCameraState,
|
||||
} from "./B04_wf1_Surface_UI_Camera";
|
||||
|
||||
export type { SurfaceCameraState } from "./B04_wf1_Surface_UI_Camera";
|
||||
|
||||
export interface SurfacePointCloudViewer {
|
||||
root: HTMLElement;
|
||||
@@ -15,66 +19,51 @@ export interface SurfacePointCloudViewer {
|
||||
optionsGroup: HTMLElement;
|
||||
statusSpan: HTMLElement;
|
||||
render: (data: SurfacePointCloudSampleResponse | null) => void;
|
||||
updateBgMap: (projectId: string, bgLayer: string) => void;
|
||||
updateGisLayer: (projectId: string, gisLayer: string) => void;
|
||||
setAxesVisible: (visible: boolean) => void;
|
||||
applyCameraState: (state: SurfaceCameraState) => void;
|
||||
onCameraChange: (listener: (state: SurfaceCameraState) => void) => void;
|
||||
resetOptions: () => void;
|
||||
dispose: () => void;
|
||||
}
|
||||
|
||||
type CameraView = "iso" | "top" | "front" | "side";
|
||||
|
||||
function makeButton(label: string): HTMLButtonElement {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.textContent = label;
|
||||
return button;
|
||||
}
|
||||
|
||||
export function createSurfacePointCloudViewer(): SurfacePointCloudViewer {
|
||||
const root = document.createElement("div");
|
||||
root.className = "point-viewer";
|
||||
|
||||
const statusSpan = document.createElement("span");
|
||||
statusSpan.className = "b04-surface__status-info";
|
||||
statusSpan.style.color = "var(--color-text-secondary)";
|
||||
statusSpan.style.fontSize = "var(--text-caption)";
|
||||
statusSpan.textContent = "포인트 데이터 로딩 중...";
|
||||
|
||||
const controlsDiv = document.createElement("div");
|
||||
controlsDiv.className = "viewer-controls";
|
||||
|
||||
const btnIso = document.createElement("button");
|
||||
btnIso.type = "button";
|
||||
btnIso.innerHTML = "🔍 사시도";
|
||||
|
||||
const btnTop = document.createElement("button");
|
||||
btnTop.type = "button";
|
||||
btnTop.textContent = "상단";
|
||||
|
||||
const btnFront = document.createElement("button");
|
||||
btnFront.type = "button";
|
||||
btnFront.textContent = "정면";
|
||||
|
||||
const btnSide = document.createElement("button");
|
||||
btnSide.type = "button";
|
||||
btnSide.textContent = "측면";
|
||||
|
||||
const btnReset = document.createElement("button");
|
||||
btnReset.type = "button";
|
||||
btnReset.innerHTML = "🔄 리셋";
|
||||
|
||||
const toggleLabel = document.createElement("label");
|
||||
toggleLabel.className = "toggle-label";
|
||||
const axesCheck = document.createElement("input");
|
||||
axesCheck.type = "checkbox";
|
||||
axesCheck.checked = true;
|
||||
toggleLabel.append(axesCheck, document.createTextNode(" 축"));
|
||||
|
||||
controlsDiv.append(btnIso, btnTop, btnFront, btnSide, btnReset, toggleLabel);
|
||||
const controls = document.createElement("div");
|
||||
controls.className = "viewer-controls";
|
||||
const viewButtons: Array<[CameraView, HTMLButtonElement]> = [
|
||||
["iso", makeButton("사시도")],
|
||||
["top", makeButton("상단")],
|
||||
["front", makeButton("정면")],
|
||||
["side", makeButton("측면")],
|
||||
];
|
||||
controls.append(...viewButtons.map(([, button]) => button));
|
||||
|
||||
const controlsGroup = document.createElement("section");
|
||||
controlsGroup.className = "b04-surface__group";
|
||||
const controlsTitle = document.createElement("h3");
|
||||
controlsTitle.className = "b04-surface__panel-title";
|
||||
controlsTitle.textContent = "뷰어 시점 제어";
|
||||
controlsGroup.append(controlsTitle, controlsDiv);
|
||||
|
||||
// 2. Options Sliders
|
||||
const optionsDiv = document.createElement("div");
|
||||
optionsDiv.className = "viewer-options";
|
||||
controlsGroup.append(controlsTitle, controls);
|
||||
|
||||
const options = document.createElement("div");
|
||||
options.className = "viewer-options";
|
||||
const sizeLabel = document.createElement("label");
|
||||
sizeLabel.textContent = "점 크기 ";
|
||||
sizeLabel.textContent = "점 크기";
|
||||
const sizeInput = document.createElement("input");
|
||||
sizeInput.type = "range";
|
||||
sizeInput.min = "0.1";
|
||||
@@ -82,9 +71,8 @@ export function createSurfacePointCloudViewer(): SurfacePointCloudViewer {
|
||||
sizeInput.step = "0.01";
|
||||
sizeInput.value = "0.42";
|
||||
sizeLabel.append(sizeInput);
|
||||
|
||||
const densityLabel = document.createElement("label");
|
||||
densityLabel.innerHTML = '밀도 <span class="viewer-option-val">100%</span>';
|
||||
densityLabel.innerHTML = '밀도 <span class="viewer-option-val">100%</span>';
|
||||
const densityInput = document.createElement("input");
|
||||
densityInput.type = "range";
|
||||
densityInput.min = "1";
|
||||
@@ -92,124 +80,50 @@ export function createSurfacePointCloudViewer(): SurfacePointCloudViewer {
|
||||
densityInput.step = "1";
|
||||
densityInput.value = "10";
|
||||
densityLabel.append(densityInput);
|
||||
|
||||
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);
|
||||
options.append(sizeLabel, densityLabel);
|
||||
|
||||
const optionsGroup = document.createElement("section");
|
||||
optionsGroup.className = "b04-surface__group";
|
||||
const optionsTitle = document.createElement("h3");
|
||||
optionsTitle.className = "b04-surface__panel-title";
|
||||
optionsTitle.textContent = "표시 옵션";
|
||||
optionsGroup.append(optionsTitle, optionsDiv);
|
||||
optionsTitle.textContent = "포인트 표시 옵션";
|
||||
optionsGroup.append(optionsTitle, options);
|
||||
|
||||
// 4. Viewer Area & Canvas
|
||||
const viewerArea = document.createElement("div");
|
||||
viewerArea.className = "three-viewer";
|
||||
viewerArea.style.height = "520px";
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.className = "b04-surface-viewer__canvas";
|
||||
viewerArea.append(canvas);
|
||||
const scaleBar = document.createElement("div");
|
||||
scaleBar.className = "b04-surface__scale";
|
||||
const scaleText = document.createElement("span");
|
||||
scaleBar.append(scaleText);
|
||||
viewerArea.append(canvas, scaleBar, statusSpan);
|
||||
root.append(viewerArea);
|
||||
|
||||
// 5. Scale Bar Overlay
|
||||
const scaleBar = document.createElement("div");
|
||||
scaleBar.style.position = "absolute";
|
||||
scaleBar.style.bottom = "16px";
|
||||
scaleBar.style.left = "16px";
|
||||
scaleBar.style.background = "rgba(255, 255, 255, 0.9)";
|
||||
scaleBar.style.border = "1.5px solid #1e293b";
|
||||
scaleBar.style.borderTop = "none";
|
||||
scaleBar.style.height = "8px";
|
||||
scaleBar.style.zIndex = "10";
|
||||
scaleBar.style.display = "none";
|
||||
scaleBar.style.flexDirection = "column";
|
||||
scaleBar.style.alignItems = "center";
|
||||
scaleBar.style.justifyContent = "flex-end";
|
||||
scaleBar.style.pointerEvents = "none";
|
||||
|
||||
const scaleText = document.createElement("span");
|
||||
scaleText.style.fontSize = "10px";
|
||||
scaleText.style.fontWeight = "bold";
|
||||
scaleText.style.color = "#1e293b";
|
||||
scaleText.style.position = "absolute";
|
||||
scaleText.style.bottom = "10px";
|
||||
scaleText.style.whiteSpace = "nowrap";
|
||||
scaleBar.append(scaleText);
|
||||
viewerArea.append(scaleBar);
|
||||
|
||||
// 6. ThreeJS Setup
|
||||
const renderer = new THREE.WebGLRenderer({
|
||||
canvas,
|
||||
antialias: RENDER_OPTIONS.antialias,
|
||||
});
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, RENDER_OPTIONS.maxPixelRatio));
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
scene.background = new THREE.Color(0xf5f7f9);
|
||||
|
||||
const camera = new THREE.PerspectiveCamera(55, 1, 0.1, 22000);
|
||||
const controls = new OrbitControls(camera, canvas);
|
||||
controls.enableDamping = true;
|
||||
controls.dampingFactor = 0.08;
|
||||
controls.screenSpacePanning = true;
|
||||
|
||||
const camera = new THREE.PerspectiveCamera(SURFACE_CAMERA_FOV, 1, 0.1, 22000);
|
||||
const orbit = new OrbitControls(camera, canvas);
|
||||
orbit.enableDamping = true;
|
||||
orbit.dampingFactor = 0.08;
|
||||
orbit.screenSpacePanning = true;
|
||||
const axes = new THREE.AxesHelper(45);
|
||||
axes.visible = axesCheck.checked;
|
||||
axes.visible = false;
|
||||
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;
|
||||
let animationFrame = 0;
|
||||
let hasConnected = false;
|
||||
let disposed = false;
|
||||
let cameraListener: ((state: SurfaceCameraState) => void) | null = null;
|
||||
let suppressCameraEvent = false;
|
||||
let referenceBounds: SurfaceBounds | null = null;
|
||||
|
||||
function resize(): void {
|
||||
const rect = viewerArea.getBoundingClientRect();
|
||||
@@ -220,93 +134,6 @@ export function createSurfacePointCloudViewer(): SurfacePointCloudViewer {
|
||||
camera.updateProjectionMatrix();
|
||||
}
|
||||
|
||||
function setCameraView(view: "iso" | "top" | "front" | "side"): void {
|
||||
const positions: Record<string, [number, number, number]> = {
|
||||
iso: [120, 95, 135],
|
||||
top: [0, 190, 0.001],
|
||||
front: [0, 60, 240],
|
||||
side: [240, 60, 0],
|
||||
};
|
||||
camera.position.set(...positions[view]);
|
||||
camera.lookAt(0, 0, 0);
|
||||
controls.target.set(0, 0, 0);
|
||||
controls.update();
|
||||
}
|
||||
|
||||
function animate(): void {
|
||||
if (!root.isConnected) {
|
||||
if (!hasConnected) {
|
||||
animationFrame = requestAnimationFrame(animate);
|
||||
} else {
|
||||
cancelAnimationFrame(animationFrame);
|
||||
clearPoints();
|
||||
controls.dispose();
|
||||
renderer.dispose();
|
||||
}
|
||||
return;
|
||||
}
|
||||
hasConnected = true;
|
||||
resize();
|
||||
controls.update();
|
||||
renderer.render(scene, camera);
|
||||
|
||||
// Update scale bar overlay
|
||||
if (currentData) {
|
||||
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 xSpan = xMax - xMin;
|
||||
const ySpan = yMax - yMin;
|
||||
const zSpan = zMax - zMin;
|
||||
const maxSpan = Math.max(xSpan, ySpan, zSpan);
|
||||
const internalScale = 180 / maxSpan;
|
||||
|
||||
const dist = camera.position.distanceTo(controls.target);
|
||||
const rect = viewerArea.getBoundingClientRect();
|
||||
const clientWidth = rect.width || 1200;
|
||||
const sceneUnitsPerPixel = (2 * Math.tan((camera.fov * Math.PI) / 360) * dist) / clientWidth;
|
||||
const metersPerPixel = sceneUnitsPerPixel / internalScale;
|
||||
|
||||
const roughMeters = 100 * metersPerPixel;
|
||||
const prettyMeters =
|
||||
roughMeters < 5
|
||||
? 2
|
||||
: roughMeters < 15
|
||||
? 10
|
||||
: roughMeters < 35
|
||||
? 20
|
||||
: roughMeters < 75
|
||||
? 50
|
||||
: roughMeters < 150
|
||||
? 100
|
||||
: roughMeters < 350
|
||||
? 200
|
||||
: roughMeters < 750
|
||||
? 500
|
||||
: roughMeters < 1500
|
||||
? 1000
|
||||
: roughMeters < 3500
|
||||
? 2000
|
||||
: roughMeters < 7500
|
||||
? 5000
|
||||
: 10000;
|
||||
|
||||
const pixels = (prettyMeters * internalScale) / sceneUnitsPerPixel;
|
||||
scaleBar.style.display = "flex";
|
||||
scaleBar.style.width = `${pixels}px`;
|
||||
scaleText.textContent =
|
||||
prettyMeters >= 1000 ? `${(prettyMeters / 1000).toFixed(0)} km` : `${prettyMeters} m`;
|
||||
} else {
|
||||
scaleBar.style.display = "none";
|
||||
}
|
||||
|
||||
animationFrame = requestAnimationFrame(animate);
|
||||
}
|
||||
|
||||
function clearPoints(): void {
|
||||
if (!pointsObject) return;
|
||||
pointsObject.geometry.dispose();
|
||||
@@ -317,325 +144,148 @@ export function createSurfacePointCloudViewer(): SurfacePointCloudViewer {
|
||||
pointsObject = null;
|
||||
}
|
||||
|
||||
function renderPointCloudInternal(data: SurfacePointCloudSampleResponse): void {
|
||||
clearPoints();
|
||||
if (data.points.length === 0) return;
|
||||
function setCameraView(view: CameraView): void {
|
||||
const directions: Record<CameraView, [number, number, number]> = {
|
||||
iso: [120, 95, 135],
|
||||
top: [0, 1, 0.00001],
|
||||
front: [0, 60, 240],
|
||||
side: [240, 60, 0],
|
||||
};
|
||||
const aspect = viewerArea.clientWidth / Math.max(viewerArea.clientHeight, 1);
|
||||
const distance = referenceBounds ? getTopFitDistance(referenceBounds, aspect) : 190;
|
||||
orbit.target.set(0, 0, 0);
|
||||
camera.position
|
||||
.set(...directions[view])
|
||||
.normalize()
|
||||
.multiplyScalar(distance);
|
||||
camera.near = Math.max(distance / 10000, 0.01);
|
||||
camera.far = distance * 100;
|
||||
camera.updateProjectionMatrix();
|
||||
camera.lookAt(orbit.target);
|
||||
orbit.update();
|
||||
}
|
||||
|
||||
function emitCameraState(): void {
|
||||
if (suppressCameraEvent || !cameraListener) return;
|
||||
const offset = camera.position.clone().sub(orbit.target);
|
||||
const distance = Math.max(offset.length(), 0.001);
|
||||
offset.normalize();
|
||||
cameraListener({
|
||||
direction: [offset.x, offset.y, offset.z],
|
||||
distanceMeters: distance,
|
||||
targetMeters: [orbit.target.x, orbit.target.y, orbit.target.z],
|
||||
});
|
||||
}
|
||||
|
||||
function applyCameraState(state: SurfaceCameraState): void {
|
||||
suppressCameraEvent = true;
|
||||
orbit.target.set(...state.targetMeters);
|
||||
camera.position
|
||||
.set(...state.direction)
|
||||
.multiplyScalar(Math.max(state.distanceMeters, 0.001))
|
||||
.add(orbit.target);
|
||||
camera.lookAt(orbit.target);
|
||||
orbit.update();
|
||||
suppressCameraEvent = false;
|
||||
}
|
||||
|
||||
function renderPointCloud(data: SurfacePointCloudSampleResponse): void {
|
||||
clearPoints();
|
||||
const bounds = data.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 [xMid, yMid, zMid] = getReferenceCenter(bounds);
|
||||
const zSpan = Math.max(zMax - zMin, 1e-9);
|
||||
const scale = 180 / Math.max(xSpan, ySpan, zSpan);
|
||||
|
||||
const hasRgb = data.rgb && data.rgb.length === data.points.length;
|
||||
const densityVal = parseInt(densityInput.value, 10);
|
||||
const step = Math.max(1, Math.ceil(10 / densityVal));
|
||||
const renderPoints: typeof data.points = [];
|
||||
const renderRgb: [number, number, number][] = [];
|
||||
for (let i = 0; i < data.points.length; i += step) {
|
||||
renderPoints.push(data.points[i]);
|
||||
if (hasRgb && data.rgb) {
|
||||
renderRgb.push(data.rgb[i]);
|
||||
}
|
||||
}
|
||||
|
||||
const positions = new Float32Array(renderPoints.length * 3);
|
||||
const colors = new Float32Array(renderPoints.length * 3);
|
||||
|
||||
renderPoints.forEach((point, index) => {
|
||||
const x = point[0];
|
||||
const y = point[1];
|
||||
const z = point[2];
|
||||
|
||||
positions[index * 3] = (x - xMid) * scale;
|
||||
positions[index * 3 + 1] = (z - zMid) * scale;
|
||||
positions[index * 3 + 2] = -(y - yMid) * scale;
|
||||
|
||||
let r = 0;
|
||||
let g = 0;
|
||||
let b = 0;
|
||||
if (hasRgb && renderRgb[index]) {
|
||||
const rgbPoint = renderRgb[index];
|
||||
const maxVal = Math.max(rgbPoint[0], rgbPoint[1], rgbPoint[2]);
|
||||
const divisor = maxVal > 255 ? 65535 : 255;
|
||||
r = rgbPoint[0] / divisor;
|
||||
g = rgbPoint[1] / divisor;
|
||||
b = rgbPoint[2] / divisor;
|
||||
referenceBounds = bounds;
|
||||
const density = Number(densityInput.value);
|
||||
const step = Math.max(1, Math.ceil(10 / density));
|
||||
const count = Math.ceil(data.points.length / step);
|
||||
const positions = new Float32Array(count * 3);
|
||||
const colors = new Float32Array(count * 3);
|
||||
const hasRgb = Boolean(data.rgb && data.rgb.length === data.points.length);
|
||||
let outputIndex = 0;
|
||||
for (let index = 0; index < data.points.length; index += step) {
|
||||
const [x, y, z] = data.points[index];
|
||||
positions[outputIndex * 3] = x - xMid;
|
||||
positions[outputIndex * 3 + 1] = z - zMid;
|
||||
positions[outputIndex * 3 + 2] = -(y - yMid);
|
||||
const rgb = hasRgb ? data.rgb?.[index] : undefined;
|
||||
if (rgb) {
|
||||
const divisor = Math.max(...rgb) > 255 ? 65535 : 255;
|
||||
colors[outputIndex * 3] = rgb[0] / divisor;
|
||||
colors[outputIndex * 3 + 1] = rgb[1] / divisor;
|
||||
colors[outputIndex * 3 + 2] = rgb[2] / divisor;
|
||||
} else {
|
||||
const t = Math.max(0, Math.min(1, (z - zMin) / zSpan));
|
||||
r = (36 + t * 190) / 255;
|
||||
g = (86 + Math.sin(t * Math.PI) * 95) / 255;
|
||||
b = (128 - t * 80) / 255;
|
||||
const ratio = Math.max(0, Math.min(1, (z - zMin) / zSpan));
|
||||
colors[outputIndex * 3] = (36 + ratio * 190) / 255;
|
||||
colors[outputIndex * 3 + 1] = (86 + Math.sin(ratio * Math.PI) * 95) / 255;
|
||||
colors[outputIndex * 3 + 2] = (128 - ratio * 80) / 255;
|
||||
}
|
||||
|
||||
colors[index * 3] = r;
|
||||
colors[index * 3 + 1] = g;
|
||||
colors[index * 3 + 2] = b;
|
||||
});
|
||||
|
||||
outputIndex += 1;
|
||||
}
|
||||
const geometry = new THREE.BufferGeometry();
|
||||
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
|
||||
geometry.setAttribute("color", new THREE.BufferAttribute(colors, 3));
|
||||
geometry.computeBoundingSphere();
|
||||
|
||||
const sizeVal = parseFloat(sizeInput.value);
|
||||
const material = new THREE.PointsMaterial({
|
||||
size: sizeVal,
|
||||
size: Number(sizeInput.value),
|
||||
vertexColors: true,
|
||||
sizeAttenuation: true,
|
||||
});
|
||||
|
||||
pointsObject = new THREE.Points(geometry, material);
|
||||
scene.add(pointsObject);
|
||||
|
||||
// Update status text
|
||||
const nearestMin10 = Math.round(zMin / 10) * 10;
|
||||
const nearestMax10 = Math.round(zMax / 10) * 10;
|
||||
statusSpan.textContent = ` ${renderPoints.length.toLocaleString()}개 점 표시 중 [높이범위: ~${nearestMin10}m ... ${nearestMax10}m~]`;
|
||||
statusSpan.textContent = `${count.toLocaleString()}개 점 표시 중`;
|
||||
}
|
||||
|
||||
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_KEY);
|
||||
};
|
||||
|
||||
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 = "데이터가 존재하지 않습니다.";
|
||||
function updateScaleBar(): void {
|
||||
if (!currentData) {
|
||||
scaleBar.hidden = true;
|
||||
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);
|
||||
}
|
||||
}
|
||||
const distance = camera.position.distanceTo(orbit.target);
|
||||
const metersPerPixel = targetPlaneMetersPerPixel(distance, viewerArea.clientHeight);
|
||||
const meters = niceScaleDistance(100 * metersPerPixel);
|
||||
scaleBar.hidden = false;
|
||||
scaleBar.style.width = `${meters / metersPerPixel}px`;
|
||||
scaleText.textContent = meters >= 1000 ? `${meters / 1000} km` : `${meters} m`;
|
||||
}
|
||||
|
||||
// Event Listeners
|
||||
btnIso.addEventListener("click", () => setCameraView("iso"));
|
||||
btnTop.addEventListener("click", () => setCameraView("top"));
|
||||
btnFront.addEventListener("click", () => setCameraView("front"));
|
||||
btnSide.addEventListener("click", () => setCameraView("side"));
|
||||
btnReset.addEventListener("click", () => setCameraView("iso"));
|
||||
function animate(): void {
|
||||
if (!root.isConnected) {
|
||||
if (!hasConnected) animationFrame = requestAnimationFrame(animate);
|
||||
else disposeViewer();
|
||||
return;
|
||||
}
|
||||
hasConnected = true;
|
||||
resize();
|
||||
orbit.update();
|
||||
updateScaleBar();
|
||||
renderer.render(scene, camera);
|
||||
animationFrame = requestAnimationFrame(animate);
|
||||
}
|
||||
|
||||
axesCheck.addEventListener("change", () => {
|
||||
axes.visible = axesCheck.checked;
|
||||
function disposeViewer(): void {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
cancelAnimationFrame(animationFrame);
|
||||
clearPoints();
|
||||
orbit.dispose();
|
||||
renderer.dispose();
|
||||
}
|
||||
|
||||
viewButtons.forEach(([view, button]) => {
|
||||
button.addEventListener("click", () => setCameraView(view));
|
||||
});
|
||||
|
||||
sizeInput.addEventListener("input", () => {
|
||||
if (pointsObject) {
|
||||
(pointsObject.material as THREE.PointsMaterial).size = parseFloat(sizeInput.value);
|
||||
}
|
||||
if (pointsObject)
|
||||
(pointsObject.material as THREE.PointsMaterial).size = Number(sizeInput.value);
|
||||
});
|
||||
|
||||
densityInput.addEventListener("input", () => {
|
||||
const val = parseInt(densityInput.value, 10);
|
||||
const valSpan = densityLabel.querySelector(".viewer-option-val");
|
||||
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);
|
||||
}
|
||||
}
|
||||
const label = densityLabel.querySelector(".viewer-option-val");
|
||||
if (label) label.textContent = `${Number(densityInput.value) * 10}%`;
|
||||
if (currentData) renderPointCloud(currentData);
|
||||
});
|
||||
|
||||
orbit.addEventListener("change", emitCameraState);
|
||||
animationFrame = requestAnimationFrame(animate);
|
||||
|
||||
return {
|
||||
@@ -643,16 +293,32 @@ export function createSurfacePointCloudViewer(): SurfacePointCloudViewer {
|
||||
controlsGroup,
|
||||
optionsGroup,
|
||||
statusSpan,
|
||||
render,
|
||||
updateBgMap,
|
||||
updateGisLayer,
|
||||
dispose: () => {
|
||||
cancelAnimationFrame(animationFrame);
|
||||
render(data) {
|
||||
currentData = data;
|
||||
clearPoints();
|
||||
clearBgMap();
|
||||
clearGisLayers();
|
||||
controls.dispose();
|
||||
renderer.dispose();
|
||||
if (!data) {
|
||||
statusSpan.textContent = "데이터가 존재하지 않습니다.";
|
||||
return;
|
||||
}
|
||||
renderPointCloud(data);
|
||||
setCameraView("top");
|
||||
},
|
||||
applyCameraState,
|
||||
onCameraChange(listener) {
|
||||
cameraListener = listener;
|
||||
},
|
||||
setAxesVisible(visible) {
|
||||
axes.visible = visible;
|
||||
},
|
||||
resetOptions() {
|
||||
axes.visible = false;
|
||||
sizeInput.value = "0.42";
|
||||
densityInput.value = "10";
|
||||
const label = densityLabel.querySelector(".viewer-option-val");
|
||||
if (label) label.textContent = "100%";
|
||||
if (currentData) renderPointCloud(currentData);
|
||||
setCameraView("iso");
|
||||
},
|
||||
dispose: disposeViewer,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -129,6 +129,7 @@ def _d8_flow_accumulation_whitebox(
|
||||
count=1,
|
||||
dtype="float32",
|
||||
nodata=nodata,
|
||||
crs="EPSG:3857",
|
||||
transform=transform,
|
||||
) as dst:
|
||||
dst.write(z_out, 1)
|
||||
|
||||
+10
-9
@@ -99,8 +99,9 @@ SURFACE_RANSAC_SEED = int(os.getenv("SURFACE_RANSAC_SEED", "42"))
|
||||
SURFACE_MODEL_SOURCE_FILTERS = tuple(
|
||||
os.getenv("SURFACE_MODEL_SOURCE_FILTERS", "grid_min_z,csf,pmf").split(",")
|
||||
)
|
||||
# dtm을 먼저 빌드해야 TIN 등고선 사전 캐시가 dtm footprint를 참조할 수 있다 (PLAN D-6)
|
||||
SURFACE_MODEL_PRECOMPUTE = tuple(
|
||||
os.getenv("SURFACE_MODEL_PRECOMPUTE", "tin,dtm,nurbs,implicit,meshfree").split(",")
|
||||
os.getenv("SURFACE_MODEL_PRECOMPUTE", "dtm,tin,nurbs,implicit,meshfree").split(",")
|
||||
)
|
||||
SURFACE_MODEL_SMOOTHING_METHODS = tuple(
|
||||
os.getenv("SURFACE_MODEL_SMOOTHING_METHODS", "dtm,tin").split(",")
|
||||
@@ -115,10 +116,10 @@ SURFACE_KEEP_LARGEST_FOOTPRINT = (
|
||||
os.getenv("SURFACE_KEEP_LARGEST_FOOTPRINT", "True").lower() == "true"
|
||||
)
|
||||
SURFACE_TILE_SIZE_M = float(os.getenv("SURFACE_TILE_SIZE_M", "50.0"))
|
||||
SURFACE_MAX_PREVIEW_VERTICES = int(os.getenv("SURFACE_MAX_PREVIEW_VERTICES", "120000"))
|
||||
SURFACE_MAX_PREVIEW_VERTICES = int(os.getenv("SURFACE_MAX_PREVIEW_VERTICES", "500000"))
|
||||
|
||||
# 표현별 파라미터
|
||||
SURFACE_TIN_MAX_INPUT_POINTS = int(os.getenv("SURFACE_TIN_MAX_INPUT_POINTS", "200000"))
|
||||
# 표현별 파라미터 (old 버전 검증값 기준 — PLAN F)
|
||||
SURFACE_TIN_MAX_INPUT_POINTS = int(os.getenv("SURFACE_TIN_MAX_INPUT_POINTS", "500000"))
|
||||
SURFACE_DTM_GRID_RESOLUTION_M = float(os.getenv("SURFACE_DTM_GRID_RESOLUTION_M", "1.0"))
|
||||
SURFACE_NURBS_DEGREE = int(os.getenv("SURFACE_NURBS_DEGREE", "3"))
|
||||
SURFACE_NURBS_PATCH_SIZE_M = float(os.getenv("SURFACE_NURBS_PATCH_SIZE_M", "50.0"))
|
||||
@@ -126,11 +127,11 @@ SURFACE_NURBS_CONTROL_POINTS_PER_AXIS = int(
|
||||
os.getenv("SURFACE_NURBS_CONTROL_POINTS_PER_AXIS", "16")
|
||||
)
|
||||
SURFACE_IMPLICIT_MAX_POINTS_PER_TILE = int(
|
||||
os.getenv("SURFACE_IMPLICIT_MAX_POINTS_PER_TILE", "20000")
|
||||
os.getenv("SURFACE_IMPLICIT_MAX_POINTS_PER_TILE", "10000")
|
||||
)
|
||||
SURFACE_IMPLICIT_SMOOTHING = float(os.getenv("SURFACE_IMPLICIT_SMOOTHING", "0.5"))
|
||||
SURFACE_MESHFREE_MAX_MODEL_POINTS = int(os.getenv("SURFACE_MESHFREE_MAX_MODEL_POINTS", "300000"))
|
||||
SURFACE_MESHFREE_POINT_RADIUS_M = float(os.getenv("SURFACE_MESHFREE_POINT_RADIUS_M", "0.5"))
|
||||
SURFACE_IMPLICIT_SMOOTHING = float(os.getenv("SURFACE_IMPLICIT_SMOOTHING", "0.1"))
|
||||
SURFACE_MESHFREE_MAX_MODEL_POINTS = int(os.getenv("SURFACE_MESHFREE_MAX_MODEL_POINTS", "500000"))
|
||||
SURFACE_MESHFREE_POINT_RADIUS_M = float(os.getenv("SURFACE_MESHFREE_POINT_RADIUS_M", "0.15"))
|
||||
|
||||
# 스무딩 파라미터
|
||||
SURFACE_SMOOTHING_DTM_SIGMA_M = float(os.getenv("SURFACE_SMOOTHING_DTM_SIGMA_M", "0.5"))
|
||||
@@ -145,7 +146,7 @@ SURFACE_SMOOTHING_TIN_TAUBIN_LAMBDA = float(os.getenv("SURFACE_SMOOTHING_TIN_TAU
|
||||
SURFACE_SMOOTHING_TIN_TAUBIN_MU = float(os.getenv("SURFACE_SMOOTHING_TIN_TAUBIN_MU", "-0.53"))
|
||||
|
||||
# 등고선 파라미터
|
||||
SURFACE_CONTOUR_INTERVAL_M = float(os.getenv("SURFACE_CONTOUR_INTERVAL_M", "5.0"))
|
||||
SURFACE_CONTOUR_INTERVAL_M = float(os.getenv("SURFACE_CONTOUR_INTERVAL_M", "1.0"))
|
||||
SURFACE_CONTOUR_GRID_RESOLUTION_M = float(os.getenv("SURFACE_CONTOUR_GRID_RESOLUTION_M", "1.0"))
|
||||
|
||||
|
||||
|
||||
+19
-19
@@ -79,14 +79,19 @@
|
||||
"ast_hash": "93732554454116e3543ac2269b9d1f0d",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/wiki/concepts/design.md": {
|
||||
"mtime": 1784260030.4069157,
|
||||
"ast_hash": "85aec17d904c548e299a4e3810669cc0",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/wiki/concepts/schema_common.md": {
|
||||
"mtime": 1783844389.0,
|
||||
"ast_hash": "ebde162a912c33c17bd42e3edb469bc6",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/wiki/concepts/storage_paths.md": {
|
||||
"mtime": 1783850555.0,
|
||||
"ast_hash": "2cf2c535406b15d5f2d7d5cde19fd4d2",
|
||||
"mtime": 1784276227.6785357,
|
||||
"ast_hash": "a0fe28424a580b6dce5ddbcb66f886e5",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/wiki/concepts/ui_templates.md": {
|
||||
@@ -100,13 +105,13 @@
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/wiki/index.md": {
|
||||
"mtime": 1784267752.6659317,
|
||||
"ast_hash": "bc91a2dfed2319cb07c08e3d148d24ab",
|
||||
"mtime": 1784273610.479596,
|
||||
"ast_hash": "635b063e8e9d1e504fa08e04f6e6673f",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/wiki/log.md": {
|
||||
"mtime": 1784267757.9505198,
|
||||
"ast_hash": "5db722c00a166bd1bd486e71a0613148",
|
||||
"mtime": 1784281064.938371,
|
||||
"ast_hash": "d0a674d4bde81eea7e8bd2edf76d6287",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/wiki/pages/A01_Home/A01_components.md": {
|
||||
@@ -250,13 +255,13 @@
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/wiki/pages/B04_wf1_Surface/B04_api.md": {
|
||||
"mtime": 1784267047.0904605,
|
||||
"ast_hash": "5558d1f973ce53a60c263b62f71b9b8d",
|
||||
"mtime": 1784281082.294356,
|
||||
"ast_hash": "688e57fa31f595d8a2eb50fe1534bae8",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/wiki/pages/B04_wf1_Surface/B04_backend.md": {
|
||||
"mtime": 1784267712.4147556,
|
||||
"ast_hash": "0de152bccf2967c5989e2ed9f0253781",
|
||||
"mtime": 1784281075.9390342,
|
||||
"ast_hash": "179006cfa5579947f12703de677b9178",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/wiki/pages/B04_wf1_Surface/B04_db.md": {
|
||||
@@ -270,8 +275,8 @@
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/wiki/pages/B04_wf1_Surface/B04_frontend.md": {
|
||||
"mtime": 1784267717.920956,
|
||||
"ast_hash": "4891c5490bbe949e6c7a7f709270dac8",
|
||||
"mtime": 1784278834.8752944,
|
||||
"ast_hash": "bb89b16cc5a0d9763cb26c174a537f54",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/wiki/pages/B05_wf2_Route/B05_api.md": {
|
||||
@@ -335,8 +340,8 @@
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/wiki/pages/B08_wf5_Quantity/B08_frontend.md": {
|
||||
"mtime": 1784259186.086623,
|
||||
"ast_hash": "d7dfc0b92c6297f7fa95444c2916461e",
|
||||
"mtime": 1784280350.6155877,
|
||||
"ast_hash": "82f0c3c8358a8f6b16395623d73f049f",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/wiki/pages/B09_wf6_Estimation/B09_frontend.md": {
|
||||
@@ -344,11 +349,6 @@
|
||||
"ast_hash": "c17bf17a317f2190131be87d2e23e9bb",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/wiki/concepts/design.md": {
|
||||
"mtime": 1784260030.4069157,
|
||||
"ast_hash": "85aec17d904c548e299a4e3810669cc0",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/wiki/pages/B10_Payment/B10_frontend.md": {
|
||||
"mtime": 1784264923.3316023,
|
||||
"ast_hash": "922e565933ffcf90a3fa96307ae4dee3",
|
||||
|
||||
@@ -28,7 +28,9 @@ from B01_Dashboard.B01_Dashboard_Router import router as b01_dashboard_router
|
||||
from B02_ProjRegister.B02_ProjRegister_Router import router as b02_proj_register_router
|
||||
from B03_FileInput.B03_FileInput_Router import router as b03_file_input_router
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Router import router as b04_surface_router
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Router import tiles_router
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Router_Contour import router as b04_surface_contour_router
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Router_GIS import router as b04_surface_gis_router
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Router_GIS import tiles_router
|
||||
from B05_wf2_Route.B05_wf2_Route_Router import router as b05_route_router
|
||||
from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Router import router as b06_section_router
|
||||
from common_util.common_util_auth import require_company, verify_session
|
||||
@@ -245,6 +247,8 @@ protected = [Depends(verify_session)]
|
||||
protected_with_company = [Depends(verify_session), Depends(require_company)]
|
||||
app.include_router(b03_file_input_router, dependencies=protected_with_company)
|
||||
app.include_router(b04_surface_router, dependencies=protected_with_company)
|
||||
app.include_router(b04_surface_contour_router, dependencies=protected_with_company)
|
||||
app.include_router(b04_surface_gis_router, dependencies=protected_with_company)
|
||||
app.include_router(tiles_router, dependencies=protected_with_company)
|
||||
app.include_router(b05_route_router, dependencies=protected_with_company)
|
||||
app.include_router(b06_section_router, dependencies=protected_with_company)
|
||||
@@ -262,4 +266,5 @@ if __name__ == "__main__":
|
||||
host=SERVER_HOST,
|
||||
port=SERVER_PORT,
|
||||
reload=DEBUG,
|
||||
access_log=False,
|
||||
)
|
||||
|
||||
+9
-8
@@ -13,17 +13,17 @@ fastapi==0.104.1
|
||||
bcrypt>=4.1,<5
|
||||
email-validator>=2.1,<3
|
||||
fiona==1.10.1
|
||||
geopandas==0.14.0
|
||||
geopandas==1.1.4
|
||||
h11==0.16.0
|
||||
idna==3.18
|
||||
ImageIO==2.37.3
|
||||
Jinja2==3.1.2
|
||||
laspy==2.4.1
|
||||
laspy==2.7.0
|
||||
mapbox-vector-tile>=2.0.1
|
||||
lazy-loader==0.5
|
||||
MarkupSafe==3.0.3
|
||||
networkx==3.6.1
|
||||
numpy==1.26.4
|
||||
numpy==2.5.0
|
||||
openpyxl==3.1.2
|
||||
packaging==26.2
|
||||
pandas==3.0.3
|
||||
@@ -34,23 +34,24 @@ pydantic_core==2.14.1
|
||||
PyMySQL==1.2.0
|
||||
pyparsing==3.3.2
|
||||
pyproj==3.7.2
|
||||
pyogrio==0.13.0
|
||||
python-dateutil==2.9.0.post0
|
||||
python-dotenv==1.0.0
|
||||
python-multipart==0.0.6
|
||||
rasterio==1.3.9
|
||||
rasterio==1.5.0
|
||||
ruff>=0.12,<1
|
||||
scikit-image==0.26.0
|
||||
scipy==1.13.1
|
||||
scipy==1.18.0
|
||||
setuptools==83.0.0
|
||||
shapely==2.0.6
|
||||
shapely==2.1.2
|
||||
six==1.17.0
|
||||
sniffio==1.3.1
|
||||
snuggs==1.4.7
|
||||
starlette==0.27.0
|
||||
tifffile==2024.8.28
|
||||
trimesh==3.23.0
|
||||
trimesh==4.12.2
|
||||
typing_extensions==4.16.0
|
||||
tzdata==2026.2
|
||||
uvicorn==0.24.0
|
||||
wheel==0.47.0
|
||||
whitebox==2.3.0
|
||||
whitebox==2.3.6
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import sys
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# Add project root to sys.path
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from B03_FileInput.B03_FileInput_Engine_Analyze import (
|
||||
analyze_prj_metadata,
|
||||
analyze_tif_metadata,
|
||||
analyze_las_metadata,
|
||||
)
|
||||
|
||||
BASE_DIR = Path("D:/02_Software_Prog/임도설계 및 견적자동화 프로그램 개발")
|
||||
PRJ_PATH = BASE_DIR / "storage/1/3/acb9170b-9ac8-49b3-82a0-51cfa32bb42d/B03_FileInput/input/prj/result.prj"
|
||||
TIF_PATH = BASE_DIR / "storage/1/3/acb9170b-9ac8-49b3-82a0-51cfa32bb42d/B03_FileInput/input/tif/result.tif"
|
||||
LAS_PATH = BASE_DIR / "storage/1/3/acb9170b-9ac8-49b3-82a0-51cfa32bb42d/B03_FileInput/input/las/cloud_merged.las"
|
||||
|
||||
def run_tests():
|
||||
print("--- 1. Testing PRJ Metadata Extraction ---")
|
||||
if PRJ_PATH.exists():
|
||||
prj_meta = analyze_prj_metadata(PRJ_PATH)
|
||||
print("PRJ Metadata:")
|
||||
for k, v in prj_meta.items():
|
||||
print(f" {k}: {v}")
|
||||
|
||||
# Assertions based on PLAN.md expectations
|
||||
assert prj_meta.get("epsg") == 5187, f"Expected horizontal EPSG to be 5187, got {prj_meta.get('epsg')}"
|
||||
assert prj_meta.get("crs_status") == "custom_vertical_crs", f"Expected custom_vertical_crs, got {prj_meta.get('crs_status')}"
|
||||
assert prj_meta.get("vertical_crs") is not None, "Expected vertical_crs metadata to be present"
|
||||
assert "KNGeoid24" in prj_meta["vertical_crs"]["name"], f"Expected KNGeoid24 in vertical crs name, got {prj_meta['vertical_crs']['name']}"
|
||||
print("PRJ test passed successfully.")
|
||||
else:
|
||||
print(f"PRJ file not found at {PRJ_PATH}")
|
||||
|
||||
print("\n--- 2. Testing TIF Metadata Extraction ---")
|
||||
if TIF_PATH.exists():
|
||||
tif_meta = analyze_tif_metadata(TIF_PATH)
|
||||
print("TIF Metadata:")
|
||||
for k, v in tif_meta.items():
|
||||
print(f" {k}: {v}")
|
||||
|
||||
assert tif_meta.get("epsg") == 5187, f"Expected EPSG to be 5187, got {tif_meta.get('epsg')}"
|
||||
assert tif_meta.get("crs_status") == "identified", f"Expected identified, got {tif_meta.get('crs_status')}"
|
||||
assert tif_meta.get("vertical_crs") is None, "Expected no vertical crs for TIF file"
|
||||
print("TIF test passed successfully.")
|
||||
else:
|
||||
print(f"TIF file not found at {TIF_PATH}")
|
||||
|
||||
print("\n--- 3. Testing LAS Metadata Extraction ---")
|
||||
if LAS_PATH.exists():
|
||||
las_meta = analyze_las_metadata(LAS_PATH)
|
||||
print("LAS Metadata:")
|
||||
for k, v in las_meta.items():
|
||||
# Exclude large dimensions output for print readability
|
||||
if k == "point_format":
|
||||
print(f" {k}: id={v.get('id')}, num_dimensions={len(v.get('dimensions', []))}")
|
||||
else:
|
||||
print(f" {k}: {v}")
|
||||
|
||||
assert las_meta.get("epsg") == 5187, f"Expected EPSG to be 5187, got {las_meta.get('epsg')}"
|
||||
assert las_meta.get("crs_status") == "custom_vertical_crs", f"Expected custom_vertical_crs, got {las_meta.get('crs_status')}"
|
||||
assert las_meta.get("vertical_crs") is not None, "Expected vertical_crs metadata to be present"
|
||||
assert "KNGeoid24" in las_meta["vertical_crs"]["name"], f"Expected KNGeoid24 in vertical crs name, got {las_meta['vertical_crs']['name']}"
|
||||
print("LAS test passed successfully.")
|
||||
else:
|
||||
print(f"LAS file not found at {LAS_PATH}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_tests()
|
||||
@@ -0,0 +1,157 @@
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
# 위키 루트 경로 설정
|
||||
wiki_root = Path(r"D:\02_Software_Prog\임도설계 및 견적자동화 프로그램 개발\docs\wiki")
|
||||
raw_root = Path(r"D:\02_Software_Prog\임도설계 및 견적자동화 프로그램 개발\docs\raw")
|
||||
|
||||
def load_frontmatter(file_path):
|
||||
content = file_path.read_text(encoding="utf-8")
|
||||
# Frontmatter regex
|
||||
match = re.match(r"^---\s*\n(.*?)\n---\s*\n", content, re.DOTALL)
|
||||
if match:
|
||||
fm_text = match.group(1)
|
||||
fm = {}
|
||||
# Simple YAML key-value parser for basic string/list properties
|
||||
for line in fm_text.splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
if ":" in line:
|
||||
key, val = line.split(":", 1)
|
||||
key = key.strip()
|
||||
val = val.strip().strip("'\"")
|
||||
fm[key] = val
|
||||
return fm, content[match.end():]
|
||||
return None, content
|
||||
|
||||
def extract_wiki_links(text):
|
||||
# Matches [[link]] or [[link|alias]] or [[link#section]] or [[link#section|alias]]
|
||||
links = re.findall(r"\[\[(.*?)\]\]", text)
|
||||
cleaned_links = []
|
||||
for l in links:
|
||||
# Split alias
|
||||
if "|" in l:
|
||||
l = l.split("|")[0]
|
||||
# Split section
|
||||
if "#" in l:
|
||||
l = l.split("#")[0]
|
||||
l = l.strip()
|
||||
if l:
|
||||
cleaned_links.append(l)
|
||||
return cleaned_links
|
||||
|
||||
def run_lint():
|
||||
print("=== Start Wiki Linting (No PyYAML, Filtered) ===")
|
||||
|
||||
all_files = list(wiki_root.glob("**/*.md"))
|
||||
index_file = wiki_root / "index.md"
|
||||
log_file = wiki_root / "log.md"
|
||||
|
||||
# 1. 파일 목록화 및 Frontmatter 체크
|
||||
pages = {}
|
||||
concepts = {}
|
||||
errors = []
|
||||
warnings = []
|
||||
|
||||
for f in all_files:
|
||||
rel_path = f.relative_to(wiki_root).as_posix()
|
||||
# Skip output files generated by graphify in graphify-out
|
||||
if rel_path.startswith("graphify-out/") or rel_path in ["index.md", "log.md", "AGENTS.md", "CLAUDE.md"]:
|
||||
continue
|
||||
|
||||
fm, body = load_frontmatter(f)
|
||||
|
||||
# 기본 규칙 검사
|
||||
if not fm:
|
||||
errors.append(f"Missing Frontmatter: {rel_path}")
|
||||
continue
|
||||
|
||||
# status 값 검사
|
||||
status = fm.get("status")
|
||||
if status not in ["draft", "stable", "stale"]:
|
||||
errors.append(f"Invalid status '{status}' in {rel_path}. Must be draft, stable, or stale.")
|
||||
|
||||
# 100줄 제한 검사 (10% 즉 110줄 허용)
|
||||
lines_count = len(f.read_text(encoding="utf-8").splitlines())
|
||||
if lines_count > 110:
|
||||
warnings.append(f"File exceeds 110 lines ({lines_count} lines): {rel_path}")
|
||||
|
||||
# 페이지와 컨셉 분류
|
||||
if "pages/" in rel_path:
|
||||
pages[rel_path] = {
|
||||
"fm": fm,
|
||||
"body": body,
|
||||
"path": f,
|
||||
"links": extract_wiki_links(body)
|
||||
}
|
||||
# 파일명 형식 규칙 5 검사: {page_id}_{기능명}.md
|
||||
filename = f.name
|
||||
page_id = fm.get("page_id")
|
||||
if not page_id:
|
||||
errors.append(f"Missing page_id in page: {rel_path}")
|
||||
else:
|
||||
expected_prefix = page_id.split("_")[0] # e.g. A01
|
||||
if not filename.startswith(expected_prefix):
|
||||
warnings.append(f"Filename does not match page_id format: {rel_path} (page_id: {page_id})")
|
||||
else:
|
||||
concepts[rel_path] = {
|
||||
"fm": fm,
|
||||
"body": body,
|
||||
"path": f,
|
||||
"links": extract_wiki_links(body)
|
||||
}
|
||||
|
||||
# 2. 위키 링크 정합성 검증 (Broken Links)
|
||||
# 개념/페이지 맵 구성
|
||||
available_links = {}
|
||||
for p_rel in pages:
|
||||
name_no_ext = Path(p_rel).with_suffix("").as_posix()
|
||||
available_links[name_no_ext] = p_rel
|
||||
# Also map short form if distinct
|
||||
short_name = Path(p_rel).name[:-3]
|
||||
available_links[short_name] = p_rel
|
||||
|
||||
for c_rel in concepts:
|
||||
name_no_ext = Path(c_rel).with_suffix("").as_posix()
|
||||
available_links[name_no_ext] = c_rel
|
||||
# Short form
|
||||
short_name = Path(c_rel).name[:-3]
|
||||
available_links[short_name] = c_rel
|
||||
# Subdirectories for concepts like db_schema/*
|
||||
if "concepts/" in name_no_ext:
|
||||
available_links[name_no_ext.replace("concepts/", "")] = c_rel
|
||||
|
||||
# 링크 검사
|
||||
for rel_path, info in {**pages, **concepts}.items():
|
||||
for link in info["links"]:
|
||||
if link.startswith("http://") or link.startswith("https://") or link.startswith("file:///"):
|
||||
continue
|
||||
normalized_link = link.replace("\\", "/")
|
||||
if normalized_link not in available_links and f"concepts/{normalized_link}" not in available_links and f"pages/{normalized_link}" not in available_links:
|
||||
warnings.append(f"Broken Link in {rel_path}: [[{link}]]")
|
||||
|
||||
# 3. index.md 등록 상태 확인
|
||||
index_content = index_file.read_text(encoding="utf-8") if index_file.exists() else ""
|
||||
for rel_path in pages:
|
||||
short_name = Path(rel_path).name[:-3]
|
||||
dir_name = Path(rel_path).parent.name
|
||||
expected_ref_dir = f"{dir_name}/{short_name}"
|
||||
if expected_ref_dir not in index_content and f"[[{short_name}]]" not in index_content and short_name not in index_content:
|
||||
warnings.append(f"Page not indexed in index.md: {rel_path} (Expected link to [[{expected_ref_dir}]] or [[{short_name}]])")
|
||||
|
||||
# 4. 결과 출력
|
||||
print(f"\nScanning completed: {len(all_files)} total markdown files.")
|
||||
print(f"Detected {len(pages)} pages and {len(concepts)} concept documents.")
|
||||
|
||||
print(f"\n--- Errors ({len(errors)}) ---")
|
||||
for e in errors:
|
||||
print(f"[ERROR] {e}")
|
||||
|
||||
print(f"\n--- Warnings ({len(warnings)}) ---")
|
||||
for w in warnings:
|
||||
print(f"[WARN] {w}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_lint()
|
||||
Reference in New Issue
Block a user