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..5349a403 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,17 @@ 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개만** 받는다. 섞여 들어오면 되돌린다 — + # 올려 두면 전처리가 어느 쪽 경로인지 갈리지 않는다(2026-08-30 사용자 지시). + if las_free and las_count: + return JSONResponse( + status_code=400, + content={ + "status": "error", + "message": "LAS 없이 설계를 켠 상태에서는 LAS/LAZ 파일을 올릴 수 없습니다.", + }, + ) + if not las_free and las_count != 1: return JSONResponse( status_code=400, content={ @@ -326,7 +345,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 +404,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() @@ -434,6 +455,16 @@ async def create_project_upload_session( session: dict[str, Any] = Depends(verify_session), ) -> ChunkSessionCreateResponse | JSONResponse: """대용량 파일 청크 업로드 세션을 생성한다.""" + # LAS 없는 설계를 켠 상태면 포인트클라우드는 받지 않는다 — 큰 LAS는 이 경로로 + # 들어오므로 여기서 막지 않으면 `/files` 검사를 통째로 비켜 간다. + if payload.las_free and Path(payload.original_filename).suffix.lower() in {".las", ".laz"}: + return JSONResponse( + status_code=400, + content={ + "status": "error", + "message": "LAS 없이 설계를 켠 상태에서는 LAS/LAZ 파일을 올릴 수 없습니다.", + }, + ) chunk_size_bytes = min(payload.chunk_size_bytes, UPLOAD_CHUNK_SIZE_BYTES) total_chunks = _total_chunks(payload.size_bytes, chunk_size_bytes) session_id = str(uuid4()) @@ -460,6 +491,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 +681,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 +786,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 +796,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 +827,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_Chain.py b/B03_FileInput/B03_FileInput_Service_Chain.py index a2c96f2b..b7b69883 100644 --- a/B03_FileInput/B03_FileInput_Service_Chain.py +++ b/B03_FileInput/B03_FileInput_Service_Chain.py @@ -149,7 +149,7 @@ async def run_auto_design_chain( save_initial_snapshot, ) from common_util.common_util_storage import resolve_stored_project_path - from common_util.common_util_surface_confirmation import surface_confirmation_defaults + from common_util.common_util_surface_confirmation import get_surface_confirmation_params from config.config_db import get_db_pool pool = get_db_pool() @@ -177,8 +177,11 @@ async def run_auto_design_chain( logger.warning("자동 설계 체인 중단(계획노선 CSV 없음): project_id=%s", project_id) return None - # 3) B05 경로 계산 — WF1 자동 확정과 같은 config 기본값을 쓴다. - defaults = surface_confirmation_defaults() + # 3) B05 경로 계산 — WF1이 **실제로 확정한** 선택값(stage 1 스냅샷)을 그대로 쓴다. + # config 기본값(csf/dtm)을 쓰면 LAS 없이 설계한 프로젝트에서 있지도 않은 모델을 + # 가리켜 404로 체인이 끊긴다(2026-08-30 실사고). + async with pool.acquire() as connection: + defaults = await get_surface_confirmation_params(connection, str(project_id)) request = RouteSolveRequest( filter_key=str(defaults["source_filter"]), method=str(defaults["method"]), diff --git a/B03_FileInput/B03_FileInput_Service_WF1.py b/B03_FileInput/B03_FileInput_Service_WF1.py index 4f10c038..e5091a92 100644 --- a/B03_FileInput/B03_FileInput_Service_WF1.py +++ b/B03_FileInput/B03_FileInput_Service_WF1.py @@ -20,6 +20,8 @@ from config.config_db import get_db_pool from config.config_system import ( AUTO_DESIGN_CHAIN_ENABLED, SEND_ANALYSIS_COMPLETION_EMAIL, + SHEET_SURFACE_DEFAULT_METHOD, + SURFACE_CONTOUR_INTERVAL_M, SURFACE_MODEL_PRECOMPUTE, SURFACE_MODEL_SOURCE_FILTERS, ) @@ -79,9 +81,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 +100,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 +144,19 @@ async def trigger_wf1_analysis_and_email( find_surface_model_for_selection, ) - selection = surface_confirmation_defaults() + # LAS 없는 설계는 도엽 서피스 모델(sheet/dtm)로 확정한다. + # 스무딩은 LAS 경로와 같이 적용한다(2026-08-30 사용자 확정) — 방식마다 + # `dtm_sheet_*_smooth.npz`를 같이 만들어 두므로 종·횡단이 그걸 샘플링한다. + selection = ( + { + "source_filter": f"sheet_{SHEET_SURFACE_DEFAULT_METHOD}", + "method": "dtm", + "smooth": True, + "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..a9372e94 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; } @@ -276,8 +332,24 @@ export async function renderB03FileInput(root: HTMLElement): Promise { renderSlot(state.slot); } - function onFileSelected(files: readonly File[], targetSlot?: FileSlot): void { - if (files.length === 0) return; + function onFileSelected( + selection: readonly File[], + targetSlot?: FileSlot, + ): void { + if (selection.length === 0) return; + // LAS 없이 설계를 켜면 포인트클라우드는 아예 받지 않는다 (2026-08-30 사용자 지시) — + // 카드를 회색으로 덮어도 파일 선택 영역·드롭으로 들어올 수 있어 여기서 걸러 낸다. + const pointCloudExtensions = slots.get("las_laz")?.extensions ?? []; + const files = lasFreeDesign + ? selection.filter( + (file) => !pointCloudExtensions.includes(getExtension(file.name)), + ) + : selection; + const blocked = files.length !== selection.length; + if (blocked && files.length === 0) { + pageError.textContent = L("B03_File_Error_LasFreeBlocked"); + return; + } // 개수는 "고른 파일 수"가 아니라 **최종적으로 차는 슬롯 수**로 센다. // 같은 슬롯을 다시 고르는 것은 교체라 개수가 늘지 않는다 — 더하기로 세면 5개를 고른 // 뒤 파일 선택 영역으로 하나만 바꾸려 해도 초과로 막힌다(2026-08-08). @@ -286,15 +358,16 @@ 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) { pageError.textContent = L("B03_File_Error_Count"); return; } - pageError.textContent = ""; + pageError.textContent = blocked ? L("B03_File_Error_LasFreeBlocked") : ""; void (async () => { for (const file of files) await assignFileToSlot(file, targetSlot); await detectPausedUploads(); @@ -325,11 +398,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 +439,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 +477,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 +509,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 +538,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 +610,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 +640,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 +668,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 +690,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 +728,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 +742,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 +778,46 @@ 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; + const card = cardMap.get("las_laz"); + card?.classList.toggle("b03-file__card--disabled", lasFreeDesign); + // 카드를 회색으로 덮는 것만으로는 선택이 막히지 않는다 — 버튼·input을 실제로 잠근다. + card + ?.querySelectorAll( + ".b03-file__card-select, .b03-file__slot-input", + ) + .forEach((element) => { + element.disabled = lasFreeDesign; + }); + // 켜기 전에 이미 골라 둔 LAS는 내린다 — 켠 채로 남아 올라가는 사고를 막는다. + if (lasFreeDesign && slots.get("las_laz")?.file) removeFile("las_laz"); + } + 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 +853,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..3789d2df 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_models: list[dict[str, Any]] = [] 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_models = 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_models, + 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_models: list[dict[str, Any]], + 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,7 @@ def run_surface_analysis( "layers": layers, } ) + models.extend(sheet_models) logger.info( "B04 WF1 분석 완료: 모델 %d개, 총 %.1fs", len(models), time.monotonic() - total_started diff --git a/B04_PreProcess/B04_PreProcess_Engine_SheetMethods.py b/B04_PreProcess/B04_PreProcess_Engine_SheetMethods.py new file mode 100644 index 00000000..fb95b752 --- /dev/null +++ b/B04_PreProcess/B04_PreProcess_Engine_SheetMethods.py @@ -0,0 +1,513 @@ +"""도엽등고선 → 표고 격자 보간 방식 모음 (비교용). + +문헌(Hutchinson 1988/89 ANUDEM; Chaplot 2006; Arun 2013 등)은 지형 복잡도·자료 밀도에 +따라 우열이 갈리며 단일 최적해가 없다고 본다. 그래서 방식을 하나로 고르지 않고 여기 +모아 두고 B04 화면에서 바꿔 가며 보게 한다(2026-08-30 사용자 지시). + +각 builder는 `(spec, burned, features, cell_m) -> (R, C) float32` 격자를 돌려준다. +`burned`는 등고 라인이 구워진 격자(라인 셀 = 표고, 그 외 NaN)다. 폐합 링 안쪽 처리와 +프리뷰·저장은 호출측(`_SheetSurface`)이 방식과 무관하게 똑같이 해 준다. +""" + +import logging +import warnings +from typing import Any, Callable + +import numpy as np + +logger = logging.getLogger(__name__) + +# TIN(도엽선)이 물어 오는 격자 밖 여유 — 테두리가 삼각망 밖으로 나가지 않을 만큼만. +SHEET_TIN_CLIP_MARGIN_M = 100.0 + +# 화면 버튼에 쓰는 이름 — 키는 surface_models.generation_params.source_filter 접미사다. +SHEET_METHOD_LABELS: dict[str, str] = { + "tin_sheet": "TIN(도엽선)", + "tin": "TIN 격자", + "biharmonic": "TPS(박판)", + "anudem": "ANUDEM형", + "multires": "다중해상도", + "laplace": "라플라스", +} + + +def _laplacian(values: np.ndarray) -> np.ndarray: + padded = np.pad(values, 1, mode="edge") + return ( + padded[:-2, 1:-1] + padded[2:, 1:-1] + padded[1:-1, :-2] + padded[1:-1, 2:] - 4.0 * values + ) + + +def _contour_vertices(burned: np.ndarray, spec: Any) -> tuple[np.ndarray, np.ndarray]: + """등고 라인 셀을 (N,2) 세계좌표와 표고로 바꾼다.""" + rows, cols = np.nonzero(np.isfinite(burned)) + xs = spec.cell_centers_x()[cols] + ys = spec.cell_centers_y()[rows] + return np.column_stack([xs, ys]), burned[rows, cols].astype(np.float64) + + +def _grid_points(spec: Any) -> tuple[np.ndarray, np.ndarray]: + grid_x, grid_y = np.meshgrid(spec.cell_centers_x(), spec.cell_centers_y()) + return grid_x, grid_y + + +# ── ① 거리 비례 (버튼에서는 뺐지만 다른 방식의 초기추정으로 계속 쓴다) ─────── +def build_distance(spec: Any, burned: np.ndarray, features: Any, cell_m: float) -> np.ndarray: + """가장 가까운 서로 다른 표고 두 라인 사이를 거리 비례로 나눈다. + + z = (L1·d2 + L2·d1) / (d1 + d2) + + 지도 제작의 고전적 손보간을 그대로 옮긴 것이다. 원뿔·능선(z=r) 형상을 정확히 + 재현하고 계단이 생기지 않는다. 표고별 거리장을 돌며 가장 작은 두 값을 추적하므로 + L1≠L2가 보장된다. + """ + from scipy.ndimage import distance_transform_edt + + levels = np.unique(burned[np.isfinite(burned)]) + if len(levels) < 2: + return np.full(burned.shape, levels[0] if len(levels) else np.nan, dtype=np.float32) + + infinity = np.float32(np.inf) + first_d = np.full(burned.shape, infinity, dtype=np.float32) + first_z = np.zeros(burned.shape, dtype=np.float32) + second_d = np.full(burned.shape, infinity, dtype=np.float32) + second_z = np.zeros(burned.shape, dtype=np.float32) + for level in levels: + distance = distance_transform_edt(burned != level, sampling=cell_m).astype(np.float32) + beats_first = distance < first_d + second_d = np.where(beats_first, first_d, second_d) + second_z = np.where(beats_first, first_z, second_z) + first_d = np.where(beats_first, distance, first_d) + first_z = np.where(beats_first, np.float32(level), first_z) + beats_second = ~beats_first & (distance < second_d) + second_d = np.where(beats_second, distance, second_d) + second_z = np.where(beats_second, np.float32(level), second_z) + + total = first_d + second_d + usable = np.isfinite(second_d) & (total > 1e-9) + surface = first_z.astype(np.float32) + surface[usable] = ( + ( + first_z[usable].astype(np.float64) * second_d[usable].astype(np.float64) + + second_z[usable].astype(np.float64) * first_d[usable].astype(np.float64) + ) + / total[usable].astype(np.float64) + ).astype(np.float32) + return surface + + +def relax_laplace(surface: np.ndarray, fixed: np.ndarray, iterations: int) -> None: + """등고 라인을 고정한 채 이웃 평균으로 다듬는다 (in-place, red-black 순서).""" + if iterations <= 0: + return + free = ~fixed & np.isfinite(surface) + if not free.any(): + return + rows, cols = np.indices(surface.shape) + red = free & (((rows + cols) & 1) == 0) + black = free & ~red + padded = np.zeros((surface.shape[0] + 2, surface.shape[1] + 2), dtype=np.float32) + for _ in range(iterations): + for colour in (red, black): + padded[1:-1, 1:-1] = surface + padded[0, 1:-1] = surface[0] + padded[-1, 1:-1] = surface[-1] + padded[1:-1, 0] = surface[:, 0] + padded[1:-1, -1] = surface[:, -1] + neighbours = ( + padded[:-2, 1:-1] + padded[2:, 1:-1] + padded[1:-1, :-2] + padded[1:-1, 2:] + ) * np.float32(0.25) + surface[colour] = neighbours[colour] + + +# ── ② 라플라스(조화) ───────────────────────────────────────────────────────── +def build_laplace(spec: Any, burned: np.ndarray, features: Any, cell_m: float) -> np.ndarray: + """등고선을 경계값으로 두고 Δz=0을 푼다. + + 면이 매끈해지지만 z=r(원뿔·능선)은 조화함수가 아니라 마루가 눌린다. 비교 기준으로 + 남겨 둔다 — ANUDEM이 라플라스 대신 박판 스플라인을 쓰는 이유를 눈으로 보기 위함. + """ + surface = build_distance(spec, burned, features, cell_m) + relax_laplace(surface, np.isfinite(burned), 400) + return surface + + +# ── ③ 박판 스플라인(중조화) ────────────────────────────────────────────────── +def solve_min_curvature(constrained: np.ndarray, guess: np.ndarray) -> np.ndarray: + """제약 셀을 고정하고 Δ²z=0(박판 스플라인)을 최소곡률 최소제곱으로 푼다. + + ANUDEM/Topo to Raster가 쓰는 박판 스플라인과 같은 연산자다. 라플라스와 달리 z=r을 + 그대로 통과시켜 능선·마루가 눌리지 않고, 경사가 등고선 너머로 자연스럽게 이어진다. + + `constrained`: 값이 고정된 셀(등고 라인, 필요하면 구조선 앵커) — 그 외는 NaN. + `guess`: 시작값이자 감쇠 기준(보통 거리 보간 결과). + """ + from scipy.sparse.linalg import LinearOperator, lsmr + + guess = guess.astype(np.float64) + burned = constrained + # 제약 셀 + **격자 테두리**를 고정한다. 최외곽 등고선 바깥이 통째로 자유면 + # 1차함수가 Δ²의 영공간에 남아 해가 하나로 정해지지 않고 켤레기울기가 발산한다 + # (2026-08-30 실측: |Δz| 2092m). 테두리는 거리 보간값으로 묶는다. + fixed = np.isfinite(burned) + fixed[0, :] = fixed[-1, :] = True + fixed[:, 0] = fixed[:, -1] = True + free = ~fixed & np.isfinite(guess) + if not free.any(): + return guess.astype(np.float32) + index = np.flatnonzero(free.ravel()) + values = np.where(np.isfinite(burned), burned, guess) + base = np.where(fixed, np.nan_to_num(values), 0.0) + + # Δ²z=0을 정규방정식(CG)으로 풀면 조건수가 격자변 4제곱이라 발산한다(실측). + # 대신 **최소곡률** 최소제곱으로 세운다 — 자유 셀에 대해 ‖Δz‖를 최소화하며, + # 그 정상해가 곧 Δ²z=0이다. 조건수가 제곱으로 줄어 LSMR이 안정적으로 푼다 + # (Briggs 1974의 최소곡률 격자화와 같은 목적함수). + # 최소곡률만으로는 제약(등고선)에서 먼 영역이 정해지지 않아 해가 폭주한다. + # ANUDEM의 거칠기 벌점과 같은 취지로 감쇠항을 붙여 거리 보간값에 묶어 둔다: + # minimize ‖Δz‖² + λ‖z − 거리보간‖² + # λ가 작을수록 더 매끈하고 클수록 거리 보간에 가깝다. + total_cells = base.size + free_count = len(index) + damping = np.float64(np.sqrt(0.02)) + anchor = guess.ravel()[index] + + def forward(vector: np.ndarray) -> np.ndarray: + # 반드시 **선형**이어야 한다 — 고정 셀 기여(base)를 여기서 더하면 아핀이 되어 + # LSMR의 전제가 깨지고 해가 폭주한다. base 몫은 우변으로만 넘긴다. + scattered = np.zeros_like(base) + scattered.ravel()[index] = vector + return np.concatenate([_laplacian(scattered).ravel(), damping * vector]) + + def adjoint(vector: np.ndarray) -> np.ndarray: + curvature = _laplacian(vector[:total_cells].reshape(base.shape)).ravel()[index] + return curvature + damping * vector[total_cells:] + + rhs = np.concatenate([-_laplacian(base).ravel(), damping * anchor]) + linear = LinearOperator( + (total_cells + free_count, free_count), + matvec=forward, + rmatvec=adjoint, + dtype=np.float64, + ) + result = lsmr(linear, rhs, x0=anchor, maxiter=400, atol=1e-8, btol=1e-8) + solution, info = result[0], result[1] + surface = base.copy() + surface.ravel()[index] = solution + surface[fixed] = values[fixed] + # 안전장치 — 발산하면 조용히 틀린 지형을 넘기지 말고 거리 보간으로 되돌린다. + drift = float(np.nanmax(np.abs(surface - guess))) + span = float(np.nanmax(guess) - np.nanmin(guess)) + if not np.isfinite(drift) or drift > max(span, 1.0): + logger.warning( + "도엽 서피스(TPS): 해가 발산해(최대 %.1fm) 거리 보간으로 되돌립니다 (info=%s).", + drift, + info, + ) + return guess.astype(np.float32) + logger.info("도엽 서피스(TPS): 최소제곱 info=%s, 최대 변화 %.2fm", info, drift) + return surface.astype(np.float32) + + +def build_biharmonic(spec: Any, burned: np.ndarray, features: Any, cell_m: float) -> np.ndarray: + """등고선만 제약으로 둔 박판 스플라인.""" + return solve_min_curvature(burned, build_distance(spec, burned, features, cell_m)) + + +# ── ④ TIN ─────────────────────────────────────────────────────────────────── +def _triangulate( + spec: Any, shape_: tuple[int, int], points: np.ndarray, values: np.ndarray +) -> np.ndarray: + """정점 구름을 Delaunay 삼각망 선형 보간해 격자로 편다. 삼각망 밖은 NaN.""" + from scipy.interpolate import LinearNDInterpolator + + if len(points) < 3: + return np.full(shape_, np.nan, dtype=np.float32) + interpolator = LinearNDInterpolator(points, values) + grid_x, grid_y = _grid_points(spec) + surface = np.empty(shape_, dtype=np.float32) + chunk = max(1, int(4_000_000 // max(spec.n_cols, 1))) + for start in range(0, spec.n_rows, chunk): + stop = min(start + chunk, spec.n_rows) + surface[start:stop] = interpolator(grid_x[start:stop], grid_y[start:stop]).astype( + np.float32 + ) + return surface + + +def build_tin(spec: Any, burned: np.ndarray, features: Any, cell_m: float) -> np.ndarray: + """격자에 구운 등고 라인 셀을 Delaunay 삼각망 선형 보간한다. + + 정점이 셀 중심에 맞춰져 있어 1m 계단이 삼각망에 그대로 실린다. 같은 표고 정점 + 3개로 이루어진 평탄 삼각형이 굴곡부·마루에 계단을 만든다. 비교 기준으로 남긴다. + """ + points, values = _contour_vertices(burned, spec) + if len(points) > 120_000: # 삼각망 비용은 정점 수에 비례한다 + step = int(np.ceil(len(points) / 120_000)) + points, values = points[::step], values[::step] + return _triangulate(spec, burned.shape, points, values) + + +def build_tin_sheet(spec: Any, burned: np.ndarray, features: Any, cell_m: float) -> np.ndarray: + """**원본 5m 도엽 등고선 정점**을 그대로 이은 고전 TIN (2026-08-30 사용자 지시). + + `tin`은 격자에 구운 라인 셀(=1m 계단으로 뭉개진 정점)을 쓰지만, 이쪽은 벡터 + 등고선의 정점을 좌표 그대로 쓴다 — 도면 등고선을 삼각망으로 잇는 측량 관행 그대로다. + 격자 밖 등고선은 물지 않는다(삼각망 비용만 커지고 결과는 같다). 다만 격자 테두리가 + 삼각망 밖으로 나가지 않도록 여유를 두고 자른다. + + 길이 필터는 두지 않는다 — 짧은 봉우리 폐합 등고선을 버리면 마루가 통째로 평평해진다. + """ + from shapely.geometry import shape as to_shape + + from B04_PreProcess.B04_PreProcess_Engine_Watershed_Grid import ( + ELEVATION_KEYS, + iter_linestrings, + ) + + xs, ys = spec.cell_centers_x(), spec.cell_centers_y() + margin = max(SHEET_TIN_CLIP_MARGIN_M, cell_m * 2.0) + x_lo, x_hi = xs[0] - margin, xs[-1] + margin + y_lo, y_hi = ys[-1] - margin, ys[0] + margin + + coords: list[np.ndarray] = [] + levels: list[np.ndarray] = [] + for feature in features or []: + properties = feature.get("properties") or {} + elevation = next( + (float(properties[key]) for key in ELEVATION_KEYS if properties.get(key) is not None), + None, + ) + geometry = feature.get("geometry") + if elevation is None or not geometry: + continue + try: + parsed = to_shape(geometry) + except Exception: # noqa: BLE001 — 손상된 피처는 건너뛴다 + continue + for line in iter_linestrings(parsed): + point = np.asarray(line.coords, dtype=np.float64)[:, :2] + inside = ( + (point[:, 0] >= x_lo) + & (point[:, 0] <= x_hi) + & (point[:, 1] >= y_lo) + & (point[:, 1] <= y_hi) + ) + if not inside.any(): + continue + coords.append(point[inside]) + levels.append(np.full(int(inside.sum()), elevation, dtype=np.float64)) + + if not coords: + logger.warning("도엽 서피스: TIN(도엽선)에 쓸 등고선 정점이 없습니다.") + return np.full(burned.shape, np.nan, dtype=np.float32) + + points = np.vstack(coords) + values = np.concatenate(levels) + # 도엽 이음매에서 같은 정점이 겹쳐 들어온다 — Qhull 비용만 늘어 미리 접는다. + _, unique = np.unique(np.round(points, 3), axis=0, return_index=True) + points, values = points[unique], values[unique] + logger.info("도엽 서피스: TIN(도엽선) 정점 %d개", len(points)) + return _triangulate(spec, burned.shape, points, values) + + +# ── ⑦ ANUDEM형 (구조선 + 배수 강제) ───────────────────────────────────────── +def _contour_corner_anchors( + spec: Any, burned: np.ndarray, guess: np.ndarray, cell_m: float +) -> tuple[np.ndarray, np.ndarray] | None: + """등고선의 국소 최대 곡률점(코너)에서 능선·계곡 구조선 앵커를 만든다. + + ANUDEM은 등고선 자체의 곡률에서 능선·계곡망을 먼저 뽑아 흐름 구조를 세운다 + (Hutchinson 1988/89). 여기서도 같은 순서를 따른다. + + ① 라인마다 정점 곡률을 재 국소 최대점(V자 꼭짓점)을 고른다 + ② 굽은 안쪽이 더 높으면 **계곡**(등고선 V가 상류를 가리킴), 낮으면 **능선** + ③ 같은 종류의 코너를 이웃 표고끼리 이어 그 사이를 선형 보간해 앵커로 심는다 + + 앵커는 박판 해의 제약으로 들어가 계곡 바닥이 이어져 내려가고 능선 마루가 선다. + """ + from scipy.ndimage import label + from scipy.spatial import cKDTree + + levels = np.unique(burned[np.isfinite(burned)]) + if len(levels) < 2: + return None + interval = float(np.diff(levels).min()) + xs = spec.cell_centers_x() + ys = spec.cell_centers_y() + + corners: list[tuple[float, float, float, int]] = [] # x, y, level, +1 계곡 / -1 능선 + span = 6 # 곡률을 재는 정점 간격(px) — 짧으면 노이즈, 길면 꼭짓점을 놓친다 + for level in levels: + labelled, count = label(burned == level) + for component_id in range(1, count + 1): + line_rows, line_cols = np.nonzero(labelled == component_id) + if len(line_rows) < 3 * span: + continue + # 라인 셀을 한 줄로 세운다 — 좌표 정렬로 근사한다(정밀 추적은 과하다). + order = np.argsort(line_cols + line_rows * 1e-3) + path = np.column_stack([line_cols[order], line_rows[order]]).astype(np.float64) + before = np.roll(path, span, axis=0) + after = np.roll(path, -span, axis=0) + first = path - before + second = after - path + first_len = np.hypot(first[:, 0], first[:, 1]) + second_len = np.hypot(second[:, 0], second[:, 1]) + valid = (first_len > 1e-6) & (second_len > 1e-6) + cosine = np.ones(len(path)) + cosine[valid] = (first[valid] * second[valid]).sum(axis=1) / ( + first_len[valid] * second_len[valid] + ) + sharp = np.flatnonzero(valid & (cosine < 0.3)) # 70도 이상 꺾인 자리 + for i in sharp[:: max(1, span)]: + # 굽은 안쪽 방향 = 두 변 단위벡터 합의 반대 + inward = -(first[i] / first_len[i] + second[i] / second_len[i]) + norm = float(np.hypot(inward[0], inward[1])) + if norm < 1e-6: + continue + probe = path[i] + inward / norm * 6.0 + probe_col = int(round(probe[0])) + probe_row = int(round(probe[1])) + if not (0 <= probe_row < guess.shape[0] and 0 <= probe_col < guess.shape[1]): + continue + inside = float(guess[probe_row, probe_col]) + if not np.isfinite(inside) or abs(inside - level) < interval * 0.15: + continue + corners.append( + ( + float(xs[int(path[i, 0])]), + float(ys[int(path[i, 1])]), + float(level), + 1 if inside > level else -1, + ) + ) + if len(corners) < 4: + logger.info("도엽 서피스(ANUDEM형): 등고선 코너가 부족해 구조선을 건너뜁니다.") + return None + + array = np.asarray(corners, dtype=np.float64) + anchor_xy: list[np.ndarray] = [] + anchor_z: list[np.ndarray] = [] + reach = interval * 20.0 # 이보다 먼 코너는 같은 구조선으로 보지 않는다 + for level in levels[:-1]: + upper = level + interval + lower_set = array[np.abs(array[:, 2] - level) < 1e-6] + upper_set = array[np.abs(array[:, 2] - upper) < 1e-6] + if not len(lower_set) or not len(upper_set): + continue + tree = cKDTree(upper_set[:, :2]) + distance, index = tree.query(lower_set[:, :2], k=1) + for i in range(len(lower_set)): + j = int(index[i]) + if distance[i] > reach or lower_set[i, 3] != upper_set[j, 3]: + continue + start = lower_set[i, :2] + end = upper_set[j, :2] + steps = max(2, int(distance[i] / max(cell_m, 1e-6) / 4)) + fraction = np.linspace(0.0, 1.0, steps + 1)[1:-1] + if not len(fraction): + continue + anchor_xy.append(start + (end - start) * fraction[:, None]) + anchor_z.append(level + interval * fraction) + if not anchor_xy: + return None + return np.vstack(anchor_xy), np.concatenate(anchor_z) + + +def _enforce_drainage(surface: np.ndarray, epsilon: float = 0.01) -> int: + """가짜 웅덩이를 메운다 — ANUDEM의 배수 강제와 같은 목적. + + 등고선만으로 만든 면에는 흐름이 끊기는 웅덩이가 남는다. 형태학적 재구성(erosion) + 으로 채우되 완전 평탄해지지 않게 아주 작은 값을 얹는다. 채운 셀 수를 반환한다. + """ + from skimage.morphology import reconstruction + + if not np.isfinite(surface).all(): + return 0 + seed = np.full(surface.shape, float(surface.max()), dtype=np.float64) + seed[0, :] = surface[0, :] + seed[-1, :] = surface[-1, :] + seed[:, 0] = surface[:, 0] + seed[:, -1] = surface[:, -1] + filled = reconstruction(seed, surface.astype(np.float64), method="erosion") + raised = filled > surface + 1e-6 + if not raised.any(): + return 0 + # 그냥 채우면 웅덩이가 통째로 평탄해져 흐름 방향이 없어진다. 채운 영역 안쪽으로 + # 갈수록 아주 조금 높아지게 해서 물이 가장자리(넘침점)로 빠져나가게 둔다 + # (Garbrecht·Martz의 평탄면 해소를 간단히 옮긴 것 — 표고 변화는 cm 단위다). + from scipy.ndimage import distance_transform_edt + + inner = distance_transform_edt(raised) + surface[raised] = (filled[raised] + epsilon * inner[raised]).astype(surface.dtype) + return int(raised.sum()) + + +def build_anudem(spec: Any, burned: np.ndarray, features: Any, cell_m: float) -> np.ndarray: + """ANUDEM형 — 등고선 곡률에서 능선·계곡 구조선을 뽑아 제약에 더하고, 박판으로 풀고, + 가짜 웅덩이를 메운다. Topo to Raster가 밟는 세 단계를 그대로 옮긴 것이다.""" + guess = build_distance(spec, burned, features, cell_m).astype(np.float64) + constrained = burned.astype(np.float64).copy() + anchors = _contour_corner_anchors(spec, burned, guess, cell_m) + if anchors is not None: + xy, z = anchors + row, col = spec.world_to_rc(xy[:, 0].copy(), xy[:, 1].copy()) + inside = (row >= 0) & (col >= 0) + row, col, z = row[inside], col[inside], z[inside] + free = ~np.isfinite(constrained[row, col]) + constrained[row[free], col[free]] = z[free] + logger.info("도엽 서피스(ANUDEM형): 구조선 앵커 %d셀", int(free.sum())) + surface = solve_min_curvature(constrained, guess).astype(np.float32) + logger.info("도엽 서피스(ANUDEM형): 가짜 웅덩이 %d셀 메움", _enforce_drainage(surface)) + return surface + + +# ── ⑧ 다중해상도 (coarse → fine) ──────────────────────────────────────────── +def build_multires(spec: Any, burned: np.ndarray, features: Any, cell_m: float) -> np.ndarray: + """성긴 격자에서 풀고 점차 세밀화한다 — ANUDEM의 다중해상도 전략. + + 전체 형상은 성긴 격자에서 싸게 잡고, 세밀한 격자에서는 등고선 근처만 다듬는다. + 한 해상도에서만 풀 때보다 넓은 밴드가 고르게 퍼지고 값싸게 수렴한다. + """ + from scipy.ndimage import zoom + + surface: np.ndarray | None = None + for factor in (8, 4, 2, 1): + if factor == 1: + coarse = burned + else: + # 성긴 격자의 제약 — 블록 안 등고 라인의 평균 표고를 대표로 쓴다. + rows = burned.shape[0] // factor * factor + cols = burned.shape[1] // factor * factor + blocks = burned[:rows, :cols].reshape(rows // factor, factor, cols // factor, factor) + # 라인이 하나도 없는 블록은 NaN이 정상이라 경고를 삼킨다. + with warnings.catch_warnings(): + warnings.simplefilter("ignore", RuntimeWarning) + coarse = np.nanmean(blocks, axis=(1, 3)).astype(np.float32) + level = build_distance(spec, coarse, features, cell_m * factor) + if surface is not None: + # 앞 단계 해를 지금 해상도로 올려 절반씩 섞는다 — 성긴 단계의 넓은 추세를 + # 이어받되 이번 해상도의 등고선 정보를 덮지 않는다. + scale = (level.shape[0] / surface.shape[0], level.shape[1] / surface.shape[1]) + upscaled = zoom(surface, scale, order=1) + free = ~np.isfinite(coarse) + level[free] = (level[free] + upscaled[free]) * 0.5 + relax_laplace(level, np.isfinite(coarse), 8) + surface = level + assert surface is not None + if surface.shape != burned.shape: # 블록 자르기로 남은 가장자리 보정 + scale = (burned.shape[0] / surface.shape[0], burned.shape[1] / surface.shape[1]) + surface = zoom(surface, scale, order=1) + line = np.isfinite(burned) + surface[line] = burned[line] + return surface.astype(np.float32) + + +SHEET_METHOD_BUILDERS: dict[str, Callable[[Any, np.ndarray, Any, float], np.ndarray]] = { + "tin_sheet": build_tin_sheet, + "tin": build_tin, + "biharmonic": build_biharmonic, + "anudem": build_anudem, + "multires": build_multires, + "laplace": build_laplace, +} diff --git a/B04_PreProcess/B04_PreProcess_Engine_SheetSurface.py b/B04_PreProcess/B04_PreProcess_Engine_SheetSurface.py new file mode 100644 index 00000000..c4cb0df1 --- /dev/null +++ b/B04_PreProcess/B04_PreProcess_Engine_SheetSurface.py @@ -0,0 +1,550 @@ +"""도엽등고선 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). + +순서는 **2D 먼저, 메시는 맨 마지막**이다(2026-08-30 사용자 지시): + ① 등고 라인을 격자에 굽고 ② 등고선 사이 거리 비례 보간으로 표고 격자를 만든 뒤 + ③ 폐합 등고선 안쪽(마루·웅덩이)을 바깥 사면 경사로 연장하고 ④ 라인을 고정한 채 + 완화(라플라스)해 등고 간격을 고르게 한다. + 격자에서 뽑는 1m 등고선이 곧 2D 보간선이며, 메시(glb)는 그 격자의 표현일 뿐이다. +""" + +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_SheetMethods import ( + SHEET_METHOD_BUILDERS, + SHEET_METHOD_LABELS, +) +from B04_PreProcess.B04_PreProcess_Engine_Watershed_Grid import grid_spec_from_bounds +from config.config_system import ( + SHEET_SURFACE_GRID_M, + SHEET_SURFACE_MARGIN_M, + SHEET_SURFACE_METHODS, + SURFACE_MAX_PREVIEW_VERTICES, + SURFACE_SMOOTHING_DTM_PREVIEW_RESOLUTION_M, + SURFACE_SMOOTHING_DTM_SIGMA_M, + SURFACE_SMOOTHING_DTM_SPLINE_SMOOTH, +) + +logger = logging.getLogger(__name__) + +# 도엽 병합 산출물 파일명 (B04_PreProcess_Router_Watershed와 같은 값) +_CONTOUR_FILE = "도엽_등고선.geojson" + +# 산출 모델 식별자 — surface_models.generation_params.source_filter 및 파일 stem에 쓴다. +SHEET_SOURCE_FILTER = "sheet" + + +def _load_features_metric( + processed_dir: Path, epsg: int, filename: str = _CONTOUR_FILE +) -> list[dict[str, Any]]: + """병합 도엽 레이어(WGS84)를 읽어 사업지 CRS(m)로 재투영한다.""" + path = processed_dir / filename + 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]: + """프리뷰용 정점·면 — 정점 수가 상한을 넘으면 격자를 성기게 딴다. + + 격자를 그대로 잇는다. 한때 NURBS 곡면을 걸었으나(2026-08-30) DTM 스무딩이 + 들어오면서 곡면 적합이 이중으로 걸려 되돌렸다 — 스무딩은 표고 정본(npz)에서 + 한 번만 한다. + """ + 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 _write_smoothed( + models_dir: Path, + stem: str, + x: np.ndarray, + y: np.ndarray, + z: np.ndarray, + valid: np.ndarray, + bounds: np.ndarray, +) -> None: + """`{stem}_smooth.npz`·`_smooth_preview.glb`를 만든다 — LAS DTM 스무딩과 같은 절차. + + `B04_PreProcess_Engine_Smooth.smooth_dtm()`은 `TerrainContext`(라이다 발자국)를 + 받으므로 그대로 못 쓴다. 그래서 계수는 **config 값을 그대로** 두고 같은 두 단계만 + 옮긴다(2026-08-30 사용자 지시 — 계수 변경 금지): + + ① 무효 영역이 번지지 않는 정규화 가우시안 (`smoothing_dtm_sigma_meters`) + ② C² 바이큐빅 B-spline 재평가 (`kx=ky=3`, `s=smoothing_dtm_spline_smooth`)를 + `smoothing_dtm_preview_resolution_meters` 격자에서 + + 화면 스무딩 토글과 확정 스냅샷이 이 파일을 찾으므로 이름 규칙을 지켜야 한다. + """ + from scipy.interpolate import RectBivariateSpline + + from B04_PreProcess.B04_PreProcess_Engine_Smooth import _masked_gaussian_filter + + cell_m = float(x[1] - x[0]) if len(x) > 1 else SHEET_SURFACE_GRID_M + sigma_pixels = SURFACE_SMOOTHING_DTM_SIGMA_M / cell_m if cell_m > 0 else 0.0 + # 결측이 하나라도 있으면 스플라인 결과가 통째로 NaN이 된다(TIN·TIN 곡면은 볼록껍질 + # 밖이 결측이다). 최근접 표고로 메워 적합하고 아래에서 원래 마스크로 되돌린다. + filled = z.astype(np.float64) + if not valid.all(): + from scipy.ndimage import distance_transform_edt + + _, (near_row, near_col) = distance_transform_edt(~valid, return_indices=True) + filled = filled[near_row, near_col] + z_pre = _masked_gaussian_filter(filled, valid, sigma_pixels) + try: + spline = RectBivariateSpline(y, x, z_pre, kx=3, ky=3, s=SURFACE_SMOOTHING_DTM_SPLINE_SMOOTH) + except Exception as exc: # noqa: BLE001 — 스무딩 실패가 원본 산출을 막으면 안 된다 + logger.warning("도엽 서피스(%s): 스무딩 스플라인 실패(%s) — 건너뜁니다.", stem, exc) + return + + # 재평가 격자는 config의 프리뷰 해상도를 쓰되 원본보다 성기게 잡지 않는다 — + # 이 npz는 화면용이자 **스무딩 확정 시 종·횡단이 샘플링하는 표고 정본**이라, + # 정점 상한(화면 사정)으로 해상도를 깎으면 설계 정밀도가 같이 깎인다. + # 메시 정점 수는 _preview_mesh가 알아서 성기게 딴다. + step = max(SURFACE_SMOOTHING_DTM_PREVIEW_RESOLUTION_M, cell_m) + sx = np.arange(x[0], x[-1] + step * 0.5, step) + sy = np.arange(y[0], y[-1] + step * 0.5, step) + sz = np.asarray(spline(sy, sx), dtype=np.float32) + # 원본 유효 마스크를 최근접으로 옮겨 무효 영역을 그대로 지킨다. + col = np.clip(np.searchsorted(x, sx) - 1, 0, len(x) - 1) + row = np.clip(np.searchsorted(y, sy) - 1, 0, len(y) - 1) + svalid = valid[np.ix_(row, col)] + sz[~svalid] = np.nan + + atomic_npz( + models_dir / f"{stem}_smooth.npz", + x=sx, + y=sy, + z=sz, + valid_mask=svalid, + bounds=bounds, + resolution=np.array([step], np.float32), + ) + vertices, faces = _preview_mesh(sx, sy, np.nan_to_num(sz, nan=float(bounds[2, 0])), svalid) + write_glb(models_dir / f"{stem}_smooth_preview.glb", vertices, faces, bounds) + + +def _rasterize_contour_levels(spec: Any, features: list[dict[str, Any]]) -> np.ndarray: + """등고 라인을 격자에 굽는다 — 셀 = 그 위를 지나는 라인의 표고, 그 외 NaN. + + 배수유역의 `rasterize_contours()`와 두 가지가 다르다(둘 다 서피스 품질 때문이다): + · `all_touched=False` — 스치는 셀까지 칠하면 라인이 2px 두께가 되고, 그 폭만큼 + 정확히 등고 표고인 **평탄 띠**가 생겨 사이 1m 등고선 간격이 찌그러진다 + (2026-08-30 사용자 지적: 보간선이 등간격이 아님). + · 길이 필터 없음 — 봉우리 폐합 링 같은 짧은 등고선을 버리면 그 일대가 통째로 + 평평해진다. 배수유역은 노이즈를 버려야 하지만 지형면은 다 있어야 한다. + + 같은 셀을 두 표고가 지나면 낮은 쪽을 남긴다(배수유역과 같은 규칙). + """ + from rasterio.features import rasterize + from shapely.geometry import shape + + from B04_PreProcess.B04_PreProcess_Engine_Watershed_Grid import ( + ELEVATION_KEYS, + grid_transform, + iter_linestrings, + ) + + by_level: dict[float, list[Any]] = {} + for feature in features: + geometry = feature.get("geometry") + if not geometry: + continue + properties = feature.get("properties") or {} + elevation = next( + (float(properties[key]) for key in ELEVATION_KEYS if properties.get(key) is not None), + None, + ) + if elevation is None: + continue + try: + parsed = shape(geometry) + except Exception: # noqa: BLE001 + continue + for line in iter_linestrings(parsed): + by_level.setdefault(elevation, []).append(line) + + burned = np.full((spec.n_rows, spec.n_cols), np.nan, dtype=np.float32) + transform = grid_transform(spec) + for elevation in sorted(by_level, reverse=True): + stamp = rasterize( + [(line, 1) for line in by_level[elevation]], + out_shape=(spec.n_rows, spec.n_cols), + transform=transform, + fill=0, + dtype="uint8", + all_touched=False, + ).astype(bool) + burned[stamp] = elevation + logger.info( + "도엽 서피스: 등고 라인 %d단을 격자에 굽어 %d셀", + len(by_level), + int(np.isfinite(burned).sum()), + ) + return burned + + +def _resolve_enclosed_interiors( + burned: np.ndarray, present: list[float], surface: np.ndarray, cell_m: float, interval_m: float +) -> np.ndarray: + """폐합 등고선 안쪽(봉우리·웅덩이)을 바깥 사면 경사로 연장한다 (in-place). + + 거리 보간은 "가장 가까운 서로 다른 두 라인 사이"를 채우므로, 마지막 등고선 + 안쪽에는 더 높은 라인이 없어 아래쪽 라인 쪽으로 끌려 **분화구처럼 파인다**. + 등고선이 'ㅜ'에서 점점 짧아지다 사라지는 마루가 바로 이 자리다(2026-08-30 + 사용자 지적). + + 폐합 라인 내부에 다른 표고 제약이 하나도 없으면 그 안이 마루(바깥이 낮을 때) + 또는 웅덩이(바깥이 높을 때)다. 바깥 사면의 국소 경사를 안쪽으로 연장하되 + ±(간격−0.5m)로 제한한다 — 다음 등고선이 없다는 사실과 모순되지 않는 범위다. + 처리한 영역의 마스크를 반환한다 — 이어지는 완화에서 함께 고정해야 한다. + """ + from scipy.ndimage import binary_dilation, binary_fill_holes, distance_transform_edt, label + + constrained = np.isfinite(burned) + band_cells = 8 + limit = max(interval_m - 0.5, 0.5) + resolved = 0 + handled = np.zeros(burned.shape, dtype=bool) + for level in present: + mask = burned == level + interior = binary_fill_holes(mask) & ~mask + if not interior.any(): + continue + components, count = label(interior) + for component_id in range(1, count + 1): + component = components == component_id + if (constrained & component).any(): + continue # 안에 다른 제약이 있으면 마루가 아니다(보통의 감싸는 링) + ring = binary_dilation(binary_fill_holes(mask) | mask) & ~component & ~mask + ring &= np.isfinite(surface) + if not ring.any(): + continue + outside_mean = float(surface[ring].mean()) + direction = 1.0 if outside_mean < level else -1.0 + inner = distance_transform_edt(component, sampling=cell_m) + # 바깥 사면 경사 — 라인 밖 band_cells 이내 유효 셀의 (낙차 / 거리) 평균. + outer_distance = distance_transform_edt(~(mask | component), sampling=cell_m) + band = (outer_distance > 0) & (outer_distance <= band_cells * cell_m) + band &= np.isfinite(surface) & ~component & ~mask + if band.any(): + slope = float( + np.mean(np.abs(level - surface[band].astype(np.float64)) / outer_distance[band]) + ) + else: + slope = 0.0 + if slope > 1e-3: + offset = np.minimum(slope * inner[component], limit) + else: + peak = float(inner.max()) + offset = (limit / 2.0) * (inner[component] / peak) if peak > 0 else 0.0 + surface[component] = level + direction * offset + handled |= component + resolved += 1 + if resolved: + logger.info("도엽 서피스: 폐합 등고선 내부 %d곳을 사면 경사로 연장", resolved) + return handled + + +def _write_method_model( + project_root: Path, + models_dir: Path, + spec: Any, + surface: np.ndarray, + method_key: str, +) -> dict[str, Any] | None: + """방식 하나의 표고 격자를 npz·프리뷰 glb로 저장하고 등록용 dict를 만든다.""" + # 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("도엽 서피스(%s): 유효 표고 셀이 없습니다.", method_key) + return None + + source_filter = f"{SHEET_SOURCE_FILTER}_{method_key}" + stem = f"dtm_{source_filter}" + model_path = models_dir / f"{stem}.npz" + preview_path = models_dir / f"{stem}_preview.glb" + finite_z = z_grid[valid_grid] + # bounds를 npz에 같이 넣는다 — 등고선 API가 이 값을 화면 원점으로 쓴다. 없으면 + # LAS structured.npz로 폴백해 메시(glb) 원점과 어긋난다(LAS 없는 설계는 아예 실패). + bounds = np.array( + [ + [x_coords[0], x_coords[-1]], + [y_coords[0], y_coords[-1]], + [float(finite_z.min()), float(finite_z.max())], + ] + ) + atomic_npz( + model_path, + x=x_coords, + y=y_coords, + z=z_grid, + valid_mask=valid_grid, + bounds=bounds, + resolution=np.array([SHEET_SURFACE_GRID_M], np.float32), + ) + vertices, faces = _preview_mesh(x_coords, y_coords, z_grid, valid_grid) + write_glb(preview_path, vertices, faces, bounds) + _write_smoothed(models_dir, stem, x_coords, y_coords, z_grid, valid_grid, bounds) + return { + "model_type": "dtm", + "source_filter": 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": source_filter, + "representation": "regular_grid", + "source": "map_sheet_contours", + "interpolation": method_key, + "interpolation_label": SHEET_METHOD_LABELS.get(method_key, method_key), + "margin_m": SHEET_SURFACE_MARGIN_M, + }, + "layers": [ + { + "layer_name": f"{stem}_preview", + "geometry_type": "MESH", + "file_path": str(preview_path.relative_to(project_root)).replace("\\", "/"), + "file_format": "glb", + } + ], + } + + +def build_sheet_surface_model( + project_root: Path, + processed_dir: Path, + models_dir: Path, + route_xy: np.ndarray, + epsg: int, + methods: list[str] | None = None, +) -> list[dict[str, Any]]: + """도엽등고선으로 방식별 DTM npz·프리뷰 glb를 만들고 등록용 dict 목록을 돌려준다. + + 방식을 하나로 고르지 않고 전부 만들어 두는 이유: 문헌상 지형에 따라 우열이 갈려 + 화면에서 바꿔 보며 정해야 한다(2026-08-30 사용자 지시). 실패하면 빈 목록 — + 호출측은 분석을 계속한다(도엽 미확보 지역 폴백). + + `route_xy`: (N, 2) 노선 정점 XY(사업지 CRS, m). + """ + started = time.monotonic() + features = _load_features_metric(processed_dir, epsg) + if not features: + return [] + + 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 + spec = grid_spec_from_bounds(x_min, y_min, x_max, y_max, SHEET_SURFACE_GRID_M) + + # ① 2D — 등고 라인을 격자에 굽는다(라인 셀 = 표고, 그 외 NaN). + burned = _rasterize_contour_levels(spec, features) + present = sorted(np.unique(burned[np.isfinite(burned)]).tolist()) + if len(present) < 2: + logger.warning("도엽 서피스: 절취 범위 안에 등고선이 부족합니다.") + return [] + # 등고 간격(m) — 마루 연장 상한의 근거. 레벨이 하나뿐이면 5m(주곡선) 폴백. + interval_m = float(np.diff(np.array(present)).min()) if len(present) > 1 else 5.0 + + selected = methods or list(SHEET_SURFACE_METHODS) + models: list[dict[str, Any]] = [] + for method_key in selected: + builder = SHEET_METHOD_BUILDERS.get(method_key) + if builder is None: + logger.warning("도엽 서피스: 알 수 없는 보간 방식 %s — 건너뜁니다.", method_key) + continue + step_started = time.monotonic() + try: + # ② 2D 보간 — 여기서 나온 격자에서 1m 등고선을 뽑으므로 화면 등고선이 곧 + # 2D 보간선이다. 메시(glb)는 그 격자의 표현일 뿐이다(사용자 지시). + surface = builder(spec, burned, features, spec.cell_m) + # ③ 폐합 등고선 안쪽(마루·웅덩이)은 방식과 무관하게 같은 규칙으로 채운다. + _resolve_enclosed_interiors(burned, present, surface, spec.cell_m, interval_m) + except Exception as exc: # noqa: BLE001 — 한 방식이 죽어도 나머지는 만든다 + logger.warning("도엽 서피스(%s) 생성 실패: %s", method_key, exc) + continue + model = _write_method_model(project_root, models_dir, spec, surface, method_key) + if model is not None: + models.append(model) + logger.info("도엽 서피스(%s) 완료 (%.1fs)", method_key, time.monotonic() - step_started) + + logger.info( + "도엽 서피스 생성 완료: %d×%d 격자, 등고 %d단, 방식 %d개 (%.1fs)", + spec.n_rows, + spec.n_cols, + len(present), + len(models), + time.monotonic() - started, + ) + return models + + +def build_sheet_surface_from_route( + project_root: Path, processed_dir: Path, models_dir: Path +) -> list[dict[str, Any]]: + """B03 업로드 계획노선 CSV를 찾아 방식별 도엽 서피스를 만든다. 없으면 빈 목록.""" + 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 [] + planned = read_planned_route_csv(route_file) + if planned is None or len(planned.vertices) < 2: + logger.warning("도엽 서피스: 계획 노선 파일을 읽지 못했습니다: %s", route_file.name) + return [] + 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 서피스 생성 중") + models = build_sheet_surface_model(project_root, processed_dir, models_dir, route_xy, epsg) + if not models: + 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": models, + } diff --git a/B04_PreProcess/B04_PreProcess_Router.py b/B04_PreProcess/B04_PreProcess_Router.py index 00977c5c..a1df3a3d 100644 --- a/B04_PreProcess/B04_PreProcess_Router.py +++ b/B04_PreProcess/B04_PreProcess_Router.py @@ -432,8 +432,26 @@ async def get_confirmed_surface(project_id: UUID) -> SurfaceConfirmedResponse | "z_max": float(bounds[2, 1]), } - # 지도(2D) 초기 화면을 도로 기준으로 맞추기 위한 계획노선 범위(없으면 None). + # LAS 없이 설계한 프로젝트는 위 두 파일이 아예 없다(도엽등고선으로 만든 + # 서피스가 정본). 그때는 확정 모델 격자의 bounds를 그대로 쓴다 — 없으면 + # B05가 "지표면 범위 정보를 찾을 수 없습니다"로 3D를 못 띄운다(2026-08-30). project_root = processed_dir.parent.parent + if bounds_payload is None and confirmed and confirmed.get("model_file_path"): + model_path = project_root / str(confirmed["model_file_path"]) + if model_path.is_file(): + with np.load(model_path) as stored: + if "bounds" in stored: + bounds = np.asarray(stored["bounds"], dtype=np.float64) + bounds_payload = { + "x_min": float(bounds[0, 0]), + "x_max": float(bounds[0, 1]), + "y_min": float(bounds[1, 0]), + "y_max": float(bounds[1, 1]), + "z_min": float(bounds[2, 0]), + "z_max": float(bounds[2, 1]), + } + + # 지도(2D) 초기 화면을 도로 기준으로 맞추기 위한 계획노선 범위(없으면 None). route_bounds = planned_route_bounds(project_root, project_epsg_from_prj(project_root)) signature = "|".join( diff --git a/B04_PreProcess/B04_PreProcess_Router_Basins.py b/B04_PreProcess/B04_PreProcess_Router_Basins.py index 1b676b5c..0ce290bc 100644 --- a/B04_PreProcess/B04_PreProcess_Router_Basins.py +++ b/B04_PreProcess/B04_PreProcess_Router_Basins.py @@ -239,7 +239,9 @@ async def _resolve( requested = parse_pipe_points((payload or {}).get("points")) signature = route_signature(context.vertices) - stored = load_pipe_points(context.stored_path, signature) if use_stored else None + stored = ( + load_pipe_points(context.stored_path, signature, context.vertices) if use_stored else None + ) points = requested or stored or None result = await asyncio.to_thread(_build, context.stored_path, context, points) @@ -305,7 +307,10 @@ async def put_pipe_points( context, detail, points, _ = resolved signature = route_signature(context.vertices) - saved = await asyncio.to_thread(save_pipe_points, context.stored_path, signature, points) + # 좌표를 같이 남긴다 — 다른 선(B05 최적 경로)으로 읽어도 그 자리에 되놓는다. + saved = await asyncio.to_thread( + save_pipe_points, context.stored_path, signature, points, context.vertices + ) await asyncio.to_thread( save_detail_basins, context.stored_path, _basin_features(context, detail, points) ) diff --git a/B04_PreProcess/B04_PreProcess_UI_Page.ts b/B04_PreProcess/B04_PreProcess_UI_Page.ts index 12e90d60..ae01a030 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 { @@ -40,6 +43,15 @@ const MODEL_METHODS = ["tin", "dtm", "nurbs", "implicit", "meshfree"] as const; const DEFAULT_FILTER = "csf"; const DEFAULT_METHOD = "dtm"; const ROUTE_STAGE = ROUTES.B05_PROFILE; +// 도엽 서피스 보간 방식 버튼 순서 — 백엔드 SHEET_SURFACE_METHODS와 같은 차례로 둔다. +const SHEET_METHOD_ORDER = [ + "tin_sheet", + "tin", + "biharmonic", + "anudem", + "multires", + "laplace", +]; function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; @@ -105,11 +117,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 +158,58 @@ 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"); + // 보간 방식 전환 줄 — 어느 방식이 이 지형에 맞는지 눈으로 비교해 정한다 + // (2026-08-30 사용자 지시). 버튼 목록은 실제 생성된 모델에서 만든다. + const sheetToolbar = document.createElement("div"); + sheetToolbar.className = "b04-surface__sheet-toolbar"; + const sheetMethodButtons = new Map(); + let sheetMethod = ""; + + function selectSheetMethod(method: string): void { + sheetMethod = method; + for (const [key, button] of sheetMethodButtons) { + button.classList.toggle("is-active", key === method); + } + const projectId = getProjectId(); + if (!projectId) return; + sheetViewer.setSelection(`sheet_${method}`, "dtm"); + sheetViewer.render(projectId, models); + } + + // 라이다 지표면 겹쳐 보기 — 확정 필터의 DTM을 반투명으로 얹는다. + const lidarLabel = document.createElement("label"); + lidarLabel.className = "toggle-label toggle-button b04-surface__sheet-lidar"; + const lidarCheck = document.createElement("input"); + lidarCheck.type = "checkbox"; + lidarLabel.append( + lidarCheck, + document.createTextNode(` ${L("B04_Surface_SheetLidar")}`), + ); + lidarCheck.addEventListener("change", () => { + void sheetViewer + .showOverlay( + lidarCheck.checked ? filterGroup.select.value : "", + "dtm", + terrainViewer.isSmoothingEnabled(), + ) + .then((loaded) => { + if (lidarCheck.checked && !loaded) { + showToast(L("B04_Surface_SheetLidar_Missing"), "warning"); + lidarCheck.checked = false; + } + }); + }); + + sheetSection.append(sheetTitle, sheetToolbar, sheetViewer.root); + sheetSection.hidden = true; let syncingCamera = false; viewer.onCameraChange((state) => { @@ -200,14 +270,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 +305,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 +340,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 +361,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 +372,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 +414,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 +428,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 +461,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 +472,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 +487,51 @@ export async function renderB04Surface(root: HTMLElement): Promise { } renderInputInfo(); updateSelectedModel(); + + // 도엽등고 3D 서피스 — sheet_* 모델이 있으면 별도 컨테이너로 보여준다. + // 보간 방식마다 모델이 하나씩 있으므로 버튼으로 갈아 끼운다. + const sheetMethods = models + .filter( + (model) => + model.model_type.toLowerCase() === "dtm" && + getModelFilter(model).startsWith("sheet_"), + ) + .map((model) => ({ + key: getModelFilter(model).slice("sheet_".length), + label: + typeof model.generation_params?.interpolation_label === "string" + ? (model.generation_params.interpolation_label as string) + : getModelFilter(model).slice("sheet_".length), + })) + // 모델 목록은 최신순이라 버튼이 뒤섞인다 — 정의 순서로 고정한다. + .sort( + (a, b) => + (SHEET_METHOD_ORDER.indexOf(a.key) + 1 || 99) - + (SHEET_METHOD_ORDER.indexOf(b.key) + 1 || 99), + ); + sheetSection.hidden = sheetMethods.length === 0; + if (sheetMethods.length) { + sheetToolbar.replaceChildren(); + sheetMethodButtons.clear(); + for (const method of sheetMethods) { + const button = document.createElement("button"); + button.type = "button"; + button.className = "b04-surface__sheet-method"; + button.textContent = method.label; + button.addEventListener("click", () => selectSheetMethod(method.key)); + sheetToolbar.append(button); + sheetMethodButtons.set(method.key, button); + } + // 스무딩 드롭다운과 라이다 토글은 오른쪽 끝에 함께 둔다. + sheetViewer.smoothingField.classList.add("b04-surface__sheet-smoothing"); + sheetToolbar.append(sheetViewer.smoothingField, lidarLabel); + sheetViewer.setSmoothing(true); + selectSheetMethod( + sheetMethods.some((method) => method.key === sheetMethod) + ? sheetMethod + : sheetMethods[0].key, + ); + } } async function onB04_Surface_Confirm_Click(): Promise { @@ -414,14 +558,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 +596,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..306c52cd 100644 --- a/B04_PreProcess/B04_PreProcess_UI_Style.css +++ b/B04_PreProcess/B04_PreProcess_UI_Style.css @@ -793,3 +793,65 @@ 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); +} + +/* 도엽 서피스 보간 방식 전환 줄 — 2026-08-30 */ +.b04-surface__sheet-toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-8); + margin: var(--spacing-12) 0; +} + +.b04-surface__sheet-method { + padding: 4px 10px; + font-size: var(--text-caption); + color: var(--color-text-body); + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-cards); + cursor: pointer; +} + +.b04-surface__sheet-method:hover { + border-color: var(--color-primary); +} + +.b04-surface__sheet-method.is-active { + color: var(--color-on-primary, #fff); + background: var(--color-primary); + border-color: var(--color-primary); +} + +.b04-surface__sheet-lidar { + margin-left: auto; +} + +/* 도엽 서피스 스무딩 드롭다운 — 방식 버튼 줄 오른쪽 끝 */ +.b04-surface__sheet-smoothing { + margin-left: auto; + display: flex; + align-items: center; + gap: var(--spacing-8); + font-size: var(--text-caption); +} + +.b04-surface__sheet-smoothing .b04-surface__select { + width: auto; + min-width: 96px; +} + +.b04-surface__sheet-toolbar .b04-surface__sheet-lidar { + margin-left: 0; +} diff --git a/B04_PreProcess/B04_PreProcess_UI_TerrainViewer.ts b/B04_PreProcess/B04_PreProcess_UI_TerrainViewer.ts index 655b8157..8f1d8fa3 100644 --- a/B04_PreProcess/B04_PreProcess_UI_TerrainViewer.ts +++ b/B04_PreProcess/B04_PreProcess_UI_TerrainViewer.ts @@ -6,7 +6,10 @@ import { API_BASE_URL } from "@config/config_frontend"; import { fetchCachedBytes, fetchCachedJson } from "../A00_Common/b_asset_cache"; import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import { createProgressCircle } from "@ui/ui_template_progress"; -import type { SurfaceBounds, SurfaceModelSummary } from "./B04_PreProcess_Api_Fetch"; +import type { + SurfaceBounds, + SurfaceModelSummary, +} from "./B04_PreProcess_Api_Fetch"; import { bindCursorPivotControls, bindSurfaceViewerTheme, @@ -18,6 +21,10 @@ import { type SurfaceCameraState, } from "./B04_PreProcess_UI_Camera"; +/** 화면에 띄우는 등고 라벨 상한 — 긴 등고선부터 채운다. 조각이 많은 지형에서 + * 라벨이 수백 개가 되면 매 프레임 위치 재계산이 화면을 멈춰 세운다(2026-08-30). */ +const MAX_CONTOUR_LABELS = 40; + function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; } @@ -31,6 +38,12 @@ export interface SurfaceTerrainViewer { render: (projectId: string, models: readonly SurfaceModelSummary[]) => void; setReferenceBounds: (bounds: SurfaceBounds) => void; setSelection: (sourceFilter: string, method: string) => void; + /** 다른 모델(예: 라이다 지표면)을 반투명으로 겹쳐 본다. 빈 문자열이면 걷어낸다. */ + showOverlay: ( + sourceFilter: string, + method: string, + smooth: boolean, + ) => Promise; applyCameraState: (state: SurfaceCameraState) => void; onCameraChange: (listener: (state: SurfaceCameraState) => void) => void; onAxesVisibilityChange: (listener: (visible: boolean) => void) => void; @@ -225,7 +238,12 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { scene.background = new THREE.Color(color); }); - const camera = new THREE.PerspectiveCamera(SURFACE_CAMERA_FOV, 1, 0.01, 100000); + const camera = new THREE.PerspectiveCamera( + SURFACE_CAMERA_FOV, + 1, + 0.01, + 100000, + ); const renderer = new THREE.WebGLRenderer({ canvas, antialias: true }); renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)); @@ -262,7 +280,8 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { function disposeObject(obj: THREE.Object3D) { obj.traverse((child) => { - const renderable = child as THREE.Mesh | THREE.Points | THREE.LineSegments; + const renderable = child as + THREE.Mesh | THREE.Points | THREE.LineSegments; renderable.geometry?.dispose(); const material = renderable.material; if (Array.isArray(material)) material.forEach((item) => item.dispose()); @@ -278,6 +297,76 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { } } + // ── 겹쳐 보기 메시 ───────────────────────────────────────────────────────── + // 도엽등고 서피스 위에 라이다 지표면을 겹쳐 두 지형을 눈으로 대조한다 + // (2026-08-30 사용자 지시). 본 메시와 카메라·좌표계를 공유하므로 같은 자리에 겹친다. + let overlayMesh: THREE.Object3D | null = null; + let overlayGeneration = 0; + + function clearOverlay() { + if (overlayMesh) { + scene.remove(overlayMesh); + disposeObject(overlayMesh); + overlayMesh = null; + } + } + + async function loadOverlay( + projectId: string, + models: readonly SurfaceModelSummary[], + sourceFilter: string, + method: string, + smooth: boolean, + ): Promise { + const generation = ++overlayGeneration; + clearOverlay(); + const match = models.find((model) => { + const configured = model.generation_params?.source_filter; + return ( + model.model_type.toLowerCase() === method.toLowerCase() && + typeof configured === "string" && + configured.toLowerCase() === sourceFilter.toLowerCase() + ); + }); + if (!match) return false; + const url = `${API_BASE_URL}/projects/${projectId}/surface/models/${match.id}/preview?smooth=${smooth}`; + try { + const buffer = await fetchCachedBytes(projectId, url); + if (generation !== overlayGeneration) return false; + return await new Promise((resolve) => { + new GLTFLoader().parse( + buffer, + "", + (gltf) => { + if (generation !== overlayGeneration) { + disposeObject(gltf.scene); + resolve(false); + return; + } + // 겹친 두 면을 구분하려고 반투명 단색으로 덮어씌운다. + gltf.scene.traverse((child) => { + if (child instanceof THREE.Mesh) { + child.material = new THREE.MeshStandardMaterial({ + color: 0x60a5fa, + transparent: true, + opacity: 0.45, + side: THREE.DoubleSide, + flatShading: false, + }); + } + }); + overlayMesh = gltf.scene; + scene.add(gltf.scene); + resolve(true); + }, + () => resolve(false), + ); + }); + } catch { + return false; + } + } + function clearContours() { while (contourGroup.children.length > 0) { const child = contourGroup.children[0]; @@ -303,8 +392,11 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { const fitCamera = (object: THREE.Object3D) => { const { span } = getFitParams(object); - const aspect = viewerArea.clientWidth / Math.max(viewerArea.clientHeight, 1); - const distance = referenceBounds ? getTopFitDistance(referenceBounds, aspect) : span * 1.2; + const aspect = + viewerArea.clientWidth / Math.max(viewerArea.clientHeight, 1); + const distance = referenceBounds + ? getTopFitDistance(referenceBounds, aspect) + : span * 1.2; controls.target.set(0, 0, 0); // 정확히 수직이면 lookAt이 화면 방향을 못 정해 첫 드래그에 화면이 뒤집힌다. camera.position.set(0, distance, distance * TOP_VIEW_TILT); @@ -366,12 +458,15 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { // model_type is TIN / DTM / NURBS / Implicit / Meshfree (we match activeMethod) // model_file_path contains the activeFilter (e.g. csf, pmf, grid_min_z) const match = currentModelsList.find((m) => { - const typeMatches = m.model_type.toLowerCase() === activeMethod.toLowerCase(); + const typeMatches = + m.model_type.toLowerCase() === activeMethod.toLowerCase(); const configuredFilter = m.generation_params?.source_filter; const filterMatches = (typeof configuredFilter === "string" && configuredFilter.toLowerCase() === activeFilter.toLowerCase()) || - Boolean(m.model_file_path?.toLowerCase().includes(activeFilter.toLowerCase())); + Boolean( + m.model_file_path?.toLowerCase().includes(activeFilter.toLowerCase()), + ); return typeMatches && filterMatches; }); @@ -382,7 +477,8 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { } const modelId = match.id; - const isSmooth = (activeMethod === "tin" || activeMethod === "dtm") && smoothingOn(); + const isSmooth = + (activeMethod === "tin" || activeMethod === "dtm") && smoothingOn(); currentModelId = modelId; currentModelSmooth = isSmooth; const generation = ++loadGeneration; @@ -429,7 +525,8 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { gltf.scene.traverse((child) => { if (child instanceof THREE.Mesh) { child.material.side = THREE.DoubleSide; - child.material.vertexColors = child.geometry.hasAttribute("color"); + child.material.vertexColors = + child.geometry.hasAttribute("color"); } }); gltf.scene.visible = surfCheck.checked; @@ -442,7 +539,8 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { }, () => { if (generation !== loadGeneration) return; - statusSpan.textContent = "3D 메쉬 파일이 없거나 로드할 수 없습니다."; + statusSpan.textContent = + "3D 메쉬 파일이 없거나 로드할 수 없습니다."; showProgress(null, null); }, ); @@ -498,6 +596,11 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { // 주곡선·보조곡선 두 덩어리로 합쳐 객체 2개만 만든다(2026-08-01). const majorPoints: THREE.Vector3[] = []; const minorPoints: THREE.Vector3[] = []; + const labelCandidates: { + level: number; + position: THREE.Vector3; + length: number; + }[] = []; data.contours.forEach((c: any) => { if (c.level < minH) minH = c.level; @@ -512,47 +615,63 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { bucket.push(points[i], points[i + 1]); } + // 라벨은 여기서 만들지 않고 후보만 모은다 — 등고선이 잘게 쪼개지면 조각마다 + // 라벨이 붙어 수백 개가 되고, 매 프레임 위치 재계산이 화면을 멈춰 세운다 + // (2026-08-30 사용자 보고). 아래에서 긴 것부터 상한만큼만 만든다. if (isMajor && points.length > 4) { - const labelPos = points[Math.floor(points.length / 2)]; - const labelDiv = document.createElement("div"); - labelDiv.className = "contour-label"; - labelDiv.innerText = `${Math.round(c.level)}m`; - labelDiv.style.position = "absolute"; - labelDiv.style.background = "rgba(255, 255, 255, 0.85)"; - labelDiv.style.border = "1px solid #d97706"; - labelDiv.style.color = "#b45309"; - labelDiv.style.padding = "1px 4px"; - labelDiv.style.borderRadius = "3px"; - labelDiv.style.fontSize = "9px"; - labelDiv.style.fontWeight = "bold"; - labelDiv.style.pointerEvents = "none"; - labelDiv.style.zIndex = "5"; - labelDiv.style.transform = "translate(-50%, -50%)"; - - (labelDiv as any).__updateLabelPos = () => { - if (!contourCheck.checked) { - labelDiv.style.display = "none"; - return; - } - const proj = labelPos.clone().project(camera); - const x = (proj.x * 0.5 + 0.5) * viewerArea.clientWidth; - const y = (-(proj.y * 0.5) + 0.5) * viewerArea.clientHeight; - - if (proj.z > 1) { - labelDiv.style.display = "none"; - } else { - labelDiv.style.display = "block"; - labelDiv.style.left = `${x}px`; - labelDiv.style.top = `${y}px`; - } - }; - - viewerArea.appendChild(labelDiv); - labelElements.push(labelDiv); - labelsDirty = true; + let length = 0; + for (let i = 0; i < points.length - 1; i++) { + length += points[i].distanceTo(points[i + 1]); + } + labelCandidates.push({ + level: c.level, + position: points[Math.floor(points.length / 2)], + length, + }); } }); + labelCandidates.sort((a, b) => b.length - a.length); + for (const candidate of labelCandidates.slice(0, MAX_CONTOUR_LABELS)) { + const labelPos = candidate.position; + const labelDiv = document.createElement("div"); + labelDiv.className = "contour-label"; + labelDiv.innerText = `${Math.round(candidate.level)}m`; + labelDiv.style.position = "absolute"; + labelDiv.style.background = "rgba(255, 255, 255, 0.85)"; + labelDiv.style.border = "1px solid #d97706"; + labelDiv.style.color = "#b45309"; + labelDiv.style.padding = "1px 4px"; + labelDiv.style.borderRadius = "3px"; + labelDiv.style.fontSize = "9px"; + labelDiv.style.fontWeight = "bold"; + labelDiv.style.pointerEvents = "none"; + labelDiv.style.zIndex = "5"; + labelDiv.style.transform = "translate(-50%, -50%)"; + + (labelDiv as any).__updateLabelPos = () => { + if (!contourCheck.checked) { + labelDiv.style.display = "none"; + return; + } + const proj = labelPos.clone().project(camera); + const x = (proj.x * 0.5 + 0.5) * viewerArea.clientWidth; + const y = (-(proj.y * 0.5) + 0.5) * viewerArea.clientHeight; + + if (proj.z > 1) { + labelDiv.style.display = "none"; + } else { + labelDiv.style.display = "block"; + labelDiv.style.left = `${x}px`; + labelDiv.style.top = `${y}px`; + } + }; + + viewerArea.appendChild(labelDiv); + labelElements.push(labelDiv); + labelsDirty = true; + } + // 합쳐 둔 점들을 주곡선·보조곡선 각각 한 덩어리로 올린다. [ { points: minorPoints, color: 0xf59e0b }, @@ -632,18 +751,26 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { if (terrainMesh && terrainMesh.visible) { scaleBar.hidden = false; const dist = camera.position.distanceTo(controls.target); - const metersPerPixel = targetPlaneMetersPerPixel(dist, viewerArea.clientHeight); + const metersPerPixel = targetPlaneMetersPerPixel( + dist, + viewerArea.clientHeight, + ); const roughMeters = 100 * metersPerPixel; const prettyMeters = niceScaleDistance(roughMeters); scaleBar.style.width = `${prettyMeters / metersPerPixel}px`; scaleLabel.textContent = - prettyMeters >= 1000 ? `${(prettyMeters / 1000).toFixed(0)} km` : `${prettyMeters} m`; + prettyMeters >= 1000 + ? `${(prettyMeters / 1000).toFixed(0)} km` + : `${prettyMeters} m`; } else { scaleBar.hidden = true; } // 등고 라벨 위치 — 화면이 실제로 움직였을 때만 다시 계산한다(매 프레임 재계산은 낭비). - if (labelsDirty || !cameraMatrixSnapshot.equals(camera.matrixWorldInverse)) { + if ( + labelsDirty || + !cameraMatrixSnapshot.equals(camera.matrixWorldInverse) + ) { labelsDirty = false; cameraMatrixSnapshot.copy(camera.matrixWorldInverse); labelElements.forEach((label) => { @@ -685,7 +812,8 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { intervalForm.addEventListener("submit", async (e) => { e.preventDefault(); const interval = Number(intervalInput.value); - if (!Number.isFinite(interval) || interval < 0.5 || currentModelId === null) return; + if (!Number.isFinite(interval) || interval < 0.5 || currentModelId === null) + return; intervalSubmit.disabled = true; await loadSelectedContours(currentModelId, currentModelSmooth, true); intervalSubmit.disabled = false; @@ -724,6 +852,19 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { activeMethod = method; syncSmoothingSupport(); }, + showOverlay(sourceFilter, method, smooth) { + if (!sourceFilter) { + clearOverlay(); + return Promise.resolve(false); + } + return loadOverlay( + currentProjectId, + currentModelsList, + sourceFilter, + method, + smooth, + ); + }, applyCameraState, onCameraChange(listener) { cameraListener = listener; @@ -739,7 +880,8 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { syncSmoothingSupport(); }, setContourInterval(interval) { - if (Number.isFinite(interval) && interval > 0) intervalInput.value = String(interval); + if (Number.isFinite(interval) && interval > 0) + intervalInput.value = String(interval); }, getContourInterval() { return Number.parseFloat(intervalInput.value); diff --git a/B05_Profile/B05_Profile_Engine_Sections.py b/B05_Profile/B05_Profile_Engine_Sections.py index c4b0defa..048ef50e 100644 --- a/B05_Profile/B05_Profile_Engine_Sections.py +++ b/B05_Profile/B05_Profile_Engine_Sections.py @@ -31,7 +31,7 @@ from common_util.common_util_drainage_pipes import ( PIPE_FACILITY_PIPE, PIPE_FACILITY_REVET, PipePoint, - parse_pipe_points, + load_pipe_points_file, pipe_anchor_clearances, route_signature, ) @@ -111,9 +111,17 @@ def _load_pipe_points(project_root: Path, polyline: list[list[float]]) -> list[P 계획선 정착과 구조물 측점 생성이 **같은 한 번의 읽기**를 쓴다 — 따로 읽으면 계획선이 물린 자리와 측점 자리가 어긋난다. - 관 지점 파일에는 저장 당시 노선 지문이 함께 있다 — 노선이 바뀌었으면 버린다 - (옛 노선의 배관 자리로 계획선을 앉히면 전부 어긋난다). 파일이 없거나 못 읽으면 빈 목록. + 관 지점 파일에는 저장 당시 노선 지문이 함께 있다. 지문이 달라도 저장분에 좌표가 있으면 + **이 계획선에 투영해 이월한다** — B04가 관 자리를 정한 선(계획노선 CSV)과 여기 계획선은 + 같은 자리를 지나면서 연장이 다르다(실측 350.11m vs 354.83m). 그래서 지문은 거의 항상 + 달랐고 관이 통째로 빠졌다(2026-08-30 사용자 지적). 좌표가 없는 구 저장분만 버린다. """ + vertices = [ + RouteVertex( + x=float(p[0]), y=float(p[1]), z=float(p[2]) if len(p) > 2 else 0.0, chainage_m=0.0 + ) + for p in polyline + ] path = ( project_root / "B04_PreProcess" @@ -123,25 +131,12 @@ def _load_pipe_points(project_root: Path, polyline: list[list[float]]) -> list[P ) if not path.is_file(): return [] - try: - document = json.loads(path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError): - logger.warning("B05 계획선: 관 지점 파일을 읽지 못했습니다 (%s)", path) + points = load_pipe_points_file(path, route_signature(vertices), vertices) + if points is None: + # 계획선 정착과 구조물 측점이 함께 빠지므로 남긴다(침묵 실패 금지). + logger.warning("B05 계획선: 저장된 관 지점을 쓰지 못했습니다 (좌표 없는 구 저장분).") return [] - vertices = [ - RouteVertex( - x=float(p[0]), y=float(p[1]), z=float(p[2]) if len(p) > 2 else 0.0, chainage_m=0.0 - ) - for p in polyline - ] - if str(document.get("route_signature") or "") != route_signature(vertices): - # 계획선 정착과 구조물 측점이 함께 빠지므로 버린 건수를 남긴다(침묵 실패 금지). - logger.warning( - "B05 계획선: 노선 지문이 달라 저장된 관 지점 %d건을 쓰지 않습니다.", - len(document.get("points") or []), - ) - return [] - return parse_pipe_points(document.get("points")) + return points def resolve_extra_stations( diff --git a/B05_Profile/B05_Profile_Engine_Solver.py b/B05_Profile/B05_Profile_Engine_Solver.py index 25f113d8..804d9c8d 100644 --- a/B05_Profile/B05_Profile_Engine_Solver.py +++ b/B05_Profile/B05_Profile_Engine_Solver.py @@ -23,6 +23,7 @@ from config.config_system import ( FOREST_ROAD_MAX_GRADE, FOREST_ROAD_MIN_CURVE_R_M, ROUTE_DEFAULT_GRADE_CLASS, + ROUTE_DIRECT_LINK_CELL_FACTOR, ROUTE_GRID_RES_M, ROUTE_MAX_COST_CELLS, ROUTE_MAX_GRADE, @@ -390,31 +391,43 @@ def solve_optimal_route( full_path_grid: list[tuple[int, int]] = [] segment_bounds: list[dict[str, Any]] = [] + direct_link_max_m = ROUTE_DIRECT_LINK_CELL_FACTOR * target_res + for i in range(len(sequence) - 1): pt_start = sequence[i] pt_end = sequence[i + 1] r_s, c_s, _ = get_grid_indices(pt_start) r_e, c_e, _ = get_grid_indices(pt_end) - segment = single_segment_dijkstra( - r_s, - c_s, - r_e, - c_e, - x_coords_sub, - y_coords_sub, - z_grid_sub, - valid_mask_sub, - dz_dx, - dz_dy, - ap_list, - weights, - max_grade, - target_res, - min_curve_radius_m, - max_uphill_grade, - max_downhill_grade, - ) + # 제어점 쌍이 문턱보다 가까우면 격자 탐색 없이 직결한다. 두 끝은 어차피 + # 원좌표로 되박히므로 이 구간 평면은 원청 계획노선 그대로 보존된다. + # 같은 칸에 스냅돼도 두 항목을 유지해 정점이 합쳐져 사라지지 않게 한다. + # 경사·곡선반경 제약은 이 구간에선 경고(curve_warning_segments)로만 남는다. + if ( + math.hypot(pt_end["x"] - pt_start["x"], pt_end["y"] - pt_start["y"]) + <= direct_link_max_m + ): + segment = [(r_s, c_s), (r_e, c_e)] + else: + segment = single_segment_dijkstra( + r_s, + c_s, + r_e, + c_e, + x_coords_sub, + y_coords_sub, + z_grid_sub, + valid_mask_sub, + dz_dx, + dz_dy, + ap_list, + weights, + max_grade, + target_res, + min_curve_radius_m, + max_uphill_grade, + max_downhill_grade, + ) if not segment: fp_note = "·금지구역(FP)" if fp_list else "" raise ValueError( diff --git a/B05_Profile/B05_Profile_Router_Confirm.py b/B05_Profile/B05_Profile_Router_Confirm.py index d324be50..14539e1f 100644 --- a/B05_Profile/B05_Profile_Router_Confirm.py +++ b/B05_Profile/B05_Profile_Router_Confirm.py @@ -96,6 +96,7 @@ async def sync_uphill_overrides_into_designs( update_cross_section_design, ) from B06_Section.B06_Section_Router import _read_cross_design_inputs + from B06_Section.B06_Section_Router_Design import ford_drop_at, ford_surface_drops if not overrides: return @@ -108,6 +109,7 @@ async def sync_uphill_overrides_into_designs( options = longitudinal["data"].get("options") if isinstance(options, dict): stored_standard = options.get("standard_cross_section") + ford_drops = ford_surface_drops(Path(project_root)) for record in designs: chainage = round(float(record["chainage_m"]), 3) side = by_chainage.get(chainage) @@ -136,6 +138,7 @@ async def sync_uphill_overrides_into_designs( rock_boundary_offset_m=design.get("rock_boundary_offset_m"), two_stage_slope=bool(design.get("two_stage_slope", True)), ditch_enabled=design.get("ditch_enabled"), + surface_drop_m=ford_drop_at(float(chainage), ford_drops), ) next_design["status"] = design.get("status", "provisional") next_design["pavement_suggested"] = design.get("pavement_suggested", pavement_suggested) diff --git a/B05_Profile/B05_Profile_UI_Drainage_Facility.ts b/B05_Profile/B05_Profile_UI_Drainage_Facility.ts index 6a9c33ad..a9f53b27 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Facility.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Facility.ts @@ -187,6 +187,10 @@ export interface FacilityOptionsForm { /** 독립 기슭막이 좌·우 칸의 이식 자리 — 배관 유입구·유출구 자리와 같은 몫이다. */ revetInletSlot: HTMLElement; revetOutletSlot: HTMLElement; + /** 세월교·BOX암거 날개벽 칸의 이식 자리 — 세월교 유입·유출 측벽을 고르면 이 칸이 + * 선다(2026-08-30 사용자 지시 1: 배관 측점·기슭막이와 같게). */ + wingInSlot: HTMLElement; + wingOutSlot: HTMLElement; /** 유입구 "구조"에 합쳐진 B06 형식 값(2026-08-29 지시 5). B06이 조정창 값과 맞춘다. */ inletStructure: () => InletStructureKind; setInletStructure: (value: InletStructureKind) => void; @@ -356,7 +360,9 @@ export function createFacilityOptionsForm( // ── 세월교 — 구체 내 배관은 배수관과 같은 관종·관경 칸을 쓰고(2026-08-17 사용자 // 지시) 수량만 따로 받는다. const fordCount = numberInput("1", "1", "련"); - const fordRow = grid(labeled("수량 (련)", fordCount)); + // 숫자 칸은 기슭막이·집수정과 같은 [-][값][+] 묶음(2026-08-30 사용자 지시 3). + // 련은 정수라 소수 자릿수를 두지 않는다. + const fordRow = grid(labeled("수량 (련)", stepper(fordCount, 1, 0))); // ── 물넘이·세월교 개략 단면 — 월류 폭 + 월류 높이 한 행(2026-08-18 사용자 지시). // 폭 기본값은 세월교 10m·물넘이 포장 5m(사용자 확정 — 지식DB 폭 수치 근거 없음). @@ -365,20 +371,24 @@ export function createFacilityOptionsForm( const fordWidth = numberInput("0.1"); const fordHeight = numberInput("0.01"); const fordWidthRow = grid( - labeled("월류 폭 (m)", fordWidth), - labeled("월류 높이 (m)", fordHeight), + labeled("월류 폭 (m)", stepper(fordWidth, 0.1)), + labeled("월류 높이 (m)", stepper(fordHeight, 0.1)), ); // 물넘이 바닥은 유입(상류)이 높고 유출이 낮게 기운다(2026-08-28 사용자 확정). // 비우면 그 측점의 **노면 횡단경사**를 그대로 쓴다 — 횡단도가 판단한다. const fordSlope = numberInput("0.1"); fordSlope.placeholder = "노면 기울기"; - const fordSlopeRow = grid(labeled("바닥 경사 유입→유출 (%)", fordSlope)); + const fordSlopeRow = grid(labeled("바닥 경사 유입→유출 (%)", stepper(fordSlope, 0.1))); const fordSummary = document.createElement("p"); fordSummary.className = "b05-drainage__facility-note"; /** 담당 유역 설계유량(㎥/s) — 개략 단면의 입력. 유역이 없으면 null. */ let designFlowM3s: number | null = null; /** 현재 조건(설계유량·월류 폭)의 필요 최소 수심(m). 계산 불가면 null. */ let fordMinDepthM: number | null = null; + /** 직전에 자동으로 채워 넣은 필요 수심(m) — 칸이 아직 그 값이면 "자동"으로 보고 + * 월류 폭이 바뀔 때마다 계산값을 계속 따라가게 한다. 사용자가 다른 값을 넣으면 + * 그때부터 그 값이 이긴다(2026-08-30 사용자: 폭을 바꿔도 높이가 안 따라온다). */ + let fordAutoDepthM: number | null = null; function syncFordSummary(): void { const section = @@ -387,10 +397,14 @@ export function createFacilityOptionsForm( : null; fordMinDepthM = section ? section.depthM : null; if (section) { - // 표시 정밀도(0.01m)로 맞춘 최소값 — 비었거나 그보다 작으면 계산값으로 채운다. + // 표시 정밀도(0.01m)로 맞춘 최소값 — 비었거나, 그보다 작거나, 아직 직전 + // 자동값 그대로면 계산값으로 다시 채운다. const min = Number(section.depthM.toFixed(2)); const current = Number.parseFloat(fordHeight.value); - if (!Number.isFinite(current) || current < min) fordHeight.value = min.toFixed(2); + const untouched = fordAutoDepthM !== null && Math.abs(current - fordAutoDepthM) < 0.005; + if (!Number.isFinite(current) || current < min || untouched) + fordHeight.value = min.toFixed(2); + fordAutoDepthM = min; } if (designFlowM3s === null) { fordSummary.textContent = @@ -423,8 +437,14 @@ export function createFacilityOptionsForm( emit(); }); + // 세월교·물넘이 항목은 **관종·관경 바로 다음**에 둔다(2026-08-30 사용자 지시 2) — + // 월류 폭·높이 → 바닥 경사 → 수량 → 개략 단면 결과. 다른 시설에서는 전부 숨는다. root.append( pipeRow, + fordWidthRow, + fordSlopeRow, + fordRow, + fordSummary, inletGroup.root, outletGroup.root, extraGroup.root, @@ -434,10 +454,6 @@ export function createFacilityOptionsForm( boxWrap, wingInFields.root, wingOutFields.root, - fordWidthRow, - fordSlopeRow, - fordRow, - fordSummary, ); let current: PipeFacility | null = null; @@ -537,6 +553,8 @@ export function createFacilityOptionsForm( extraSlot, revetInletSlot: revetInlet.slot, revetOutletSlot: revetOutlet.slot, + wingInSlot: wingInFields.slot, + wingOutSlot: wingOutFields.slot, setRevetSideLabels(labels) { revetSideLabels = labels; syncVisibility(); @@ -605,6 +623,8 @@ export function createFacilityOptionsForm( fordCount.value = isFord ? text("pipe_count") : ""; fordWidth.value = text("ford_width_m"); fordHeight.value = text("ford_height_m"); + // 새 시설을 올리는 참이다 — 저장된 높이는 사용자 값으로 보고 자동 추적을 끊는다. + fordAutoDepthM = null; fordSlope.value = text("ford_slope_pct"); revetSide.value = text("side") || "양쪽"; const spread = legacyRevetOptions(options); diff --git a/B05_Profile/B05_Profile_UI_Drainage_Facility_Fields.ts b/B05_Profile/B05_Profile_UI_Drainage_Facility_Fields.ts index 803c24a8..0bead6cd 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Facility_Fields.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Facility_Fields.ts @@ -84,7 +84,9 @@ export function numberInput(step: string, min = "0", placeholder = ""): HTMLInpu /** 스텝 묶음 입력에 붙일 일련번호 — 라벨이 가리킬 대상을 명시하는 데 쓴다. */ let stepperSeq = 0; -export function stepper(input: HTMLInputElement, stepM: number): HTMLElement { +/** `decimals` — 표시 자릿수. 련·각도처럼 정수로 세는 칸은 0을 준다(2026-08-30 사용자: + * 세월교 상세도 같은 양식으로 맞추되 "1.0련"·"45.0°"로 보이면 안 된다). */ +export function stepper(input: HTMLInputElement, stepM: number, decimals = 1): HTMLElement { const wrap = document.createElement("div"); wrap.className = "b05-structure__stepper"; // 라벨이 이 입력을 가리키게 id를 붙인다. 없으면