260717_2
This commit is contained in:
@@ -160,9 +160,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",
|
||||
},
|
||||
|
||||
@@ -13,10 +13,12 @@ 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_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 config.config_system import build_surface_model_config
|
||||
|
||||
# 진행 콜백 시그니처: (진행률 0~100, 현재 단계 키, 메시지)
|
||||
ProgressCallback = Callable[[int, str, str], None]
|
||||
GROUND_POINT_SAMPLE_LIMIT = 500_000
|
||||
|
||||
|
||||
def _relative_to_project(project_root: Path, path: Path) -> str:
|
||||
@@ -24,6 +26,43 @@ 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:
|
||||
"""필터링된 지면 포인트 미리보기 캐시를 생성하고 경로를 반환한다."""
|
||||
with np.load(structured_path) as structured:
|
||||
xyz = np.asarray(structured["xyz"], dtype=np.float32)
|
||||
if mask is None:
|
||||
mask = build_ground_masks(structured, [filter_key])[filter_key]
|
||||
ground_indexes = np.flatnonzero(mask)
|
||||
ground_point_count = int(len(ground_indexes))
|
||||
if ground_point_count > GROUND_POINT_SAMPLE_LIMIT:
|
||||
rng = np.random.default_rng(20260717)
|
||||
ground_indexes = rng.choice(ground_indexes, GROUND_POINT_SAMPLE_LIMIT, replace=False)
|
||||
points = xyz[ground_indexes]
|
||||
if len(points):
|
||||
bounds = np.column_stack((points.min(axis=0), points.max(axis=0)))
|
||||
else:
|
||||
bounds = np.zeros((3, 2), dtype=np.float64)
|
||||
|
||||
arrays = {
|
||||
"xyz": points,
|
||||
"bounds": np.asarray(bounds, dtype=np.float64),
|
||||
"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,
|
||||
@@ -76,6 +115,8 @@ def run_surface_analysis(
|
||||
_report(40, "ground_filter", "지면 필터 적용 중")
|
||||
masks = build_ground_masks(data, source_filters)
|
||||
ground_summary = summarize_masks(data, masks)
|
||||
for filter_key, mask in masks.items():
|
||||
cache_ground_points(structured_path, filter_key, mask)
|
||||
|
||||
# 3. 지표면 5종 모델 빌드
|
||||
_report(70, "surface_model", "지표면 모델 생성 중")
|
||||
|
||||
@@ -302,3 +302,74 @@ 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 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
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
@@ -13,16 +14,18 @@ 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 B04_wf1_Surface.B04_wf1_Surface_Engine import run_surface_analysis
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Engine import cache_ground_points, run_surface_analysis
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Engine_Contour import (
|
||||
CONTOUR_EXTRACTOR_VERSION,
|
||||
extract_contours,
|
||||
)
|
||||
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,
|
||||
update_project_status,
|
||||
)
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Schema import (
|
||||
SurfaceAnalyzeRequest,
|
||||
@@ -36,6 +39,7 @@ from B04_wf1_Surface.B04_wf1_Surface_Schema import (
|
||||
SurfaceModelSummary,
|
||||
SurfacePointCloudSampleResponse,
|
||||
)
|
||||
from common_util.common_util_atomic import atomic_write_bytes
|
||||
from common_util.common_util_json import atomic_write_json
|
||||
from common_util.common_util_storage import resolve_stored_project_path
|
||||
from common_util.common_util_workflow_state import (
|
||||
@@ -48,7 +52,14 @@ 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
|
||||
MODEL_REPRESENTATIONS = {
|
||||
"meshfree": "meshfree_surfels",
|
||||
"dtm": "regular_grid",
|
||||
"tin": "triangular_mesh",
|
||||
"nurbs": "bspline_surface",
|
||||
"implicit": "local_rbf_height_field",
|
||||
}
|
||||
# 분석 진행률 파일: B04 산출 폴더 아래에 원자적으로 기록/조회한다.
|
||||
PROGRESS_FILE_RELATIVE = ("B04_wf1_Surface", "processed", "progress.json")
|
||||
|
||||
@@ -82,77 +93,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 +262,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 +277,27 @@ 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"
|
||||
if not source_path.is_file():
|
||||
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 +305,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
|
||||
@@ -635,6 +590,7 @@ async def get_surface_model_contour(
|
||||
|
||||
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("_")
|
||||
@@ -653,13 +609,68 @@ async def get_surface_model_contour(
|
||||
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 not math.isfinite(interval) or interval < 0.5:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"status": "error", "message": "등고선 간격은 0.5m 이상이어야 합니다."},
|
||||
)
|
||||
if fallback_files:
|
||||
contour_path = fallback_files[0]
|
||||
contour_model_path = (
|
||||
models_dir / f"{stem}_smooth.npz"
|
||||
if smooth and method in ("dtm", "tin")
|
||||
else model_path
|
||||
)
|
||||
representation = (
|
||||
"regular_grid"
|
||||
if smooth and method == "dtm"
|
||||
else "triangular_mesh"
|
||||
if smooth and method == "tin"
|
||||
else MODEL_REPRESENTATIONS.get(method)
|
||||
)
|
||||
if not contour_model_path.is_file() or representation is None:
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={
|
||||
"status": "error",
|
||||
"message": "등고선 생성에 필요한 모델 파일이 없습니다.",
|
||||
},
|
||||
)
|
||||
contours = await asyncio.to_thread(
|
||||
extract_contours,
|
||||
contour_model_path,
|
||||
representation,
|
||||
interval,
|
||||
1.0,
|
||||
None,
|
||||
)
|
||||
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(
|
||||
@@ -680,202 +691,3 @@ async def get_surface_model_contour(
|
||||
status_code=500,
|
||||
content={"status": "error", "message": "등고선 파일 조회 중 오류가 발생했습니다."},
|
||||
)
|
||||
|
||||
|
||||
# VWorld 메타 API
|
||||
@router.get("/{project_id}/vworld-meta", response_model=None)
|
||||
async def get_vworld_meta(
|
||||
project_id: UUID, layer_name: str = "satellite"
|
||||
) -> dict[str, Any] | JSONResponse:
|
||||
"""VWorld 위성 맵 이미지 매핑 좌표 메타데이터를 반환합니다."""
|
||||
pool = get_db_pool()
|
||||
try:
|
||||
async with pool.acquire() as connection:
|
||||
stored_path = await get_project_storage_relative_path(connection, project_id)
|
||||
project_root = Path(resolve_stored_project_path(stored_path))
|
||||
target_dir = project_root / "B04_wf1_Surface" / "processed"
|
||||
|
||||
target_layer = "white" if layer_name.lower() in ["gray", "white"] else layer_name.lower()
|
||||
meta_name = f"vworld_{target_layer}_meta.json"
|
||||
meta_path = target_dir / meta_name
|
||||
|
||||
if not meta_path.exists() and target_layer == "satellite":
|
||||
meta_path = target_dir / "vworld_meta.json"
|
||||
|
||||
if not meta_path.exists():
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={
|
||||
"status": "error",
|
||||
"message": f"VWorld {layer_name} 메타데이터를 찾을 수 없습니다.",
|
||||
},
|
||||
)
|
||||
return json.loads(meta_path.read_text(encoding="utf-8"))
|
||||
except Exception as exc:
|
||||
return JSONResponse(status_code=500, content={"status": "error", "message": str(exc)})
|
||||
|
||||
|
||||
# VWorld 맵 API
|
||||
@router.get("/{project_id}/vworld-map", response_model=None)
|
||||
async def get_vworld_map(
|
||||
project_id: UUID, layer_name: str = "satellite"
|
||||
) -> FileResponse | JSONResponse:
|
||||
"""배경 지도 레이어 PNG 이미지를 반환합니다."""
|
||||
pool = get_db_pool()
|
||||
try:
|
||||
async with pool.acquire() as connection:
|
||||
stored_path = await get_project_storage_relative_path(connection, project_id)
|
||||
project_root = Path(resolve_stored_project_path(stored_path))
|
||||
target_dir = project_root / "B04_wf1_Surface" / "processed"
|
||||
|
||||
target_layer = layer_name.lower()
|
||||
if target_layer in ["gray", "white"]:
|
||||
target_layer = "white"
|
||||
|
||||
map_name = f"vworld_{target_layer}.png"
|
||||
map_path = target_dir / map_name
|
||||
|
||||
if not map_path.exists() and target_layer == "satellite":
|
||||
map_path = target_dir / "vworld_map.png"
|
||||
|
||||
if not map_path.exists():
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={
|
||||
"status": "error",
|
||||
"message": f"VWorld {layer_name} 지도가 존재하지 않습니다.",
|
||||
},
|
||||
)
|
||||
return FileResponse(map_path, media_type="image/png")
|
||||
except Exception as exc:
|
||||
return JSONResponse(status_code=500, content={"status": "error", "message": str(exc)})
|
||||
|
||||
|
||||
# GeoJSON 조회 API
|
||||
@router.get("/{project_id}/geojson", response_model=None)
|
||||
async def get_project_geojson(project_id: UUID, layer: str) -> dict[str, Any] | JSONResponse:
|
||||
"""저장된 프로젝트의 특정 GeoJSON 레이어 데이터를 반환합니다."""
|
||||
pool = get_db_pool()
|
||||
try:
|
||||
async with pool.acquire() as connection:
|
||||
stored_path = await get_project_storage_relative_path(connection, project_id)
|
||||
project_root = Path(resolve_stored_project_path(stored_path))
|
||||
target_dir = project_root / "B04_wf1_Surface" / "processed"
|
||||
|
||||
layer_mapping = {
|
||||
"지적도": "연속지적도_bounds.geojson",
|
||||
"용도지역": "용도지역도_bounds.geojson",
|
||||
"행정구역_시군구": "행정구역_시군구_bounds.geojson",
|
||||
"행정구역_읍면동": "행정구역_읍면동_bounds.geojson",
|
||||
"수계망": "수계망_물줄기_bounds.geojson",
|
||||
"등고선": "등고선_bounds.geojson",
|
||||
"산사태": "산사태위험등급_bounds.geojson",
|
||||
}
|
||||
|
||||
filename = layer_mapping.get(layer)
|
||||
if not filename:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"status": "error", "message": "유효하지 않은 레이어명입니다."},
|
||||
)
|
||||
|
||||
filepath = target_dir / filename
|
||||
if not filepath.exists():
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={
|
||||
"status": "error",
|
||||
"message": f"요청한 레이어({layer}) 파일이 존재하지 않습니다.",
|
||||
},
|
||||
)
|
||||
|
||||
if layer == "등고선":
|
||||
simplified_filepath = target_dir / "등고선_bounds_simplified.geojson"
|
||||
if simplified_filepath.exists():
|
||||
try:
|
||||
return json.loads(simplified_filepath.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
import geopandas as gpd
|
||||
|
||||
gdf = gpd.read_file(filepath)
|
||||
gdf["geometry"] = gdf["geometry"].simplify(
|
||||
tolerance=0.00003, preserve_topology=True
|
||||
)
|
||||
simplified_filepath.write_text(gdf.to_json(), encoding="utf-8")
|
||||
return json.loads(simplified_filepath.read_text(encoding="utf-8"))
|
||||
except Exception as e:
|
||||
logger.warning("등고선 단순화 처리 실패 (원본 전송): %s", e)
|
||||
|
||||
return json.loads(filepath.read_text(encoding="utf-8"))
|
||||
except Exception as exc:
|
||||
return JSONResponse(status_code=500, content={"status": "error", "message": str(exc)})
|
||||
|
||||
|
||||
# 별도 tiles_router 정의 (prefix 없음)
|
||||
tiles_router = APIRouter(tags=["B04 MVT Tiles"])
|
||||
|
||||
|
||||
@tiles_router.get("/tiles/{project_id}/{layer}/{z}/{x}/{y}.pbf", response_model=None)
|
||||
async def get_vector_tile(project_id: UUID, layer: str, z: int, x: int, y: int) -> Response:
|
||||
"""프로젝트의 특정 레이어에 대한 정밀 벡터 타일(MVT) 조각을 동적으로 렌더링하여 반환합니다."""
|
||||
pool = get_db_pool()
|
||||
try:
|
||||
async with pool.acquire() as connection:
|
||||
stored_path = await get_project_storage_relative_path(connection, project_id)
|
||||
project_root = Path(resolve_stored_project_path(stored_path))
|
||||
target_dir = project_root / "B04_wf1_Surface" / "processed"
|
||||
|
||||
layer_mapping = {
|
||||
"지적도": "연속지적도_bounds.geojson",
|
||||
"용도지역": "용도지역도_bounds.geojson",
|
||||
"행정구역_시군구": "행정구역_시군구_bounds.geojson",
|
||||
"행정구역_읍면동": "행정구역_읍면동_bounds.geojson",
|
||||
"수계망": "수계망_물줄기_bounds.geojson",
|
||||
"등고선": "등고선_bounds.geojson",
|
||||
"산사태": "산사태위험등급_bounds.geojson",
|
||||
"임도노선": "임도노선.geojson",
|
||||
}
|
||||
|
||||
filename = layer_mapping.get(layer)
|
||||
if not filename:
|
||||
raise HTTPException(status_code=400, detail="유효하지 않은 레이어명입니다.")
|
||||
|
||||
filepath = target_dir / filename
|
||||
|
||||
if layer == "임도노선" and not filepath.exists():
|
||||
from config.config_system import PROJECT_ROOT
|
||||
|
||||
road_shp_candidates = list(PROJECT_ROOT.glob("samples/**/*_Polyline.shp"))
|
||||
if road_shp_candidates:
|
||||
try:
|
||||
import geopandas as gpd
|
||||
|
||||
gdf = gpd.read_file(road_shp_candidates[0])
|
||||
gdf = gdf.to_crs(epsg=4326)
|
||||
filepath.write_text(gdf.to_json(), encoding="utf-8")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not filepath.exists():
|
||||
import mapbox_vector_tile
|
||||
|
||||
empty_tile = mapbox_vector_tile.encode([]) if mapbox_vector_tile else b""
|
||||
return Response(content=empty_tile, media_type="application/x-protobuf")
|
||||
|
||||
cache_key = f"{project_id}_{layer}"
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Engine_MvtHelper import generate_mvt_tile
|
||||
|
||||
mvt_bytes = generate_mvt_tile(filepath, cache_key, z, x, y, layer_name=layer)
|
||||
return Response(
|
||||
content=mvt_bytes,
|
||||
media_type="application/x-protobuf",
|
||||
headers={"Content-Encoding": "identity"},
|
||||
)
|
||||
except Exception:
|
||||
import mapbox_vector_tile
|
||||
|
||||
empty_tile = mapbox_vector_tile.encode([]) if mapbox_vector_tile else b""
|
||||
return Response(content=empty_tile, media_type="application/x-protobuf")
|
||||
|
||||
@@ -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")
|
||||
@@ -61,6 +61,7 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
|
||||
makeOption("hybrid", L("B04_Surface_Map_Hybrid")),
|
||||
makeOption("white", L("B04_Surface_Map_White")),
|
||||
);
|
||||
backgroundSelect.value = "satellite";
|
||||
backgroundLabel.append(backgroundSelect);
|
||||
|
||||
const gisLabel = document.createElement("label");
|
||||
@@ -74,6 +75,7 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
|
||||
makeOption("행정구역_시군구", L("B04_Surface_Map_Sigungu")),
|
||||
makeOption("행정구역_읍면동", L("B04_Surface_Map_Eupmyeondong")),
|
||||
);
|
||||
gisSelect.value = "지적도";
|
||||
gisLabel.append(gisSelect);
|
||||
|
||||
const resetButton = document.createElement("button");
|
||||
@@ -123,6 +125,15 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
|
||||
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 +141,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 [
|
||||
@@ -194,12 +206,12 @@ 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 metersPerPixel = meta.width_meters / getMapRect(width, height).width / scale;
|
||||
const meters = prettyScaleDistance(100 * metersPerPixel);
|
||||
const pixels = meters / metersPerPixel;
|
||||
scaleBar.hidden = false;
|
||||
@@ -228,7 +240,7 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
|
||||
if (feature.geometry) drawGeometry(context, feature.geometry, width, height);
|
||||
});
|
||||
updateImageTransform();
|
||||
drawScaleBar(width);
|
||||
drawScaleBar(width, height);
|
||||
}
|
||||
|
||||
async function loadLayers(): Promise<void> {
|
||||
|
||||
@@ -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,64 @@ 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;
|
||||
});
|
||||
|
||||
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 +159,7 @@ export async function renderB04Surface(root: HTMLElement): Promise<void> {
|
||||
try {
|
||||
workflowState = await fetchWorkflowState(layoutProjectId);
|
||||
} catch {
|
||||
/* 조회 실패 시 stages 미전달 → 전체 이동 허용 */
|
||||
/* 조회 실패 시 전체 이동 허용 */
|
||||
}
|
||||
}
|
||||
|
||||
@@ -219,9 +173,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 +184,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 +214,128 @@ 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);
|
||||
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);
|
||||
mapViewer.render(projectId);
|
||||
try {
|
||||
pointCloud = await fetchSurfacePointCloud(projectId, filterGroup.select.value);
|
||||
viewer.render(pointCloud);
|
||||
} catch {
|
||||
pointCloud = null;
|
||||
viewer.render(null);
|
||||
}
|
||||
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 +344,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 +365,13 @@ export async function renderB04Surface(root: HTMLElement): Promise<void> {
|
||||
selectedInputFile = inputFiles.find((file) => String(file.id) === inputSelect.value) ?? null;
|
||||
renderInputInfo();
|
||||
});
|
||||
filterGroup.select.addEventListener("change", () => {
|
||||
updateSelectedModel();
|
||||
void updatePointCloudForFilter();
|
||||
});
|
||||
methodGroup.select.addEventListener("change", updateSelectedModel);
|
||||
|
||||
root.replaceChildren(layout.root);
|
||||
const projectId = getProjectId();
|
||||
if (projectId) void onB04_Surface_Refresh_Click();
|
||||
if (projectId) void onB04_Surface_Reset_Click();
|
||||
}
|
||||
|
||||
@@ -88,9 +88,30 @@
|
||||
.b04-surface__workspace {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
min-height: calc(100vh - var(--spacing-64));
|
||||
}
|
||||
|
||||
.b04-surface__workspace > .b04-surface__status {
|
||||
justify-content: flex-end;
|
||||
padding: var(--spacing-12) var(--spacing-24);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.b04-surface__viewers {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--spacing-16);
|
||||
width: 100%;
|
||||
padding: var(--spacing-16) var(--spacing-24);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.b04-surface__viewers > * {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.b04-surface__topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -229,6 +250,14 @@
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.terrain-model-group {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-cards);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.point-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -307,6 +336,12 @@
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.viewer-options label.is-disabled {
|
||||
color: var(--color-text-muted);
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.viewer-option-val {
|
||||
font-variant-numeric: tabular-nums;
|
||||
min-width: 38px;
|
||||
@@ -330,6 +365,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);
|
||||
@@ -410,7 +480,7 @@
|
||||
}
|
||||
|
||||
.b04-map__image {
|
||||
object-fit: fill;
|
||||
object-fit: contain;
|
||||
transform-origin: center;
|
||||
user-select: none;
|
||||
}
|
||||
@@ -468,3 +538,9 @@
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.b04-surface__viewers {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,171 +4,38 @@ 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 { SurfaceCameraState } from "./B04_wf1_Surface_UI_Viewer";
|
||||
|
||||
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;
|
||||
setSelection: (sourceFilter: string, method: string) => void;
|
||||
applyCameraState: (state: SurfaceCameraState) => void;
|
||||
onCameraChange: (listener: (state: SurfaceCameraState) => 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);
|
||||
|
||||
const rightControls = document.createElement("div");
|
||||
rightControls.style.display = "flex";
|
||||
rightControls.style.alignItems = "center";
|
||||
rightControls.style.gap = "var(--spacing-16)";
|
||||
rightControls.className = "viewer-options";
|
||||
|
||||
// Surface Toggle
|
||||
const surfLabel = document.createElement("label");
|
||||
@@ -222,14 +89,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";
|
||||
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";
|
||||
@@ -308,6 +182,10 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
|
||||
// Three.js context variables
|
||||
let currentProjectId = "";
|
||||
let currentModelsList: readonly SurfaceModelSummary[] = [];
|
||||
const sceneCenter = new THREE.Vector3();
|
||||
let cameraListener: ((state: SurfaceCameraState) => void) | null = null;
|
||||
let suppressCameraEvent = false;
|
||||
let smoothPreferred = true;
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
scene.background = new THREE.Color(0xf5f7f9);
|
||||
@@ -379,6 +257,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
|
||||
|
||||
const fitCamera = (object: THREE.Object3D) => {
|
||||
const { center, span } = getFitParams(object);
|
||||
sceneCenter.copy(center);
|
||||
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);
|
||||
@@ -387,21 +266,37 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
|
||||
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);
|
||||
const targetOffset = controls.target.clone().sub(sceneCenter);
|
||||
offset.normalize();
|
||||
cameraListener({
|
||||
direction: [offset.x, offset.y, offset.z],
|
||||
distanceMeters: distance,
|
||||
targetMeters: [targetOffset.x, targetOffset.y, targetOffset.z],
|
||||
});
|
||||
}
|
||||
|
||||
function applyCameraState(state: SurfaceCameraState): void {
|
||||
suppressCameraEvent = true;
|
||||
controls.target.set(...state.targetMeters).add(sceneCenter);
|
||||
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
|
||||
@@ -418,9 +313,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) {
|
||||
@@ -661,14 +559,6 @@ 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;
|
||||
});
|
||||
@@ -680,6 +570,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
|
||||
});
|
||||
|
||||
smoothCheck.addEventListener("change", () => {
|
||||
smoothPreferred = smoothCheck.checked;
|
||||
updateSelectedModel();
|
||||
});
|
||||
|
||||
@@ -695,6 +586,8 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
|
||||
updateSelectedModel();
|
||||
});
|
||||
|
||||
controls.addEventListener("change", emitCameraState);
|
||||
|
||||
// Resize handler
|
||||
const resizeObserver = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
@@ -711,16 +604,35 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
|
||||
|
||||
return {
|
||||
root,
|
||||
optionsGroup,
|
||||
render(projectId, models) {
|
||||
currentProjectId = projectId;
|
||||
currentModelsList = models;
|
||||
updateSelectedModel();
|
||||
},
|
||||
updateBgMap(_projectId, _bgLayer) {
|
||||
// 포인트클라우드 뷰어와 인터페이스 조화를 위해 빈 껍데기 함수 정의
|
||||
setSelection(sourceFilter, method) {
|
||||
activeFilter = sourceFilter;
|
||||
activeMethod = method;
|
||||
syncSmoothingSupport();
|
||||
},
|
||||
updateGisLayer(_projectId, _gisLayer) {
|
||||
// 포인트클라우드 뷰어와 인터페이스 조화를 위해 빈 껍데기 함수 정의
|
||||
applyCameraState,
|
||||
onCameraChange(listener) {
|
||||
cameraListener = listener;
|
||||
},
|
||||
isSmoothingEnabled() {
|
||||
return !smoothCheck.disabled && smoothCheck.checked;
|
||||
},
|
||||
resetOptions() {
|
||||
axesCheck.checked = true;
|
||||
axes.visible = true;
|
||||
surfCheck.checked = true;
|
||||
smoothPreferred = true;
|
||||
syncSmoothingSupport();
|
||||
contourCheck.checked = true;
|
||||
contourGroup.visible = true;
|
||||
intervalInput.value = "5.0";
|
||||
if (terrainMesh) terrainMesh.visible = true;
|
||||
void updateSelectedModel();
|
||||
},
|
||||
dispose() {
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
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 {
|
||||
fetchVWorldMeta,
|
||||
getVWorldMapUrl,
|
||||
fetchGisGeoJson,
|
||||
type SurfacePointCloudSampleResponse,
|
||||
} from "./B04_wf1_Surface_Api_Fetch";
|
||||
import type { SurfacePointCloudSampleResponse } from "./B04_wf1_Surface_Api_Fetch";
|
||||
|
||||
export interface SurfaceCameraState {
|
||||
direction: [number, number, number];
|
||||
distanceMeters: number;
|
||||
targetMeters: [number, number, number];
|
||||
}
|
||||
|
||||
export interface SurfacePointCloudViewer {
|
||||
root: HTMLElement;
|
||||
@@ -15,66 +15,62 @@ export interface SurfacePointCloudViewer {
|
||||
optionsGroup: HTMLElement;
|
||||
statusSpan: HTMLElement;
|
||||
render: (data: SurfacePointCloudSampleResponse | null) => void;
|
||||
updateBgMap: (projectId: string, bgLayer: string) => void;
|
||||
updateGisLayer: (projectId: string, gisLayer: string) => 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;
|
||||
}
|
||||
|
||||
function prettyScaleDistance(roughMeters: number): number {
|
||||
const values = [2, 10, 20, 50, 100, 200, 500, 1000, 2000, 5000, 10000];
|
||||
return values.find((value) => value >= roughMeters) ?? values[values.length - 1];
|
||||
}
|
||||
|
||||
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 controls = document.createElement("div");
|
||||
controls.className = "viewer-controls";
|
||||
const viewButtons: Array<[CameraView, HTMLButtonElement]> = [
|
||||
["iso", makeButton("사시도")],
|
||||
["top", makeButton("상단")],
|
||||
["front", makeButton("정면")],
|
||||
["side", makeButton("측면")],
|
||||
];
|
||||
const resetViewButton = makeButton("리셋");
|
||||
const axesLabel = document.createElement("label");
|
||||
axesLabel.className = "toggle-label";
|
||||
const axesCheck = document.createElement("input");
|
||||
axesCheck.type = "checkbox";
|
||||
axesCheck.checked = true;
|
||||
toggleLabel.append(axesCheck, document.createTextNode(" 축"));
|
||||
|
||||
controlsDiv.append(btnIso, btnTop, btnFront, btnSide, btnReset, toggleLabel);
|
||||
axesLabel.append(axesCheck, document.createTextNode(" 축"));
|
||||
controls.append(...viewButtons.map(([, button]) => button), resetViewButton, axesLabel);
|
||||
|
||||
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 +78,8 @@ export function createSurfacePointCloudViewer(): SurfacePointCloudViewer {
|
||||
sizeInput.step = "0.01";
|
||||
sizeInput.value = "0.42";
|
||||
sizeLabel.append(sizeInput);
|
||||
|
||||
const densityLabel = document.createElement("label");
|
||||
densityLabel.innerHTML = '밀도 <span class="viewer-option-val">100%</span>';
|
||||
densityLabel.innerHTML = '밀도 <span class="viewer-option-val">100%</span>';
|
||||
const densityInput = document.createElement("input");
|
||||
densityInput.type = "range";
|
||||
densityInput.min = "1";
|
||||
@@ -92,124 +87,49 @@ 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 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;
|
||||
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 sceneScale = 1;
|
||||
|
||||
function resize(): void {
|
||||
const rect = viewerArea.getBoundingClientRect();
|
||||
@@ -220,93 +140,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,10 +150,49 @@ export function createSurfacePointCloudViewer(): SurfacePointCloudViewer {
|
||||
pointsObject = null;
|
||||
}
|
||||
|
||||
function renderPointCloudInternal(data: SurfacePointCloudSampleResponse): void {
|
||||
clearPoints();
|
||||
if (data.points.length === 0) return;
|
||||
function setCameraView(view: CameraView): void {
|
||||
const positions: Record<CameraView, [number, number, number]> = {
|
||||
iso: [120, 95, 135],
|
||||
top: [0, 190, 0.001],
|
||||
front: [0, 60, 240],
|
||||
side: [240, 60, 0],
|
||||
};
|
||||
orbit.target.set(0, 0, 0);
|
||||
camera.position.set(...positions[view]);
|
||||
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 / sceneScale,
|
||||
targetMeters: [
|
||||
orbit.target.x / sceneScale,
|
||||
orbit.target.y / sceneScale,
|
||||
orbit.target.z / sceneScale,
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function applyCameraState(state: SurfaceCameraState): void {
|
||||
suppressCameraEvent = true;
|
||||
orbit.target.set(...state.targetMeters).multiplyScalar(sceneScale);
|
||||
camera.position
|
||||
.set(...state.direction)
|
||||
.multiplyScalar(Math.max(state.distanceMeters * sceneScale, 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;
|
||||
@@ -328,314 +200,115 @@ export function createSurfacePointCloudViewer(): SurfacePointCloudViewer {
|
||||
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 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;
|
||||
const scale = 180 / Math.max(xMax - xMin, yMax - yMin, zSpan, 1e-9);
|
||||
sceneScale = scale;
|
||||
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) * scale;
|
||||
positions[outputIndex * 3 + 1] = (z - zMid) * scale;
|
||||
positions[outputIndex * 3 + 2] = -(y - yMid) * scale;
|
||||
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 bounds = currentData.bounds;
|
||||
const span = Math.max(
|
||||
bounds.x_max - bounds.x_min,
|
||||
bounds.y_max - bounds.y_min,
|
||||
bounds.z_max - bounds.z_min,
|
||||
1e-9,
|
||||
);
|
||||
const internalScale = 180 / span;
|
||||
const width = viewerArea.clientWidth || 1;
|
||||
const distance = camera.position.distanceTo(orbit.target);
|
||||
const sceneUnitsPerPixel = (2 * Math.tan((camera.fov * Math.PI) / 360) * distance) / width;
|
||||
const metersPerPixel = sceneUnitsPerPixel / internalScale;
|
||||
const meters = prettyScaleDistance(100 * metersPerPixel);
|
||||
scaleBar.hidden = false;
|
||||
scaleBar.style.width = `${(meters * internalScale) / sceneUnitsPerPixel}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);
|
||||
}
|
||||
|
||||
function disposeViewer(): void {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
cancelAnimationFrame(animationFrame);
|
||||
clearPoints();
|
||||
orbit.dispose();
|
||||
renderer.dispose();
|
||||
}
|
||||
|
||||
viewButtons.forEach(([view, button]) => {
|
||||
button.addEventListener("click", () => setCameraView(view));
|
||||
});
|
||||
resetViewButton.addEventListener("click", () => setCameraView("iso"));
|
||||
axesCheck.addEventListener("change", () => {
|
||||
axes.visible = axesCheck.checked;
|
||||
});
|
||||
|
||||
sizeInput.addEventListener("input", () => {
|
||||
if (pointsObject) {
|
||||
(pointsObject.material as THREE.PointsMaterial).size = 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 +316,30 @@ 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;
|
||||
},
|
||||
resetOptions() {
|
||||
axesCheck.checked = true;
|
||||
axes.visible = true;
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
+11
-11
@@ -79,6 +79,11 @@
|
||||
"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",
|
||||
@@ -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": 1784273614.860682,
|
||||
"ast_hash": "509c18e4f8dc914ee6fc8d0abed6abfe",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/wiki/pages/A01_Home/A01_components.md": {
|
||||
@@ -270,8 +275,8 @@
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/wiki/pages/B04_wf1_Surface/B04_frontend.md": {
|
||||
"mtime": 1784267717.920956,
|
||||
"ast_hash": "4891c5490bbe949e6c7a7f709270dac8",
|
||||
"mtime": 1784273544.11365,
|
||||
"ast_hash": "c7505151247ab901363aaf50e942013c",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/wiki/pages/B05_wf2_Route/B05_api.md": {
|
||||
@@ -344,11 +349,6 @@
|
||||
"ast_hash": "c17bf17a317f2190131be87d2e23e9bb",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/wiki/concepts/design.md": {
|
||||
"mtime": 1784260030.4069157,
|
||||
"ast_hash": "85aec17d904c548e299a4e3810669cc0",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/wiki/pages/B10_Payment/B10_frontend.md": {
|
||||
"mtime": 1784264923.3316023,
|
||||
"ast_hash": "922e565933ffcf90a3fa96307ae4dee3",
|
||||
|
||||
@@ -28,7 +28,8 @@ 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_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 +246,7 @@ 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_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)
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
# 위키 루트 경로 설정
|
||||
wiki_root = Path(r"D:\02_Software_Prog\임도설계 및 견적자동화 프로그램 개발\docs\wiki")
|
||||
raw_root = Path(r"D:\02_Software_Prog\임도설계 및 견적자동화 프로그램 개발\docs\raw")
|
||||
|
||||
def load_frontmatter(file_path):
|
||||
content = file_path.read_text(encoding="utf-8")
|
||||
# Frontmatter regex
|
||||
match = re.match(r"^---\s*\n(.*?)\n---\s*\n", content, re.DOTALL)
|
||||
if match:
|
||||
fm_text = match.group(1)
|
||||
fm = {}
|
||||
# Simple YAML key-value parser for basic string/list properties
|
||||
for line in fm_text.splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
if ":" in line:
|
||||
key, val = line.split(":", 1)
|
||||
key = key.strip()
|
||||
val = val.strip().strip("'\"")
|
||||
fm[key] = val
|
||||
return fm, content[match.end():]
|
||||
return None, content
|
||||
|
||||
def extract_wiki_links(text):
|
||||
# Matches [[link]] or [[link|alias]] or [[link#section]] or [[link#section|alias]]
|
||||
links = re.findall(r"\[\[(.*?)\]\]", text)
|
||||
cleaned_links = []
|
||||
for l in links:
|
||||
# Split alias
|
||||
if "|" in l:
|
||||
l = l.split("|")[0]
|
||||
# Split section
|
||||
if "#" in l:
|
||||
l = l.split("#")[0]
|
||||
l = l.strip()
|
||||
if l:
|
||||
cleaned_links.append(l)
|
||||
return cleaned_links
|
||||
|
||||
def run_lint():
|
||||
print("=== Start Wiki Linting (No PyYAML, Filtered) ===")
|
||||
|
||||
all_files = list(wiki_root.glob("**/*.md"))
|
||||
index_file = wiki_root / "index.md"
|
||||
log_file = wiki_root / "log.md"
|
||||
|
||||
# 1. 파일 목록화 및 Frontmatter 체크
|
||||
pages = {}
|
||||
concepts = {}
|
||||
errors = []
|
||||
warnings = []
|
||||
|
||||
for f in all_files:
|
||||
rel_path = f.relative_to(wiki_root).as_posix()
|
||||
# Skip output files generated by graphify in graphify-out
|
||||
if rel_path.startswith("graphify-out/") or rel_path in ["index.md", "log.md", "AGENTS.md", "CLAUDE.md"]:
|
||||
continue
|
||||
|
||||
fm, body = load_frontmatter(f)
|
||||
|
||||
# 기본 규칙 검사
|
||||
if not fm:
|
||||
errors.append(f"Missing Frontmatter: {rel_path}")
|
||||
continue
|
||||
|
||||
# status 값 검사
|
||||
status = fm.get("status")
|
||||
if status not in ["draft", "stable", "stale"]:
|
||||
errors.append(f"Invalid status '{status}' in {rel_path}. Must be draft, stable, or stale.")
|
||||
|
||||
# 100줄 제한 검사 (10% 즉 110줄 허용)
|
||||
lines_count = len(f.read_text(encoding="utf-8").splitlines())
|
||||
if lines_count > 110:
|
||||
warnings.append(f"File exceeds 110 lines ({lines_count} lines): {rel_path}")
|
||||
|
||||
# 페이지와 컨셉 분류
|
||||
if "pages/" in rel_path:
|
||||
pages[rel_path] = {
|
||||
"fm": fm,
|
||||
"body": body,
|
||||
"path": f,
|
||||
"links": extract_wiki_links(body)
|
||||
}
|
||||
# 파일명 형식 규칙 5 검사: {page_id}_{기능명}.md
|
||||
filename = f.name
|
||||
page_id = fm.get("page_id")
|
||||
if not page_id:
|
||||
errors.append(f"Missing page_id in page: {rel_path}")
|
||||
else:
|
||||
expected_prefix = page_id.split("_")[0] # e.g. A01
|
||||
if not filename.startswith(expected_prefix):
|
||||
warnings.append(f"Filename does not match page_id format: {rel_path} (page_id: {page_id})")
|
||||
else:
|
||||
concepts[rel_path] = {
|
||||
"fm": fm,
|
||||
"body": body,
|
||||
"path": f,
|
||||
"links": extract_wiki_links(body)
|
||||
}
|
||||
|
||||
# 2. 위키 링크 정합성 검증 (Broken Links)
|
||||
# 개념/페이지 맵 구성
|
||||
available_links = {}
|
||||
for p_rel in pages:
|
||||
name_no_ext = Path(p_rel).with_suffix("").as_posix()
|
||||
available_links[name_no_ext] = p_rel
|
||||
# Also map short form if distinct
|
||||
short_name = Path(p_rel).name[:-3]
|
||||
available_links[short_name] = p_rel
|
||||
|
||||
for c_rel in concepts:
|
||||
name_no_ext = Path(c_rel).with_suffix("").as_posix()
|
||||
available_links[name_no_ext] = c_rel
|
||||
# Short form
|
||||
short_name = Path(c_rel).name[:-3]
|
||||
available_links[short_name] = c_rel
|
||||
# Subdirectories for concepts like db_schema/*
|
||||
if "concepts/" in name_no_ext:
|
||||
available_links[name_no_ext.replace("concepts/", "")] = c_rel
|
||||
|
||||
# 링크 검사
|
||||
for rel_path, info in {**pages, **concepts}.items():
|
||||
for link in info["links"]:
|
||||
if link.startswith("http://") or link.startswith("https://") or link.startswith("file:///"):
|
||||
continue
|
||||
normalized_link = link.replace("\\", "/")
|
||||
if normalized_link not in available_links and f"concepts/{normalized_link}" not in available_links and f"pages/{normalized_link}" not in available_links:
|
||||
warnings.append(f"Broken Link in {rel_path}: [[{link}]]")
|
||||
|
||||
# 3. index.md 등록 상태 확인
|
||||
index_content = index_file.read_text(encoding="utf-8") if index_file.exists() else ""
|
||||
for rel_path in pages:
|
||||
short_name = Path(rel_path).name[:-3]
|
||||
dir_name = Path(rel_path).parent.name
|
||||
expected_ref_dir = f"{dir_name}/{short_name}"
|
||||
if expected_ref_dir not in index_content and f"[[{short_name}]]" not in index_content and short_name not in index_content:
|
||||
warnings.append(f"Page not indexed in index.md: {rel_path} (Expected link to [[{expected_ref_dir}]] or [[{short_name}]])")
|
||||
|
||||
# 4. 결과 출력
|
||||
print(f"\nScanning completed: {len(all_files)} total markdown files.")
|
||||
print(f"Detected {len(pages)} pages and {len(concepts)} concept documents.")
|
||||
|
||||
print(f"\n--- Errors ({len(errors)}) ---")
|
||||
for e in errors:
|
||||
print(f"[ERROR] {e}")
|
||||
|
||||
print(f"\n--- Warnings ({len(warnings)}) ---")
|
||||
for w in warnings:
|
||||
print(f"[WARN] {w}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_lint()
|
||||
Reference in New Issue
Block a user