260718_0
This commit is contained in:
@@ -34,6 +34,7 @@ dist/
|
||||
storage/
|
||||
0_old/
|
||||
docs/
|
||||
graphify-out/
|
||||
|
||||
# 로그 파일
|
||||
*.log
|
||||
|
||||
@@ -14,7 +14,7 @@ from scipy.interpolate import RBFInterpolator, RectBivariateSpline
|
||||
from skimage import measure
|
||||
|
||||
# 등고선 캐시 형식/추출 규칙이 바뀔 때 증가시킨다.
|
||||
CONTOUR_EXTRACTOR_VERSION = 3
|
||||
CONTOUR_EXTRACTOR_VERSION = 4
|
||||
|
||||
|
||||
def extract_contours_from_grid(
|
||||
@@ -189,17 +189,32 @@ def _tin_face_coverage_mask(
|
||||
if not len(boundary_edges):
|
||||
return np.zeros(xx.shape, dtype=bool)
|
||||
|
||||
from shapely import get_parts, intersects_xy, linestrings, polygonize, union_all
|
||||
from shapely import get_parts, linestrings, polygonize
|
||||
import rasterio.features
|
||||
import affine
|
||||
|
||||
boundary_lines = linestrings(vertices[boundary_edges, :2])
|
||||
polygons = list(get_parts(polygonize(boundary_lines)))
|
||||
if not polygons:
|
||||
return np.zeros(xx.shape, dtype=bool)
|
||||
coverage = union_all(polygons)
|
||||
xx_flat = np.asarray(xx, dtype=np.float64).ravel()
|
||||
yy_flat = np.asarray(yy, dtype=np.float64).ravel()
|
||||
res_flat = np.asarray(intersects_xy(coverage, xx_flat, yy_flat), dtype=bool)
|
||||
return res_flat.reshape(xx.shape)
|
||||
|
||||
x_coords = xx[0, :]
|
||||
y_coords = yy[:, 0]
|
||||
dx = float(x_coords[1] - x_coords[0]) if len(x_coords) > 1 else 1.0
|
||||
dy = float(y_coords[1] - y_coords[0]) if len(y_coords) > 1 else 1.0
|
||||
|
||||
transform = affine.Affine(dx, 0.0, x_coords[0] - dx / 2.0,
|
||||
0.0, dy, y_coords[0] - dy / 2.0)
|
||||
|
||||
mask = rasterio.features.rasterize(
|
||||
polygons,
|
||||
out_shape=xx.shape,
|
||||
transform=transform,
|
||||
fill=0,
|
||||
default_value=1,
|
||||
dtype='uint8'
|
||||
)
|
||||
return mask.astype(bool)
|
||||
|
||||
|
||||
def _grid_axes(x_min: float, x_max: float, y_min: float, y_max: float, target_grid_m: float):
|
||||
@@ -210,6 +225,26 @@ def _grid_axes(x_min: float, x_max: float, y_min: float, y_max: float, target_gr
|
||||
return x_coords, y_coords
|
||||
|
||||
|
||||
def adaptive_contour_grid_resolution(
|
||||
model_npz_path: Path, representation: str, minimum_meters: float = 1.0
|
||||
) -> float:
|
||||
"""모델 범위가 넓어도 온디맨드 격자가 10만 셀을 넘지 않게 조정한다."""
|
||||
with np.load(model_npz_path) as data:
|
||||
if representation == "regular_grid":
|
||||
x_values, y_values = data["x"], data["y"]
|
||||
elif representation == "triangular_mesh":
|
||||
x_values, y_values = data["vertices"][:, 0], data["vertices"][:, 1]
|
||||
elif representation == "bspline_surface":
|
||||
x_values, y_values = data["control_x"], data["control_y"]
|
||||
elif representation == "local_rbf_height_field":
|
||||
x_values, y_values = data["centers_xy"][:, 0], data["centers_xy"][:, 1]
|
||||
else:
|
||||
x_values, y_values = data["points"][:, 0], data["points"][:, 1]
|
||||
width = float(np.ptp(x_values))
|
||||
height = float(np.ptp(y_values))
|
||||
return max(minimum_meters, float(np.sqrt(width * height / 100_000)))
|
||||
|
||||
|
||||
def extract_contours(
|
||||
model_npz_path: Path,
|
||||
representation: str,
|
||||
@@ -270,7 +305,7 @@ def extract_contours(
|
||||
control_z,
|
||||
kx=min(degree, len(control_y) - 1),
|
||||
ky=min(degree, len(control_x) - 1),
|
||||
s=float(len(control_x) * len(control_y)) * 0.01,
|
||||
s=0,
|
||||
)
|
||||
x_coords, y_coords = _grid_axes(
|
||||
float(control_x[0]),
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
@@ -33,6 +34,7 @@ from common_util.common_util_json import atomic_write_json
|
||||
|
||||
# 진행률 콜백: (overall_percent, detail_message)
|
||||
ProgressReporter = Callable[[int, str], None]
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 같은 프로세스에서 동일 프로젝트 계산 요청이 겹치면 두 번째 요청을 즉시 취소.
|
||||
_ACTIVE_TERRAIN_BUILDS: set[str] = set()
|
||||
@@ -54,7 +56,7 @@ def _cache_contours(
|
||||
metadata: dict[str, Any],
|
||||
) -> None:
|
||||
"""빌드 완료 직후 기본 간격 등고선을 사전 추출·캐싱한다 (원본 + 스무딩)."""
|
||||
interval = float(config.get("contour_interval_meters", 5.0))
|
||||
interval = float(config.get("contour_interval_meters", 1.0))
|
||||
target_grid_m = float(config.get("contour_grid_resolution_meters", 1.0))
|
||||
model_path = output_dir / f"{stem}.npz"
|
||||
|
||||
@@ -106,6 +108,37 @@ def _cache_contours(
|
||||
)
|
||||
|
||||
|
||||
def _cache_contours_safely(
|
||||
output_dir: Path,
|
||||
stem: str,
|
||||
filter_key: str,
|
||||
method: str,
|
||||
representation: str,
|
||||
config: dict[str, Any],
|
||||
bounds_info: dict[str, Any],
|
||||
metadata: dict[str, Any],
|
||||
) -> None:
|
||||
"""등고선 사전 캐시 실패를 기록하되 모델 빌드는 유지한다."""
|
||||
try:
|
||||
_cache_contours(
|
||||
output_dir,
|
||||
stem,
|
||||
filter_key,
|
||||
method,
|
||||
representation,
|
||||
config,
|
||||
bounds_info,
|
||||
metadata,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"등고선 사전 캐시 실패: filter=%s, method=%s, error=%s",
|
||||
filter_key,
|
||||
method,
|
||||
exc,
|
||||
)
|
||||
|
||||
|
||||
_REPRESENTATIONS = {
|
||||
"meshfree": "meshfree_surfels",
|
||||
"dtm": "regular_grid",
|
||||
@@ -152,7 +185,11 @@ def _build_all_terrain_models(
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
manifest_path = output_dir / "manifest.json"
|
||||
filters = tuple(key for key in config["source_filters"] if key in ground_masks)
|
||||
methods = tuple(key for key in config["precompute"] if key in BUILDERS)
|
||||
methods_list = [key for key in config["precompute"] if key in BUILDERS]
|
||||
if "dtm" in methods_list:
|
||||
methods_list.remove("dtm")
|
||||
methods_list.insert(0, "dtm")
|
||||
methods = tuple(methods_list)
|
||||
signature = config_signature(config)
|
||||
bounds = np.asarray(structured_data["bounds"], dtype=np.float64)
|
||||
xyz = structured_data["xyz"]
|
||||
@@ -213,6 +250,19 @@ def _build_all_terrain_models(
|
||||
)
|
||||
filter_entry["methods"][method] = entry
|
||||
_write_json_file(manifest_path, manifest)
|
||||
interval = float(config.get("contour_interval_meters", 1.0))
|
||||
contour_path = output_dir / f"contour_{filter_key}_{method}_{interval}m.json"
|
||||
if not contour_path.exists():
|
||||
_cache_contours_safely(
|
||||
output_dir,
|
||||
stem,
|
||||
filter_key,
|
||||
method,
|
||||
entry.get("representation", _REPRESENTATIONS[method]),
|
||||
config,
|
||||
manifest.get("bounds", {}),
|
||||
entry,
|
||||
)
|
||||
done_units += 1
|
||||
_report(f"{filter_key}-{method} 캐시 재사용")
|
||||
continue
|
||||
@@ -258,19 +308,16 @@ def _build_all_terrain_models(
|
||||
metadata["smooth"] = {"status": "failed", "error": str(smooth_exc)}
|
||||
|
||||
filter_entry["methods"][method] = metadata
|
||||
try:
|
||||
_cache_contours(
|
||||
output_dir,
|
||||
stem,
|
||||
filter_key,
|
||||
method,
|
||||
metadata.get("representation", "regular_grid"),
|
||||
config,
|
||||
manifest.get("bounds", {}),
|
||||
metadata,
|
||||
)
|
||||
except Exception:
|
||||
pass # 등고선 사전 캐시는 실패해도 모델 빌드를 무효화하지 않는다.
|
||||
_cache_contours_safely(
|
||||
output_dir,
|
||||
stem,
|
||||
filter_key,
|
||||
method,
|
||||
metadata.get("representation", "regular_grid"),
|
||||
config,
|
||||
manifest.get("bounds", {}),
|
||||
metadata,
|
||||
)
|
||||
except Exception as exc:
|
||||
failures += 1
|
||||
filter_entry["methods"][method] = {
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
@@ -19,10 +18,6 @@ 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,
|
||||
@@ -43,7 +38,6 @@ 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 (
|
||||
@@ -57,13 +51,6 @@ 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 = 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")
|
||||
|
||||
@@ -517,14 +504,12 @@ async def get_surface_model_preview(
|
||||
content={"status": "error", "message": "해당 모델을 찾을 수 없습니다."},
|
||||
)
|
||||
model_type, model_file_path = row[0], row[1]
|
||||
|
||||
project_root = Path(resolve_stored_project_path(stored_path))
|
||||
if not model_file_path:
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={"status": "error", "message": "모델 파일 경로가 없습니다."},
|
||||
)
|
||||
|
||||
model_path = project_root / model_file_path
|
||||
models_dir = model_path.parent
|
||||
stem = model_path.stem
|
||||
@@ -561,144 +546,3 @@ async def get_surface_model_preview(
|
||||
status_code=500,
|
||||
content={"status": "error", "message": "프리뷰 파일 조회 중 오류가 발생했습니다."},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{project_id}/surface/models/{model_id}/contour", response_model=None)
|
||||
async def get_surface_model_contour(
|
||||
project_id: UUID,
|
||||
model_id: int,
|
||||
interval: float = 5.0,
|
||||
smooth: bool = False,
|
||||
) -> FileResponse | JSONResponse:
|
||||
"""지표면 모델의 등고선 JSON 파일을 반환한다."""
|
||||
pool = get_db_pool()
|
||||
try:
|
||||
async with pool.acquire() as connection:
|
||||
stored_path = await get_project_storage_relative_path(connection, project_id)
|
||||
async with connection.cursor() as cursor:
|
||||
await cursor.execute(
|
||||
"""
|
||||
SELECT model_type, model_file_path
|
||||
FROM surface_models
|
||||
WHERE id = %s AND project_id = %s
|
||||
""",
|
||||
(model_id, str(project_id)),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
if not row:
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={"status": "error", "message": "해당 모델을 찾을 수 없습니다."},
|
||||
)
|
||||
model_type, model_file_path = row[0], row[1]
|
||||
|
||||
project_root = Path(resolve_stored_project_path(stored_path))
|
||||
if not model_file_path:
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={"status": "error", "message": "모델 파일 경로가 없습니다."},
|
||||
)
|
||||
|
||||
model_path = project_root / model_file_path
|
||||
models_dir = model_path.parent
|
||||
structured_path = project_root / "B04_wf1_Surface" / "processed" / "structured.npz"
|
||||
stem = model_path.stem
|
||||
|
||||
parts = stem.split("_")
|
||||
if len(parts) >= 2:
|
||||
method = parts[0]
|
||||
filter_key = "_".join(parts[1:])
|
||||
else:
|
||||
method = model_type
|
||||
filter_key = "csf"
|
||||
|
||||
if smooth and method in ("dtm", "tin"):
|
||||
contour_filename = f"contour_{filter_key}_{method}_smooth_{interval}m.json"
|
||||
else:
|
||||
contour_filename = f"contour_{filter_key}_{method}_{interval}m.json"
|
||||
|
||||
contour_path = models_dir / contour_filename
|
||||
|
||||
if not contour_path.is_file():
|
||||
if not math.isfinite(interval) or interval < 0.5:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"status": "error", "message": "등고선 간격은 0.5m 이상이어야 합니다."},
|
||||
)
|
||||
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(
|
||||
status_code=404,
|
||||
content={
|
||||
"status": "error",
|
||||
"message": "등고선 파일이 생성되지 않았거나 존재하지 않습니다.",
|
||||
},
|
||||
)
|
||||
|
||||
return FileResponse(contour_path, media_type="application/json", filename=contour_filename)
|
||||
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"지표면 모델 등고선 조회 실패: project_id=%s, model_id=%s", project_id, model_id
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"status": "error", "message": "등고선 파일 조회 중 오류가 발생했습니다."},
|
||||
)
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
"""B04 지표면 모델 등고선 FastAPI 라우터 (700줄 규정에 따라 본 라우터에서 분리)."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import re
|
||||
import time
|
||||
from pathlib import Path
|
||||
from uuid import UUID
|
||||
|
||||
import numpy as np
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
|
||||
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Engine_Contour import (
|
||||
CONTOUR_EXTRACTOR_VERSION,
|
||||
adaptive_contour_grid_resolution,
|
||||
extract_contours,
|
||||
)
|
||||
from common_util.common_util_atomic import atomic_write_bytes
|
||||
from common_util.common_util_storage import resolve_stored_project_path
|
||||
from config.config_db import get_db_pool
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/projects", tags=["B04 Surface Contour"])
|
||||
|
||||
MODEL_REPRESENTATIONS = {
|
||||
"meshfree": "meshfree_surfels",
|
||||
"dtm": "regular_grid",
|
||||
"tin": "triangular_mesh",
|
||||
"nurbs": "bspline_surface",
|
||||
"implicit": "local_rbf_height_field",
|
||||
}
|
||||
|
||||
|
||||
def _is_contour_cache_current(contour_path: Path) -> bool:
|
||||
"""캐시 JSON 선두의 extractor_version이 현재 추출기 버전과 일치하는지 검사한다."""
|
||||
try:
|
||||
with contour_path.open("rb") as cache_file:
|
||||
head = cache_file.read(256).decode("utf-8", errors="ignore")
|
||||
except OSError:
|
||||
return False
|
||||
match = re.search(r'"extractor_version"\s*:\s*(\d+)', head)
|
||||
return bool(match) and int(match.group(1)) == CONTOUR_EXTRACTOR_VERSION
|
||||
|
||||
|
||||
@router.get("/{project_id}/surface/models/{model_id}/contour", response_model=None)
|
||||
async def get_surface_model_contour(
|
||||
project_id: UUID,
|
||||
model_id: int,
|
||||
interval: float = 1.0,
|
||||
smooth: bool = False,
|
||||
recalculate: bool = False,
|
||||
) -> FileResponse | JSONResponse:
|
||||
"""지표면 모델의 등고선 JSON 파일을 반환한다."""
|
||||
pool = get_db_pool()
|
||||
try:
|
||||
async with pool.acquire() as connection:
|
||||
stored_path = await get_project_storage_relative_path(connection, project_id)
|
||||
async with connection.cursor() as cursor:
|
||||
await cursor.execute(
|
||||
"""
|
||||
SELECT model_type, model_file_path
|
||||
FROM surface_models
|
||||
WHERE id = %s AND project_id = %s
|
||||
""",
|
||||
(model_id, str(project_id)),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
if not row:
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={"status": "error", "message": "해당 모델을 찾을 수 없습니다."},
|
||||
)
|
||||
model_type, model_file_path = row[0], row[1]
|
||||
|
||||
project_root = Path(resolve_stored_project_path(stored_path))
|
||||
if not model_file_path:
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={"status": "error", "message": "모델 파일 경로가 없습니다."},
|
||||
)
|
||||
|
||||
model_path = project_root / model_file_path
|
||||
models_dir = model_path.parent
|
||||
structured_path = project_root / "B04_wf1_Surface" / "processed" / "structured.npz"
|
||||
stem = model_path.stem
|
||||
parts = stem.split("_")
|
||||
if len(parts) >= 2:
|
||||
method = parts[0]
|
||||
filter_key = "_".join(parts[1:])
|
||||
else:
|
||||
method = model_type
|
||||
filter_key = "csf"
|
||||
if smooth and method in ("dtm", "tin"):
|
||||
contour_filename = f"contour_{filter_key}_{method}_smooth_{interval}m.json"
|
||||
else:
|
||||
contour_filename = f"contour_{filter_key}_{method}_{interval}m.json"
|
||||
contour_path = models_dir / contour_filename
|
||||
if recalculate or not _is_contour_cache_current(contour_path):
|
||||
if not math.isfinite(interval) or interval < 0.5:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"status": "error", "message": "등고선 간격은 0.5m 이상이어야 합니다."},
|
||||
)
|
||||
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": "등고선 생성에 필요한 모델 파일이 없습니다.",
|
||||
},
|
||||
)
|
||||
generation_started = time.monotonic()
|
||||
contours = await asyncio.to_thread(
|
||||
extract_contours,
|
||||
contour_model_path,
|
||||
representation,
|
||||
interval,
|
||||
adaptive_contour_grid_resolution(contour_model_path, representation),
|
||||
None,
|
||||
)
|
||||
logger.info(
|
||||
"등고선 온디맨드 계산: filter=%s, method=%s, smooth=%s, "
|
||||
"interval=%.1f, duration=%.1fs",
|
||||
filter_key,
|
||||
method,
|
||||
smooth,
|
||||
interval,
|
||||
time.monotonic() - generation_started,
|
||||
)
|
||||
with np.load(contour_model_path) as model_data:
|
||||
if "bounds" in model_data:
|
||||
model_bounds = np.asarray(model_data["bounds"], dtype=float)
|
||||
bounds_payload = {
|
||||
"x": model_bounds[0].tolist(),
|
||||
"y": model_bounds[1].tolist(),
|
||||
"z": model_bounds[2].tolist(),
|
||||
}
|
||||
else:
|
||||
with np.load(structured_path) as structured:
|
||||
model_bounds = np.asarray(structured["bounds"], dtype=float)
|
||||
bounds_payload = {
|
||||
"x": model_bounds[0].tolist(),
|
||||
"y": model_bounds[1].tolist(),
|
||||
"z": model_bounds[2].tolist(),
|
||||
}
|
||||
payload = {
|
||||
"extractor_version": CONTOUR_EXTRACTOR_VERSION,
|
||||
"project_id": str(project_id),
|
||||
"source_filter": filter_key,
|
||||
"method": method,
|
||||
"interval": interval,
|
||||
"bounds": bounds_payload,
|
||||
"contours": contours,
|
||||
}
|
||||
atomic_write_bytes(
|
||||
contour_path,
|
||||
json.dumps(payload, ensure_ascii=False).encode("utf-8"),
|
||||
)
|
||||
|
||||
if not contour_path.is_file():
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={
|
||||
"status": "error",
|
||||
"message": "등고선 파일이 생성되지 않았거나 존재하지 않습니다.",
|
||||
},
|
||||
)
|
||||
|
||||
return FileResponse(contour_path, media_type="application/json", filename=contour_filename)
|
||||
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"지표면 모델 등고선 조회 실패: project_id=%s, model_id=%s", project_id, model_id
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"status": "error", "message": "등고선 파일 조회 중 오류가 발생했습니다."},
|
||||
)
|
||||
@@ -27,17 +27,23 @@ type GeoJsonCollection = {
|
||||
features?: GeoJsonFeature[];
|
||||
};
|
||||
|
||||
const BACKGROUND_LAYERS = ["white", "satellite", "hybrid"] as const;
|
||||
const GIS_LAYERS = ["지적도", "수계망", "산사태", "행정구역_시군구", "행정구역_읍면동"] as const;
|
||||
type BackgroundLayer = (typeof BACKGROUND_LAYERS)[number];
|
||||
type GisLayer = (typeof GIS_LAYERS)[number];
|
||||
|
||||
const GIS_LAYER_COLORS: Record<GisLayer, string> = {
|
||||
지적도: "#f97316",
|
||||
수계망: "#0ea5e9",
|
||||
산사태: "#ef4444",
|
||||
행정구역_시군구: "#7c3aed",
|
||||
행정구역_읍면동: "#22c55e",
|
||||
};
|
||||
|
||||
function L(key: keyof typeof ui_locales): string {
|
||||
return ui_locales[key][currentLanguageIndex];
|
||||
}
|
||||
|
||||
function makeOption(value: string, label: string): HTMLOptionElement {
|
||||
const option = document.createElement("option");
|
||||
option.value = value;
|
||||
option.textContent = label;
|
||||
return option;
|
||||
}
|
||||
|
||||
export function createSurfaceMapViewer(): SurfaceMapViewer {
|
||||
const root = document.createElement("section");
|
||||
root.className = "b04-map";
|
||||
@@ -49,44 +55,38 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
|
||||
|
||||
const controls = document.createElement("div");
|
||||
controls.className = "b04-map__controls";
|
||||
const backgroundLabel = document.createElement("label");
|
||||
backgroundLabel.textContent = L("B04_Surface_Map_Background");
|
||||
const backgroundSelect = document.createElement("select");
|
||||
backgroundSelect.append(
|
||||
makeOption("none", L("B04_Surface_Map_None")),
|
||||
makeOption("satellite", L("B04_Surface_Map_Satellite")),
|
||||
makeOption("hybrid", L("B04_Surface_Map_Hybrid")),
|
||||
makeOption("white", L("B04_Surface_Map_White")),
|
||||
);
|
||||
backgroundSelect.value = "satellite";
|
||||
backgroundLabel.append(backgroundSelect);
|
||||
const backgroundGroup = document.createElement("div");
|
||||
backgroundGroup.className = "b04-map__control-group";
|
||||
const backgroundTitle = document.createElement("span");
|
||||
backgroundTitle.textContent = L("B04_Surface_Map_Background");
|
||||
const backgroundButtons = document.createElement("div");
|
||||
backgroundButtons.className = "b04-map__layer-buttons";
|
||||
backgroundGroup.append(backgroundTitle, backgroundButtons);
|
||||
|
||||
const gisLabel = document.createElement("label");
|
||||
gisLabel.textContent = L("B04_Surface_Map_GisLayer");
|
||||
const gisSelect = document.createElement("select");
|
||||
gisSelect.append(
|
||||
makeOption("none", L("B04_Surface_Map_None")),
|
||||
makeOption("지적도", L("B04_Surface_Map_Cadastral")),
|
||||
makeOption("수계망", L("B04_Surface_Map_Water")),
|
||||
makeOption("산사태", L("B04_Surface_Map_Landslide")),
|
||||
makeOption("행정구역_시군구", L("B04_Surface_Map_Sigungu")),
|
||||
makeOption("행정구역_읍면동", L("B04_Surface_Map_Eupmyeondong")),
|
||||
);
|
||||
gisSelect.value = "지적도";
|
||||
gisLabel.append(gisSelect);
|
||||
const gisGroup = document.createElement("div");
|
||||
gisGroup.className = "b04-map__control-group";
|
||||
const gisTitle = document.createElement("span");
|
||||
gisTitle.textContent = L("B04_Surface_Map_GisLayer");
|
||||
const gisButtons = document.createElement("div");
|
||||
gisButtons.className = "b04-map__layer-buttons";
|
||||
gisGroup.append(gisTitle, gisButtons);
|
||||
|
||||
const resetButton = document.createElement("button");
|
||||
resetButton.type = "button";
|
||||
resetButton.textContent = L("B04_Surface_Map_Reset");
|
||||
controls.append(backgroundLabel, gisLabel, resetButton);
|
||||
controls.append(backgroundGroup, gisGroup, resetButton);
|
||||
header.append(title, controls);
|
||||
|
||||
const viewport = document.createElement("div");
|
||||
viewport.className = "b04-map__viewport";
|
||||
const image = document.createElement("img");
|
||||
image.className = "b04-map__image";
|
||||
image.alt = L("B04_Surface_Map_ImageAlt");
|
||||
image.draggable = false;
|
||||
const backgroundImages = new Map<BackgroundLayer, HTMLImageElement>();
|
||||
BACKGROUND_LAYERS.forEach((layer) => {
|
||||
const image = document.createElement("img");
|
||||
image.className = "b04-map__image";
|
||||
image.alt = L("B04_Surface_Map_ImageAlt");
|
||||
image.draggable = false;
|
||||
backgroundImages.set(layer, image);
|
||||
});
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.className = "b04-map__canvas";
|
||||
const empty = document.createElement("p");
|
||||
@@ -98,21 +98,87 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
|
||||
scaleBar.className = "b04-map__scale";
|
||||
const scaleText = document.createElement("span");
|
||||
scaleBar.append(scaleText);
|
||||
viewport.append(image, canvas, empty, status, scaleBar);
|
||||
viewport.append(
|
||||
...BACKGROUND_LAYERS.map((layer) => backgroundImages.get(layer)!),
|
||||
canvas,
|
||||
empty,
|
||||
status,
|
||||
scaleBar,
|
||||
);
|
||||
root.append(header, viewport);
|
||||
|
||||
let currentProjectId: string | null = null;
|
||||
let referenceBounds: SurfaceBounds | null = null;
|
||||
let meta: VWorldMeta | null = null;
|
||||
let geoJson: GeoJsonCollection | null = null;
|
||||
const geoJsonLayers = new Map<GisLayer, GeoJsonCollection>();
|
||||
const activeBackgrounds = new Set<BackgroundLayer>(BACKGROUND_LAYERS);
|
||||
const activeGisLayers = new Set<GisLayer>(GIS_LAYERS);
|
||||
let scale = 1;
|
||||
let offsetX = 0;
|
||||
let offsetY = 0;
|
||||
let dragStart: { x: number; y: number; offsetX: number; offsetY: number } | null = null;
|
||||
let loadSequence = 0;
|
||||
|
||||
function makeLayerButton<T extends string>(
|
||||
label: string,
|
||||
activeLayers: Set<T>,
|
||||
layer: T,
|
||||
color?: string,
|
||||
): HTMLButtonElement {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "b04-map__layer-button is-active";
|
||||
button.textContent = label;
|
||||
button.setAttribute("aria-pressed", "true");
|
||||
if (color) {
|
||||
button.classList.add("b04-map__layer-button--gis");
|
||||
button.style.setProperty("--b04-layer-color", color);
|
||||
}
|
||||
button.addEventListener("click", () => {
|
||||
if (activeLayers.has(layer)) activeLayers.delete(layer);
|
||||
else activeLayers.add(layer);
|
||||
const isActive = activeLayers.has(layer);
|
||||
button.classList.toggle("is-active", isActive);
|
||||
button.setAttribute("aria-pressed", String(isActive));
|
||||
syncLayerVisibility();
|
||||
});
|
||||
return button;
|
||||
}
|
||||
|
||||
const backgroundLabels: Record<BackgroundLayer, string> = {
|
||||
white: L("B04_Surface_Map_White"),
|
||||
satellite: L("B04_Surface_Map_Satellite"),
|
||||
hybrid: L("B04_Surface_Map_Hybrid"),
|
||||
};
|
||||
BACKGROUND_LAYERS.forEach((layer) => {
|
||||
backgroundButtons.append(makeLayerButton(backgroundLabels[layer], activeBackgrounds, layer));
|
||||
});
|
||||
|
||||
const gisLabels: Record<GisLayer, string> = {
|
||||
지적도: L("B04_Surface_Map_Cadastral"),
|
||||
수계망: L("B04_Surface_Map_Water"),
|
||||
산사태: L("B04_Surface_Map_Landslide"),
|
||||
행정구역_시군구: L("B04_Surface_Map_Sigungu"),
|
||||
행정구역_읍면동: L("B04_Surface_Map_Eupmyeondong"),
|
||||
};
|
||||
GIS_LAYERS.forEach((layer) => {
|
||||
gisButtons.append(
|
||||
makeLayerButton(gisLabels[layer], activeGisLayers, layer, GIS_LAYER_COLORS[layer]),
|
||||
);
|
||||
});
|
||||
|
||||
function updateImageTransform(): void {
|
||||
image.style.transform = `translate(${offsetX}px, ${offsetY}px) scale(${scale})`;
|
||||
backgroundImages.forEach((image) => {
|
||||
image.style.transform = `translate(${offsetX}px, ${offsetY}px) scale(${scale})`;
|
||||
});
|
||||
}
|
||||
|
||||
function syncLayerVisibility(): void {
|
||||
backgroundImages.forEach((image, layer) => {
|
||||
image.hidden = !activeBackgrounds.has(layer);
|
||||
});
|
||||
empty.hidden = activeBackgrounds.size > 0 || activeGisLayers.size > 0;
|
||||
drawVectorLayer();
|
||||
}
|
||||
|
||||
function fitReferenceBounds(): void {
|
||||
@@ -176,7 +242,7 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
|
||||
ring: unknown,
|
||||
width: number,
|
||||
height: number,
|
||||
fill: boolean,
|
||||
closed: boolean,
|
||||
): void {
|
||||
if (!Array.isArray(ring) || ring.length === 0) return;
|
||||
const points = ring.filter(
|
||||
@@ -190,13 +256,7 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
|
||||
if (index === 0) context.moveTo(x, y);
|
||||
else context.lineTo(x, y);
|
||||
});
|
||||
if (fill) {
|
||||
context.closePath();
|
||||
context.save();
|
||||
context.globalAlpha = 0.16;
|
||||
context.fill();
|
||||
context.restore();
|
||||
}
|
||||
if (closed) context.closePath();
|
||||
context.stroke();
|
||||
}
|
||||
|
||||
@@ -249,12 +309,13 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
|
||||
if (!context) return;
|
||||
context.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
context.clearRect(0, 0, width, height);
|
||||
const styles = getComputedStyle(root);
|
||||
context.strokeStyle = styles.getPropertyValue("--b04-map-vector").trim();
|
||||
context.fillStyle = styles.getPropertyValue("--b04-map-vector").trim();
|
||||
context.lineWidth = 1.5;
|
||||
geoJson?.features?.forEach((feature) => {
|
||||
if (feature.geometry) drawGeometry(context, feature.geometry, width, height);
|
||||
GIS_LAYERS.forEach((layer) => {
|
||||
if (!activeGisLayers.has(layer)) return;
|
||||
context.strokeStyle = GIS_LAYER_COLORS[layer];
|
||||
geoJsonLayers.get(layer)?.features?.forEach((feature) => {
|
||||
if (feature.geometry) drawGeometry(context, feature.geometry, width, height);
|
||||
});
|
||||
});
|
||||
updateImageTransform();
|
||||
drawScaleBar(width, height);
|
||||
@@ -262,44 +323,49 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
|
||||
|
||||
async function loadLayers(): Promise<void> {
|
||||
if (!currentProjectId) return;
|
||||
const projectId = currentProjectId;
|
||||
const sequence = ++loadSequence;
|
||||
const background = backgroundSelect.value;
|
||||
const gisLayer = gisSelect.value;
|
||||
empty.hidden = background !== "none" || gisLayer !== "none";
|
||||
image.hidden = background === "none";
|
||||
image.removeAttribute("src");
|
||||
backgroundImages.forEach((image) => image.removeAttribute("src"));
|
||||
meta = null;
|
||||
geoJson = null;
|
||||
geoJsonLayers.clear();
|
||||
resetView();
|
||||
if (background === "none" && gisLayer === "none") {
|
||||
status.textContent = "";
|
||||
return;
|
||||
}
|
||||
status.textContent = L("B04_Surface_Map_Loading");
|
||||
const mapLayer = background === "none" ? "white" : background;
|
||||
try {
|
||||
const [nextMeta, nextGeoJson] = await Promise.all([
|
||||
fetchVWorldMeta(currentProjectId, mapLayer),
|
||||
gisLayer === "none" ? Promise.resolve(null) : fetchGisGeoJson(currentProjectId, gisLayer),
|
||||
]);
|
||||
const nextMeta = await fetchVWorldMeta(projectId, "satellite");
|
||||
const loadedLayers = await Promise.all(
|
||||
GIS_LAYERS.map(async (layer) => {
|
||||
try {
|
||||
const data = (await fetchGisGeoJson(projectId, layer)) as GeoJsonCollection;
|
||||
return [layer, data] as const;
|
||||
} catch {
|
||||
return [layer, null] as const;
|
||||
}
|
||||
}),
|
||||
);
|
||||
if (sequence !== loadSequence) return;
|
||||
meta = nextMeta;
|
||||
geoJson = nextGeoJson as GeoJsonCollection | null;
|
||||
if (background !== "none") {
|
||||
image.src = `${getVWorldMapUrl(currentProjectId, mapLayer)}&_t=${Date.now()}`;
|
||||
}
|
||||
status.textContent = geoJson?.features
|
||||
? L("B04_Surface_Map_Features").replace("{count}", geoJson.features.length.toLocaleString())
|
||||
: "";
|
||||
loadedLayers.forEach(([layer, data]) => {
|
||||
if (data) geoJsonLayers.set(layer, data);
|
||||
});
|
||||
BACKGROUND_LAYERS.forEach((layer) => {
|
||||
backgroundImages.get(layer)!.src = `${getVWorldMapUrl(projectId, layer)}&_t=${Date.now()}`;
|
||||
});
|
||||
const featureCount = [...geoJsonLayers.values()].reduce(
|
||||
(sum, collection) => sum + (collection.features?.length ?? 0),
|
||||
0,
|
||||
);
|
||||
status.textContent = L("B04_Surface_Map_Features").replace(
|
||||
"{count}",
|
||||
featureCount.toLocaleString(),
|
||||
);
|
||||
resetView();
|
||||
syncLayerVisibility();
|
||||
} catch (error) {
|
||||
if (sequence !== loadSequence) return;
|
||||
status.textContent = error instanceof Error ? error.message : L("B04_Surface_Map_LoadFailed");
|
||||
}
|
||||
}
|
||||
|
||||
backgroundSelect.addEventListener("change", () => void loadLayers());
|
||||
gisSelect.addEventListener("change", () => void loadLayers());
|
||||
resetButton.addEventListener("click", resetView);
|
||||
viewport.addEventListener(
|
||||
"wheel",
|
||||
|
||||
@@ -497,7 +497,8 @@
|
||||
|
||||
.b04-map__header,
|
||||
.b04-map__controls,
|
||||
.b04-map__controls label {
|
||||
.b04-map__control-group,
|
||||
.b04-map__layer-buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
@@ -517,13 +518,17 @@
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.b04-map__controls label {
|
||||
.b04-map__control-group {
|
||||
gap: var(--spacing-8);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
.b04-map__controls select,
|
||||
.b04-map__layer-buttons {
|
||||
gap: var(--spacing-4);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.b04-map__controls button {
|
||||
min-height: 34px;
|
||||
padding: 0 var(--spacing-12);
|
||||
@@ -537,6 +542,27 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.b04-map__controls .b04-map__layer-button {
|
||||
padding: var(--spacing-8);
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.b04-map__layer-button.is-active {
|
||||
opacity: 1;
|
||||
box-shadow: inset 0 0 0 1px currentColor;
|
||||
}
|
||||
|
||||
.b04-map__layer-button--gis {
|
||||
border-color: var(--color-border);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.b04-map__layer-button--gis.is-active {
|
||||
border-color: var(--b04-layer-color);
|
||||
color: var(--b04-layer-color);
|
||||
box-shadow: inset 0 0 0 1px var(--b04-layer-color);
|
||||
}
|
||||
|
||||
.b04-map__viewport {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
@@ -565,6 +591,7 @@
|
||||
object-fit: contain;
|
||||
transform-origin: center;
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.b04-map__canvas {
|
||||
|
||||
@@ -328,7 +328,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
|
||||
if (activeMethod === "meshfree") {
|
||||
new PLYLoader().load(
|
||||
previewUrl,
|
||||
(geometry) => {
|
||||
async (geometry) => {
|
||||
geometry.computeBoundingSphere();
|
||||
const material = new THREE.PointsMaterial({
|
||||
size: 0.35,
|
||||
@@ -340,8 +340,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
|
||||
terrainMesh = points;
|
||||
scene.add(points);
|
||||
fitCamera(points);
|
||||
statusSpan.textContent = `${activeFilter.toUpperCase()} · ${activeMethod.toUpperCase()} 표시 중`;
|
||||
void loadContourLines(modelId, isSmooth);
|
||||
await loadSelectedContours(modelId, isSmooth);
|
||||
},
|
||||
undefined,
|
||||
() => {
|
||||
@@ -351,7 +350,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
|
||||
} else {
|
||||
new GLTFLoader().load(
|
||||
previewUrl,
|
||||
(gltf) => {
|
||||
async (gltf) => {
|
||||
gltf.scene.traverse((child) => {
|
||||
if (child instanceof THREE.Mesh) {
|
||||
child.material.side = THREE.DoubleSide;
|
||||
@@ -362,8 +361,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
|
||||
terrainMesh = gltf.scene;
|
||||
scene.add(gltf.scene);
|
||||
fitCamera(gltf.scene);
|
||||
statusSpan.textContent = `${activeFilter.toUpperCase()} · ${activeMethod.toUpperCase()} 표시 중`;
|
||||
void loadContourLines(modelId, isSmooth);
|
||||
await loadSelectedContours(modelId, isSmooth);
|
||||
},
|
||||
undefined,
|
||||
() => {
|
||||
@@ -376,14 +374,27 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadContourLines(modelId: number, isSmooth: boolean): Promise<boolean> {
|
||||
async function loadContourLines(
|
||||
modelId: number,
|
||||
isSmooth: boolean,
|
||||
recalculate = false,
|
||||
): Promise<boolean> {
|
||||
const interval = parseFloat(intervalInput.value) || 1.0;
|
||||
const contourUrl = `${API_BASE_URL}/projects/${currentProjectId}/surface/models/${modelId}/contour?interval=${interval}&smooth=${isSmooth}`;
|
||||
const projectId = currentProjectId;
|
||||
const contourUrl = `${API_BASE_URL}/projects/${projectId}/surface/models/${modelId}/contour?interval=${interval}&smooth=${isSmooth}&recalculate=${recalculate}`;
|
||||
|
||||
try {
|
||||
const res = await fetch(contourUrl);
|
||||
const res = await fetch(contourUrl, { cache: "no-store" });
|
||||
if (!res.ok) throw new Error("등고선 조회 실패");
|
||||
const data = await res.json();
|
||||
if (
|
||||
currentProjectId !== projectId ||
|
||||
currentModelId !== modelId ||
|
||||
currentModelSmooth !== isSmooth ||
|
||||
(parseFloat(intervalInput.value) || 1.0) !== interval
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
clearContours();
|
||||
|
||||
@@ -486,6 +497,27 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSelectedContours(
|
||||
modelId: number,
|
||||
isSmooth: boolean,
|
||||
recalculate = false,
|
||||
): Promise<void> {
|
||||
const projectId = currentProjectId;
|
||||
const interval = parseFloat(intervalInput.value) || 1.0;
|
||||
statusSpan.textContent = `등고선 ${interval}m 계산 중...`;
|
||||
const loaded = await loadContourLines(modelId, isSmooth, recalculate);
|
||||
if (
|
||||
currentProjectId === projectId &&
|
||||
currentModelId === modelId &&
|
||||
currentModelSmooth === isSmooth &&
|
||||
(parseFloat(intervalInput.value) || 1.0) === interval
|
||||
) {
|
||||
statusSpan.textContent = loaded
|
||||
? `${activeFilter.toUpperCase()} · ${activeMethod.toUpperCase()} · 등고선 ${interval}m`
|
||||
: "등고선 계산 또는 조회에 실패했습니다.";
|
||||
}
|
||||
}
|
||||
|
||||
// Animation render loop
|
||||
let animationFrameId = 0;
|
||||
let hasConnected = false;
|
||||
@@ -559,11 +591,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
|
||||
const interval = Number(intervalInput.value);
|
||||
if (!Number.isFinite(interval) || interval < 0.5 || currentModelId === null) return;
|
||||
intervalSubmit.disabled = true;
|
||||
statusSpan.textContent = `등고선 ${interval}m 계산 중...`;
|
||||
const loaded = await loadContourLines(currentModelId, currentModelSmooth);
|
||||
statusSpan.textContent = loaded
|
||||
? `${activeFilter.toUpperCase()} · ${activeMethod.toUpperCase()} · 등고선 ${interval}m`
|
||||
: "등고선 계산 또는 조회에 실패했습니다.";
|
||||
await loadSelectedContours(currentModelId, currentModelSmooth, true);
|
||||
intervalSubmit.disabled = false;
|
||||
});
|
||||
|
||||
|
||||
@@ -145,7 +145,7 @@ SURFACE_SMOOTHING_TIN_TAUBIN_LAMBDA = float(os.getenv("SURFACE_SMOOTHING_TIN_TAU
|
||||
SURFACE_SMOOTHING_TIN_TAUBIN_MU = float(os.getenv("SURFACE_SMOOTHING_TIN_TAUBIN_MU", "-0.53"))
|
||||
|
||||
# 등고선 파라미터
|
||||
SURFACE_CONTOUR_INTERVAL_M = float(os.getenv("SURFACE_CONTOUR_INTERVAL_M", "5.0"))
|
||||
SURFACE_CONTOUR_INTERVAL_M = float(os.getenv("SURFACE_CONTOUR_INTERVAL_M", "1.0"))
|
||||
SURFACE_CONTOUR_GRID_RESOLUTION_M = float(os.getenv("SURFACE_CONTOUR_GRID_RESOLUTION_M", "1.0"))
|
||||
|
||||
|
||||
|
||||
+10
-10
@@ -110,8 +110,8 @@
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/wiki/log.md": {
|
||||
"mtime": 1784276174.9796534,
|
||||
"ast_hash": "a9bc801087f68a8cf53b4ccd025587ea",
|
||||
"mtime": 1784281064.938371,
|
||||
"ast_hash": "d0a674d4bde81eea7e8bd2edf76d6287",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/wiki/pages/A01_Home/A01_components.md": {
|
||||
@@ -255,13 +255,13 @@
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/wiki/pages/B04_wf1_Surface/B04_api.md": {
|
||||
"mtime": 1784276164.8003862,
|
||||
"ast_hash": "1ca8565f179fb4643bd4997cb6f1bc46",
|
||||
"mtime": 1784281082.294356,
|
||||
"ast_hash": "688e57fa31f595d8a2eb50fe1534bae8",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/wiki/pages/B04_wf1_Surface/B04_backend.md": {
|
||||
"mtime": 1784276156.6492395,
|
||||
"ast_hash": "f3f818ea467831d6f60136ece8e3bb9b",
|
||||
"mtime": 1784281075.9390342,
|
||||
"ast_hash": "179006cfa5579947f12703de677b9178",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/wiki/pages/B04_wf1_Surface/B04_db.md": {
|
||||
@@ -275,8 +275,8 @@
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/wiki/pages/B04_wf1_Surface/B04_frontend.md": {
|
||||
"mtime": 1784276170.2440593,
|
||||
"ast_hash": "514a6d8a8e8a3b0093bbbad7df764651",
|
||||
"mtime": 1784278834.8752944,
|
||||
"ast_hash": "bb89b16cc5a0d9763cb26c174a537f54",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/wiki/pages/B05_wf2_Route/B05_api.md": {
|
||||
@@ -340,8 +340,8 @@
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/wiki/pages/B08_wf5_Quantity/B08_frontend.md": {
|
||||
"mtime": 1784259186.086623,
|
||||
"ast_hash": "d7dfc0b92c6297f7fa95444c2916461e",
|
||||
"mtime": 1784280350.6155877,
|
||||
"ast_hash": "82f0c3c8358a8f6b16395623d73f049f",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/wiki/pages/B09_wf6_Estimation/B09_frontend.md": {
|
||||
|
||||
@@ -28,6 +28,7 @@ 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_Contour import router as b04_surface_contour_router
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Router_GIS import router as b04_surface_gis_router
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Router_GIS import tiles_router
|
||||
from B05_wf2_Route.B05_wf2_Route_Router import router as b05_route_router
|
||||
@@ -246,6 +247,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_contour_router, dependencies=protected_with_company)
|
||||
app.include_router(b04_surface_gis_router, dependencies=protected_with_company)
|
||||
app.include_router(tiles_router, dependencies=protected_with_company)
|
||||
app.include_router(b05_route_router, dependencies=protected_with_company)
|
||||
|
||||
Reference in New Issue
Block a user