This commit is contained in:
2026-07-17 17:16:09 +09:00
parent cf459f97a6
commit f63e44fefd
13 changed files with 1169 additions and 1272 deletions
+3 -1
View File
@@ -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",
},
+41
View File
@@ -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
+101 -289
View File
@@ -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> {
+186 -272
View File
@@ -1,17 +1,4 @@
/* =============================================================================
* B04_wf1_Surface_UI_Page.ts
* 로그인 후 04: 1차 워크플로우 (지표면 모델 분석)
*
* 데스크톱 IDE 스타일 레이아웃 (createWorkflowLayout):
* 헤더: 페이지 타이틀 + 진행 단계 스텝바 (숨김/복원 토글)
* 좌측 오버레이 패널: 입력 파일 자동 선택 + 지면 필터/지표면 표현 + 실행 옵션
* 우측 메인: 3D 포인트클라우드 뷰어 + 지면 통계 + 지표면 모델 카드
*
* 이벤트 핸들러 명명 (frontend.md §4): onB04_Surface_[기능]_[액션]
* 텍스트는 ui_template_locale에 선(先) 등록 후 참조 (frontend.md §3).
* ========================================================================== */
import { CURRENT_PROJECT_ID_KEY } from "@config/config_frontend";
import { CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend";
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
import {
createButton,
@@ -20,6 +7,7 @@ import {
showLoadingOverlay,
showToast,
} from "@ui/ui_template_elements";
import { createWorkflowLayout } from "@ui/ui_template_workflow_layout";
import { workflowSteps } from "../A00_Common/b_page_scaffold";
import {
fetchWorkflowState,
@@ -28,66 +16,31 @@ import {
type WorkflowState,
} from "../A00_Common/b_workflow_nav";
import {
analyzeSurface,
confirmSurfaceModel,
fetchSurfaceGroundStats,
fetchSurfacePointCloud,
fetchSurfaceStatus,
listSurfaceInputFiles,
listSurfaceModels,
type SurfaceInputFileSummary,
type SurfaceModelSummary,
type SurfacePointCloudSampleResponse,
type SurfaceStatusResponse,
} from "./B04_wf1_Surface_Api_Fetch";
import { createWorkflowLayout } from "@ui/ui_template_workflow_layout";
import { createSurfacePointCloudViewer } from "./B04_wf1_Surface_UI_Viewer";
import { createSurfaceMapViewer } from "./B04_wf1_Surface_UI_MapViewer";
import { createSurfaceTerrainViewer } from "./B04_wf1_Surface_UI_TerrainViewer";
import { createSurfacePointCloudViewer } from "./B04_wf1_Surface_UI_Viewer";
import "./B04_wf1_Surface_UI_Style.css";
/** locale 헬퍼 */
const SOURCE_FILTERS = ["grid_min_z", "csf", "pmf"] as const;
const MODEL_METHODS = ["tin", "dtm", "nurbs", "implicit", "meshfree"] as const;
const DEFAULT_FILTER = "csf";
const DEFAULT_METHOD = "dtm";
const ROUTE_STAGE = ROUTES.B05_WF2_ROUTE;
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
}
/** 선택 가능한 지면 필터 (config_system.SURFACE_MODEL_SOURCE_FILTERS + ransac) */
const SOURCE_FILTERS = ["grid_min_z", "csf", "pmf", "ransac"] as const;
/** 선택 가능한 지표면 표현 (config_system.SURFACE_MODEL_PRECOMPUTE) */
const MODEL_METHODS = ["tin", "dtm", "nurbs", "implicit", "meshfree"] as const;
/** 체크박스 그룹 하나 생성 (라벨 + 항목들). 선택 값 Set을 반환. */
function buildCheckboxGroup(
legend: string,
values: readonly string[],
defaults: readonly string[],
): { root: HTMLElement; selected: Set<string> } {
const selected = new Set<string>(defaults);
const root = document.createElement("fieldset");
root.className = "b04-surface__group";
const legendEl = document.createElement("legend");
legendEl.className = "b04-surface__group-legend";
legendEl.textContent = legend;
root.append(legendEl);
for (const value of values) {
const item = document.createElement("label");
item.className = "b04-surface__check";
const box = document.createElement("input");
box.type = "checkbox";
box.value = value;
box.checked = selected.has(value);
box.addEventListener("change", () => {
if (box.checked) selected.add(value);
else selected.delete(value);
});
const text = document.createElement("span");
text.textContent = value;
item.append(box, text);
root.append(item);
}
return { root, selected };
}
function buildInfoLine(label: string, value: unknown): HTMLElement {
const row = document.createElement("div");
row.className = "b04-surface__line";
@@ -99,9 +52,41 @@ function buildInfoLine(label: string, value: unknown): HTMLElement {
return row;
}
function buildSelectGroup(
title: string,
values: readonly string[],
defaultValue: string,
): { root: HTMLElement; select: HTMLSelectElement } {
const root = document.createElement("section");
root.className = "b04-surface__group";
const heading = document.createElement("h3");
heading.className = "b04-surface__panel-title";
heading.textContent = title;
const select = document.createElement("select");
select.className = "b04-surface__select";
values.forEach((value) => {
const option = document.createElement("option");
option.value = value;
option.textContent = value.replaceAll("_", " ").toUpperCase();
select.append(option);
});
select.value = defaultValue;
root.append(heading, select);
return { root, select };
}
function getModelFilter(model: SurfaceModelSummary): string {
const configured = model.generation_params?.source_filter;
if (typeof configured === "string") return configured.toLowerCase();
const path = model.model_file_path?.toLowerCase() ?? "";
return SOURCE_FILTERS.find((filter) => path.includes(filter)) ?? "";
}
export async function renderB04Surface(root: HTMLElement): Promise<void> {
let selectedInputFile: SurfaceInputFileSummary | null = null;
let inputFiles: SurfaceInputFileSummary[] = [];
let models: SurfaceModelSummary[] = [];
let pointCloud: SurfacePointCloudSampleResponse | null = null;
const inputSelect = document.createElement("select");
inputSelect.className = "b04-surface__select";
@@ -109,95 +94,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();
}
+77 -1
View File
@@ -88,9 +88,30 @@
.b04-surface__workspace {
display: flex;
flex-direction: column;
width: 100%;
max-width: none;
min-height: calc(100vh - var(--spacing-64));
}
.b04-surface__workspace > .b04-surface__status {
justify-content: flex-end;
padding: var(--spacing-12) var(--spacing-24);
border-bottom: 1px solid var(--color-border);
}
.b04-surface__viewers {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--spacing-16);
width: 100%;
padding: var(--spacing-16) var(--spacing-24);
box-sizing: border-box;
}
.b04-surface__viewers > * {
min-width: 0;
}
.b04-surface__topbar {
display: flex;
align-items: center;
@@ -229,6 +250,14 @@
background: var(--color-surface);
}
.terrain-model-group {
min-width: 0;
overflow: hidden;
border: 1px solid var(--color-border);
border-radius: var(--radius-cards);
background: var(--color-surface);
}
.point-toolbar {
display: flex;
align-items: center;
@@ -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);
+204 -517
View File
@@ -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 = '밀도&nbsp;<span class="viewer-option-val">100%</span>';
densityLabel.innerHTML = '밀도 <span class="viewer-option-val">100%</span>';
const densityInput = document.createElement("input");
densityInput.type = "range";
densityInput.min = "1";
@@ -92,124 +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
View File
@@ -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",
+3 -1
View File
@@ -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)
+157
View File
@@ -0,0 +1,157 @@
import os
import re
from pathlib import Path
# 위키 루트 경로 설정
wiki_root = Path(r"D:\02_Software_Prog\임도설계 및 견적자동화 프로그램 개발\docs\wiki")
raw_root = Path(r"D:\02_Software_Prog\임도설계 및 견적자동화 프로그램 개발\docs\raw")
def load_frontmatter(file_path):
content = file_path.read_text(encoding="utf-8")
# Frontmatter regex
match = re.match(r"^---\s*\n(.*?)\n---\s*\n", content, re.DOTALL)
if match:
fm_text = match.group(1)
fm = {}
# Simple YAML key-value parser for basic string/list properties
for line in fm_text.splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
if ":" in line:
key, val = line.split(":", 1)
key = key.strip()
val = val.strip().strip("'\"")
fm[key] = val
return fm, content[match.end():]
return None, content
def extract_wiki_links(text):
# Matches [[link]] or [[link|alias]] or [[link#section]] or [[link#section|alias]]
links = re.findall(r"\[\[(.*?)\]\]", text)
cleaned_links = []
for l in links:
# Split alias
if "|" in l:
l = l.split("|")[0]
# Split section
if "#" in l:
l = l.split("#")[0]
l = l.strip()
if l:
cleaned_links.append(l)
return cleaned_links
def run_lint():
print("=== Start Wiki Linting (No PyYAML, Filtered) ===")
all_files = list(wiki_root.glob("**/*.md"))
index_file = wiki_root / "index.md"
log_file = wiki_root / "log.md"
# 1. 파일 목록화 및 Frontmatter 체크
pages = {}
concepts = {}
errors = []
warnings = []
for f in all_files:
rel_path = f.relative_to(wiki_root).as_posix()
# Skip output files generated by graphify in graphify-out
if rel_path.startswith("graphify-out/") or rel_path in ["index.md", "log.md", "AGENTS.md", "CLAUDE.md"]:
continue
fm, body = load_frontmatter(f)
# 기본 규칙 검사
if not fm:
errors.append(f"Missing Frontmatter: {rel_path}")
continue
# status 값 검사
status = fm.get("status")
if status not in ["draft", "stable", "stale"]:
errors.append(f"Invalid status '{status}' in {rel_path}. Must be draft, stable, or stale.")
# 100줄 제한 검사 (10% 즉 110줄 허용)
lines_count = len(f.read_text(encoding="utf-8").splitlines())
if lines_count > 110:
warnings.append(f"File exceeds 110 lines ({lines_count} lines): {rel_path}")
# 페이지와 컨셉 분류
if "pages/" in rel_path:
pages[rel_path] = {
"fm": fm,
"body": body,
"path": f,
"links": extract_wiki_links(body)
}
# 파일명 형식 규칙 5 검사: {page_id}_{기능명}.md
filename = f.name
page_id = fm.get("page_id")
if not page_id:
errors.append(f"Missing page_id in page: {rel_path}")
else:
expected_prefix = page_id.split("_")[0] # e.g. A01
if not filename.startswith(expected_prefix):
warnings.append(f"Filename does not match page_id format: {rel_path} (page_id: {page_id})")
else:
concepts[rel_path] = {
"fm": fm,
"body": body,
"path": f,
"links": extract_wiki_links(body)
}
# 2. 위키 링크 정합성 검증 (Broken Links)
# 개념/페이지 맵 구성
available_links = {}
for p_rel in pages:
name_no_ext = Path(p_rel).with_suffix("").as_posix()
available_links[name_no_ext] = p_rel
# Also map short form if distinct
short_name = Path(p_rel).name[:-3]
available_links[short_name] = p_rel
for c_rel in concepts:
name_no_ext = Path(c_rel).with_suffix("").as_posix()
available_links[name_no_ext] = c_rel
# Short form
short_name = Path(c_rel).name[:-3]
available_links[short_name] = c_rel
# Subdirectories for concepts like db_schema/*
if "concepts/" in name_no_ext:
available_links[name_no_ext.replace("concepts/", "")] = c_rel
# 링크 검사
for rel_path, info in {**pages, **concepts}.items():
for link in info["links"]:
if link.startswith("http://") or link.startswith("https://") or link.startswith("file:///"):
continue
normalized_link = link.replace("\\", "/")
if normalized_link not in available_links and f"concepts/{normalized_link}" not in available_links and f"pages/{normalized_link}" not in available_links:
warnings.append(f"Broken Link in {rel_path}: [[{link}]]")
# 3. index.md 등록 상태 확인
index_content = index_file.read_text(encoding="utf-8") if index_file.exists() else ""
for rel_path in pages:
short_name = Path(rel_path).name[:-3]
dir_name = Path(rel_path).parent.name
expected_ref_dir = f"{dir_name}/{short_name}"
if expected_ref_dir not in index_content and f"[[{short_name}]]" not in index_content and short_name not in index_content:
warnings.append(f"Page not indexed in index.md: {rel_path} (Expected link to [[{expected_ref_dir}]] or [[{short_name}]])")
# 4. 결과 출력
print(f"\nScanning completed: {len(all_files)} total markdown files.")
print(f"Detected {len(pages)} pages and {len(concepts)} concept documents.")
print(f"\n--- Errors ({len(errors)}) ---")
for e in errors:
print(f"[ERROR] {e}")
print(f"\n--- Warnings ({len(warnings)}) ---")
for w in warnings:
print(f"[WARN] {w}")
if __name__ == "__main__":
run_lint()