260718_0
This commit is contained in:
@@ -196,7 +196,16 @@ async def trigger_wf1_analysis_and_email(
|
||||
)
|
||||
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Engine import run_surface_analysis
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Router import save_surface_analysis_to_db
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Router import (
|
||||
save_surface_analysis_to_db,
|
||||
write_surface_progress,
|
||||
)
|
||||
|
||||
# 업로드 자동 분석도 progress.json에 진행률을 기록한다 (PLAN C-4)
|
||||
write_surface_progress(project_root, 5, "analyzing", "WF1 분석을 시작합니다.")
|
||||
|
||||
def _on_progress(percent: int, stage: str, message: str) -> None:
|
||||
write_surface_progress(project_root, percent, stage, message)
|
||||
|
||||
logger.info("WF1 분석 엔진 시작: las_path=%s", las_path)
|
||||
analysis_result = await asyncio.to_thread(
|
||||
@@ -206,6 +215,7 @@ async def trigger_wf1_analysis_and_email(
|
||||
source_filters=source_filters,
|
||||
methods=methods,
|
||||
force=False,
|
||||
on_progress=_on_progress,
|
||||
)
|
||||
logger.info("WF1 분석 엔진 완료: 모델 %d개 생성됨", len(analysis_result.get("models", [])))
|
||||
|
||||
@@ -229,6 +239,13 @@ async def trigger_wf1_analysis_and_email(
|
||||
await connection.rollback()
|
||||
raise
|
||||
|
||||
write_surface_progress(
|
||||
project_root,
|
||||
100,
|
||||
"awaiting_confirmation",
|
||||
"WF1 분석이 완료되었습니다. 사용할 모델을 확정하세요.",
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"SEND_ANALYSIS_COMPLETION_EMAIL=%s, project_info=%s",
|
||||
SEND_ANALYSIS_COMPLETION_EMAIL,
|
||||
|
||||
@@ -4,24 +4,44 @@
|
||||
동기 계산 파이프라인. 라우터에서 asyncio.to_thread로 호출한다.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Engine_Ground import build_ground_masks, summarize_masks
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Engine_Ground import (
|
||||
build_ground_masks,
|
||||
run_ground_filter,
|
||||
summarize_masks,
|
||||
)
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Engine_Pipeline import build_all_terrain_models
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Engine_Structurize import structurize_las
|
||||
from common_util.common_util_atomic import atomic_write_npz
|
||||
from common_util.common_util_json import atomic_write_json
|
||||
from config.config_system import build_surface_model_config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 진행 콜백 시그니처: (진행률 0~100, 현재 단계 키, 메시지)
|
||||
ProgressCallback = Callable[[int, str, str], None]
|
||||
GROUND_POINT_SAMPLE_LIMIT = 500_000
|
||||
GROUND_POINT_CACHE_VERSION = 2
|
||||
|
||||
|
||||
def _source_identity(las_path: Path) -> dict[str, Any]:
|
||||
"""입력 LAS의 정체성(이름·크기·수정시각)으로 캐시 세대를 식별한다 (PLAN B-2)."""
|
||||
stat = las_path.stat()
|
||||
return {
|
||||
"filename": las_path.name,
|
||||
"size_bytes": int(stat.st_size),
|
||||
"mtime": float(stat.st_mtime),
|
||||
}
|
||||
|
||||
|
||||
def _relative_to_project(project_root: Path, path: Path) -> str:
|
||||
"""프로젝트 루트 기준 posix 상대 경로 문자열."""
|
||||
return path.relative_to(project_root).as_posix()
|
||||
@@ -32,11 +52,20 @@ def cache_ground_points(
|
||||
filter_key: str,
|
||||
mask: np.ndarray | None = None,
|
||||
) -> Path:
|
||||
"""필터링된 지면 포인트 미리보기 캐시를 생성하고 경로를 반환한다."""
|
||||
"""필터링된 지면 포인트 미리보기 캐시를 생성하고 경로를 반환한다.
|
||||
|
||||
mask 미전달 시 영구 저장된 mask_{filter}.npy를 우선 재사용한다 (PLAN A-2).
|
||||
"""
|
||||
with np.load(structured_path) as structured:
|
||||
xyz = np.asarray(structured["xyz"], dtype=np.float32)
|
||||
mask_path = structured_path.parent / f"mask_{filter_key}.npy"
|
||||
if mask is None and mask_path.is_file():
|
||||
stored_mask = np.load(mask_path)
|
||||
if len(stored_mask) == len(xyz):
|
||||
mask = np.asarray(stored_mask, dtype=bool)
|
||||
if mask is None:
|
||||
mask = build_ground_masks(structured, [filter_key])[filter_key]
|
||||
np.save(mask_path, np.asarray(mask, dtype=bool))
|
||||
ground_indexes = np.flatnonzero(mask)
|
||||
ground_points = xyz[ground_indexes]
|
||||
ground_point_count = int(len(ground_points))
|
||||
@@ -94,15 +123,37 @@ def run_surface_analysis(
|
||||
if on_progress is not None:
|
||||
on_progress(percent, stage, message)
|
||||
|
||||
total_started = time.monotonic()
|
||||
stage_root = project_root / "B04_wf1_Surface"
|
||||
processed_dir = stage_root / "processed"
|
||||
models_dir = stage_root / "models"
|
||||
processed_dir.mkdir(parents=True, exist_ok=True)
|
||||
models_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 1. LAS 구조화 (structured.npz)
|
||||
_report(10, "structurize", "LAS 구조화 중")
|
||||
structured_path = structurize_las(las_path, processed_dir)
|
||||
# 0. 입력 세대 검증: LAS가 바뀌었으면 모든 캐시를 재계산한다 (PLAN B-2)
|
||||
identity_path = processed_dir / "source_identity.json"
|
||||
current_identity = _source_identity(las_path)
|
||||
stored_identity: dict[str, Any] | None = None
|
||||
if identity_path.is_file():
|
||||
try:
|
||||
stored_identity = json.loads(identity_path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
stored_identity = None
|
||||
rebuild = force or stored_identity != current_identity
|
||||
|
||||
# 1. LAS 구조화 (structured.npz) — 캐시가 유효하면 스킵 (PLAN B-1)
|
||||
structured_path = processed_dir / "structured.npz"
|
||||
if rebuild or not structured_path.is_file():
|
||||
_report(10, "structurize", "LAS 구조화 중")
|
||||
step_started = time.monotonic()
|
||||
structured_path = structurize_las(las_path, processed_dir)
|
||||
atomic_write_json(identity_path, current_identity)
|
||||
logger.info(
|
||||
"B04 LAS 구조화 완료: %s (%.1fs)", las_path.name, time.monotonic() - step_started
|
||||
)
|
||||
else:
|
||||
_report(10, "structurize", "구조화 캐시 재사용")
|
||||
logger.info("B04 구조화 캐시 재사용: %s", structured_path.name)
|
||||
with np.load(structured_path) as structured:
|
||||
xyz = structured["xyz"]
|
||||
bounds = structured["bounds"]
|
||||
@@ -120,11 +171,37 @@ def run_surface_analysis(
|
||||
}
|
||||
data = {"xyz": xyz, "bounds": bounds}
|
||||
|
||||
# 2. 지면 필터 실행
|
||||
# 2. 지면 필터 실행 — mask_{filter}.npy 영구 캐시 우선 재사용 (PLAN A-1)
|
||||
_report(40, "ground_filter", "지면 필터 적용 중")
|
||||
masks = build_ground_masks(data, source_filters)
|
||||
masks: dict[str, np.ndarray] = {}
|
||||
for filter_key in source_filters:
|
||||
mask_path = processed_dir / f"mask_{filter_key}.npy"
|
||||
mask: np.ndarray | None = None
|
||||
if not rebuild and mask_path.is_file():
|
||||
stored_mask = np.load(mask_path)
|
||||
if len(stored_mask) == total_points:
|
||||
mask = np.asarray(stored_mask, dtype=bool)
|
||||
logger.info("B04 지면 필터 캐시 재사용: %s", mask_path.name)
|
||||
if mask is None:
|
||||
step_started = time.monotonic()
|
||||
mask = np.asarray(run_ground_filter(filter_key, data), dtype=bool)
|
||||
np.save(mask_path, mask)
|
||||
logger.info(
|
||||
"B04 지면 필터 계산 완료: %s (%.1fs)",
|
||||
filter_key,
|
||||
time.monotonic() - step_started,
|
||||
)
|
||||
masks[filter_key] = mask
|
||||
ground_summary = summarize_masks(data, masks)
|
||||
for filter_key, mask in masks.items():
|
||||
cache_path = processed_dir / f"ground_points_{filter_key}.npz"
|
||||
if not rebuild and cache_path.is_file():
|
||||
with np.load(cache_path) as cached:
|
||||
if (
|
||||
"cache_version" in cached
|
||||
and int(cached["cache_version"]) == GROUND_POINT_CACHE_VERSION
|
||||
):
|
||||
continue
|
||||
cache_ground_points(structured_path, filter_key, mask)
|
||||
|
||||
# 3. 지표면 5종 모델 빌드
|
||||
@@ -132,18 +209,28 @@ def run_surface_analysis(
|
||||
config = build_surface_model_config()
|
||||
config["source_filters"] = list(source_filters)
|
||||
config["precompute"] = list(methods)
|
||||
manifest = build_all_terrain_models(data, masks, models_dir, config, force=force)
|
||||
step_started = time.monotonic()
|
||||
|
||||
# 3-2. VWorld 지도 및 국가 GIS 벡터 다운로드
|
||||
def _model_progress(percent: int, detail: str) -> None:
|
||||
_report(70 + int(percent * 0.2), "surface_model", detail)
|
||||
|
||||
manifest = build_all_terrain_models(
|
||||
data, masks, models_dir, config, force=rebuild, progress=_model_progress
|
||||
)
|
||||
logger.info(
|
||||
"B04 지표면 모델 빌드 완료: status=%s (%.1fs)",
|
||||
manifest.get("status"),
|
||||
time.monotonic() - step_started,
|
||||
)
|
||||
|
||||
# 3-2. VWorld 지도 및 국가 GIS 벡터 다운로드 (기존 산출물이 있으면 스킵)
|
||||
_report(90, "download_maps", "VWorld 지도 및 GIS 벡터 데이터 다운로드 중")
|
||||
try:
|
||||
# project_root 내의 .prj 파일 탐색 (B03_FileInput/input 또는 root 내 존재할 수 있음)
|
||||
prj_files = (
|
||||
list(project_root.glob("**/B03_FileInput/input/*.prj"))
|
||||
+ list(project_root.glob("*.prj"))
|
||||
+ list(project_root.glob("**/*.prj"))
|
||||
# 입력 LAS와 같은 폴더의 .prj를 우선 사용 (PLAN E-1: 전체 재귀 glob 제거)
|
||||
prj_candidates = sorted(las_path.parent.glob("*.prj")) or sorted(
|
||||
project_root.glob("B03_FileInput/**/*.prj")
|
||||
)
|
||||
prj_path = prj_files[0] if prj_files else project_root / "result.prj"
|
||||
prj_path = prj_candidates[0] if prj_candidates else project_root / "result.prj"
|
||||
|
||||
bounds_dict_for_download = {
|
||||
"x": [float(bounds[0, 0]), float(bounds[0, 1])],
|
||||
@@ -161,7 +248,11 @@ def run_surface_analysis(
|
||||
{"layer": "white", "ext": "png"},
|
||||
]
|
||||
for item in layers:
|
||||
meta_path = processed_dir / f"vworld_{item['layer'].lower()}_meta.json"
|
||||
if not rebuild and meta_path.is_file():
|
||||
continue
|
||||
try:
|
||||
step_started = time.monotonic()
|
||||
download_vworld_satellite_map(
|
||||
prj_path,
|
||||
bounds_dict_for_download,
|
||||
@@ -169,16 +260,25 @@ def run_surface_analysis(
|
||||
layer_name=item["layer"],
|
||||
ext=item["ext"],
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
logger.info(
|
||||
"B04 VWorld %s 지도 다운로드 완료 (%.1fs)",
|
||||
item["layer"],
|
||||
time.monotonic() - step_started,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("B04 VWorld %s 지도 다운로드 실패: %s", item["layer"], exc)
|
||||
|
||||
try:
|
||||
download_all_gis_vectors(prj_path, bounds_dict_for_download, processed_dir)
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
if rebuild or not any(processed_dir.glob("*_bounds.geojson")):
|
||||
try:
|
||||
step_started = time.monotonic()
|
||||
download_all_gis_vectors(prj_path, bounds_dict_for_download, processed_dir)
|
||||
logger.info(
|
||||
"B04 국가 GIS 벡터 다운로드 완료 (%.1fs)", time.monotonic() - step_started
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("B04 국가 GIS 벡터 다운로드 실패: %s", exc)
|
||||
except Exception as exc:
|
||||
logger.warning("B04 지도·GIS 다운로드 단계 실패: %s", exc)
|
||||
|
||||
_report(95, "saving", "결과 저장 중")
|
||||
|
||||
@@ -228,6 +328,9 @@ def run_surface_analysis(
|
||||
}
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"B04 WF1 분석 완료: 모델 %d개, 총 %.1fs", len(models), time.monotonic() - total_started
|
||||
)
|
||||
return {
|
||||
"processed": processed,
|
||||
"ground_summary": ground_summary,
|
||||
|
||||
@@ -189,9 +189,9 @@ def _tin_face_coverage_mask(
|
||||
if not len(boundary_edges):
|
||||
return np.zeros(xx.shape, dtype=bool)
|
||||
|
||||
from shapely import get_parts, linestrings, polygonize
|
||||
import rasterio.features
|
||||
import affine
|
||||
import rasterio.features
|
||||
from shapely import get_parts, linestrings, polygonize
|
||||
|
||||
boundary_lines = linestrings(vertices[boundary_edges, :2])
|
||||
polygons = list(get_parts(polygonize(boundary_lines)))
|
||||
@@ -203,16 +203,10 @@ def _tin_face_coverage_mask(
|
||||
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)
|
||||
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'
|
||||
polygons, out_shape=xx.shape, transform=transform, fill=0, default_value=1, dtype="uint8"
|
||||
)
|
||||
return mask.astype(bool)
|
||||
|
||||
@@ -225,26 +219,6 @@ 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,
|
||||
@@ -305,7 +279,7 @@ def extract_contours(
|
||||
control_z,
|
||||
kx=min(degree, len(control_y) - 1),
|
||||
ky=min(degree, len(control_x) - 1),
|
||||
s=0,
|
||||
s=float(len(control_x) * len(control_y)) * 0.01,
|
||||
)
|
||||
x_coords, y_coords = _grid_axes(
|
||||
float(control_x[0]),
|
||||
|
||||
@@ -252,7 +252,13 @@ def _build_all_terrain_models(
|
||||
_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():
|
||||
needs_contours = not contour_path.exists()
|
||||
if method in config.get("smoothing_methods", ("dtm", "tin")):
|
||||
smooth_contour_path = (
|
||||
output_dir / f"contour_{filter_key}_{method}_smooth_{interval}m.json"
|
||||
)
|
||||
needs_contours = needs_contours or not smooth_contour_path.exists()
|
||||
if needs_contours:
|
||||
_cache_contours_safely(
|
||||
output_dir,
|
||||
stem,
|
||||
@@ -281,6 +287,9 @@ def _build_all_terrain_models(
|
||||
method_started = time.monotonic()
|
||||
filter_entry["methods"][method] = {"status": "running", "error": None}
|
||||
_write_json_file(manifest_path, manifest)
|
||||
# 모델을 새로 만들기 전에 구세대 등고선 캐시를 제거해 모델-등고선 세대 불일치를 막는다.
|
||||
for stale_contour in output_dir.glob(f"contour_{filter_key}_{method}_*.json"):
|
||||
stale_contour.unlink(missing_ok=True)
|
||||
try:
|
||||
metadata = BUILDERS[method](
|
||||
context,
|
||||
|
||||
@@ -318,6 +318,21 @@ async def update_project_status(
|
||||
)
|
||||
|
||||
|
||||
async def delete_project_surface_models(connection: aiomysql.Connection, project_id: UUID) -> int:
|
||||
"""프로젝트의 기존 지표면 모델 행을 모두 제거한다.
|
||||
|
||||
모든 분석 세대가 동일 파일 경로를 재사용하므로 재분석 시 구세대 행을
|
||||
남기면 존재하지 않는 파일을 가리키게 된다. terrain_layers는 FK CASCADE,
|
||||
routes.surface_model_id는 FK SET NULL로 함께 정리된다.
|
||||
"""
|
||||
async with connection.cursor() as cursor:
|
||||
await cursor.execute(
|
||||
"DELETE FROM surface_models WHERE project_id = %s",
|
||||
(str(project_id),),
|
||||
)
|
||||
return int(cursor.rowcount)
|
||||
|
||||
|
||||
async def save_surface_analysis_to_db(
|
||||
connection: aiomysql.Connection,
|
||||
*,
|
||||
@@ -326,12 +341,13 @@ async def save_surface_analysis_to_db(
|
||||
analysis_result: dict[str, Any],
|
||||
source_filters: list[str],
|
||||
) -> list[int]:
|
||||
"""WF1 분석 결과를 DB에 저장한다.
|
||||
"""WF1 분석 결과를 DB에 저장한다 (기존 모델 행은 교체).
|
||||
|
||||
이 함수는 트랜잭션을 시작하거나 종료하지 않는다. 호출자는 같은 커넥션에서
|
||||
begin/commit/rollback을 한 번만 수행해야 한다.
|
||||
"""
|
||||
input_file = await get_input_file(connection, project_id, input_file_id)
|
||||
await delete_project_surface_models(connection, project_id)
|
||||
processed = analysis_result["processed"]
|
||||
processed_cloud_id = await create_processed_point_cloud(
|
||||
connection,
|
||||
|
||||
@@ -16,12 +16,12 @@ 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
|
||||
from config.config_system import SURFACE_CONTOUR_GRID_RESOLUTION_M
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/projects", tags=["B04 Surface Contour"])
|
||||
@@ -35,9 +35,11 @@ MODEL_REPRESENTATIONS = {
|
||||
}
|
||||
|
||||
|
||||
def _is_contour_cache_current(contour_path: Path) -> bool:
|
||||
"""캐시 JSON 선두의 extractor_version이 현재 추출기 버전과 일치하는지 검사한다."""
|
||||
def _is_contour_cache_current(contour_path: Path, model_path: Path) -> bool:
|
||||
"""캐시가 현재 추출기 버전이고 기반 모델 npz보다 최신인지 검사한다."""
|
||||
try:
|
||||
if contour_path.stat().st_mtime < model_path.stat().st_mtime:
|
||||
return False
|
||||
with contour_path.open("rb") as cache_file:
|
||||
head = cache_file.read(256).decode("utf-8", errors="ignore")
|
||||
except OSError:
|
||||
@@ -96,27 +98,19 @@ async def get_surface_model_contour(
|
||||
filter_key = "csf"
|
||||
if smooth and method in ("dtm", "tin"):
|
||||
contour_filename = f"contour_{filter_key}_{method}_smooth_{interval}m.json"
|
||||
contour_model_path = models_dir / f"{stem}_smooth.npz"
|
||||
representation = "regular_grid" if method == "dtm" else "triangular_mesh"
|
||||
else:
|
||||
contour_filename = f"contour_{filter_key}_{method}_{interval}m.json"
|
||||
contour_model_path = model_path
|
||||
representation = MODEL_REPRESENTATIONS.get(method)
|
||||
contour_path = models_dir / contour_filename
|
||||
if recalculate or not _is_contour_cache_current(contour_path):
|
||||
if recalculate or not _is_contour_cache_current(contour_path, contour_model_path):
|
||||
if not math.isfinite(interval) or interval < 0.5:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"status": "error", "message": "등고선 간격은 0.5m 이상이어야 합니다."},
|
||||
)
|
||||
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,
|
||||
@@ -131,7 +125,7 @@ async def get_surface_model_contour(
|
||||
contour_model_path,
|
||||
representation,
|
||||
interval,
|
||||
adaptive_contour_grid_resolution(contour_model_path, representation),
|
||||
SURFACE_CONTOUR_GRID_RESOLUTION_M,
|
||||
None,
|
||||
)
|
||||
logger.info(
|
||||
|
||||
@@ -175,6 +175,8 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
|
||||
let smoothPreferred = true;
|
||||
let currentModelId: number | null = null;
|
||||
let currentModelSmooth = false;
|
||||
// 선택 변경 후 늦게 도착한 이전 로더 콜백이 장면을 오염시키지 않도록 세대를 추적한다.
|
||||
let loadGeneration = 0;
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
scene.background = new THREE.Color(0xf5f7f9);
|
||||
@@ -320,6 +322,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
|
||||
const isSmooth = (activeMethod === "tin" || activeMethod === "dtm") && smoothCheck.checked;
|
||||
currentModelId = modelId;
|
||||
currentModelSmooth = isSmooth;
|
||||
const generation = ++loadGeneration;
|
||||
|
||||
statusSpan.textContent = "3D 메쉬 파일 다운로드 중...";
|
||||
const previewUrl = `${API_BASE_URL}/projects/${currentProjectId}/surface/models/${modelId}/preview?smooth=${isSmooth}`;
|
||||
@@ -329,6 +332,10 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
|
||||
new PLYLoader().load(
|
||||
previewUrl,
|
||||
async (geometry) => {
|
||||
if (generation !== loadGeneration) {
|
||||
geometry.dispose();
|
||||
return;
|
||||
}
|
||||
geometry.computeBoundingSphere();
|
||||
const material = new THREE.PointsMaterial({
|
||||
size: 0.35,
|
||||
@@ -344,6 +351,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
|
||||
},
|
||||
undefined,
|
||||
() => {
|
||||
if (generation !== loadGeneration) return;
|
||||
statusSpan.textContent = "3D 파일 로드에 실패했습니다.";
|
||||
},
|
||||
);
|
||||
@@ -351,6 +359,10 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
|
||||
new GLTFLoader().load(
|
||||
previewUrl,
|
||||
async (gltf) => {
|
||||
if (generation !== loadGeneration) {
|
||||
disposeObject(gltf.scene);
|
||||
return;
|
||||
}
|
||||
gltf.scene.traverse((child) => {
|
||||
if (child instanceof THREE.Mesh) {
|
||||
child.material.side = THREE.DoubleSide;
|
||||
@@ -365,6 +377,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
|
||||
},
|
||||
undefined,
|
||||
() => {
|
||||
if (generation !== loadGeneration) return;
|
||||
statusSpan.textContent = "3D 메쉬 파일이 없거나 로드할 수 없습니다.";
|
||||
},
|
||||
);
|
||||
|
||||
@@ -99,8 +99,9 @@ SURFACE_RANSAC_SEED = int(os.getenv("SURFACE_RANSAC_SEED", "42"))
|
||||
SURFACE_MODEL_SOURCE_FILTERS = tuple(
|
||||
os.getenv("SURFACE_MODEL_SOURCE_FILTERS", "grid_min_z,csf,pmf").split(",")
|
||||
)
|
||||
# dtm을 먼저 빌드해야 TIN 등고선 사전 캐시가 dtm footprint를 참조할 수 있다 (PLAN D-6)
|
||||
SURFACE_MODEL_PRECOMPUTE = tuple(
|
||||
os.getenv("SURFACE_MODEL_PRECOMPUTE", "tin,dtm,nurbs,implicit,meshfree").split(",")
|
||||
os.getenv("SURFACE_MODEL_PRECOMPUTE", "dtm,tin,nurbs,implicit,meshfree").split(",")
|
||||
)
|
||||
SURFACE_MODEL_SMOOTHING_METHODS = tuple(
|
||||
os.getenv("SURFACE_MODEL_SMOOTHING_METHODS", "dtm,tin").split(",")
|
||||
@@ -115,10 +116,10 @@ SURFACE_KEEP_LARGEST_FOOTPRINT = (
|
||||
os.getenv("SURFACE_KEEP_LARGEST_FOOTPRINT", "True").lower() == "true"
|
||||
)
|
||||
SURFACE_TILE_SIZE_M = float(os.getenv("SURFACE_TILE_SIZE_M", "50.0"))
|
||||
SURFACE_MAX_PREVIEW_VERTICES = int(os.getenv("SURFACE_MAX_PREVIEW_VERTICES", "120000"))
|
||||
SURFACE_MAX_PREVIEW_VERTICES = int(os.getenv("SURFACE_MAX_PREVIEW_VERTICES", "500000"))
|
||||
|
||||
# 표현별 파라미터
|
||||
SURFACE_TIN_MAX_INPUT_POINTS = int(os.getenv("SURFACE_TIN_MAX_INPUT_POINTS", "200000"))
|
||||
# 표현별 파라미터 (old 버전 검증값 기준 — PLAN F)
|
||||
SURFACE_TIN_MAX_INPUT_POINTS = int(os.getenv("SURFACE_TIN_MAX_INPUT_POINTS", "500000"))
|
||||
SURFACE_DTM_GRID_RESOLUTION_M = float(os.getenv("SURFACE_DTM_GRID_RESOLUTION_M", "1.0"))
|
||||
SURFACE_NURBS_DEGREE = int(os.getenv("SURFACE_NURBS_DEGREE", "3"))
|
||||
SURFACE_NURBS_PATCH_SIZE_M = float(os.getenv("SURFACE_NURBS_PATCH_SIZE_M", "50.0"))
|
||||
@@ -126,11 +127,11 @@ SURFACE_NURBS_CONTROL_POINTS_PER_AXIS = int(
|
||||
os.getenv("SURFACE_NURBS_CONTROL_POINTS_PER_AXIS", "16")
|
||||
)
|
||||
SURFACE_IMPLICIT_MAX_POINTS_PER_TILE = int(
|
||||
os.getenv("SURFACE_IMPLICIT_MAX_POINTS_PER_TILE", "20000")
|
||||
os.getenv("SURFACE_IMPLICIT_MAX_POINTS_PER_TILE", "10000")
|
||||
)
|
||||
SURFACE_IMPLICIT_SMOOTHING = float(os.getenv("SURFACE_IMPLICIT_SMOOTHING", "0.5"))
|
||||
SURFACE_MESHFREE_MAX_MODEL_POINTS = int(os.getenv("SURFACE_MESHFREE_MAX_MODEL_POINTS", "300000"))
|
||||
SURFACE_MESHFREE_POINT_RADIUS_M = float(os.getenv("SURFACE_MESHFREE_POINT_RADIUS_M", "0.5"))
|
||||
SURFACE_IMPLICIT_SMOOTHING = float(os.getenv("SURFACE_IMPLICIT_SMOOTHING", "0.1"))
|
||||
SURFACE_MESHFREE_MAX_MODEL_POINTS = int(os.getenv("SURFACE_MESHFREE_MAX_MODEL_POINTS", "500000"))
|
||||
SURFACE_MESHFREE_POINT_RADIUS_M = float(os.getenv("SURFACE_MESHFREE_POINT_RADIUS_M", "0.15"))
|
||||
|
||||
# 스무딩 파라미터
|
||||
SURFACE_SMOOTHING_DTM_SIGMA_M = float(os.getenv("SURFACE_SMOOTHING_DTM_SIGMA_M", "0.5"))
|
||||
|
||||
Reference in New Issue
Block a user