diff --git a/B03_FileInput/B03_FileInput_Api_Fetch.ts b/B03_FileInput/B03_FileInput_Api_Fetch.ts index 2bb31c47..d2f53bb2 100644 --- a/B03_FileInput/B03_FileInput_Api_Fetch.ts +++ b/B03_FileInput/B03_FileInput_Api_Fetch.ts @@ -69,12 +69,15 @@ export async function uploadProjectFiles( const controller = new AbortController(); const timeoutId = window.setTimeout(() => controller.abort(), API_TIMEOUT_MS); try { - const response = await fetch(`${API_BASE_URL}/projects/${projectId}/files`, { - method: "POST", - credentials: "include", - body: formData, - signal: controller.signal, - }); + const response = await fetch( + `${API_BASE_URL}/projects/${projectId}/files`, + { + method: "POST", + credentials: "include", + body: formData, + signal: controller.signal, + }, + ); return await readJsonOrThrow(response); } finally { window.clearTimeout(timeoutId); @@ -87,19 +90,24 @@ export async function createUploadSession( chunkSizeBytes: number, fingerprint?: string | null, completeUpload = false, + lasFree = false, ): Promise { - const response = await fetch(`${API_BASE_URL}/projects/${projectId}/upload-sessions`, { - method: "POST", - credentials: "include", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - original_filename: file.name, - size_bytes: file.size, - chunk_size_bytes: chunkSizeBytes, - fingerprint: fingerprint ?? null, - complete_upload: completeUpload, - }), - }); + const response = await fetch( + `${API_BASE_URL}/projects/${projectId}/upload-sessions`, + { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + original_filename: file.name, + size_bytes: file.size, + chunk_size_bytes: chunkSizeBytes, + fingerprint: fingerprint ?? null, + complete_upload: completeUpload, + las_free: lasFree, + }), + }, + ); return await readJsonOrThrow(response); } @@ -128,18 +136,23 @@ export async function finalizeUploadSession( totalChunks: number, completeUpload: boolean, fingerprint?: string | null, + lasFree = false, ): Promise { - const response = await fetch(`${API_BASE_URL}/projects/${projectId}/finalize`, { - method: "POST", - credentials: "include", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - session_id: sessionId, - total_chunks: totalChunks, - complete_upload: completeUpload, - fingerprint: fingerprint ?? null, - }), - }); + const response = await fetch( + `${API_BASE_URL}/projects/${projectId}/finalize`, + { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + session_id: sessionId, + total_chunks: totalChunks, + complete_upload: completeUpload, + fingerprint: fingerprint ?? null, + las_free: lasFree, + }), + }, + ); return await readJsonOrThrow(response); } @@ -147,10 +160,13 @@ export async function fetchUploadStatus( projectId: string, sessionId: string, ): Promise { - const response = await fetch(`${API_BASE_URL}/projects/${projectId}/upload-status/${sessionId}`, { - method: "GET", - credentials: "include", - }); + const response = await fetch( + `${API_BASE_URL}/projects/${projectId}/upload-status/${sessionId}`, + { + method: "GET", + credentials: "include", + }, + ); return await readJsonOrThrow(response); } @@ -182,11 +198,16 @@ export interface UploadOverviewResponse { } /** 재접속 시 업로드 현황(정본) — 완료 파일 목록 + 중단 세션 + 완료 여부. */ -export async function fetchUploadOverview(projectId: string): Promise { - const response = await fetch(`${API_BASE_URL}/projects/${projectId}/upload-overview`, { - method: "GET", - credentials: "include", - }); +export async function fetchUploadOverview( + projectId: string, +): Promise { + const response = await fetch( + `${API_BASE_URL}/projects/${projectId}/upload-overview`, + { + method: "GET", + credentials: "include", + }, + ); return await readJsonOrThrow(response); } @@ -200,10 +221,15 @@ export interface WF1AnalysisStatus { error?: string; } -export async function checkWF1AnalysisStatus(projectId: string): Promise { - const response = await fetch(`${API_BASE_URL}/projects/${projectId}/surface/status`, { - method: "GET", - credentials: "include", - }); +export async function checkWF1AnalysisStatus( + projectId: string, +): Promise { + const response = await fetch( + `${API_BASE_URL}/projects/${projectId}/surface/status`, + { + method: "GET", + credentials: "include", + }, + ); return await readJsonOrThrow(response); } diff --git a/B03_FileInput/B03_FileInput_Repository.py b/B03_FileInput/B03_FileInput_Repository.py index e8abf383..a35eab16 100644 --- a/B03_FileInput/B03_FileInput_Repository.py +++ b/B03_FileInput/B03_FileInput_Repository.py @@ -64,8 +64,8 @@ async def create_input_file( async def get_project_input_readiness( connection: aiomysql.Connection, project_id: UUID, -) -> tuple[set[str], int | None]: - """현재 업로드 파일 유형과 최신 포인트클라우드 입력 ID를 반환한다.""" +) -> tuple[set[str], int | None, int | None]: + """업로드 파일 유형, 최신 포인트클라우드 입력 ID, 최신 계획노선 CSV 입력 ID를 반환한다.""" async with connection.cursor(aiomysql.DictCursor) as cursor: await cursor.execute( """ @@ -83,7 +83,12 @@ async def get_project_input_readiness( (int(row["id"]) for row in rows if str(row.get("file_type") or "") in {"las", "laz"}), None, ) - return file_types, point_cloud_id + # LAS 없는 설계(2026-08-30)의 WF1 입력 — 계획노선 CSV가 분석 원천이 된다. + route_csv_id = next( + (int(row["id"]) for row in rows if str(row.get("file_type") or "") == "csv"), + None, + ) + return file_types, point_cloud_id, route_csv_id async def get_project_storage_relative_path( diff --git a/B03_FileInput/B03_FileInput_Router.py b/B03_FileInput/B03_FileInput_Router.py index 8fc2850b..fb748686 100644 --- a/B03_FileInput/B03_FileInput_Router.py +++ b/B03_FileInput/B03_FileInput_Router.py @@ -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: diff --git a/B03_FileInput/B03_FileInput_Schema.py b/B03_FileInput/B03_FileInput_Schema.py index 0dff7e03..3506e764 100644 --- a/B03_FileInput/B03_FileInput_Schema.py +++ b/B03_FileInput/B03_FileInput_Schema.py @@ -57,6 +57,8 @@ class ChunkSessionCreateRequest(FileUploadDescriptor): chunk_size_bytes: int = Field(default=UPLOAD_CHUNK_SIZE_BYTES, gt=0) complete_upload: bool = False + # LAS 없는 설계(도엽등고선 기반, 2026-08-30) — 완료 판정에서 LAS 필수를 면제한다. + las_free: bool = False # 파일 지문 — 같은 이름으로 **같은 내용**이 다시 올라오는지 전송 전에 가린다. # 화면이 파일 크기 + 앞·중간·끝 조각으로 만든다([[fileFingerprint]]). fingerprint: str | None = Field(default=None, max_length=128) @@ -96,6 +98,8 @@ class UploadFinalizeRequest(BaseModel): session_id: str = Field(min_length=1, max_length=36) total_chunks: int = Field(gt=0) complete_upload: bool = True + # LAS 없는 설계(도엽등고선 기반, 2026-08-30) — 완료 판정에서 LAS 필수를 면제한다. + las_free: bool = False # 세션 생성 때 쓴 지문을 그대로 다시 받아 입력 파일에 남긴다. 다음에 같은 파일이 # 올라오면 이 값으로 전송을 건너뛴다(upload_sessions에 컬럼을 더하지 않으려는 선택). fingerprint: str | None = Field(default=None, max_length=128) diff --git a/B03_FileInput/B03_FileInput_Service_WF1.py b/B03_FileInput/B03_FileInput_Service_WF1.py index 4f10c038..c51e004c 100644 --- a/B03_FileInput/B03_FileInput_Service_WF1.py +++ b/B03_FileInput/B03_FileInput_Service_WF1.py @@ -20,6 +20,7 @@ from config.config_db import get_db_pool from config.config_system import ( AUTO_DESIGN_CHAIN_ENABLED, SEND_ANALYSIS_COMPLETION_EMAIL, + SURFACE_CONTOUR_INTERVAL_M, SURFACE_MODEL_PRECOMPUTE, SURFACE_MODEL_SOURCE_FILTERS, ) @@ -79,9 +80,15 @@ async def trigger_wf1_analysis_and_email( input_file = await get_input_file(connection, project_id, input_file_id) project_root = Path(resolve_stored_project_path(stored_path)) - las_path = project_root / Path(str(input_file["raw_file_path"])) - if not las_path.is_file(): - raise FileNotFoundError("원본 LAS/LAZ 파일을 찾을 수 없습니다.") + source_path = project_root / Path(str(input_file["raw_file_path"])) + # LAS 없는 설계(2026-08-30): 입력이 계획노선 CSV면 도엽등고선 서피스 분석으로 간다. + las_free = str(input_file.get("file_type") or "").lower() not in {"las", "laz"} + if not source_path.is_file(): + raise FileNotFoundError( + "계획 노선 파일을 찾을 수 없습니다." + if las_free + else "원본 LAS/LAZ 파일을 찾을 수 없습니다." + ) from B04_PreProcess.B04_PreProcess_Engine import run_surface_analysis from B04_PreProcess.B04_PreProcess_Repository import save_surface_analysis_to_db @@ -92,15 +99,27 @@ async def trigger_wf1_analysis_and_email( def _on_progress(percent: int, stage: str, message: str) -> None: write_surface_progress(project_root, percent, stage, message) - analysis_result = await asyncio.to_thread( - run_surface_analysis, - project_root, - las_path, - source_filters=source_filters, - methods=methods, - force=False, - on_progress=_on_progress, - ) + if las_free: + from B04_PreProcess.B04_PreProcess_Engine_SheetSurface import ( + run_sheet_surface_analysis, + ) + + analysis_result = await asyncio.to_thread( + run_sheet_surface_analysis, + project_root, + source_path, + on_progress=_on_progress, + ) + else: + analysis_result = await asyncio.to_thread( + run_surface_analysis, + project_root, + source_path, + source_filters=source_filters, + methods=methods, + force=False, + on_progress=_on_progress, + ) auto_confirmation_error: str | None = None auto_confirmed = False @@ -124,7 +143,17 @@ async def trigger_wf1_analysis_and_email( find_surface_model_for_selection, ) - selection = surface_confirmation_defaults() + # LAS 없는 설계는 도엽 서피스 모델(sheet/dtm)로 확정한다. + selection = ( + { + "source_filter": "sheet", + "method": "dtm", + "smooth": False, + "contour_interval_m": SURFACE_CONTOUR_INTERVAL_M, + } + if las_free + else surface_confirmation_defaults() + ) try: model_id = await find_surface_model_for_selection( connection, project_id, selection diff --git a/B03_FileInput/B03_FileInput_UI_Page.ts b/B03_FileInput/B03_FileInput_UI_Page.ts index 73280f5d..3dd678ca 100644 --- a/B03_FileInput/B03_FileInput_UI_Page.ts +++ b/B03_FileInput/B03_FileInput_UI_Page.ts @@ -9,8 +9,14 @@ import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import { createButton, createTag, showToast } from "@ui/ui_template_elements"; import { createGeneralLayout } from "@ui/ui_template_general_layout"; import { createWorkflowOverlays } from "@ui/ui_template_overlay"; -import { createStepBar, WORKFLOW_STEP_ICONS } from "@ui/ui_template_workflow_layout"; -import { fetchUploadOverview, type UploadedFileResult } from "./B03_FileInput_Api_Fetch"; +import { + createStepBar, + WORKFLOW_STEP_ICONS, +} from "@ui/ui_template_workflow_layout"; +import { + fetchUploadOverview, + type UploadedFileResult, +} from "./B03_FileInput_Api_Fetch"; import { clearPreloadMark } from "../A00_Common/b_asset_cache"; import { navigateTo } from "../A00_Common/router"; import { attachTempBatch } from "../B01_Dashboard/B01_Dashboard_Api_Temp"; @@ -66,6 +72,10 @@ export async function renderB03FileInput(root: HTMLElement): Promise { // 단계로 넘어가는 것을 막는다(2026-08-08 사용자 지시). let pageRoot: HTMLElement | null = null; let isUploading = false; + // LAS 없는 설계(도엽등고선 기반, 2026-08-30) — 프로젝트별로 기억한다. + let lasFreeDesign = activeProjectId + ? localStorage.getItem(`b03_las_free_${activeProjectId}`) === "1" + : false; function clearDerivedCaches(projectId: string): void { clearRouteLatestCache(projectId); @@ -114,7 +124,10 @@ export async function renderB03FileInput(root: HTMLElement): Promise { return Array.from(slots.values()).filter((state) => state.file); } - function setCardState(slot: FileSlot, stateName: "empty" | "selected" | UploadStatus): void { + function setCardState( + slot: FileSlot, + stateName: "empty" | "selected" | UploadStatus, + ): void { const card = cardMap.get(slot); if (!card) return; card.classList.remove( @@ -128,7 +141,9 @@ export async function renderB03FileInput(root: HTMLElement): Promise { const cssState = stateName === "failed" ? "error" : stateName; card.classList.add(`b03-file__card--${cssState}`); - const badgeContainer = card.querySelector(".b03-file__card-badge-container"); + const badgeContainer = card.querySelector( + ".b03-file__card-badge-container", + ); if (badgeContainer) { badgeContainer.replaceChildren(); if (stateName === "empty") { @@ -183,18 +198,38 @@ export async function renderB03FileInput(root: HTMLElement): Promise { const card = cardMap.get(slot); if (!state || !card) return; - const fileName = card.querySelector(".b03-file__file-name"); - const fileSize = card.querySelector(".b03-file__file-size"); - const progress = card.querySelector(".b03-file__progress-bar"); - const progressBytes = card.querySelector(".b03-file__progress-bytes"); - const progressSpeed = card.querySelector(".b03-file__progress-speed"); - const progressEta = card.querySelector(".b03-file__progress-eta"); - const error = card.querySelector(".b03-file__error-message"); - const remove = card.querySelector(".b03-file__card-remove"); + const fileName = card.querySelector( + ".b03-file__file-name", + ); + const fileSize = card.querySelector( + ".b03-file__file-size", + ); + const progress = card.querySelector( + ".b03-file__progress-bar", + ); + const progressBytes = card.querySelector( + ".b03-file__progress-bytes", + ); + const progressSpeed = card.querySelector( + ".b03-file__progress-speed", + ); + const progressEta = card.querySelector( + ".b03-file__progress-eta", + ); + const error = card.querySelector( + ".b03-file__error-message", + ); + const remove = card.querySelector( + ".b03-file__card-remove", + ); - const percent = state.file ? Math.min(100, (state.progressBytes / state.file.size) * 100) : 0; + const percent = state.file + ? Math.min(100, (state.progressBytes / state.file.size) * 100) + : 0; // 로컬 파일이 없어도 서버에 업로드된 파일이 있으면 그 정보(정본)를 보여준다. - if (fileName) fileName.textContent = state.file?.name ?? state.serverUploaded?.name ?? ""; + if (fileName) + fileName.textContent = + state.file?.name ?? state.serverUploaded?.name ?? ""; if (fileSize) { fileSize.textContent = state.file ? formatBytes(state.file.size) @@ -220,8 +255,13 @@ export async function renderB03FileInput(root: HTMLElement): Promise { if (remove) remove.hidden = !state.file; if (state.error) setCardState(slot, "failed"); - else if (!state.file) setCardState(slot, state.serverUploaded ? "completed" : "empty"); - else setCardState(slot, state.uploadStatus === "pending" ? "selected" : state.uploadStatus); + else if (!state.file) + setCardState(slot, state.serverUploaded ? "completed" : "empty"); + else + setCardState( + slot, + state.uploadStatus === "pending" ? "selected" : state.uploadStatus, + ); updateUploadButton(); } @@ -233,19 +273,29 @@ export async function renderB03FileInput(root: HTMLElement): Promise { renderSlot(slot); } - function validateFileForSlot(file: File, state: FileSlotState): string | null { + function validateFileForSlot( + file: File, + state: FileSlotState, + ): string | null { const extension = getExtension(file.name); const maxBytes = UPLOAD_MAX_MB * 1024 * 1024; - if (!state.extensions.includes(extension)) return L("B03_File_Error_SlotType"); - if (file.size === 0 || file.size > maxBytes) return L("B03_File_Error_Size"); + if (!state.extensions.includes(extension)) + return L("B03_File_Error_SlotType"); + if (file.size === 0 || file.size > maxBytes) + return L("B03_File_Error_Size"); return null; } - async function assignFileToSlot(file: File, targetSlot?: FileSlot): Promise { + async function assignFileToSlot( + file: File, + targetSlot?: FileSlot, + ): Promise { const extension = getExtension(file.name); const state = targetSlot ? slots.get(targetSlot) - : Array.from(slots.values()).find((candidate) => candidate.extensions.includes(extension)); + : Array.from(slots.values()).find((candidate) => + candidate.extensions.includes(extension), + ); if (!state) { pageError.textContent = `${L("B03_File_Error_Extension")} ${file.name}`; return; @@ -256,13 +306,19 @@ export async function renderB03FileInput(root: HTMLElement): Promise { return; } if (!targetSlot && state.file && state.file.name !== file.name) { - showErrorMessage(state.slot, `${L("B03_File_Error_DuplicateSlot")} ${file.name}`); + showErrorMessage( + state.slot, + `${L("B03_File_Error_DuplicateSlot")} ${file.name}`, + ); return; } // 서버에 이미 완료된 슬롯이면 교체 확인을 받는다(2026-08-04 사용자 지시). 이어올리기로 // 같은 파일을 다시 고르는 경우는 업로드가 미완료라 serverUploaded가 없어 묻지 않는다. if (state.serverUploaded) { - const accepted = await confirmReplaceUpload(L(state.labelKey), state.serverUploaded.name); + const accepted = await confirmReplaceUpload( + L(state.labelKey), + state.serverUploaded.name, + ); if (!accepted) return; } @@ -286,8 +342,9 @@ export async function renderB03FileInput(root: HTMLElement): Promise { const extension = getExtension(file.name); const slot = targetSlot ?? - Array.from(slots.values()).find((candidate) => candidate.extensions.includes(extension)) - ?.slot; + Array.from(slots.values()).find((candidate) => + candidate.extensions.includes(extension), + )?.slot; if (slot) occupied.add(slot); } if (occupied.size > UPLOAD_MAX_FILES) { @@ -325,11 +382,19 @@ export async function renderB03FileInput(root: HTMLElement): Promise { // 필수 슬롯은 로컬 선택이 없어도 **서버에 이미 올라간 파일**이 있으면 충족이다 — // 재접속 후 파일 하나만 교체 업로드하는 흐름을 막지 않는다(2026-08-04 사용자 지시). const missingRequired = Array.from(slots.values()).some( - (state) => state.isRequired && !state.file && !state.serverUploaded, + (state) => + state.isRequired && + !state.file && + !state.serverUploaded && + // LAS 없는 설계면 포인트클라우드 카드는 필수에서 뺀다. + !(lasFreeDesign && state.slot === "las_laz"), ); if (missingRequired) return L("B03_File_Error_RequiredSlots"); - const lasState = slots.get("las_laz"); - if (!lasState?.file && !lasState?.serverUploaded) return L("B03_File_Error_Las"); + if (!lasFreeDesign) { + const lasState = slots.get("las_laz"); + if (!lasState?.file && !lasState?.serverUploaded) + return L("B03_File_Error_Las"); + } for (const state of selected) { if (state.error) return state.error; const validation = validateFileForSlot(state.file!, state); @@ -358,7 +423,10 @@ export async function renderB03FileInput(root: HTMLElement): Promise { candidate.extensions.includes(extension), ); if (state) { - state.serverUploaded = { name: file.original_filename, sizeMb: file.file_size_mb }; + state.serverUploaded = { + name: file.original_filename, + sizeMb: file.file_size_mb, + }; } } for (const slot of slots.keys()) renderSlot(slot); @@ -393,22 +461,30 @@ export async function renderB03FileInput(root: HTMLElement): Promise { if (!card) throw new Error("file-card-template is invalid"); card.dataset.slotId = state.slot; card.querySelector(".b03-file__card-icon")!.textContent = state.icon; - card.querySelector(".b03-file__card-label")!.textContent = L(state.labelKey); + card.querySelector(".b03-file__card-label")!.textContent = L( + state.labelKey, + ); // 지형 래스터만 선택 항목이라 확장자 옆에 표시해 둔다. const extLabel = state.extensions.join(", "); card.querySelector(".b03-file__card-ext")!.textContent = state.isRequired ? extLabel : `${extLabel} · ${L("B03_File_Card_Optional")}`; - const input = card.querySelector(".b03-file__slot-input")!; + const input = card.querySelector( + ".b03-file__slot-input", + )!; input.accept = state.extensions.join(","); - const select = card.querySelector(".b03-file__card-select")!; + const select = card.querySelector( + ".b03-file__card-select", + )!; select.textContent = L("B03_File_Card_Select"); select.addEventListener("click", () => input.click()); input.addEventListener("change", () => { onFileSelected(input.files ? Array.from(input.files) : [], state.slot); input.value = ""; }); - const remove = card.querySelector(".b03-file__card-remove")!; + const remove = card.querySelector( + ".b03-file__card-remove", + )!; remove.textContent = "×"; remove.title = L("B03_File_Card_Remove"); remove.setAttribute("aria-label", L("B03_File_Card_Remove")); @@ -417,7 +493,10 @@ export async function renderB03FileInput(root: HTMLElement): Promise { return card; } - function createCardGroup(title: string, groupSlots: readonly FileSlot[]): HTMLElement { + function createCardGroup( + title: string, + groupSlots: readonly FileSlot[], + ): HTMLElement { const group = document.createElement("section"); group.className = "b03-file__group"; if (title) { @@ -443,7 +522,9 @@ export async function renderB03FileInput(root: HTMLElement): Promise { if (!activeProjectId) return; for (const state of selectedStates()) { - const stored = localStorage.getItem(makeSessionKey(activeProjectId, state.file!)); + const stored = localStorage.getItem( + makeSessionKey(activeProjectId, state.file!), + ); if (!stored) continue; const session = JSON.parse(stored) as StoredUploadSession; state.uploadSessionId = session.uploadSessionId; @@ -513,7 +594,8 @@ export async function renderB03FileInput(root: HTMLElement): Promise { showToast(L("B03_File_Analysis_StillRunning"), "warning"); } } catch (error) { - const detail = error instanceof Error ? error.message : L("B03_Temp_Attach_Failed"); + const detail = + error instanceof Error ? error.message : L("B03_Temp_Attach_Failed"); pageError.textContent = `${L("B03_Temp_Attach_Failed")} ${detail}`; showToast(L("B03_Temp_Attach_Failed"), "error"); } @@ -542,7 +624,9 @@ export async function renderB03FileInput(root: HTMLElement): Promise { } } - async function startChunkedUpload(targetStates = selectedStates()): Promise { + async function startChunkedUpload( + targetStates = selectedStates(), + ): Promise { if (isUploading) return; // 보관함에서 불러온 자료가 지정돼 있으면 그것을 옮기는 것이 이 버튼의 동작이다. if (tempPicker.selected()) { @@ -568,8 +652,12 @@ export async function renderB03FileInput(root: HTMLElement): Promise { for (let index = 0; index < targetStates.length; index += 1) { const state = targetStates[index]; uploaded.push( - ...(await uploadOneFile(activeProjectId, state, index === targetStates.length - 1, () => - renderSlot(state.slot), + ...(await uploadOneFile( + activeProjectId, + state, + index === targetStates.length - 1, + () => renderSlot(state.slot), + lasFreeDesign, )), ); } @@ -586,8 +674,11 @@ export async function renderB03FileInput(root: HTMLElement): Promise { showToast(L("B03_File_Analysis_StillRunning"), "warning"); } } catch (error) { - const failed = targetStates.find((state) => state.uploadStatus === "uploading"); - const detail = error instanceof Error ? error.message : L("B03_File_Upload_Failed"); + const failed = targetStates.find( + (state) => state.uploadStatus === "uploading", + ); + const detail = + error instanceof Error ? error.message : L("B03_File_Upload_Failed"); if (failed) showErrorMessage(failed.slot, detail); pageError.textContent = `${L("B03_File_Upload_Failed")} ${detail}`; showToast(L("B03_File_Upload_Failed"), "error"); @@ -621,7 +712,9 @@ export async function renderB03FileInput(root: HTMLElement): Promise { function onB03_File_Drop(event: DragEvent): void { event.preventDefault(); dropzone.classList.remove("is-dragging"); - onFileSelected(event.dataTransfer?.files ? Array.from(event.dataTransfer.files) : []); + onFileSelected( + event.dataTransfer?.files ? Array.from(event.dataTransfer.files) : [], + ); } fileInput.addEventListener("change", onB03_File_Select_Change); @@ -633,7 +726,9 @@ export async function renderB03FileInput(root: HTMLElement): Promise { event.preventDefault(); dropzone.classList.add("is-dragging"); }); - dropzone.addEventListener("dragleave", () => dropzone.classList.remove("is-dragging")); + dropzone.addEventListener("dragleave", () => + dropzone.classList.remove("is-dragging"), + ); dropzone.addEventListener("drop", onB03_File_Drop); uploadButton = createButton({ @@ -667,9 +762,37 @@ export async function renderB03FileInput(root: HTMLElement): Promise { "tif", ]); + // LAS 없는 설계 토글 — 켜면 포인트클라우드 카드를 비활성화하고 필수에서 뺀다. + const lasFreeRow = document.createElement("label"); + lasFreeRow.className = "b03-file__lasfree"; + lasFreeRow.title = L("B03_File_LasFree_Hint"); + const lasFreeCheck = document.createElement("input"); + lasFreeCheck.type = "checkbox"; + const lasFreeText = document.createElement("span"); + lasFreeText.textContent = L("B03_File_LasFree_Toggle"); + lasFreeRow.append(lasFreeCheck, lasFreeText); + function applyLasFreeState(): void { + lasFreeCheck.checked = lasFreeDesign; + cardMap + .get("las_laz") + ?.classList.toggle("b03-file__card--disabled", lasFreeDesign); + } + lasFreeCheck.addEventListener("change", () => { + lasFreeDesign = lasFreeCheck.checked; + if (activeProjectId) { + localStorage.setItem( + `b03_las_free_${activeProjectId}`, + lasFreeDesign ? "1" : "0", + ); + } + applyLasFreeState(); + pageError.textContent = ""; + }); + const cardsContainer = document.createElement("div"); - cardsContainer.className = "b03-file__control-panel b03-file__cards-container-panel"; - cardsContainer.append(inputsGroup); + cardsContainer.className = + "b03-file__control-panel b03-file__cards-container-panel"; + cardsContainer.append(lasFreeRow, inputsGroup); const workflowState = activeProjectId ? await fetchWorkflowState(activeProjectId).catch(() => undefined) @@ -705,6 +828,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise { root.replaceChildren(layout.root); for (const slot of slots.keys()) renderSlot(slot); + applyLasFreeState(); void relockWhileInitialPipelineRuns(); void registerB03ServiceWorker(); void applyUploadOverview(); diff --git a/B03_FileInput/B03_FileInput_UI_Style.css b/B03_FileInput/B03_FileInput_UI_Style.css index dd55313f..ba5c4e5a 100644 --- a/B03_FileInput/B03_FileInput_UI_Style.css +++ b/B03_FileInput/B03_FileInput_UI_Style.css @@ -277,7 +277,9 @@ color: var(--color-royal-amethyst, #3e0079); background: var(--color-mist-violet, #edecff); font-size: var(--text-body-sm, 14px); - margin-right: var(--spacing-8); /* 아이콘 우측 마진 추가 (아이콘 좌측 여유 확대 효과) */ + margin-right: var( + --spacing-8 + ); /* 아이콘 우측 마진 추가 (아이콘 좌측 여유 확대 효과) */ } .b03-file__card-heading { @@ -321,7 +323,9 @@ font-size: var(--text-body-sm, 14px); line-height: 1; padding: 0; - margin-left: var(--spacing-8); /* 취소 버튼 좌측 여유 추가 (취소 버튼 우측 여유 확보) */ + margin-left: var( + --spacing-8 + ); /* 취소 버튼 좌측 여유 추가 (취소 버튼 우측 여유 확보) */ transition: all var(--transition-base, 0.2s); } @@ -474,3 +478,20 @@ grid-template-columns: 1fr; /* 모바일에서는 1행 1열 구조 */ } } + +/* LAS 없는 설계(도엽등고선 기반) 토글 — 2026-08-30 */ +.b03-file__lasfree { + display: flex; + align-items: center; + gap: 8px; + padding: 4px 2px; + font-size: var(--text-body); + color: var(--color-text-body); + cursor: pointer; + user-select: none; +} + +.b03-file__card--disabled { + opacity: 0.45; + pointer-events: none; +} diff --git a/B03_FileInput/B03_FileInput_UI_Upload.ts b/B03_FileInput/B03_FileInput_UI_Upload.ts index 90b57c79..7f018a6c 100644 --- a/B03_FileInput/B03_FileInput_UI_Upload.ts +++ b/B03_FileInput/B03_FileInput_UI_Upload.ts @@ -6,8 +6,14 @@ * 갱신하고, 화면 갱신은 호출측이 넘긴 콜백으로만 한다 — 이 파일은 DOM 구조를 모른다. * ========================================================================== */ -import { PROGRESS_UPDATE_INTERVAL_MS, UPLOAD_CHUNK_SIZE_MB } from "@config/config_frontend"; -import { fetchWorkflowState, type WorkflowState } from "../A00_Common/b_workflow_nav"; +import { + PROGRESS_UPDATE_INTERVAL_MS, + UPLOAD_CHUNK_SIZE_MB, +} from "@config/config_frontend"; +import { + fetchWorkflowState, + type WorkflowState, +} from "../A00_Common/b_workflow_nav"; import { createButton } from "@ui/ui_template_elements"; import { fileFingerprint } from "./B03_FileInput_Fingerprint"; import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; @@ -18,7 +24,10 @@ import { uploadFileChunk, type UploadedFileResult, } from "./B03_FileInput_Api_Fetch"; -import { saveB03UploadedFile, updateB03AnalysisState } from "./B03_FileInput_State"; +import { + saveB03UploadedFile, + updateB03AnalysisState, +} from "./B03_FileInput_State"; import { makeSessionKey, type FileSlotState, @@ -33,7 +42,10 @@ function L(key: keyof typeof ui_locales): string { * 완료된 슬롯 재업로드 확인 모달 — 기존 파일·분석 결과가 교체된다는 경고에 사용자의 * 명시적 확인을 받는다(2026-08-04 사용자 지시). 확인 시에만 resolve(true). */ -export function confirmReplaceUpload(slotLabel: string, fileName: string): Promise { +export function confirmReplaceUpload( + slotLabel: string, + fileName: string, +): Promise { return new Promise((resolve) => { const backdrop = document.createElement("div"); backdrop.className = "b03-file__modal-backdrop"; @@ -98,6 +110,7 @@ export async function uploadOneFile( state: FileSlotState, completeUpload: boolean, onProgress: () => void, + lasFree = false, ): Promise { const file = state.file; if (!file) return []; @@ -107,7 +120,9 @@ export async function uploadOneFile( const chunkSizeBytes = UPLOAD_CHUNK_SIZE_MB * 1024 * 1024; // 같은 파일을 다시 고른 경우 전송을 통째로 건너뛴다 — 라이다는 한 번에 몇 분씩 걸린다. - const fingerprint = state.uploadSessionId ? null : await fileFingerprint(file); + const fingerprint = state.uploadSessionId + ? null + : await fileFingerprint(file); let session = state.uploadSessionId; if (!session) { const created = await createUploadSession( @@ -116,6 +131,7 @@ export async function uploadOneFile( chunkSizeBytes, fingerprint, completeUpload, + lasFree, ); if (created.already_uploaded) { state.progressBytes = file.size; @@ -140,11 +156,22 @@ export async function uploadOneFile( const start = chunkIndex * chunkSizeBytes; const end = Math.min(file.size, start + chunkSizeBytes); const chunkStartedAt = performance.now(); - await uploadFileChunk(projectId, session, chunkIndex, file.slice(start, end)); - const elapsedSec = Math.max(0.001, (performance.now() - chunkStartedAt) / 1000); + await uploadFileChunk( + projectId, + session, + chunkIndex, + file.slice(start, end), + ); + const elapsedSec = Math.max( + 0.001, + (performance.now() - chunkStartedAt) / 1000, + ); state.progressBytes = end; state.speedMbs = (end - start) / 1024 / 1024 / elapsedSec; - state.etaSeconds = state.speedMbs > 0 ? (file.size - end) / 1024 / 1024 / state.speedMbs : null; + state.etaSeconds = + state.speedMbs > 0 + ? (file.size - end) / 1024 / 1024 / state.speedMbs + : null; const stored: StoredUploadSession = { key: storageKey, @@ -161,7 +188,10 @@ export async function uploadOneFile( localStorage.setItem(storageKey, JSON.stringify(stored)); const now = performance.now(); - if (now - lastPaintAt > PROGRESS_UPDATE_INTERVAL_MS || chunkIndex === totalChunks - 1) { + if ( + now - lastPaintAt > PROGRESS_UPDATE_INTERVAL_MS || + chunkIndex === totalChunks - 1 + ) { lastPaintAt = now; onProgress(); } @@ -173,6 +203,7 @@ export async function uploadOneFile( totalChunks, completeUpload, fingerprint, + lasFree, ); localStorage.removeItem(storageKey); saveB03UploadedFile(projectId, { @@ -182,7 +213,10 @@ export async function uploadOneFile( }); state.progressBytes = file.size; state.speedMbs = - file.size / 1024 / 1024 / Math.max(0.001, (performance.now() - startedAt) / 1000); + file.size / + 1024 / + 1024 / + Math.max(0.001, (performance.now() - startedAt) / 1000); state.etaSeconds = 0; state.uploadStatus = "completed"; onProgress(); @@ -203,9 +237,12 @@ export async function uploadOneFile( * * 전처리가 실패했으면 더 기다릴 게 없으므로 잠금을 푼다. */ -export function isInitialPipelineRunning(state: WorkflowState | undefined): boolean { +export function isInitialPipelineRunning( + state: WorkflowState | undefined, +): boolean { if (!state?.stages?.length) return false; - const stageAt = (stageNo: number) => state.stages.find((stage) => stage.stage_no === stageNo); + const stageAt = (stageNo: number) => + state.stages.find((stage) => stage.stage_no === stageNo); const fileInput = stageAt(0); const preprocess = stageAt(1); const section = stageAt(3); diff --git a/B04_PreProcess/B04_PreProcess_Engine.py b/B04_PreProcess/B04_PreProcess_Engine.py index 1e877ce2..5fa04bf9 100644 --- a/B04_PreProcess/B04_PreProcess_Engine.py +++ b/B04_PreProcess/B04_PreProcess_Engine.py @@ -223,11 +223,64 @@ def run_surface_analysis( time.monotonic() - step_started, ) - # 3-2. VWorld 지도 및 국가 GIS 벡터 다운로드 (기존 산출물이 있으면 스킵) + # 3-2·3-3. VWorld 지도·국가 GIS 벡터·수치지형도 도엽 (공용 블록 — 도엽 서피스도 사용) _report(90, "download_maps", "VWorld 지도 및 GIS 벡터 데이터 다운로드 중") + las_bounds_dict = { + "x": [float(bounds[0, 0]), float(bounds[0, 1])], + "y": [float(bounds[1, 0]), float(bounds[1, 1])], + "z": [float(bounds[2, 0]), float(bounds[2, 1])], + } + download_geodata( + project_root, processed_dir, las_bounds_dict, las_path.parent, rebuild, report=_report + ) + + # 3-4. 도엽등고선 3D 서피스 — LAS가 있어도 참고용으로 같이 만들어 영구저장한다 + # (2026-08-30 사용자 확정). 실패해도 분석은 계속한다. + _report(94, "surface_model", "도엽등고선 3D 서피스 생성 중") + sheet_model: dict[str, Any] | None = None try: - # 입력 LAS와 같은 폴더의 .prj를 우선 사용 (PLAN E-1: 전체 재귀 glob 제거) - prj_candidates = sorted(las_path.parent.glob("*.prj")) or sorted( + from B04_PreProcess.B04_PreProcess_Engine_SheetSurface import ( + build_sheet_surface_from_route, + ) + + sheet_model = build_sheet_surface_from_route(project_root, processed_dir, models_dir) + except Exception as exc: + logger.warning("도엽등고선 서피스 생성 실패: %s", exc) + + _report(95, "saving", "결과 저장 중") + + return _collect_analysis_result( + project_root, + models_dir, + structured_path, + bounds_dict, + stats, + total_points, + ground_summary, + manifest, + sheet_model, + total_started, + ) + + +def download_geodata( + project_root: Path, + processed_dir: Path, + las_bounds_dict: dict[str, list[float]], + prj_search_dir: Path, + rebuild: bool, + *, + default_epsg: str = "EPSG:5186", + report: Any = None, +) -> None: + """VWorld 지도·국가 GIS 벡터·수치지형도 도엽 확보 (공용 블록). + + LAS 분석(run_surface_analysis)과 LAS 없는 도엽 서피스 분석이 같이 쓴다. + 실패해도 예외를 밖으로 던지지 않는다 — 분석 본체를 막지 않는다. + """ + try: + # 입력 파일과 같은 폴더의 .prj를 우선 사용 (PLAN E-1: 전체 재귀 glob 제거) + prj_candidates = sorted(prj_search_dir.glob("*.prj")) or sorted( project_root.glob("B03_FileInput/**/*.prj") ) prj_path = prj_candidates[0] if prj_candidates else project_root / "result.prj" @@ -243,12 +296,7 @@ def run_surface_analysis( get_epsg_from_prj, ) - las_bounds_dict = { - "x": [float(bounds[0, 0]), float(bounds[0, 1])], - "y": [float(bounds[1, 0]), float(bounds[1, 1])], - "z": [float(bounds[2, 0]), float(bounds[2, 1])], - } - project_epsg = "EPSG:5186" + project_epsg = default_epsg if prj_path.exists(): project_epsg = get_epsg_from_prj(prj_path.read_text(encoding="utf-8", errors="ignore")) # 국가 GIS 벡터는 라이다∪계획노선 범위로 받는다. @@ -305,7 +353,8 @@ def run_surface_analysis( # 3-3. 1:5,000 수치지형도 도엽 확보 → 프로젝트 영구저장소 # 기준은 계획노선 시점·종점 (같은 도엽이면 9매, 이웃 도엽에 걸치면 12매). # (실패해도 분석은 계속 — 폴백은 수동 다운로드 + 인제스트) - _report(92, "download_maps", "수치지형도 도엽 확보 중") + if report is not None: + report(92, "download_maps", "수치지형도 도엽 확보 중") try: from B04_PreProcess.B04_PreProcess_Engine_Extent import sheet_reference_points_wgs84 from B04_PreProcess.B04_PreProcess_Engine_MapSheet import neighbors_for_points @@ -356,8 +405,20 @@ def run_surface_analysis( except Exception as exc: logger.warning("B04 지도·GIS 다운로드 단계 실패: %s", exc) - _report(95, "saving", "결과 저장 중") +def _collect_analysis_result( + project_root: Path, + models_dir: Path, + structured_path: Path, + bounds_dict: dict[str, float], + stats: dict[str, Any], + total_points: int, + ground_summary: dict[str, Any], + manifest: dict[str, Any], + sheet_model: dict[str, Any] | None, + total_started: float, +) -> dict[str, Any]: + """manifest에서 모델 목록을 추려 분석 결과 dict를 조립한다.""" processed = { "processed_file_path": _relative_to_project(project_root, structured_path), "converted_file_path": None, @@ -403,6 +464,8 @@ def run_surface_analysis( "layers": layers, } ) + if sheet_model is not None: + models.append(sheet_model) logger.info( "B04 WF1 분석 완료: 모델 %d개, 총 %.1fs", len(models), time.monotonic() - total_started diff --git a/B04_PreProcess/B04_PreProcess_Engine_SheetSurface.py b/B04_PreProcess/B04_PreProcess_Engine_SheetSurface.py new file mode 100644 index 00000000..e9c13960 --- /dev/null +++ b/B04_PreProcess/B04_PreProcess_Engine_SheetSurface.py @@ -0,0 +1,299 @@ +"""도엽등고선 3D 서피스 — 1:5,000 수치지형도 등고선으로 DTM 격자를 만든다. + +LAS 없는 설계(2026-08-30 사용자 확정)의 지형 원천이자, LAS가 있어도 참고용으로 +같이 만들어 두는 서피스다. 산출 형식은 LAS 파이프라인의 DTM과 완전히 같게 맞춘다 +(`dtm_sheet.npz`: x/y/z/valid_mask) — 종·횡단·배수 세부설계가 쓰는 +`build_surface_sampler(models_dir, "sheet", "dtm", smooth=False)`가 무수정으로 돈다. + +절취 범위는 노선 XY bbox + `SHEET_SURFACE_MARGIN_M`(300m) 직사각형(사용자 확정), +격자는 `SHEET_SURFACE_GRID_M`(1m). 등고선→정점 구름→Delaunay TIN 보간은 배수유역 +엔진(`_Watershed_Grid`)의 검증된 경로를 그대로 쓴다. +""" + +import json +import logging +import time +from pathlib import Path +from typing import Any + +import numpy as np +from pyproj import Transformer + +from B04_PreProcess.B04_PreProcess_Engine_ModelContext import ( + atomic_npz, + clip_and_compact_mesh, + grid_faces, + grid_vertices, + write_glb, +) +from B04_PreProcess.B04_PreProcess_Engine_Watershed_Grid import ( + build_contour_cloud, + grid_spec_from_bounds, + interpolate_elevation, +) +from config.config_system import ( + SHEET_SURFACE_GRID_M, + SHEET_SURFACE_MARGIN_M, + SURFACE_MAX_PREVIEW_VERTICES, +) + +logger = logging.getLogger(__name__) + +# 도엽 병합 산출물 파일명 (B04_PreProcess_Router_Watershed와 같은 값) +_CONTOUR_FILE = "도엽_등고선.geojson" + +# 산출 모델 식별자 — surface_models.generation_params.source_filter 및 파일 stem에 쓴다. +SHEET_SOURCE_FILTER = "sheet" + + +def _load_contour_features_metric(processed_dir: Path, epsg: int) -> list[dict[str, Any]]: + """병합 도엽 등고선(WGS84)을 읽어 사업지 CRS(m)로 재투영한다.""" + path = processed_dir / _CONTOUR_FILE + if not path.exists(): + logger.warning("도엽 서피스: 등고선 파일이 없습니다: %s", path) + return [] + try: + with path.open("r", encoding="utf-8") as file: + data = json.load(file) + except (OSError, json.JSONDecodeError): + logger.warning("도엽 서피스: 등고선 GeoJSON을 읽지 못했습니다: %s", path) + return [] + features = data.get("features") + if not isinstance(features, list): + return [] + transformer = Transformer.from_crs("EPSG:4326", f"EPSG:{epsg}", always_xy=True) + + def _map(coords: Any) -> Any: + if not isinstance(coords, list): + return coords + if coords and isinstance(coords[0], (int, float)): + x, y = transformer.transform(coords[0], coords[1]) + return [x, y, *coords[2:]] + return [_map(item) for item in coords] + + converted: list[dict[str, Any]] = [] + for feature in features: + geometry = feature.get("geometry") or {} + coordinates = _map(geometry.get("coordinates")) + if coordinates is None: + continue + converted.append( + { + "type": "Feature", + "properties": feature.get("properties") or {}, + "geometry": {"type": geometry.get("type"), "coordinates": coordinates}, + } + ) + return converted + + +def _preview_mesh( + x: np.ndarray, y: np.ndarray, z: np.ndarray, valid: np.ndarray +) -> tuple[np.ndarray, np.ndarray]: + """프리뷰용 정점·면 — 정점 수가 상한을 넘으면 격자를 성기게 딴다.""" + stride = 1 + while (len(x) // stride + 1) * (len(y) // stride + 1) > SURFACE_MAX_PREVIEW_VERTICES: + stride += 1 + px, py = x[::stride], y[::stride] + pz, pv = z[::stride, ::stride], valid[::stride, ::stride] + vertices = grid_vertices(px, py, pz.astype(np.float64)) + faces = grid_faces(len(py), len(px)) + return clip_and_compact_mesh(vertices, faces, pv.reshape(-1)) + + +def build_sheet_surface_model( + project_root: Path, + processed_dir: Path, + models_dir: Path, + route_xy: np.ndarray, + epsg: int, +) -> dict[str, Any] | None: + """도엽등고선으로 DTM npz·프리뷰 glb를 만들고 surface_models 등록용 dict를 돌려준다. + + `route_xy`: (N, 2) 노선 정점 XY(사업지 CRS, m). 실패하면 None — 호출측은 + 분석을 계속한다(도엽 미확보 지역 폴백). + """ + started = time.monotonic() + features = _load_contour_features_metric(processed_dir, epsg) + if not features: + return None + + x_min = float(np.min(route_xy[:, 0])) - SHEET_SURFACE_MARGIN_M + x_max = float(np.max(route_xy[:, 0])) + SHEET_SURFACE_MARGIN_M + y_min = float(np.min(route_xy[:, 1])) - SHEET_SURFACE_MARGIN_M + y_max = float(np.max(route_xy[:, 1])) + SHEET_SURFACE_MARGIN_M + + # 저지대 제거(floor) 없이 전부 쓴다 — 종·횡단은 낮은 지반도 필요하다. + cloud = build_contour_cloud(features, None, (x_min, y_min, x_max, y_max)) + if cloud.is_empty: + logger.warning("도엽 서피스: 절취 범위 안에 등고선 정점이 없습니다.") + return None + + spec = grid_spec_from_bounds(x_min, y_min, x_max, y_max, SHEET_SURFACE_GRID_M) + surface = interpolate_elevation(spec, cloud) # (R, C), 북→남 행 순서, 외부 NaN + + # DtmGridSampler 규약에 맞춰 y 오름차순으로 뒤집어 저장한다. + x_coords = spec.cell_centers_x() + y_coords = spec.cell_centers_y()[::-1] + z_grid = surface[::-1, :].astype(np.float32) + valid_grid = np.isfinite(z_grid) + if not valid_grid.any(): + logger.warning("도엽 서피스: 유효 표고 셀이 없습니다.") + return None + + stem = f"dtm_{SHEET_SOURCE_FILTER}" + model_path = models_dir / f"{stem}.npz" + preview_path = models_dir / f"{stem}_preview.glb" + atomic_npz( + model_path, + x=x_coords, + y=y_coords, + z=z_grid, + valid_mask=valid_grid, + resolution=np.array([SHEET_SURFACE_GRID_M], np.float32), + ) + + vertices, faces = _preview_mesh(x_coords, y_coords, z_grid, valid_grid) + finite_z = z_grid[valid_grid] + bounds = np.array( + [ + [x_coords[0], x_coords[-1]], + [y_coords[0], y_coords[-1]], + [float(finite_z.min()), float(finite_z.max())], + ] + ) + write_glb(preview_path, vertices, faces, bounds) + + logger.info( + "도엽 서피스 생성 완료: %d×%d 격자, 정점 %d개 (%.1fs)", + spec.n_rows, + spec.n_cols, + cloud.xy.shape[0], + time.monotonic() - started, + ) + return { + "model_type": "dtm", + "source_filter": SHEET_SOURCE_FILTER, + "representation": "regular_grid", + "model_file_path": str(model_path.relative_to(project_root)).replace("\\", "/"), + "resolution_m": SHEET_SURFACE_GRID_M, + "generation_params": { + "source_filter": SHEET_SOURCE_FILTER, + "representation": "regular_grid", + "source": "map_sheet_contours", + "margin_m": SHEET_SURFACE_MARGIN_M, + }, + "layers": [ + { + "layer_name": f"dtm_{SHEET_SOURCE_FILTER}_preview", + "geometry_type": "MESH", + "file_path": str(preview_path.relative_to(project_root)).replace("\\", "/"), + "file_format": "glb", + } + ], + } + + +def build_sheet_surface_from_route( + project_root: Path, processed_dir: Path, models_dir: Path +) -> dict[str, Any] | None: + """B03 업로드 계획노선 CSV를 찾아 도엽 서피스를 만든다. 없거나 실패하면 None.""" + from common_util.common_util_route_geometry import ( + find_planned_route_file, + read_planned_route_csv, + ) + + route_file = find_planned_route_file(project_root / "B03_FileInput" / "input") + if route_file is None: + logger.warning("도엽 서피스: 계획 노선 파일이 없습니다.") + return None + planned = read_planned_route_csv(route_file) + if planned is None or len(planned.vertices) < 2: + logger.warning("도엽 서피스: 계획 노선 파일을 읽지 못했습니다: %s", route_file.name) + return None + route_xy = np.array([(v.x, v.y) for v in planned.vertices], dtype=np.float64) + return build_sheet_surface_model( + project_root, processed_dir, models_dir, route_xy, planned.epsg or 5186 + ) + + +def run_sheet_surface_analysis( + project_root: Path, + route_csv_path: Path, + *, + on_progress: Any = None, +) -> dict[str, Any]: + """LAS 없는 WF1 — 도엽 확보 후 도엽등고선 서피스만으로 분석 결과를 만든다. + + 반환 형식은 `run_surface_analysis()`와 같다(save_surface_analysis_to_db 호환). + """ + from common_util.common_util_route_geometry import read_planned_route_csv + + def _report(percent: int, stage: str, message: str) -> None: + if on_progress is not None: + on_progress(percent, stage, message) + + stage_root = project_root / "B04_PreProcess" + 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) + + planned = read_planned_route_csv(route_csv_path) + if planned is None or len(planned.vertices) < 2: + raise ValueError(f"계획 노선 파일을 읽지 못했습니다: {route_csv_path.name}") + epsg = planned.epsg or 5186 + route_xy = np.array([(v.x, v.y) for v in planned.vertices], dtype=np.float64) + + bounds_dict = { + "x": [float(route_xy[:, 0].min()), float(route_xy[:, 0].max())], + "y": [float(route_xy[:, 1].min()), float(route_xy[:, 1].max())], + "z": [ + float(min(v.z for v in planned.vertices)), + float(max(v.z for v in planned.vertices)), + ], + } + + # VWorld 지도·GIS 벡터·도엽 확보 — LAS 경로와 같은 공용 블록 (지연 import로 순환 회피) + _report(30, "download_maps", "VWorld 지도 및 수치지형도 도엽 확보 중") + from B04_PreProcess.B04_PreProcess_Engine import download_geodata + + download_geodata( + project_root, + processed_dir, + bounds_dict, + route_csv_path.parent, + rebuild=False, + default_epsg=f"EPSG:{epsg}", + report=_report, + ) + + _report(70, "surface_model", "도엽등고선 3D 서피스 생성 중") + model = build_sheet_surface_model(project_root, processed_dir, models_dir, route_xy, epsg) + if model is None: + raise ValueError("도엽등고선으로 지표면을 만들지 못했습니다 — 도엽 확보를 확인하세요.") + + _report(95, "saving", "결과 저장 중") + return { + "processed": { + "processed_file_path": str( + (processed_dir / _CONTOUR_FILE).relative_to(project_root) + ).replace("\\", "/"), + "converted_file_path": None, + "point_count": int(len(route_xy)), + "bounds": { + "x_min": bounds_dict["x"][0], + "x_max": bounds_dict["x"][1], + "y_min": bounds_dict["y"][0], + "y_max": bounds_dict["y"][1], + }, + "statistics": { + "min_z": bounds_dict["z"][0], + "max_z": bounds_dict["z"][1], + "mean_z": None, + }, + }, + "ground_summary": {}, + "manifest": {"status": "sheet_only"}, + "models": [model], + } diff --git a/B04_PreProcess/B04_PreProcess_UI_Page.ts b/B04_PreProcess/B04_PreProcess_UI_Page.ts index 12e90d60..8fd19a77 100644 --- a/B04_PreProcess/B04_PreProcess_UI_Page.ts +++ b/B04_PreProcess/B04_PreProcess_UI_Page.ts @@ -9,7 +9,10 @@ import { } from "@ui/ui_template_elements"; import { createWorkflowLayout } from "@ui/ui_template_workflow_layout"; import { fetchDashboardMe } from "../B01_Dashboard/B01_Dashboard_Api_Fetch"; -import { clearPreloadMark, purgeOtherProjects } from "../A00_Common/b_asset_cache"; +import { + clearPreloadMark, + purgeOtherProjects, +} from "../A00_Common/b_asset_cache"; import { clearRouteLatestCache } from "../B05_Profile/B05_Profile_Api_Fetch"; import { workflowSteps } from "../A00_Common/b_page_scaffold"; import { @@ -105,11 +108,17 @@ export async function renderB04Surface(root: HTMLElement): Promise { if (guardedProjectId) { const user = await fetchDashboardMe(); if (user.role !== "SYSTEM_ADMIN") { - const workflowState = await fetchWorkflowState(guardedProjectId).catch(() => undefined); - const surfaceStage = workflowState?.stages.find((stage) => stage.stage_no === 1); + const workflowState = await fetchWorkflowState(guardedProjectId).catch( + () => undefined, + ); + const surfaceStage = workflowState?.stages.find( + (stage) => stage.stage_no === 1, + ); goToWorkflowStage( guardedProjectId, - surfaceStage?.state === "COMPLETE" ? ROUTES.B05_PROFILE : ROUTES.B03_FILE_INPUT, + surfaceStage?.state === "COMPLETE" + ? ROUTES.B05_PROFILE + : ROUTES.B03_FILE_INPUT, ); return; } @@ -140,6 +149,16 @@ export async function renderB04Surface(root: HTMLElement): Promise { const viewer = createSurfacePointCloudViewer(); const terrainViewer = createSurfaceTerrainViewer(); const mapViewer = createSurfaceMapViewer(); + // 도엽등고 3D 서피스 — 전처리에서 함께 생성되는 참고 서피스(LAS 없는 설계의 지형 원천, + // 2026-08-30). 모델 목록에 sheet/dtm이 있을 때만 별도 컨테이너로 보여준다. + const sheetViewer = createSurfaceTerrainViewer(); + const sheetSection = document.createElement("section"); + sheetSection.className = "b04-surface__sheet-section ui-sidebar-section"; + const sheetTitle = document.createElement("h3"); + sheetTitle.className = "b04-surface__panel-title"; + sheetTitle.textContent = L("B04_Surface_SheetSurface"); + sheetSection.append(sheetTitle, sheetViewer.root); + sheetSection.hidden = true; let syncingCamera = false; viewer.onCameraChange((state) => { @@ -200,14 +219,20 @@ export async function renderB04Surface(root: HTMLElement): Promise { const actionRow = document.createElement("div"); actionRow.className = "ui-sidebar-actions"; actionRow.append(confirmButton, resetButton); - panel.append(inputGroup, analysisGroup, displayGroup, viewer.controlsGroup, actionRow); + panel.append( + inputGroup, + analysisGroup, + displayGroup, + viewer.controlsGroup, + actionRow, + ); 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"; - workspace.append(statusBox, viewers, mapViewer.root); + workspace.append(statusBox, viewers, sheetSection, mapViewer.root); let workflowState: WorkflowState | undefined; const layoutProjectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); @@ -229,7 +254,8 @@ export async function renderB04Surface(root: HTMLElement): Promise { 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]); }, }); @@ -263,7 +289,11 @@ export async function renderB04Surface(root: HTMLElement): Promise { return; } const variant = - status.status === "completed" ? "success" : status.status === "failed" ? "danger" : "warning"; + status.status === "completed" + ? "success" + : status.status === "failed" + ? "danger" + : "warning"; statusBox.append( createTag(`${status.progress_percent}%`, variant), document.createTextNode(status.message), @@ -280,7 +310,9 @@ export async function renderB04Surface(root: HTMLElement): Promise { inputInfo.append( buildInfoLine( "좌표계", - selectedInputFile.crs_epsg ? `EPSG:${selectedInputFile.crs_epsg}` : null, + selectedInputFile.crs_epsg + ? `EPSG:${selectedInputFile.crs_epsg}` + : null, ), buildInfoLine( "크기", @@ -289,7 +321,10 @@ export async function renderB04Surface(root: HTMLElement): Promise { : `${selectedInputFile.file_size_mb.toFixed(2)} MB`, ), buildInfoLine("포인트 수", pointCloud?.point_count.toLocaleString()), - buildInfoLine("표시 포인트 수", pointCloud?.sampled_count.toLocaleString()), + buildInfoLine( + "표시 포인트 수", + pointCloud?.sampled_count.toLocaleString(), + ), buildInfoLine("높이 범위", heightRange), ); } @@ -328,7 +363,10 @@ export async function renderB04Surface(root: HTMLElement): Promise { function updateSelectedModel(): void { const projectId = getProjectId(); if (!projectId) return; - terrainViewer.setSelection(filterGroup.select.value, methodGroup.select.value); + terrainViewer.setSelection( + filterGroup.select.value, + methodGroup.select.value, + ); terrainViewer.render(projectId, models); confirmButton.disabled = !findSelectedModel(); } @@ -339,14 +377,20 @@ export async function renderB04Surface(root: HTMLElement): Promise { showLoadingOverlay(); viewer.setLoading("포인트 데이터 로딩 중…"); try { - pointCloud = await fetchSurfacePointCloud(projectId, filterGroup.select.value); + pointCloud = await fetchSurfacePointCloud( + projectId, + filterGroup.select.value, + ); terrainViewer.setReferenceBounds(pointCloud.bounds); viewer.render(pointCloud); renderInputInfo(); } catch (error) { pointCloud = null; viewer.render(null); - const detail = error instanceof Error ? error.message : "지면 포인트 조회에 실패했습니다."; + const detail = + error instanceof Error + ? error.message + : "지면 포인트 조회에 실패했습니다."; showToast(detail, "error"); } finally { hideLoadingOverlay(); @@ -366,7 +410,8 @@ export async function renderB04Surface(root: HTMLElement): Promise { // 확정본과 같은 조합에서 시작해야 B05와 같은 파일을 보고, 보관함도 한 벌만 쓴다. // 확정 이력이 없을 때만 개발 기본값(csf·dtm)으로 둔다. if (confirmed.model_id) { - if (confirmed.source_filter) filterGroup.select.value = confirmed.source_filter; + if (confirmed.source_filter) + filterGroup.select.value = confirmed.source_filter; if (confirmed.method) methodGroup.select.value = confirmed.method; terrainViewer.setSmoothing(confirmed.smooth ?? false); } @@ -376,7 +421,10 @@ export async function renderB04Surface(root: HTMLElement): Promise { renderStatus(status); viewer.setLoading("포인트 데이터 로딩 중…"); try { - pointCloud = await fetchSurfacePointCloud(projectId, filterGroup.select.value); + pointCloud = await fetchSurfacePointCloud( + projectId, + filterGroup.select.value, + ); terrainViewer.setReferenceBounds(pointCloud.bounds); viewer.render(pointCloud); // 지도(2D)는 계획노선 기준으로 연다 — 라이다 범위와 다루는 범위가 다르다. @@ -388,6 +436,19 @@ export async function renderB04Surface(root: HTMLElement): Promise { } renderInputInfo(); updateSelectedModel(); + + // 도엽등고 3D 서피스 — sheet/dtm 모델이 있으면 별도 컨테이너로 보여준다. + const hasSheetModel = models.some( + (model) => + model.model_type.toLowerCase() === "dtm" && + getModelFilter(model) === "sheet", + ); + sheetSection.hidden = !hasSheetModel; + if (hasSheetModel) { + sheetViewer.setSelection("sheet", "dtm"); + sheetViewer.setSmoothing(false); + sheetViewer.render(projectId, models); + } } async function onB04_Surface_Confirm_Click(): Promise { @@ -414,14 +475,20 @@ export async function renderB04Surface(root: HTMLElement): Promise { L("B04_Surface_Confirm_Success") .replace("{filter}", filterGroup.select.value) .replace("{method}", methodGroup.select.value) - .replace("{smoothing}", terrainViewer.isSmoothingEnabled() ? "ON" : "OFF"), + .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"); + const detail = + error instanceof Error + ? error.message + : L("B04_Surface_Confirm_Failed"); showToast(`${L("B04_Surface_Confirm_Failed")} ${detail}`, "error"); } finally { hideLoadingOverlay(); @@ -446,7 +513,8 @@ export async function renderB04Surface(root: HTMLElement): Promise { } inputSelect.addEventListener("change", () => { - selectedInputFile = inputFiles.find((file) => String(file.id) === inputSelect.value) ?? null; + selectedInputFile = + inputFiles.find((file) => String(file.id) === inputSelect.value) ?? null; renderInputInfo(); }); filterGroup.select.addEventListener("change", () => { diff --git a/B04_PreProcess/B04_PreProcess_UI_Style.css b/B04_PreProcess/B04_PreProcess_UI_Style.css index 6d979b58..c3b73a52 100644 --- a/B04_PreProcess/B04_PreProcess_UI_Style.css +++ b/B04_PreProcess/B04_PreProcess_UI_Style.css @@ -793,3 +793,14 @@ grid-template-columns: 1fr; } } + +/* 도엽등고 3D 서피스 컨테이너 — 2026-08-30 */ +.b04-surface__sheet-section { + margin: 0 var(--spacing-24) var(--spacing-16); + padding: var(--spacing-16); + box-sizing: border-box; +} + +.b04-surface__sheet-section > .terrain-model-group { + margin-top: var(--spacing-12); +} diff --git a/config/config_system.py b/config/config_system.py index 86aa1cfd..e49ca9e6 100644 --- a/config/config_system.py +++ b/config/config_system.py @@ -183,6 +183,12 @@ SURFACE_SMOOTHING_TIN_TAUBIN_MU = float(os.getenv("SURFACE_SMOOTHING_TIN_TAUBIN_ 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")) +# 도엽등고선 3D 서피스 (LAS 없는 설계 — 2026-08-30 사용자 확정) +# 노선 XY bbox에 더하는 절취 여유(m). 300m = 횡단·코리도·성토면 여유(사용자 확정값). +SHEET_SURFACE_MARGIN_M = float(os.getenv("SHEET_SURFACE_MARGIN_M", "300.0")) +# 도엽등고선 DTM 격자 한 변(m). LAS DTM·등고선 캐시와 같은 1m(사용자 확정값). +SHEET_SURFACE_GRID_M = float(os.getenv("SHEET_SURFACE_GRID_M", "1.0")) + # 일반 사용자 WF1 자동 확정 기본값 SURFACE_CONFIRM_DEFAULT_FILTER = os.getenv("SURFACE_CONFIRM_DEFAULT_FILTER", "csf") SURFACE_CONFIRM_DEFAULT_METHOD = os.getenv("SURFACE_CONFIRM_DEFAULT_METHOD", "dtm") diff --git a/ui_template/ui_template_locale_b1.ts b/ui_template/ui_template_locale_b1.ts index c4857696..753aaa47 100644 --- a/ui_template/ui_template_locale_b1.ts +++ b/ui_template/ui_template_locale_b1.ts @@ -29,16 +29,31 @@ export const ui_locales_b1 = { B01_Account_Field_Name: ["이름", "Name"], B01_Account_Field_Email: ["이메일", "Email"], B01_Account_Field_Phone: ["연락처", "Phone"], - B01_Account_Field_Phone_Placeholder: ["연락처를 입력하세요", "Enter phone number"], + B01_Account_Field_Phone_Placeholder: [ + "연락처를 입력하세요", + "Enter phone number", + ], B01_Account_Field_CurrentPw: ["현재 비밀번호", "Current password"], B01_Account_Field_NewPw: ["새 비밀번호", "New password"], B01_Account_Field_ConfirmPw: ["새 비밀번호 확인", "Confirm new password"], B01_Account_Save_Profile: ["기본 정보 저장", "Save profile"], B01_Account_Save_Password: ["비밀번호 변경", "Change password"], - B01_Account_Success_Profile: ["기본 정보가 저장되었습니다.", "Profile has been saved."], - B01_Account_Success_Password: ["비밀번호가 변경되었습니다.", "Password has been changed."], - B01_Account_Error_Required: ["필수 항목을 입력하세요.", "Please fill in required fields."], - B01_Account_Error_PwMismatch: ["새 비밀번호가 일치하지 않습니다.", "New passwords do not match."], + B01_Account_Success_Profile: [ + "기본 정보가 저장되었습니다.", + "Profile has been saved.", + ], + B01_Account_Success_Password: [ + "비밀번호가 변경되었습니다.", + "Password has been changed.", + ], + B01_Account_Error_Required: [ + "필수 항목을 입력하세요.", + "Please fill in required fields.", + ], + B01_Account_Error_PwMismatch: [ + "새 비밀번호가 일치하지 않습니다.", + "New passwords do not match.", + ], B01_Account_Error_PwLength: [ "비밀번호는 8자 이상이어야 합니다.", "Password must be at least 8 characters.", @@ -79,8 +94,14 @@ export const ui_locales_b1 = { /* --- B01 임시 보관함 (프로젝트 생성 전 업로드, 2026-08-08) --- */ B01_Temp_Section: ["임시 보관함", "Temporary storage"], B01_Temp_Field_Name: ["보관 이름", "Storage name"], - B01_Temp_Field_Name_Placeholder: ["예: 2026년 3공구 측량자료", "e.g. 2026 Section 3 survey"], - B01_Temp_Field_Files: ["파일 선택 (계획노선·라이다·좌표계·래스터)", "Select files"], + B01_Temp_Field_Name_Placeholder: [ + "예: 2026년 3공구 측량자료", + "e.g. 2026 Section 3 survey", + ], + B01_Temp_Field_Files: [ + "파일 선택 (계획노선·라이다·좌표계·래스터)", + "Select files", + ], B01_Temp_Btn_Pick: ["파일 선택", "Choose files"], B01_Temp_Btn_Add: ["파일 추가", "Add files"], B01_Temp_Modal_Create: ["임시 자료 등록", "New stored set"], @@ -100,7 +121,10 @@ export const ui_locales_b1 = { "Delete this file from temporary storage?", ], B01_Temp_File_Delete_Success: ["파일을 삭제했습니다.", "File deleted."], - B01_Temp_File_Delete_Failed: ["파일 삭제에 실패했습니다.", "Failed to delete the file."], + B01_Temp_File_Delete_Failed: [ + "파일 삭제에 실패했습니다.", + "Failed to delete the file.", + ], /* 보관 기간은 섹션 제목 옆 태그로만 알린다(안내 문단 폐기, 2026-08-08). */ B01_Temp_Hint_Days: ["일 보관", " days retained"], B01_Temp_Status_Uploading: ["업로드 중", "Uploading"], @@ -111,9 +135,18 @@ export const ui_locales_b1 = { B01_Temp_Meta_Linked: ["프로젝트로 이동 완료", "Moved to project"], B01_Temp_Error_Name: ["보관 이름을 입력하세요.", "Enter a storage name."], B01_Temp_Error_Files: ["올릴 파일을 선택하세요.", "Select files to upload."], - B01_Temp_Upload_Success: ["보관함에 저장했습니다.", "Saved to temporary storage."], - B01_Temp_Upload_Failed: ["보관함 업로드에 실패했습니다.", "Failed to upload."], - B01_Temp_Load_Failed: ["보관함을 불러오지 못했습니다.", "Failed to load storage."], + B01_Temp_Upload_Success: [ + "보관함에 저장했습니다.", + "Saved to temporary storage.", + ], + B01_Temp_Upload_Failed: [ + "보관함 업로드에 실패했습니다.", + "Failed to upload.", + ], + B01_Temp_Load_Failed: [ + "보관함을 불러오지 못했습니다.", + "Failed to load storage.", + ], B01_Temp_Delete_Confirm: [ "이 보관 자료를 삭제할까요? 되돌릴 수 없습니다.", "Delete this stored set? This cannot be undone.", @@ -152,7 +185,10 @@ export const ui_locales_b1 = { B01_Dashboard_Modal_FindCompany: ["회사 검색", "Find company"], B01_Dashboard_Modal_AddMember: ["팀원 추가", "Add member"], B01_Dashboard_Saved: ["저장되었습니다.", "Saved."], - B01_Dashboard_LoadFailed: ["대시보드를 불러오지 못했습니다.", "Failed to load dashboard."], + B01_Dashboard_LoadFailed: [ + "대시보드를 불러오지 못했습니다.", + "Failed to load dashboard.", + ], B01_Dashboard_RequestFailed: ["요청 처리에 실패했습니다.", "Request failed."], // 프로젝트 관리 @@ -164,7 +200,10 @@ export const ui_locales_b1 = { B01_Dashboard_EditUser: ["사용자 수정", "Edit User"], B01_Dashboard_DeleteUser: ["사용자 삭제", "Delete User"], B01_Dashboard_ChangeRole: ["역할 변경", "Change Role"], - B01_Dashboard_SelectAvailableUsers: ["사용 가능한 사용자 선택", "Select Available Users"], + B01_Dashboard_SelectAvailableUsers: [ + "사용 가능한 사용자 선택", + "Select Available Users", + ], // 확인 메시지 B01_Dashboard_Confirm_DeleteProject: [ @@ -176,7 +215,10 @@ export const ui_locales_b1 = { "[하드 삭제 모드] 업로드한 라이다 원본과 모든 계산 결과가 서버에서 영구 삭제됩니다. 복구할 수 없습니다. 삭제하시겠습니까?", "[Hard delete mode] The uploaded LiDAR source and every computed result will be permanently erased from the server. This cannot be recovered. Delete anyway?", ], - B01_Dashboard_Confirm_DeleteUser: ["사용자를 삭제하시겠습니까?", "Delete this user?"], + B01_Dashboard_Confirm_DeleteUser: [ + "사용자를 삭제하시겠습니까?", + "Delete this user?", + ], B01_Dashboard_Confirm_LastAdmin: [ "회사의 유일한 관리자는 삭제할 수 없습니다.", "Cannot delete the last admin of the company.", @@ -207,15 +249,24 @@ export const ui_locales_b1 = { B02_Proj_RoadType_Work: ["작업임도", "Work forest road"], B02_Proj_Field_Year: ["사업 연도", "Project year"], B02_Proj_Field_Length: ["예상 연장 (m)", "Estimated length (m)"], - B02_Proj_Field_Length_Placeholder: ["예상 노선 길이", "Estimated route length"], + B02_Proj_Field_Length_Placeholder: [ + "예상 노선 길이", + "Estimated route length", + ], B02_Proj_Field_Memo: ["비고", "Notes"], - B02_Proj_Field_Memo_Placeholder: ["추가 메모 (선택)", "Additional notes (optional)"], + B02_Proj_Field_Memo_Placeholder: [ + "추가 메모 (선택)", + "Additional notes (optional)", + ], B02_Proj_Submit: ["프로젝트 생성", "Create project"], B02_Proj_Success: [ "프로젝트가 생성되었습니다. 파일 입력 단계로 이동합니다.", "Project created. Moving to the file input step.", ], - B02_Proj_Error_Required: ["필수 항목을 입력하세요.", "Please fill in required fields."], + B02_Proj_Error_Required: [ + "필수 항목을 입력하세요.", + "Please fill in required fields.", + ], /* --- B03_FileInput 파일 입력 --- */ B03_File_Title: ["파일입력", "File Input"], @@ -236,7 +287,10 @@ export const ui_locales_b1 = { "현재 프로젝트가 선택되지 않았습니다. 프로젝트를 먼저 생성하거나 선택하세요.", "No current project is selected. Create or select a project first.", ], - B03_File_Error_Required: ["업로드할 파일을 선택하세요.", "Select files to upload."], + B03_File_Error_Required: [ + "업로드할 파일을 선택하세요.", + "Select files to upload.", + ], B03_File_Error_Count: [ "한 번에 업로드할 수 있는 파일 수를 초과했습니다.", "Too many files were selected for one upload.", @@ -245,10 +299,22 @@ export const ui_locales_b1 = { "LAS 또는 LAZ 파일을 정확히 1개 선택하세요.", "Select exactly one LAS or LAZ file.", ], - B03_File_Error_Extension: ["허용되지 않은 파일 형식입니다.", "Unsupported file type."], - B03_File_Error_Size: ["파일 크기 제한을 초과했습니다.", "File size limit exceeded."], - B03_File_Upload_Success: ["입력 파일 업로드를 완료했습니다.", "Input files uploaded."], - B03_File_Upload_Failed: ["파일 업로드에 실패했습니다.", "File upload failed."], + B03_File_Error_Extension: [ + "허용되지 않은 파일 형식입니다.", + "Unsupported file type.", + ], + B03_File_Error_Size: [ + "파일 크기 제한을 초과했습니다.", + "File size limit exceeded.", + ], + B03_File_Upload_Success: [ + "입력 파일 업로드를 완료했습니다.", + "Input files uploaded.", + ], + B03_File_Upload_Failed: [ + "파일 업로드에 실패했습니다.", + "File upload failed.", + ], B03_File_Analysis_InProgress: [ "WF1 분석이 백그라운드에서 진행 중입니다. 완료되면 자동으로 이동합니다.", "WF1 analysis is running in the background. You will move automatically when it completes.", @@ -264,12 +330,23 @@ export const ui_locales_b1 = { B03_File_Group_Inputs: ["입력 자료", "Input files"], B03_File_Slot_PlannedRoute: ["계획노선 좌표", "Planned Route Coordinates"], B03_File_Slot_PointCloud: ["포인트클라우드", "Point Cloud"], + B03_File_LasFree_Toggle: [ + "LAS 없이 설계 (도엽등고선 기반)", + "Design without LAS (map sheet contours)", + ], + B03_File_LasFree_Hint: [ + "포인트클라우드 없이 1:5,000 수치지형도 등고선으로 지형을 만듭니다.", + "Terrain is built from 1:5,000 map sheet contours without a point cloud.", + ], B03_File_Slot_Projection: ["좌표계 정의", "Projection"], B03_File_Slot_RasterCoord: ["래스터 좌표", "Raster Coord 1"], B03_File_Slot_TerrainDem: ["지형 래스터", "Terrain DEM"], B03_File_Slot_CadDrawing: ["CAD 도면", "CAD Drawing"], /* --- B03 임시 보관함 불러오기 (2026-08-08) --- */ - B03_Temp_Btn_Open: ["임시 보관함에서 불러오기", "Load from temporary storage"], + B03_Temp_Btn_Open: [ + "임시 보관함에서 불러오기", + "Load from temporary storage", + ], B03_Temp_None: ["선택된 보관 자료 없음", "No stored set selected"], B03_Temp_Selected: ["선택됨:", "Selected:"], B03_Temp_FileCount: ["개 파일", " files"], @@ -279,12 +356,18 @@ export const ui_locales_b1 = { "No usable stored set. Upload all required files in the dashboard temporary storage first.", ], B03_Temp_Select_Required: ["보관 자료를 선택하세요.", "Select a stored set."], - B03_Temp_Load_Failed: ["보관 자료를 불러오지 못했습니다.", "Failed to load stored sets."], + B03_Temp_Load_Failed: [ + "보관 자료를 불러오지 못했습니다.", + "Failed to load stored sets.", + ], B03_Temp_Attach_Success: [ "보관 자료를 프로젝트로 옮겼습니다. 분석을 시작합니다.", "Stored files moved to the project. Analysis started.", ], - B03_Temp_Attach_Failed: ["보관 자료 연결에 실패했습니다.", "Failed to attach stored files."], + B03_Temp_Attach_Failed: [ + "보관 자료 연결에 실패했습니다.", + "Failed to attach stored files.", + ], B03_Temp_Attach_NoAnalysis: [ "파일은 옮겼지만 라이다 파일이 없어 분석을 시작하지 못했습니다.", "Files moved, but analysis did not start (no point cloud file).", @@ -313,7 +396,10 @@ export const ui_locales_b1 = { B03_File_Status_Completed: ["완료", "Completed"], B03_File_Status_Failed: ["실패", "Failed"], B03_File_Status_Detected: ["중단된 업로드 감지", "Paused upload detected"], - B03_File_Restore_State: ["저장된 업로드/분석 상태 복구", "Restored upload/analysis state"], + B03_File_Restore_State: [ + "저장된 업로드/분석 상태 복구", + "Restored upload/analysis state", + ], B03_File_Resume_Button: ["업로드 재개", "Resume upload"], B03_File_New_Button: ["새 파일로 시작", "Start new file"], B03_File_Overview_Complete: [ @@ -350,6 +436,10 @@ export const ui_locales_b1 = { B04_Surface_Group_Filters: ["지면 필터", "Ground filter"], B04_Surface_Group_Methods: ["서피스", "Surface"], B04_Surface_Group_Display: ["모델 표시 옵션", "Model display options"], + B04_Surface_SheetSurface: [ + "도엽등고 3D 서피스", + "Map-sheet contour 3D surface", + ], B04_Surface_Group_ViewControls: ["뷰어 시점 제어", "Viewer camera"], B04_Surface_Field_Smoothing: ["스무딩", "Smoothing"], B04_Surface_Smoothing_On: ["적용", "On"], @@ -371,10 +461,16 @@ export const ui_locales_b1 = { B04_Surface_Input_FileName: ["파일명", "File name"], B04_Surface_Input_Crs: ["좌표계", "CRS"], B04_Surface_Input_Size: ["크기(MB)", "Size (MB)"], - B04_Surface_PointCloud_Title: ["포인트클라우드 미리보기", "Point cloud preview"], + B04_Surface_PointCloud_Title: [ + "포인트클라우드 미리보기", + "Point cloud preview", + ], B04_Surface_Status_Unknown: ["상태 미확인", "Unknown"], B04_Surface_GroundStats_Title: ["지면 필터 통계", "Ground filter stats"], - B04_Surface_GroundStats_Empty: ["표시할 지면 통계가 없습니다.", "No ground stats to display."], + B04_Surface_GroundStats_Empty: [ + "표시할 지면 통계가 없습니다.", + "No ground stats to display.", + ], B04_Surface_GroundStats_Filter: ["필터", "Filter"], B04_Surface_GroundStats_SourcePoints: ["지면 포인트", "Ground points"], B04_Surface_Result_Title: ["생성된 지표면 모델", "Generated Surface Models"], @@ -392,8 +488,14 @@ export const ui_locales_b1 = { "모델을 확정했습니다. 필터: {filter}, 기법: {method}, 스무딩/표현: {smoothing}", "Model confirmed. Filter: {filter}, method: {method}, smoothing/representation: {smoothing}", ], - B04_Surface_Confirm_Failed: ["모델 확정에 실패했습니다.", "Failed to confirm model."], - B04_Surface_Map_Title: ["2D 배경 지도 및 GIS 레이어", "2D Basemap and GIS Layers"], + B04_Surface_Confirm_Failed: [ + "모델 확정에 실패했습니다.", + "Failed to confirm model.", + ], + B04_Surface_Map_Title: [ + "2D 배경 지도 및 GIS 레이어", + "2D Basemap and GIS Layers", + ], B04_Surface_Map_Background: ["배경 지도", "Basemap"], B04_Surface_Map_GisLayer: ["국가 GIS 레이어", "National GIS Layer"], B04_Surface_Map_None: ["없음", "None"], @@ -424,14 +526,20 @@ export const ui_locales_b1 = { "Failed to load the drainage analysis.", ], /* {message}=원인 */ - B04_Surface_Watershed_Failed: ["유역 분석 실패: {message}", "Basin analysis failed: {message}"], + B04_Surface_Watershed_Failed: [ + "유역 분석 실패: {message}", + "Basin analysis failed: {message}", + ], B04_Surface_Watershed_NoSaved: [ "저장된 배수유역 분석이 없습니다. [유역 분석]을 누르세요.", "No stored drainage analysis. Press [Basin analysis].", ], B04_Surface_Watershed_Origin_Cached: ["저장분", "Cached"], /* {seconds}=재산정에 걸린 시간(초) */ - B04_Surface_Watershed_Origin_Recomputed: ["재산정 {seconds}초", "Recomputed in {seconds}s"], + B04_Surface_Watershed_Origin_Recomputed: [ + "재산정 {seconds}초", + "Recomputed in {seconds}s", + ], /* 도로 유입 흐름 강도 */ B04_Surface_Flow_Strength: ["흐름 강도", "Flow strength"], B04_Surface_Flow_Strength_Tip: [ @@ -444,7 +552,10 @@ export const ui_locales_b1 = { "노선 위에서 물이 특히 많이 모이는 자리(유입 집중점)를 마커로 표시합니다. 마커를 누르면 그 지점으로 들어오는 셀들의 외곽선을 보여 줍니다.", "Marks the spots along the route that collect the most water. Click a marker to outline the cells draining into it.", ], - B04_Surface_Flow_Inflow_Loading: ["유입 셀을 불러오는 중…", "Loading the contributing cells…"], + B04_Surface_Flow_Inflow_Loading: [ + "유입 셀을 불러오는 중…", + "Loading the contributing cells…", + ], /* {index}=마커 번호, {chainage}=누가거리, {area}=유입면적, {cells}=셀 수, {path}=최장 유하장 */ B04_Surface_Flow_Inflow_Summary: [ "유입 집중점 {index} · 측점 {chainage}m — 유입면적 {area} · 셀 {cells}개 · 최장 유하장 {path}m", @@ -516,16 +627,37 @@ export const ui_locales_b1 = { "배경 지도 또는 GIS 레이어를 선택하세요.", "Select a basemap or GIS layer.", ], - B04_Surface_Map_Loading: ["지도 레이어를 불러오는 중입니다.", "Loading map layers."], + B04_Surface_Map_Loading: [ + "지도 레이어를 불러오는 중입니다.", + "Loading map layers.", + ], B04_Surface_Map_Features: ["{count}개 객체 표시", "Showing {count} features"], - B04_Surface_Map_LoadFailed: ["지도 레이어를 불러오지 못했습니다.", "Failed to load map."], - B04_Surface_Error_Project: ["먼저 프로젝트를 선택하세요.", "Select a project first."], - B04_Surface_Error_InputId: ["유효한 입력 파일 ID를 입력하세요.", "Enter a valid input file ID."], + B04_Surface_Map_LoadFailed: [ + "지도 레이어를 불러오지 못했습니다.", + "Failed to load map.", + ], + B04_Surface_Error_Project: [ + "먼저 프로젝트를 선택하세요.", + "Select a project first.", + ], + B04_Surface_Error_InputId: [ + "유효한 입력 파일 ID를 입력하세요.", + "Enter a valid input file ID.", + ], B04_Surface_Error_Selection: [ "지면 필터와 지표면 표현을 각각 1개 이상 선택하세요.", "Select at least one filter and one method.", ], - B04_Surface_Analyze_Success: ["지표면 분석을 완료했습니다.", "Surface analysis complete."], - B04_Surface_Analyze_Failed: ["지표면 분석에 실패했습니다.", "Surface analysis failed."], - B04_Surface_Load_Failed: ["모델 목록을 불러오지 못했습니다.", "Failed to load models."], + B04_Surface_Analyze_Success: [ + "지표면 분석을 완료했습니다.", + "Surface analysis complete.", + ], + B04_Surface_Analyze_Failed: [ + "지표면 분석에 실패했습니다.", + "Surface analysis failed.", + ], + B04_Surface_Load_Failed: [ + "모델 목록을 불러오지 못했습니다.", + "Failed to load models.", + ], } as const satisfies Record;