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:
2026-07-18 15:37:56 +09:00
25 changed files with 2247 additions and 1633 deletions
+3 -1
View File
@@ -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
+1
View File
@@ -34,6 +34,7 @@ dist/
storage/
0_old/
docs/
graphify-out/
# 로그 파일
*.log
+152 -26
View File
@@ -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]:
+18 -1
View File
@@ -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,
+13 -2
View File
@@ -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",
},
+176 -23
View File
@@ -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
+37 -370
View File
@@ -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;
}
+184 -88
View File
@@ -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() {
+193 -272
View File
@@ -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();
}
+193 -8
View File
@@ -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);
+195 -529
View File
@@ -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 = '밀도&nbsp;<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
View File
@@ -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
View File
@@ -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",
+6 -1
View File
@@ -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
View File
@@ -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
+70
View File
@@ -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()
+157
View File
@@ -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()