- 필터 = 업체 분류 · 점진 TIN · 둘의 다듬기 판만 · 분석 허용 목록 = Ground 등록부(available_filters) 한 벌 - 표현 = DTM · implicit 만 · 평활 = DTM 만 · config 기본 목록 · PRECOMPUTE · 설정 사전 맞춤(설정 지문 바뀜 — 768 에서 다시 지음) - 등고선 = 격자 · 굳힌 격자만(삼각망 굽기 · 외곽 맞춤 · implicit RBF 폴백 걷음) · 표본기 · 경로 찾기 tin · nurbs · meshfree 갈래 걷음 - 수치지형도 = 라플라스만(TIN 둘 · TPS · ANUDEM형 · 다중해상도 걷음) · B04 단추 하나 - 안 부르는 길 — B04 JSON 점군 GET · ground-stats GET + TS · B07 옛 계획평면도(라이다) 짓기 · lidar_source · LIDAR_ID · plan_lidar 종류 이름 · SOURCE_FILTER_LABELS · MODEL_METHODS(py) · MESH_* 설정 - 옛 manifest 의 지운 갈래 줄은 DB 로 되살리지 않음 · 옛 csf 행은 미리보기 · 등고선 그대로 열림 · 지운 이름 요청 = 400 - 낡은 글 — Ground · Pipeline 머리 · WF1 「없으면 csf」 · B07 음영 기본 「csf」 → 빈 값 · 등고선 폴백 「csf」 → 404 · B04 3D 보기 처음값 · B05 Schema · B06 Prebuild · 씨닝 설명 - 시험 지움 2 — test_b04_contour_raster · test_b04_contour_equivalence(지운 삼각망 굽기 · 없는 프로젝트를 봐 건너뛰던 것) - 시험 덜어냄 — test_b04_ground_filters CSF · 격자 최저 네 경우(지운 필터) - 시험 고침 — 허용 목록 · 형 검사에 걸린 csf · tin · grid_min_z · meshfree · tin_sheet 견본을 ptd · implicit · laplace 로(54_22 · 238_1 · 443 · 342_2 · 346_3 · 351_1 · 442_2 · 271 + helper · 344_1 · 133_1 · 343_2 · 637 · 754_1 · 763_1 깨진 tin → 깨진 implicit · 763_3 · b05 둘 · b06 · sheet_surface TIN(도엽선) 경우 지움) · 이름만 바꿈(273_2 · 343_1 · 343_3 · 624_1) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
219 lines
9.5 KiB
Python
219 lines
9.5 KiB
Python
"""B04 지표면 분석 **상태 조회·모델 프리뷰** 라우터.
|
|
|
|
`B04_PreProcess_Router` 가 700줄을 넘겨 떼어낸 조각이다(2026-09-04). 경로·응답·로직은
|
|
옮기기 전 그대로이고, 본체가 `include_router` 로 이 라우터를 그대로 실어 붙인다.
|
|
"""
|
|
|
|
import logging
|
|
from pathlib import Path
|
|
from uuid import UUID
|
|
|
|
import aiomysql
|
|
from fastapi import APIRouter, Request
|
|
from fastapi.responses import JSONResponse, Response
|
|
|
|
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
|
|
from B04_PreProcess.B04_PreProcess_Router_Progress import read_surface_progress
|
|
from common_util.common_util_http_cache import cached_file_response
|
|
from common_util.common_util_initial_snapshot import is_designing
|
|
from common_util.common_util_storage import resolve_stored_project_path
|
|
from config.config_db import get_db_pool
|
|
|
|
logger = logging.getLogger(__name__)
|
|
# prefix 는 본체(`_Router`)가 `include_router` 할 때 붙는다 — 여기서 또 주면
|
|
# 경로가 `/api/projects/api/projects/...` 로 겹친다.
|
|
router = APIRouter(tags=["B04 Surface Analysis"])
|
|
|
|
|
|
@router.get("/{project_id}/surface/status")
|
|
async def get_wf1_analysis_status(project_id: UUID) -> dict:
|
|
"""WF1 분석 상태를 조회한다."""
|
|
pool = get_db_pool()
|
|
try:
|
|
async with pool.acquire() as connection:
|
|
async with connection.cursor(aiomysql.DictCursor) as cursor:
|
|
await cursor.execute(
|
|
"""
|
|
SELECT state, progress_percent, message,
|
|
(SELECT COUNT(*) FROM surface_models
|
|
WHERE project_id = %s) as model_count
|
|
FROM project_workflow_stages
|
|
WHERE project_id = %s AND stage_no = 1
|
|
""",
|
|
(str(project_id), str(project_id)),
|
|
)
|
|
row = await cursor.fetchone()
|
|
|
|
# 만약 새 테이블에 정보가 없다면 기존 projects 테이블에서 조회 (백필 미작동 대비)
|
|
if not row:
|
|
await cursor.execute(
|
|
"""
|
|
SELECT p.status as project_status, COUNT(sm.id) as model_count
|
|
FROM projects p
|
|
LEFT JOIN surface_models sm ON sm.project_id = p.id
|
|
WHERE p.id = %s AND p.deleted_at IS NULL
|
|
GROUP BY p.id, p.status
|
|
""",
|
|
(str(project_id),),
|
|
)
|
|
fallback_row = await cursor.fetchone()
|
|
if not fallback_row:
|
|
return JSONResponse(
|
|
status_code=404,
|
|
content={"status": "error", "message": "프로젝트를 찾을 수 없습니다."},
|
|
)
|
|
model_count = int(fallback_row["model_count"])
|
|
project_status = str(fallback_row.get("project_status") or "NEW")
|
|
|
|
if project_status == "WF1_FAILED":
|
|
state = "FAILED"
|
|
progress_percent = 0
|
|
message = "WF1 분석에 실패했습니다."
|
|
elif model_count > 0 or project_status == "WF1_COMPLETE":
|
|
state = "COMPLETE"
|
|
progress_percent = 100
|
|
message = "WF1 분석이 완료되었습니다."
|
|
elif project_status == "WF1_ANALYZING":
|
|
state = "IN_PROGRESS"
|
|
progress_percent = 30
|
|
message = "WF1 분석이 진행 중입니다."
|
|
else:
|
|
state = "NOT_STARTED"
|
|
progress_percent = 0
|
|
message = "WF1 분석 대기 중입니다."
|
|
else:
|
|
state = row["state"]
|
|
progress_percent = row["progress_percent"]
|
|
message = row["message"] or ""
|
|
model_count = int(row["model_count"])
|
|
|
|
if state == "FAILED":
|
|
status = "failed"
|
|
current_stage = "failed"
|
|
if not message:
|
|
message = "WF1 분석에 실패했습니다."
|
|
elif state == "COMPLETE":
|
|
status = "completed"
|
|
progress_percent = 100
|
|
current_stage = "completed"
|
|
if not message:
|
|
message = "WF1 분석이 완료되었습니다."
|
|
elif state == "IN_PROGRESS":
|
|
status = "in_progress"
|
|
current_stage = "surface_analysis"
|
|
if not message:
|
|
message = "WF1 분석이 진행 중입니다."
|
|
# 진행률 파일이 있으면 실제 단계별 진행률로 대체
|
|
try:
|
|
async with pool.acquire() as connection:
|
|
stored_path = await get_project_storage_relative_path(connection, project_id)
|
|
progress = read_surface_progress(Path(resolve_stored_project_path(stored_path)))
|
|
if progress:
|
|
progress_percent = int(progress.get("progress_percent", progress_percent))
|
|
current_stage = str(progress.get("current_stage", current_stage))
|
|
message = str(progress.get("message", message))
|
|
except LookupError:
|
|
pass
|
|
else:
|
|
status = "pending"
|
|
progress_percent = 0
|
|
current_stage = "pending"
|
|
if not message:
|
|
message = "WF1 분석 대기 중입니다."
|
|
|
|
# 초기 설계 체인이 도는 동안은 아직 들어갈 때가 아니다 — WF1(stage 1)이 COMPLETE라도
|
|
# 마커가 있으면 진행 중으로 돌려준다(2026-08-29 사용자 확정, CLAUDE.md 5장).
|
|
# 여기서 덮어쓰는 이유: 위 분기는 stage 1만 보므로 체인 구간을 "완료"로 답한다.
|
|
try:
|
|
async with pool.acquire() as connection:
|
|
stored_path = await get_project_storage_relative_path(connection, project_id)
|
|
if stored_path and is_designing(Path(resolve_stored_project_path(stored_path))):
|
|
status = "in_progress"
|
|
current_stage = "initial_design"
|
|
message = "초기 설계를 계산하는 중입니다."
|
|
except (LookupError, OSError, ValueError):
|
|
pass
|
|
|
|
return {
|
|
"project_id": str(project_id),
|
|
"status": status,
|
|
"model_count": model_count,
|
|
"progress_percent": progress_percent,
|
|
"current_stage": current_stage,
|
|
"message": message,
|
|
}
|
|
except Exception:
|
|
logger.exception("WF1 분석 상태 조회 실패: project_id=%s", project_id)
|
|
return JSONResponse(
|
|
status_code=500,
|
|
content={"status": "error", "message": "분석 상태 조회 중 오류가 발생했습니다."},
|
|
)
|
|
|
|
|
|
@router.get("/{project_id}/surface/models/{model_id}/preview", response_model=None)
|
|
async def get_surface_model_preview(
|
|
request: Request,
|
|
project_id: UUID,
|
|
model_id: int,
|
|
smooth: bool = False,
|
|
) -> Response | JSONResponse:
|
|
"""지표면 모델의 3D 프리뷰 파일(GLB/PLY)을 반환한다."""
|
|
pool = get_db_pool()
|
|
try:
|
|
async with pool.acquire() as connection:
|
|
stored_path = await get_project_storage_relative_path(connection, project_id)
|
|
async with connection.cursor() as cursor:
|
|
await cursor.execute(
|
|
"""
|
|
SELECT model_type, model_file_path
|
|
FROM surface_models
|
|
WHERE id = %s AND project_id = %s
|
|
""",
|
|
(model_id, str(project_id)),
|
|
)
|
|
row = await cursor.fetchone()
|
|
if not row:
|
|
return JSONResponse(
|
|
status_code=404,
|
|
content={"status": "error", "message": "해당 모델을 찾을 수 없습니다."},
|
|
)
|
|
model_type, model_file_path = row[0], row[1]
|
|
project_root = Path(resolve_stored_project_path(stored_path))
|
|
if not model_file_path:
|
|
return JSONResponse(
|
|
status_code=404,
|
|
content={"status": "error", "message": "모델 파일 경로가 없습니다."},
|
|
)
|
|
model_path = project_root / model_file_path
|
|
models_dir = model_path.parent
|
|
stem = model_path.stem
|
|
|
|
if smooth and model_type == "dtm":
|
|
preview_filename = f"{stem}_smooth_preview.glb"
|
|
else:
|
|
preview_filename = f"{stem}_preview.glb"
|
|
|
|
preview_path = models_dir / preview_filename
|
|
if not preview_path.is_file():
|
|
return JSONResponse(
|
|
status_code=404,
|
|
content={
|
|
"status": "error",
|
|
"message": "프리뷰 파일이 생성되지 않았거나 존재하지 않습니다.",
|
|
},
|
|
)
|
|
|
|
media_type = "model/gltf-binary"
|
|
|
|
# 브라우저가 이미 같은 파일을 갖고 있으면 304만 돌려준다(새로고침이 빨라진다).
|
|
return cached_file_response(request, preview_path, media_type, preview_filename)
|
|
|
|
except Exception:
|
|
logger.exception(
|
|
"지표면 모델 프리뷰 조회 실패: project_id=%s, model_id=%s", project_id, model_id
|
|
)
|
|
return JSONResponse(
|
|
status_code=500,
|
|
content={"status": "error", "message": "프리뷰 파일 조회 중 오류가 발생했습니다."},
|
|
)
|