feat(B03/B04): LAS 없이 도엽등고선으로 3D 서피스를 만들어 설계한다
- B03: 'LAS 없이 설계' 토글 — LAS 필수카드 비활성화, las_free 플래그로
업로드·완료 검증 면제, 계획노선 CSV를 WF1 분석 입력으로 사용
- B04: 신규 Engine_SheetSurface — 도엽_등고선.geojson을 노선 bbox+300m
직사각형으로 절취, 배수유역 엔진의 등고선 정점구름·Delaunay TIN 보간을
재사용해 dtm_sheet.npz(1m 격자, LAS DTM과 동일 형식) + 프리뷰 glb 생성.
build_surface_sampler('sheet','dtm')로 종·횡단·배수 하류 계산 무수정 동작
- WF1: las_free면 run_sheet_surface_analysis로 분기, sheet/dtm 자동 확정.
VWorld·도엽 확보 블록을 download_geodata()로 추출해 두 경로가 공유
- LAS가 있어도 도엽 서피스를 함께 생성·등록(참고용), B04에
'도엽등고 3D 서피스' 별도 컨테이너로 표시
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -93,15 +93,16 @@ def _is_point_cloud_result(result: UploadedFileResult) -> bool:
|
||||
return result.file_type.lower() in _POINT_CLOUD_FILE_TYPES
|
||||
|
||||
|
||||
def _missing_required_file_types(file_types: set[str]) -> list[str]:
|
||||
def _missing_required_file_types(file_types: set[str], las_free: bool = False) -> list[str]:
|
||||
missing = sorted(_REQUIRED_FILE_TYPES - file_types)
|
||||
if not file_types.intersection(_POINT_CLOUD_FILE_TYPES):
|
||||
# LAS 없는 설계(도엽등고선 기반, 2026-08-30)는 LAS 필수를 면제한다.
|
||||
if not las_free and not file_types.intersection(_POINT_CLOUD_FILE_TYPES):
|
||||
missing.append("las/laz")
|
||||
return missing
|
||||
|
||||
|
||||
def _require_complete_file_set(file_types: set[str]) -> None:
|
||||
missing = _missing_required_file_types(file_types)
|
||||
def _require_complete_file_set(file_types: set[str], las_free: bool = False) -> None:
|
||||
missing = _missing_required_file_types(file_types, las_free)
|
||||
if missing:
|
||||
raise ValueError(f"B03 필수 입력 파일이 없습니다: {', '.join(missing)}")
|
||||
|
||||
@@ -153,11 +154,17 @@ async def _already_uploaded(
|
||||
async def _complete_file_input_if_ready(
|
||||
connection: aiomysql.Connection,
|
||||
project_id: UUID,
|
||||
las_free: bool = False,
|
||||
) -> int:
|
||||
file_types, point_cloud_input_id = await get_project_input_readiness(connection, project_id)
|
||||
_require_complete_file_set(file_types)
|
||||
file_types, point_cloud_input_id, route_csv_input_id = await get_project_input_readiness(
|
||||
connection, project_id
|
||||
)
|
||||
_require_complete_file_set(file_types, las_free)
|
||||
if point_cloud_input_id is None:
|
||||
raise ValueError("LAS 또는 LAZ 입력 파일을 찾을 수 없습니다.")
|
||||
if not las_free:
|
||||
raise ValueError("LAS 또는 LAZ 입력 파일을 찾을 수 없습니다.")
|
||||
if route_csv_input_id is None:
|
||||
raise ValueError("계획 노선 CSV 입력 파일을 찾을 수 없습니다.")
|
||||
# 자료가 갈렸으니 옛 계산 결과(파일 + DB)를 지우고 진행 표시도 되돌린다. 남겨 두면
|
||||
# 아직 다시 만들어지지 않은 뒷단계 화면이 옛 결과를 새 자료 것인 양 보여준다.
|
||||
stored_path = await get_project_storage_relative_path(connection, project_id)
|
||||
@@ -168,7 +175,8 @@ async def _complete_file_input_if_ready(
|
||||
async with connection.cursor(aiomysql.DictCursor) as cursor:
|
||||
await reset_stages_after_input_change(cursor, str(project_id))
|
||||
await complete_stage(cursor, str(project_id), 0)
|
||||
return point_cloud_input_id
|
||||
# LAS가 있으면 LAS, 없으면(las_free) 계획노선 CSV가 WF1 분석 입력이다.
|
||||
return point_cloud_input_id if point_cloud_input_id is not None else int(route_csv_input_id)
|
||||
|
||||
|
||||
def _write_stage_metadata(
|
||||
@@ -289,6 +297,7 @@ async def _send_upload_complete_notification(
|
||||
async def upload_project_files(
|
||||
project_id: UUID,
|
||||
files: list[UploadFile] = File(...),
|
||||
las_free: bool = Form(False),
|
||||
session: dict[str, Any] = Depends(verify_session),
|
||||
) -> FileUploadResponse | JSONResponse:
|
||||
"""프로젝트 입력 파일을 저장·분석하고 DB 메타데이터를 기록한다."""
|
||||
@@ -308,7 +317,8 @@ async def upload_project_files(
|
||||
content={"status": "error", "message": "동일한 파일명을 중복 업로드할 수 없습니다."},
|
||||
)
|
||||
las_count = sum(Path(filename).suffix.lower() in {".las", ".laz"} for filename in filenames)
|
||||
if las_count != 1:
|
||||
# LAS 없는 설계(las_free)는 LAS 0개를 허용한다 — 올렸다면 정상 경로로 취급.
|
||||
if las_count != 1 and not (las_free and las_count == 0):
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={
|
||||
@@ -326,7 +336,7 @@ async def upload_project_files(
|
||||
},
|
||||
)
|
||||
request_file_types = {Path(filename).suffix.lower().lstrip(".") for filename in filenames}
|
||||
missing_required = _missing_required_file_types(request_file_types)
|
||||
missing_required = _missing_required_file_types(request_file_types, las_free)
|
||||
if missing_required:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
@@ -385,7 +395,9 @@ async def upload_project_files(
|
||||
metadata=metadata,
|
||||
)
|
||||
)
|
||||
point_cloud_input_id = await _complete_file_input_if_ready(connection, project_id)
|
||||
point_cloud_input_id = await _complete_file_input_if_ready(
|
||||
connection, project_id, las_free
|
||||
)
|
||||
await connection.commit()
|
||||
except Exception:
|
||||
await connection.rollback()
|
||||
@@ -460,6 +472,7 @@ async def create_project_upload_session(
|
||||
point_cloud_input_id = await _complete_file_input_if_ready(
|
||||
connection,
|
||||
project_id,
|
||||
payload.las_free,
|
||||
)
|
||||
await connection.commit()
|
||||
except Exception:
|
||||
@@ -649,6 +662,7 @@ async def finalize_project_upload(
|
||||
point_cloud_input_id = await _complete_file_input_if_ready(
|
||||
connection,
|
||||
project_id,
|
||||
payload.las_free,
|
||||
)
|
||||
await connection.commit()
|
||||
except Exception:
|
||||
@@ -753,7 +767,9 @@ async def get_project_upload_overview(
|
||||
async with pool.acquire() as connection:
|
||||
files = await list_project_input_files(connection, project_id)
|
||||
sessions = await list_incomplete_upload_sessions(connection, project_id)
|
||||
file_types, point_cloud_id = await get_project_input_readiness(connection, project_id)
|
||||
file_types, point_cloud_id, _route_csv_id = await get_project_input_readiness(
|
||||
connection, project_id
|
||||
)
|
||||
async with connection.cursor(aiomysql.DictCursor) as cursor:
|
||||
state = await get_workflow_state(cursor, str(project_id))
|
||||
stages = (state or {}).get("stages") or []
|
||||
@@ -761,6 +777,11 @@ async def get_project_upload_overview(
|
||||
int(stage.get("stage_no", -1)) == 1 and str(stage.get("state")) == "COMPLETE"
|
||||
for stage in stages
|
||||
)
|
||||
# LAS 없는 설계로 stage 0을 마친 프로젝트는 LAS가 없어도 필수 충족으로 본다.
|
||||
stage0_complete = any(
|
||||
int(stage.get("stage_no", -1)) == 0 and str(stage.get("state")) == "COMPLETE"
|
||||
for stage in stages
|
||||
)
|
||||
return UploadOverviewResponse(
|
||||
files=[
|
||||
UploadOverviewFile(
|
||||
@@ -787,7 +808,8 @@ async def get_project_upload_overview(
|
||||
)
|
||||
for row in sessions
|
||||
],
|
||||
required_complete=_REQUIRED_FILE_TYPES <= file_types and point_cloud_id is not None,
|
||||
required_complete=_REQUIRED_FILE_TYPES <= file_types
|
||||
and (point_cloud_id is not None or stage0_complete),
|
||||
analysis_complete=analysis_complete,
|
||||
)
|
||||
except Exception:
|
||||
|
||||
Reference in New Issue
Block a user