From 050a5a2ba377b9e2557c924a6e954b14ee6f95eb Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 18 Jul 2026 17:48:51 +0900 Subject: [PATCH 1/5] =?UTF-8?q?B05=20=ED=8E=98=EC=9D=B4=EC=A7=80=20?= =?UTF-8?q?=EC=B4=88=EC=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- B03_FileInput/B03_FileInput_Router.py | 152 +---- B03_FileInput/B03_FileInput_Service_WF1.py | 166 ++++++ B03_FileInput/B03_FileInput_UI_Page.ts | 16 +- B04_wf1_Surface/B04_wf1_Surface_Api_Fetch.ts | 8 +- B04_wf1_Surface/B04_wf1_Surface_Router.py | 47 +- B04_wf1_Surface/B04_wf1_Surface_Schema.py | 6 + B04_wf1_Surface/B04_wf1_Surface_Service.py | 54 ++ B04_wf1_Surface/B04_wf1_Surface_UI_Page.ts | 20 +- .../B04_wf1_Surface_UI_TerrainViewer.ts | 4 + B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts | 51 ++ B05_wf2_Route/B05_wf2_Route_Engine.py | 5 +- B05_wf2_Route/B05_wf2_Route_Repository.py | 57 +- B05_wf2_Route/B05_wf2_Route_Router.py | 51 +- B05_wf2_Route/B05_wf2_Route_Schema.py | 16 + B05_wf2_Route/B05_wf2_Route_UI_Markers.ts | 256 +++++++++ B05_wf2_Route/B05_wf2_Route_UI_Page.ts | 535 +++++++----------- B05_wf2_Route/B05_wf2_Route_UI_Panel.ts | 294 ++++++++++ B05_wf2_Route/B05_wf2_Route_UI_Style.css | 212 +++---- B05_wf2_Route/B05_wf2_Route_UI_Viewer.ts | 261 +++++++++ .../common_util_surface_confirmation.py | 101 ++++ config/config_system.py | 7 + db_management/001_create_schema.sql | 3 + db_management/009_route_points_xyz.sql | 7 + ui_template/ui_template_workflow_layout.ts | 20 + 24 files changed, 1749 insertions(+), 600 deletions(-) create mode 100644 B03_FileInput/B03_FileInput_Service_WF1.py create mode 100644 B04_wf1_Surface/B04_wf1_Surface_Service.py create mode 100644 B05_wf2_Route/B05_wf2_Route_UI_Markers.ts create mode 100644 B05_wf2_Route/B05_wf2_Route_UI_Panel.ts create mode 100644 B05_wf2_Route/B05_wf2_Route_UI_Viewer.ts create mode 100644 common_util/common_util_surface_confirmation.py create mode 100644 db_management/009_route_points_xyz.sql diff --git a/B03_FileInput/B03_FileInput_Router.py b/B03_FileInput/B03_FileInput_Router.py index 6a69697b..f69d088b 100644 --- a/B03_FileInput/B03_FileInput_Router.py +++ b/B03_FileInput/B03_FileInput_Router.py @@ -7,12 +7,10 @@ from typing import Any from uuid import UUID, uuid4 import aiomysql -from fastapi import APIRouter, File, Form, UploadFile +from fastapi import APIRouter, Depends, File, Form, UploadFile from fastapi.responses import JSONResponse from B03_FileInput.B03_FileInput_Email import ( - send_analysis_completion_email, - send_analysis_error_email, send_file_upload_complete_email, ) from B03_FileInput.B03_FileInput_Engine import ( @@ -44,20 +42,17 @@ from B03_FileInput.B03_FileInput_Schema import ( UploadFinalizeRequest, UploadStatusResponse, ) +from B03_FileInput.B03_FileInput_Service_WF1 import trigger_wf1_analysis_and_email +from common_util.common_util_auth import verify_session from common_util.common_util_json import atomic_write_json from common_util.common_util_storage import resolve_stored_project_path from common_util.common_util_workflow import load_project_workflow from common_util.common_util_workflow_state import ( complete_stage, - fail_stage, get_workflow_state, - start_stage, ) from config.config_db import get_db_pool from config.config_system import ( - SEND_ANALYSIS_COMPLETION_EMAIL, - SURFACE_MODEL_PRECOMPUTE, - SURFACE_MODEL_SOURCE_FILTERS, UPLOAD_CHUNK_SIZE_BYTES, UPLOAD_MAX_FILES, ) @@ -144,147 +139,11 @@ async def _send_upload_complete_notification( ) -async def trigger_wf1_analysis_and_email( - *, - project_id: UUID, - input_file_id: int, -) -> None: - """WF1 분석 실행, DB 저장, 완료/오류 이메일 발송을 백그라운드에서 수행한다. - - 실행 흐름: - 1. 프로젝트 저장 경로와 입력 파일 정보를 조회한다. - 2. 무거운 WF1 분석은 워커 스레드에서 실행한다. - 3. 분석 결과를 단일 DB 트랜잭션으로 저장한다. - 4. 완료 이메일은 SEND_ANALYSIS_COMPLETION_EMAIL 설정이 켜진 경우에만 발송한다. - 5. 분석 또는 DB 저장 실패 시 오류 이메일을 발송한다. - """ - pool = get_db_pool() - project_info: dict[str, Any] | None = None - try: - source_filters = list(SURFACE_MODEL_SOURCE_FILTERS) - methods = list(SURFACE_MODEL_PRECOMPUTE) - params = { - "input_file_id": str(input_file_id), - "source_filters": source_filters, - "methods": methods, - "force": False, - } - - async with pool.acquire() as connection: - async with connection.cursor() as cursor: - await start_stage(cursor, str(project_id), 1, params) - await connection.commit() - - stored_path = await get_project_storage_relative_path(connection, project_id) - project_info = await _get_project_notification_info(connection, project_id) - if not project_info or not project_info.get("user_email"): - logger.warning("WF1 분석 이메일 수신자 없음: project_id=%s", project_id) - - from B04_wf1_Surface.B04_wf1_Surface_Repository import get_input_file - - 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 파일을 찾을 수 없습니다.") - - logger.info( - "WF1 백그라운드 분석 시작: project_id=%s input_file_id=%s", - project_id, - input_file_id, - ) - - from B04_wf1_Surface.B04_wf1_Surface_Engine import run_surface_analysis - from B04_wf1_Surface.B04_wf1_Surface_Router import ( - save_surface_analysis_to_db, - write_surface_progress, - ) - - # 업로드 자동 분석도 progress.json에 진행률을 기록한다 (PLAN C-4) - write_surface_progress(project_root, 5, "analyzing", "WF1 분석을 시작합니다.") - - def _on_progress(percent: int, stage: str, message: str) -> None: - write_surface_progress(project_root, percent, stage, message) - - logger.info("WF1 분석 엔진 시작: las_path=%s", las_path) - analysis_result = await asyncio.to_thread( - run_surface_analysis, - project_root, - las_path, - source_filters=source_filters, - methods=methods, - force=False, - on_progress=_on_progress, - ) - logger.info("WF1 분석 엔진 완료: 모델 %d개 생성됨", len(analysis_result.get("models", []))) - - logger.info("WF1 분석 결과 DB 저장 시작: project_id=%s", project_id) - async with pool.acquire() as connection: - await connection.begin() - try: - await save_surface_analysis_to_db( - connection, - project_id=project_id, - input_file_id=input_file_id, - analysis_result=analysis_result, - source_filters=source_filters, - ) - async with connection.cursor() as cursor: - await complete_stage(cursor, str(project_id), 1) - await connection.commit() - logger.info("WF1 분석 결과 DB 저장 완료: project_id=%s", project_id) - except Exception as e: - logger.exception("WF1 분석 결과 DB 저장 실패: %s", e) - await connection.rollback() - raise - - write_surface_progress( - project_root, - 100, - "awaiting_confirmation", - "WF1 분석이 완료되었습니다. 사용할 모델을 확정하세요.", - ) - - logger.info( - "SEND_ANALYSIS_COMPLETION_EMAIL=%s, project_info=%s", - SEND_ANALYSIS_COMPLETION_EMAIL, - bool(project_info), - ) - if SEND_ANALYSIS_COMPLETION_EMAIL and project_info and project_info.get("user_email"): - logger.info("WF1 완료 이메일 발송 시작: to=%s", project_info["user_email"]) - await send_analysis_completion_email( - project_id=project_id, - project_name=str(project_info.get("project_name") or project_id), - to_email=str(project_info["user_email"]), - analysis_result=analysis_result, - ) - logger.info("WF1 완료 이메일 발송 완료: project_id=%s", project_id) - else: - logger.info( - "WF1 완료 이메일 발송 스킵: SEND_ANALYSIS_COMPLETION_EMAIL=%s, has_email=%s", - SEND_ANALYSIS_COMPLETION_EMAIL, - project_info and project_info.get("user_email") is not None, - ) - logger.info("WF1 백그라운드 분석 완료: project_id=%s", project_id) - except Exception as exc: - logger.exception("WF1 백그라운드 분석 실패: project_id=%s", project_id) - async with pool.acquire() as connection, connection.cursor() as cursor: - await fail_stage(cursor, str(project_id), 1, str(exc)) - await connection.commit() - if project_info and project_info.get("user_email"): - await send_analysis_error_email( - project_id=project_id, - project_name=str(project_info.get("project_name") or project_id), - to_email=str(project_info["user_email"]), - error_message=str(exc), - ) - - @router.post("/{project_id}/files", response_model=FileUploadResponse) async def upload_project_files( project_id: UUID, files: list[UploadFile] = File(...), + session: dict[str, Any] = Depends(verify_session), ) -> FileUploadResponse | JSONResponse: """프로젝트 입력 파일을 저장·분석하고 DB 메타데이터를 기록한다.""" if not files or len(files) > UPLOAD_MAX_FILES: @@ -391,6 +250,7 @@ async def upload_project_files( trigger_wf1_analysis_and_email( project_id=project_id, input_file_id=point_cloud_result.input_file_id, + user_role=str(session["role"]), ), task_name=f"b04-wf1-auto-{project_id}", ) @@ -525,6 +385,7 @@ async def upload_project_chunk( async def finalize_project_upload( project_id: UUID, payload: UploadFinalizeRequest, + session: dict[str, Any] = Depends(verify_session), ) -> FileUploadResponse | JSONResponse: """청크 업로드를 최종 병합하고 input_files 메타데이터를 기록한다.""" pool = get_db_pool() @@ -617,6 +478,7 @@ async def finalize_project_upload( trigger_wf1_analysis_and_email( project_id=project_id, input_file_id=result.input_file_id, + user_role=str(session["role"]), ), task_name=f"b04-wf1-auto-{project_id}", ) diff --git a/B03_FileInput/B03_FileInput_Service_WF1.py b/B03_FileInput/B03_FileInput_Service_WF1.py new file mode 100644 index 00000000..9444606f --- /dev/null +++ b/B03_FileInput/B03_FileInput_Service_WF1.py @@ -0,0 +1,166 @@ +"""B03 업로드 이후 WF1 백그라운드 분석·자동 확정 서비스.""" + +import asyncio +import logging +from pathlib import Path +from typing import Any +from uuid import UUID + +import aiomysql + +from B03_FileInput.B03_FileInput_Email import ( + send_analysis_completion_email, + send_analysis_error_email, +) +from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path +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_workflow_state import fail_stage, start_stage +from config.config_db import get_db_pool +from config.config_system import ( + SEND_ANALYSIS_COMPLETION_EMAIL, + SURFACE_MODEL_PRECOMPUTE, + SURFACE_MODEL_SOURCE_FILTERS, +) + +logger = logging.getLogger(__name__) + + +async def _get_project_notification_info( + connection: aiomysql.Connection, + project_id: UUID, +) -> dict[str, Any] | None: + async with connection.cursor(aiomysql.DictCursor) as cursor: + await cursor.execute( + """ + SELECT p.id, p.name AS project_name, u.email AS user_email, u.name AS user_name + FROM projects p + JOIN users u ON u.id = p.user_id + WHERE p.id = %s AND p.deleted_at IS NULL AND u.deleted_at IS NULL + """, + (str(project_id),), + ) + row = await cursor.fetchone() + return dict(row) if row else None + + +async def trigger_wf1_analysis_and_email( + *, + project_id: UUID, + input_file_id: int, + user_role: str, +) -> None: + """WF1 분석·DB 저장 후 역할에 따라 자동 확정하고 이메일을 발송한다.""" + pool = get_db_pool() + project_info: dict[str, Any] | None = None + try: + source_filters = list(SURFACE_MODEL_SOURCE_FILTERS) + methods = list(SURFACE_MODEL_PRECOMPUTE) + params = { + "input_file_id": str(input_file_id), + "source_filters": source_filters, + "methods": methods, + "force": False, + } + async with pool.acquire() as connection: + async with connection.cursor() as cursor: + await start_stage(cursor, str(project_id), 1, params) + await connection.commit() + stored_path = await get_project_storage_relative_path(connection, project_id) + project_info = await _get_project_notification_info(connection, project_id) + from B04_wf1_Surface.B04_wf1_Surface_Repository import get_input_file + + 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 파일을 찾을 수 없습니다.") + + from B04_wf1_Surface.B04_wf1_Surface_Engine import run_surface_analysis + from B04_wf1_Surface.B04_wf1_Surface_Repository import save_surface_analysis_to_db + from B04_wf1_Surface.B04_wf1_Surface_Router import write_surface_progress + + write_surface_progress(project_root, 5, "analyzing", "WF1 분석을 시작합니다.") + + def _on_progress(percent: int, stage: str, message: str) -> None: + write_surface_progress(project_root, percent, stage, message) + + 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, + ) + + auto_confirmation_error: str | None = None + auto_confirmed = False + async with pool.acquire() as connection: + await connection.begin() + try: + await save_surface_analysis_to_db( + connection, + project_id=project_id, + input_file_id=input_file_id, + analysis_result=analysis_result, + source_filters=source_filters, + ) + if user_role != "SYSTEM_ADMIN": + from B04_wf1_Surface.B04_wf1_Surface_Service import ( + confirm_surface_selection, + find_surface_model_for_selection, + ) + + selection = surface_confirmation_defaults() + try: + model_id = await find_surface_model_for_selection( + connection, project_id, selection + ) + except LookupError as exc: + auto_confirmation_error = str(exc) + logger.error( + "WF1 자동 확정 보류: project_id=%s reason=%s", + project_id, + auto_confirmation_error, + ) + else: + await confirm_surface_selection(connection, project_id, model_id, selection) + auto_confirmed = True + await connection.commit() + except Exception: + await connection.rollback() + raise + + if auto_confirmation_error: + progress_stage = "awaiting_confirmation" + progress_message = f"WF1 자동 확정 보류 — {auto_confirmation_error}" + elif auto_confirmed: + progress_stage = "completed" + progress_message = "지표면 모델 자동 확정 완료 — 노선 설계로 이동하세요." + else: + progress_stage = "awaiting_confirmation" + progress_message = "WF1 분석이 완료되었습니다. 사용할 모델을 확정하세요." + write_surface_progress(project_root, 100, progress_stage, progress_message) + + if SEND_ANALYSIS_COMPLETION_EMAIL and project_info and project_info.get("user_email"): + await send_analysis_completion_email( + project_id=project_id, + project_name=str(project_info.get("project_name") or project_id), + to_email=str(project_info["user_email"]), + analysis_result=analysis_result, + ) + except Exception as exc: + logger.exception("WF1 백그라운드 분석 실패: project_id=%s", project_id) + async with pool.acquire() as connection, connection.cursor() as cursor: + await fail_stage(cursor, str(project_id), 1, str(exc)) + await connection.commit() + if project_info and project_info.get("user_email"): + await send_analysis_error_email( + project_id=project_id, + project_name=str(project_info.get("project_name") or project_id), + to_email=str(project_info["user_email"]), + error_message=str(exc), + ) diff --git a/B03_FileInput/B03_FileInput_UI_Page.ts b/B03_FileInput/B03_FileInput_UI_Page.ts index 2ee48e66..7b618c41 100644 --- a/B03_FileInput/B03_FileInput_UI_Page.ts +++ b/B03_FileInput/B03_FileInput_UI_Page.ts @@ -12,6 +12,7 @@ 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 { fetchDashboardMe } from "../B01_Dashboard/B01_Dashboard_Api_Fetch"; import { checkWF1AnalysisStatus, createUploadSession, @@ -50,6 +51,9 @@ function L(key: keyof typeof ui_locales): string { } export async function renderB03FileInput(root: HTMLElement): Promise { + const dashboardUser = await fetchDashboardMe(); + const completionRoute = + dashboardUser.role === "SYSTEM_ADMIN" ? ROUTES.B04_WF1_SURFACE : ROUTES.B05_WF2_ROUTE; const slots = initializeSlots(); const cardMap = new Map(); const resultList = document.createElement("ul"); @@ -441,6 +445,14 @@ export async function renderB03FileInput(root: HTMLElement): Promise { if (status.status === "completed") { return true; } + if ( + status.current_stage === "awaiting_confirmation" && + status.message.includes("자동 확정 보류") + ) { + pageError.textContent = status.message; + showToast(status.message, "warning"); + return false; + } } catch {} await new Promise((resolve) => setTimeout(resolve, 5000)); } @@ -468,7 +480,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise { const analysisComplete = await pollWF1Analysis(activeProjectId); if (analysisComplete) { - navigateTo(ROUTES.B04_WF1_SURFACE); + navigateTo(completionRoute); } else { showToast(L("B03_File_Analysis_StillRunning"), "warning"); } @@ -576,7 +588,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise { label: L("B03_File_Restore_State"), container: resumeBanner, poll: pollWF1Analysis, - onComplete: () => navigateTo(ROUTES.B04_WF1_SURFACE), + onComplete: () => navigateTo(completionRoute), }); } } diff --git a/B04_wf1_Surface/B04_wf1_Surface_Api_Fetch.ts b/B04_wf1_Surface/B04_wf1_Surface_Api_Fetch.ts index 2a69aec3..edcd77f8 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_Api_Fetch.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_Api_Fetch.ts @@ -37,6 +37,11 @@ export interface SurfaceConfirmResponse { confirmed: boolean; } +export interface SurfaceConfirmOptions { + smooth: boolean; + contour_interval_m: number; +} + /** 저장된 지표면 모델 요약 (SurfaceModelSummary) */ export interface SurfaceModelSummary { id: number; @@ -152,10 +157,11 @@ export async function listSurfaceModels(projectId: string): Promise { return requestJson(`/projects/${projectId}/surface/confirm`, { method: "POST", - body: JSON.stringify({ model_id: modelId }), + body: JSON.stringify({ model_id: modelId, ...options }), }); } diff --git a/B04_wf1_Surface/B04_wf1_Surface_Router.py b/B04_wf1_Surface/B04_wf1_Surface_Router.py index f6f8c23b..c00175f4 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_Router.py +++ b/B04_wf1_Surface/B04_wf1_Surface_Router.py @@ -9,7 +9,7 @@ from uuid import UUID import aiomysql import numpy as np -from fastapi import APIRouter +from fastapi import APIRouter, Depends from fastapi.responses import FileResponse, JSONResponse from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path @@ -20,7 +20,6 @@ from B04_wf1_Surface.B04_wf1_Surface_Engine import ( ) from B04_wf1_Surface.B04_wf1_Surface_Repository import ( clear_confirmed_surface_models, - confirm_surface_model, get_input_file, list_project_point_cloud_inputs, list_surface_models, @@ -38,10 +37,12 @@ from B04_wf1_Surface.B04_wf1_Surface_Schema import ( SurfaceModelSummary, SurfacePointCloudSampleResponse, ) +from B04_wf1_Surface.B04_wf1_Surface_Service import confirm_surface_selection +from common_util.common_util_auth import require_system_admin from common_util.common_util_json import atomic_write_json 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_workflow_state import ( - complete_stage, fail_stage, start_stage, update_stage_progress, @@ -205,7 +206,9 @@ async def get_surface_models(project_id: UUID) -> SurfaceModelListResponse | JSO @router.post("/{project_id}/surface/confirm", response_model=SurfaceConfirmResponse) async def confirm_surface( - project_id: UUID, request: SurfaceConfirmRequest + project_id: UUID, + request: SurfaceConfirmRequest, + _session: dict[str, Any] = Depends(require_system_admin), ) -> SurfaceConfirmResponse | JSONResponse: """사용자가 선택한 지표면 모델을 확정하고 WF1을 완료한다.""" pool = get_db_pool() @@ -213,9 +216,39 @@ async def confirm_surface( async with pool.acquire() as connection: await connection.begin() try: - await confirm_surface_model(connection, project_id, request.model_id) - async with connection.cursor() as cursor: - await complete_stage(cursor, str(project_id), 1) + models = await list_surface_models(connection, project_id) + selected_model = next( + (model for model in models if model["id"] == request.model_id), + None, + ) + if selected_model is None: + raise LookupError("확정할 지표면 모델을 찾을 수 없습니다.") + generation_params = selected_model.get("generation_params") or {} + source_filter = generation_params.get("source_filter") + if not source_filter: + raise LookupError("선택한 모델의 지면 필터 정보를 찾을 수 없습니다.") + + selection = surface_confirmation_defaults() + selection.update( + { + "source_filter": str(source_filter), + "method": str(selected_model["model_type"]), + "smooth": ( + request.smooth if request.smooth is not None else selection["smooth"] + ), + "contour_interval_m": ( + request.contour_interval_m + if request.contour_interval_m is not None + else selection["contour_interval_m"] + ), + } + ) + await confirm_surface_selection( + connection, + project_id, + request.model_id, + selection, + ) await connection.commit() except Exception: await connection.rollback() diff --git a/B04_wf1_Surface/B04_wf1_Surface_Schema.py b/B04_wf1_Surface/B04_wf1_Surface_Schema.py index b2f32020..13605251 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_Schema.py +++ b/B04_wf1_Surface/B04_wf1_Surface_Schema.py @@ -48,6 +48,12 @@ class SurfaceConfirmRequest(BaseModel): model_config = ConfigDict(extra="forbid") model_id: int = Field(gt=0, description="확정할 surface_models.id") + smooth: bool | None = Field(default=None, description="확정 시 적용한 스무딩 여부") + contour_interval_m: float | None = Field( + default=None, + gt=0, + description="확정 시 적용한 등고선 간격(m)", + ) class SurfaceConfirmResponse(BaseModel): diff --git a/B04_wf1_Surface/B04_wf1_Surface_Service.py b/B04_wf1_Surface/B04_wf1_Surface_Service.py new file mode 100644 index 00000000..2a89dd8f --- /dev/null +++ b/B04_wf1_Surface/B04_wf1_Surface_Service.py @@ -0,0 +1,54 @@ +"""B04 지표면 모델 확정 서비스.""" + +from typing import Any +from uuid import UUID + +import aiomysql + +from B04_wf1_Surface.B04_wf1_Surface_Repository import ( + confirm_surface_model, + list_surface_models, +) +from common_util.common_util_surface_confirmation import ( + merge_surface_confirmation_params, +) +from common_util.common_util_workflow_state import complete_stage + + +async def find_surface_model_for_selection( + connection: aiomysql.Connection, + project_id: UUID, + selection: dict[str, Any], +) -> int: + """필터·표현 기본값과 일치하는 COMPLETE 모델 ID를 찾는다.""" + for model in await list_surface_models(connection, project_id): + generation_params = model.get("generation_params") or {} + if ( + model["status"] == "COMPLETE" + and model["model_type"].lower() == str(selection["method"]).lower() + and str(generation_params.get("source_filter", "")).lower() + == str(selection["source_filter"]).lower() + ): + return int(model["id"]) + raise LookupError( + "자동 확정 기본값과 일치하는 지표면 모델이 없습니다: " + f"filter={selection['source_filter']}, method={selection['method']}" + ) + + +async def confirm_surface_selection( + connection: aiomysql.Connection, + project_id: UUID, + model_id: int, + selection: dict[str, Any], +) -> dict[str, Any]: + """모델 확정, 선택값 스냅샷, WF1 완료를 현재 트랜잭션에서 처리한다.""" + await confirm_surface_model(connection, project_id, model_id) + normalized = await merge_surface_confirmation_params( + connection, + str(project_id), + selection, + ) + async with connection.cursor() as cursor: + await complete_stage(cursor, str(project_id), 1) + return normalized diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_Page.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_Page.ts index 803a44f0..4b2d5a50 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_Page.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_Page.ts @@ -8,6 +8,7 @@ import { showToast, } from "@ui/ui_template_elements"; import { createWorkflowLayout } from "@ui/ui_template_workflow_layout"; +import { fetchDashboardMe } from "../B01_Dashboard/B01_Dashboard_Api_Fetch"; import { workflowSteps } from "../A00_Common/b_page_scaffold"; import { fetchWorkflowState, @@ -83,6 +84,20 @@ function getModelFilter(model: SurfaceModelSummary): string { } export async function renderB04Surface(root: HTMLElement): Promise { + const guardedProjectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); + 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); + goToWorkflowStage( + guardedProjectId, + surfaceStage?.state === "COMPLETE" ? ROUTES.B05_WF2_ROUTE : ROUTES.B03_FILE_INPUT, + ); + return; + } + } + let selectedInputFile: SurfaceInputFileSummary | null = null; let inputFiles: SurfaceInputFileSummary[] = []; let models: SurfaceModelSummary[] = []; @@ -332,7 +347,10 @@ export async function renderB04Surface(root: HTMLElement): Promise { } showLoadingOverlay(); try { - await confirmSurfaceModel(projectId, model.id); + await confirmSurfaceModel(projectId, model.id, { + smooth: terrainViewer.isSmoothingEnabled(), + contour_interval_m: terrainViewer.getContourInterval(), + }); showToast( L("B04_Surface_Confirm_Success") .replace("{filter}", filterGroup.select.value) diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_TerrainViewer.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_TerrainViewer.ts index c111eee0..4d34198f 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_TerrainViewer.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_TerrainViewer.ts @@ -22,6 +22,7 @@ export interface SurfaceTerrainViewer { onCameraChange: (listener: (state: SurfaceCameraState) => void) => void; onAxesVisibilityChange: (listener: (visible: boolean) => void) => void; isSmoothingEnabled: () => boolean; + getContourInterval: () => number; resetOptions: () => void; dispose: () => void; } @@ -650,6 +651,9 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { isSmoothingEnabled() { return !smoothCheck.disabled && smoothCheck.checked; }, + getContourInterval() { + return Number.parseFloat(intervalInput.value); + }, resetOptions() { axesCheck.checked = false; axes.visible = false; diff --git a/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts b/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts index ab65c699..8f23d88e 100644 --- a/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts +++ b/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts @@ -17,9 +17,14 @@ import { API_BASE_URL, API_TIMEOUT_MS } from "@config/config_frontend"; export interface RoutePoint { x: number; y: number; + z?: number; order?: number; } +export interface CirclePoint extends RoutePoint { + radius_m: number; +} + /** 경로 탐색 실행 요청 (RouteSolveRequest) */ export interface RouteSolveRequest { filter_key: string; @@ -30,10 +35,16 @@ export interface RouteSolveRequest { bp: RoutePoint; ep: RoutePoint; cp?: RoutePoint[]; + ap?: CirclePoint[]; + fp?: CirclePoint[]; grade_class?: string; + paved?: boolean; min_curve_radius_m?: number | null; max_uphill_grade?: number | null; max_downhill_grade?: number | null; + min_uphill_grade?: number | null; + min_downhill_grade?: number | null; + allow_avoid_pass_through?: boolean; } /** 경로 탐색 실행 결과 (RouteSolveResponse) */ @@ -55,6 +66,40 @@ export interface RouteConfirmResponse { confirmed: boolean; } +export interface RouteLatestResponse { + status: string; + project_id: string; + route: { + id: number; + status: string; + surface_model_id: number | null; + total_length_m: number | null; + min_slope: number | null; + max_slope: number | null; + mean_slope: number | null; + cost_score: number | null; + algorithm_params?: Record | null; + } | null; + route_points: Array; + surface_params: { + source_filter: string; + method: string; + smooth: boolean; + contour_interval_m: number; + }; + route_params: { + points?: { + bp?: RoutePoint | null; + ep?: RoutePoint | null; + cp?: RoutePoint[]; + ap?: CirclePoint[]; + fp?: CirclePoint[]; + }; + options?: Record; + algorithm?: string; + } | null; +} + /** 공통 fetch 헬퍼: 타임아웃 + 인증 헤더 + 오류 응답 변환. */ async function requestJson(path: string, init: RequestInit): Promise { const controller = new AbortController(); @@ -96,3 +141,9 @@ export async function confirmRoute(projectId: string): Promise { + return requestJson(`/projects/${projectId}/route/latest`, { + method: "GET", + }); +} diff --git a/B05_wf2_Route/B05_wf2_Route_Engine.py b/B05_wf2_Route/B05_wf2_Route_Engine.py index 11d3ce09..0b751b09 100644 --- a/B05_wf2_Route/B05_wf2_Route_Engine.py +++ b/B05_wf2_Route/B05_wf2_Route_Engine.py @@ -45,7 +45,7 @@ def _sample_render_points( points: list[dict[str, Any]] = [] for seq, idx in enumerate(indices): idx = min(idx, n - 1) - _, _, z = polyline[idx] + x, y, z = polyline[idx] # 국소 경사(%) — 직전 정점과의 차이 slope_pct = 0.0 if idx > 0: @@ -56,6 +56,9 @@ def _sample_render_points( slope_pct = abs(z1 - z0) / h * 100.0 points.append( { + "x": round(x, 3), + "y": round(y, 3), + "z": round(z, 3), "chainage_m": round(chainage_m[idx], 3) if idx < len(chainage_m) else None, "elevation_m": round(z, 3), "slope_percent": round(slope_pct, 3), diff --git a/B05_wf2_Route/B05_wf2_Route_Repository.py b/B05_wf2_Route/B05_wf2_Route_Repository.py index 7438325b..44f5ce45 100644 --- a/B05_wf2_Route/B05_wf2_Route_Repository.py +++ b/B05_wf2_Route/B05_wf2_Route_Repository.py @@ -79,13 +79,16 @@ async def insert_route_points( ) -> int: """경로 렌더링 샘플 포인트를 일괄 저장하고 저장 건수를 반환한다. - 각 point dict: {chainage_m, elevation_m, slope_percent, sequence_num} + 각 point dict: {x, y, z, chainage_m, elevation_m, slope_percent, sequence_num} """ if not points: return 0 rows = [ ( route_id, + point.get("x"), + point.get("y"), + point.get("z"), point.get("chainage_m"), point.get("elevation_m"), point.get("slope_percent"), @@ -97,9 +100,10 @@ async def insert_route_points( await cursor.executemany( """ INSERT INTO route_points ( - route_id, chainage_m, elevation_m, slope_percent, sequence_num + route_id, model_x, model_y, model_z, + chainage_m, elevation_m, slope_percent, sequence_num ) - VALUES (%s, %s, %s, %s, %s) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s) """, rows, ) @@ -149,13 +153,16 @@ async def get_latest_route( connection: aiomysql.Connection, project_id: UUID ) -> dict[str, Any] | None: """프로젝트의 최신 경로를 조회한다 (없으면 None).""" - async with connection.cursor() as cursor: + async with connection.cursor(aiomysql.DictCursor) as cursor: await cursor.execute( """ - SELECT id, status, total_length_m, route_data_path, computed_at - FROM routes - WHERE project_id = %s - ORDER BY computed_at DESC, id DESC + SELECT r.id, r.status, r.surface_model_id, r.total_length_m, + r.route_data_path, r.constraints, r.algorithm_params, r.computed_at, + rs.min_slope, rs.max_slope, rs.mean_slope, rs.cost_score + FROM routes r + LEFT JOIN route_statistics rs ON rs.route_id = r.id + WHERE r.project_id = %s + ORDER BY r.computed_at DESC, r.id DESC LIMIT 1 """, (str(project_id),), @@ -163,13 +170,33 @@ async def get_latest_route( row = await cursor.fetchone() if not row: return None - return { - "id": int(row[0]), - "status": row[1], - "total_length_m": row[2], - "route_data_path": row[3], - "computed_at": row[4].isoformat() if row[4] else None, - } + result = dict(row) + result["id"] = int(result["id"]) + result["computed_at"] = result["computed_at"].isoformat() if result["computed_at"] else None + for key in ("constraints", "algorithm_params"): + if isinstance(result.get(key), str): + result[key] = json.loads(result[key]) + return result + + +async def get_route_points( + connection: aiomysql.Connection, + route_id: int, +) -> list[dict[str, Any]]: + """최신 경로의 DB 렌더 좌표를 순서대로 조회한다.""" + async with connection.cursor(aiomysql.DictCursor) as cursor: + await cursor.execute( + """ + SELECT model_x AS x, model_y AS y, model_z AS z, + chainage_m, elevation_m, slope_percent, sequence_num + FROM route_points + WHERE route_id = %s + ORDER BY sequence_num ASC, id ASC + """, + (route_id,), + ) + rows = await cursor.fetchall() + return [dict(row) for row in rows] async def confirm_route(connection: aiomysql.Connection, route_id: int) -> None: diff --git a/B05_wf2_Route/B05_wf2_Route_Router.py b/B05_wf2_Route/B05_wf2_Route_Router.py index 50ffe89d..2b3aa4f7 100644 --- a/B05_wf2_Route/B05_wf2_Route_Router.py +++ b/B05_wf2_Route/B05_wf2_Route_Router.py @@ -5,6 +5,7 @@ import logging from pathlib import Path from uuid import UUID +import aiomysql from fastapi import APIRouter from fastapi.responses import JSONResponse @@ -15,15 +16,23 @@ from B05_wf2_Route.B05_wf2_Route_Repository import ( create_route, create_route_statistics, get_latest_route, + get_route_points, insert_route_points, ) from B05_wf2_Route.B05_wf2_Route_Schema import ( RouteConfirmResponse, + RouteLatestResponse, RouteSolveRequest, RouteSolveResponse, ) from common_util.common_util_storage import resolve_stored_project_path -from common_util.common_util_workflow_state import complete_stage, fail_stage, start_stage +from common_util.common_util_surface_confirmation import get_surface_confirmation_params +from common_util.common_util_workflow_state import ( + complete_stage, + fail_stage, + get_workflow_state, + start_stage, +) from config.config_db import get_db_pool logger = logging.getLogger(__name__) @@ -41,7 +50,7 @@ async def solve_route( "filter_key": request.filter_key, "method": request.method, "smooth": request.smooth, - "points": request.points, + "points": request.points_data(), "options": request.options(), "algorithm": request.algorithm, "surface_model_id": request.surface_model_id, @@ -79,7 +88,11 @@ async def solve_route( end_chainage_m=metrics.get("length_m"), grade_percent=design["grade_percent"], constraints=design["constraints"], - algorithm_params=design["algorithm_params"], + algorithm_params={ + **design["algorithm_params"], + "metrics": metrics, + "curve_warning_segments": solver.get("curve_warning_segments", []), + }, route_data_path=design["route_data_path"], ) await insert_route_points(connection, route_id, design["render_points"]) @@ -92,8 +105,6 @@ async def solve_route( mean_slope=stats["mean_slope"], cost_score=stats["cost_score"], ) - async with connection.cursor() as cursor: - await complete_stage(cursor, str(project_id), 2) await connection.commit() except Exception: await connection.rollback() @@ -133,6 +144,36 @@ async def solve_route( ) +@router.get("/{project_id}/route/latest", response_model=RouteLatestResponse) +async def read_latest_route(project_id: UUID) -> RouteLatestResponse | JSONResponse: + """최신 경로와 DB 렌더 좌표, WF1/WF2 입력 스냅샷을 반환한다.""" + pool = get_db_pool() + try: + async with pool.acquire() as connection: + latest = await get_latest_route(connection, project_id) + route_points = await get_route_points(connection, latest["id"]) if latest else [] + surface_params = await get_surface_confirmation_params(connection, str(project_id)) + async with connection.cursor(aiomysql.DictCursor) as cursor: + workflow = await get_workflow_state(cursor, str(project_id)) + route_stage = next( + (stage for stage in workflow["stages"] if stage["stage_no"] == 2), + None, + ) + return RouteLatestResponse( + project_id=str(project_id), + route=latest, + route_points=route_points, + surface_params=surface_params, + route_params=route_stage.get("params") if route_stage else None, + ) + except Exception: + logger.exception("B05 최신 경로 조회 실패: project_id=%s", project_id) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "최신 경로 조회 중 오류가 발생했습니다."}, + ) + + @router.post("/{project_id}/route/confirm", response_model=RouteConfirmResponse) async def confirm_latest_route(project_id: UUID) -> RouteConfirmResponse | JSONResponse: """프로젝트의 최신 경로를 확정(CONFIRMED)한다.""" diff --git a/B05_wf2_Route/B05_wf2_Route_Schema.py b/B05_wf2_Route/B05_wf2_Route_Schema.py index 6c4abb92..bd4ec272 100644 --- a/B05_wf2_Route/B05_wf2_Route_Schema.py +++ b/B05_wf2_Route/B05_wf2_Route_Schema.py @@ -14,6 +14,7 @@ class RoutePoint(BaseModel): x: float y: float + z: float | None = None order: int | None = None @@ -49,6 +50,8 @@ class RouteSolveRequest(BaseModel): min_curve_radius_m: float | None = None max_uphill_grade: float | None = None max_downhill_grade: float | None = None + min_uphill_grade: float | None = None + min_downhill_grade: float | None = None weights: dict[str, float] | None = None allow_avoid_pass_through: bool = Field(default=False) @@ -76,6 +79,8 @@ class RouteSolveRequest(BaseModel): "min_curve_radius_m": self.min_curve_radius_m, "max_uphill_grade": self.max_uphill_grade, "max_downhill_grade": self.max_downhill_grade, + "min_uphill_grade": self.min_uphill_grade, + "min_downhill_grade": self.min_downhill_grade, "weights": self.weights, "allow_avoid_pass_through": self.allow_avoid_pass_through, } @@ -100,3 +105,14 @@ class RouteConfirmResponse(BaseModel): project_id: str route_id: int confirmed: bool = True + + +class RouteLatestResponse(BaseModel): + """새로고침 복원을 위한 최신 경로·입력·통계 응답.""" + + status: str = "success" + project_id: str + route: dict[str, Any] | None = None + route_points: list[dict[str, Any]] = Field(default_factory=list) + surface_params: dict[str, Any] + route_params: dict[str, Any] | None = None diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Markers.ts b/B05_wf2_Route/B05_wf2_Route_UI_Markers.ts new file mode 100644 index 00000000..a8d5750a --- /dev/null +++ b/B05_wf2_Route/B05_wf2_Route_UI_Markers.ts @@ -0,0 +1,256 @@ +import * as THREE from "three"; + +export type RoutePointKind = "bp" | "ep" | "cp" | "ap" | "fp"; + +export interface PlacedRoutePoint { + id: string; + type: RoutePointKind; + x: number; + y: number; + z: number; + radius_m?: number; +} + +export interface RouteDesignPoints { + bp: PlacedRoutePoint | null; + ep: PlacedRoutePoint | null; + cp: PlacedRoutePoint[]; + ap: PlacedRoutePoint[]; + fp: PlacedRoutePoint[]; +} + +export interface ModelBounds { + x: [number, number]; + y: [number, number]; + z: [number, number]; +} + +const COLORS: Record = { + bp: 0x10b981, + ep: 0xef4444, + cp: 0xf59e0b, + ap: 0x64748b, + fp: 0xdc2626, +}; + +function emptyPoints(): RouteDesignPoints { + return { bp: null, ep: null, cp: [], ap: [], fp: [] }; +} + +function disposeGroup(group: THREE.Group): void { + group.traverse((object) => { + if (object instanceof THREE.Mesh || object instanceof THREE.Line) { + object.geometry.dispose(); + const materials = Array.isArray(object.material) ? object.material : [object.material]; + materials.forEach((material) => material.dispose()); + } + }); + group.clear(); +} + +export function modelToScene(point: Pick, bounds: ModelBounds) { + const cx = (bounds.x[0] + bounds.x[1]) / 2; + const cy = (bounds.y[0] + bounds.y[1]) / 2; + const cz = (bounds.z[0] + bounds.z[1]) / 2; + return new THREE.Vector3(point.x - cx, point.z - cz, -(point.y - cy)); +} + +export function sceneToModel(point: THREE.Vector3, bounds: ModelBounds) { + const cx = (bounds.x[0] + bounds.x[1]) / 2; + const cy = (bounds.y[0] + bounds.y[1]) / 2; + const cz = (bounds.z[0] + bounds.z[1]) / 2; + return { x: point.x + cx, y: -point.z + cy, z: point.y + cz }; +} + +export function createRouteMarkers(scene: THREE.Scene, getBounds: () => ModelBounds | null) { + const markerGroup = new THREE.Group(); + const routeGroup = new THREE.Group(); + scene.add(markerGroup, routeGroup); + let points = emptyPoints(); + let selectedId: string | null = null; + let changeListener: ((points: RouteDesignPoints) => void) | undefined; + let selectionListener: ((point: PlacedRoutePoint | null) => void) | undefined; + + function allPoints(): PlacedRoutePoint[] { + return [points.bp, points.ep, ...points.cp, ...points.ap, ...points.fp].filter( + (point): point is PlacedRoutePoint => Boolean(point), + ); + } + + function selected(): PlacedRoutePoint | null { + return allPoints().find((point) => point.id === selectedId) ?? null; + } + + function renderMarkers(): void { + disposeGroup(markerGroup); + const bounds = getBounds(); + if (!bounds) return; + allPoints().forEach((point) => { + const material = new THREE.MeshBasicMaterial({ color: COLORS[point.type] }); + const marker = new THREE.Mesh(new THREE.SphereGeometry(1.6, 18, 12), material); + marker.position.copy(modelToScene(point, bounds)); + marker.position.y += 1.6; + marker.userData.routePointId = point.id; + if (point.id === selectedId) marker.scale.setScalar(1.35); + markerGroup.add(marker); + if ((point.type === "ap" || point.type === "fp") && point.radius_m) { + const zone = new THREE.Mesh( + new THREE.CylinderGeometry(point.radius_m, point.radius_m, 0.35, 40), + new THREE.MeshBasicMaterial({ + color: COLORS[point.type], + transparent: true, + opacity: 0.22, + depthWrite: false, + }), + ); + zone.position.copy(modelToScene(point, bounds)); + zone.position.y += 0.2; + markerGroup.add(zone); + } + }); + } + + function notify(): void { + renderMarkers(); + changeListener?.(points); + selectionListener?.(selected()); + } + + function place(type: RoutePointKind, model: { x: number; y: number; z: number }): void { + const point: PlacedRoutePoint = { + ...model, + id: `${type}-${crypto.randomUUID()}`, + type, + ...(type === "ap" || type === "fp" ? { radius_m: 25 } : {}), + }; + if (type === "bp" || type === "ep") points = { ...points, [type]: point }; + else points = { ...points, [type]: [...points[type], point] }; + selectedId = point.id; + notify(); + } + + function updateSelected(values: Partial): void { + const current = selected(); + if (!current) return; + const update = (point: PlacedRoutePoint) => + point.id === current.id ? { ...point, ...values } : point; + if (current.type === "bp" || current.type === "ep") { + points = { ...points, [current.type]: update(current) }; + } else { + points = { ...points, [current.type]: points[current.type].map(update) }; + } + notify(); + } + + function deleteSelected(): void { + const current = selected(); + if (!current) return; + if (current.type === "bp" || current.type === "ep") + points = { ...points, [current.type]: null }; + else + points = { + ...points, + [current.type]: points[current.type].filter((p) => p.id !== current.id), + }; + selectedId = null; + notify(); + } + + function renderRoute( + polyline: Array<{ x: number; y: number; z?: number }>, + gradeClass: string, + warnings: Array<{ polyline_start_index: number; polyline_end_index: number }> = [], + ): void { + disposeGroup(routeGroup); + const bounds = getBounds(); + if (!bounds || polyline.length < 2) return; + const linePoints = polyline.map((point) => + modelToScene({ x: point.x, y: point.y, z: point.z ?? 0 }, bounds).add( + new THREE.Vector3(0, 0.35, 0), + ), + ); + routeGroup.add( + new THREE.Line( + new THREE.BufferGeometry().setFromPoints(linePoints), + new THREE.LineBasicMaterial({ color: 0x38bdf8 }), + ), + ); + warnings.forEach((warning) => { + const segment = linePoints.slice( + Math.max(0, warning.polyline_start_index), + warning.polyline_end_index + 1, + ); + if (segment.length > 1) { + routeGroup.add( + new THREE.Line( + new THREE.BufferGeometry().setFromPoints(segment), + new THREE.LineBasicMaterial({ color: 0xef4444 }), + ), + ); + const marker = new THREE.Mesh( + new THREE.SphereGeometry(1.2, 12, 8), + new THREE.MeshBasicMaterial({ color: 0xef4444 }), + ); + marker.position.copy(segment[Math.floor(segment.length / 2)]); + marker.position.y += 1.2; + routeGroup.add(marker); + } + }); + const width = gradeClass === "trunk" ? 4 : gradeClass === "branch" ? 3 : 2.5; + const perpendiculars: THREE.Vector3[] = []; + for (let index = 0; index < linePoints.length; index += 10) { + const previous = linePoints[Math.max(0, index - 1)]; + const next = linePoints[Math.min(linePoints.length - 1, index + 1)]; + const direction = next.clone().sub(previous).normalize(); + const perpendicular = new THREE.Vector3(-direction.z, 0, direction.x); + perpendiculars.push( + linePoints[index].clone().addScaledVector(perpendicular, width / 2), + linePoints[index].clone().addScaledVector(perpendicular, -width / 2), + ); + } + routeGroup.add( + new THREE.LineSegments( + new THREE.BufferGeometry().setFromPoints(perpendiculars), + new THREE.LineBasicMaterial({ color: 0xfacc15 }), + ), + ); + } + + return { + group: markerGroup, + getPoints: () => points, + getSelected: selected, + setPoints(next: RouteDesignPoints) { + points = next; + selectedId = null; + notify(); + }, + place, + moveSelected(model: { x: number; y: number; z: number }) { + updateSelected(model); + }, + updateSelected, + deleteSelected, + selectObject(object: THREE.Object3D | undefined) { + selectedId = + typeof object?.userData.routePointId === "string" ? object.userData.routePointId : null; + renderMarkers(); + selectionListener?.(selected()); + }, + renderMarkers, + renderRoute, + onChange(listener: (next: RouteDesignPoints) => void) { + changeListener = listener; + }, + onSelectionChange(listener: (point: PlacedRoutePoint | null) => void) { + selectionListener = listener; + }, + dispose() { + disposeGroup(markerGroup); + disposeGroup(routeGroup); + scene.remove(markerGroup, routeGroup); + }, + }; +} + +export type RouteMarkers = ReturnType; diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Page.ts b/B05_wf2_Route/B05_wf2_Route_UI_Page.ts index 5ac72a80..2f4eb8a3 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Page.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Page.ts @@ -1,378 +1,273 @@ -/* ============================================================================= - * B05_wf2_Route_UI_Page.ts - * 로그인 후 05: 2차 워크플로우 (경로 설계) - * - * 3단 레이아웃 (frontend.md §2): - * 상단: 페이지 타이틀 + 진행 단계 스텝바 (createWorkflowLayout) - * 좌측: 경로 제어점(BP/EP/CP) 좌표 + 기반 지표면 + 설계 제약 폼 - * 우측: 경로 탐색 결과(연장·경사·비용) 카드 - * - * 이벤트 핸들러 명명 (frontend.md §4): onB05_Route_[기능]_[액션] - * 텍스트는 ui_template_locale에 선(先) 등록 후 참조 (frontend.md §3). - * ========================================================================== */ - import { CURRENT_PROJECT_ID_KEY } from "@config/config_frontend"; -import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; -import { - createButton, - createInputField, - createSelectField, - hideLoadingOverlay, - showLoadingOverlay, - showToast, - type InputFieldHandle, -} from "@ui/ui_template_elements"; +import { hideLoadingOverlay, showLoadingOverlay, showToast } from "@ui/ui_template_elements"; import { createWorkflowLayout } from "@ui/ui_template_workflow_layout"; import { workflowSteps } from "../A00_Common/b_page_scaffold"; import { fetchWorkflowState, goToWorkflowStage, WORKFLOW_STEP_ROUTES, - type WorkflowState, } from "../A00_Common/b_workflow_nav"; import { - confirmRoute, - solveRoute, - type RoutePoint, - type RouteSolveResponse, -} from "./B05_wf2_Route_Api_Fetch"; -import { + fetchSurfacePointCloud, listSurfaceModels, type SurfaceModelSummary, } from "../B04_wf1_Surface/B04_wf1_Surface_Api_Fetch"; +import { + confirmRoute, + fetchLatestRoute, + solveRoute, + type CirclePoint, + type RouteLatestResponse, + type RoutePoint, +} from "./B05_wf2_Route_Api_Fetch"; +import { + type ModelBounds, + type PlacedRoutePoint, + type RouteDesignPoints, + type RoutePointKind, +} from "./B05_wf2_Route_UI_Markers"; +import { createRoutePanel, type RoutePanelValues } from "./B05_wf2_Route_UI_Panel"; +import { createRouteViewer } from "./B05_wf2_Route_UI_Viewer"; import "./B05_wf2_Route_UI_Style.css"; -/** locale 헬퍼 */ -function L(key: keyof typeof ui_locales): string { - return ui_locales[key][currentLanguageIndex]; +function toBounds(bounds: { + x_min: number; + x_max: number; + y_min: number; + y_max: number; + z_min: number; + z_max: number; +}): ModelBounds { + return { + x: [bounds.x_min, bounds.x_max], + y: [bounds.y_min, bounds.y_max], + z: [bounds.z_min, bounds.z_max], + }; } -/** 임도 등급 (config_system.ROUTE_GRADE_CLASSES) */ -const GRADE_CLASSES = ["trunk", "branch", "work"] as const; -/** 경로 알고리즘 (Schema.algorithm 허용값) */ -const ALGORITHMS = ["dijkstra", "ridge_valley"] as const; - -/** X/Y 좌표 한 쌍 입력 행 생성. */ -function buildPointRow(label: string): { - root: HTMLElement; - x: InputFieldHandle; - y: InputFieldHandle; -} { - const root = document.createElement("div"); - root.className = "b05-route__point"; - const caption = document.createElement("span"); - caption.className = "b05-route__point-label"; - caption.textContent = label; - const x = createInputField({ label: L("B05_Route_Field_X"), type: "number" }); - const y = createInputField({ label: L("B05_Route_Field_Y"), type: "number" }); - const row = document.createElement("div"); - row.className = "b05-route__point-row"; - row.append(x.root, y.root); - root.append(caption, row); - return { root, x, y }; +function placed( + type: RoutePointKind, + point: RoutePoint | CirclePoint, + index = 0, +): PlacedRoutePoint { + return { + id: `${type}-restored-${index}`, + type, + x: point.x, + y: point.y, + z: point.z ?? 0, + ...(type === "ap" || type === "fp" ? { radius_m: (point as CirclePoint).radius_m ?? 25 } : {}), + }; } -/** 숫자 입력값을 파싱. 빈 값이면 null. */ -function parseNumber(value: string): number | null { - const trimmed = value.trim(); - if (!trimmed) return null; - const parsed = Number(trimmed); - return Number.isFinite(parsed) ? parsed : null; +function restorePoints(latest: RouteLatestResponse): RouteDesignPoints { + const points = latest.route_params?.points; + return { + bp: points?.bp ? placed("bp", points.bp) : null, + ep: points?.ep ? placed("ep", points.ep) : null, + cp: (points?.cp ?? []).map((point, index) => placed("cp", point, index)), + ap: (points?.ap ?? []).map((point, index) => placed("ap", point, index)), + fp: (points?.fp ?? []).map((point, index) => placed("fp", point, index)), + }; +} + +function routePoint(point: PlacedRoutePoint): RoutePoint { + return { x: point.x, y: point.y, z: point.z }; +} + +function circlePoint(point: PlacedRoutePoint): CirclePoint { + return { ...routePoint(point), radius_m: point.radius_m ?? 25 }; } export async function renderB05Route(root: HTMLElement): Promise { + const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); + if (!projectId) { + showToast("프로젝트를 먼저 선택하세요.", "error"); + return; + } + const activeProjectId: string = projectId; + + const viewer = createRouteViewer(); let confirmedSurface: SurfaceModelSummary | null = null; - /* ---- 좌측: 경로 제어점 ---- */ - const pointsGroup = document.createElement("fieldset"); - pointsGroup.className = "b05-route__group"; - const pointsLegend = document.createElement("legend"); - pointsLegend.className = "b05-route__group-legend"; - pointsLegend.textContent = L("B05_Route_Group_Points"); - const bpRow = buildPointRow(L("B05_Route_Point_BP")); - const epRow = buildPointRow(L("B05_Route_Point_EP")); - const cpContainer = document.createElement("div"); - cpContainer.className = "b05-route__cp-list"; - const cpRows: { root: HTMLElement; x: InputFieldHandle; y: InputFieldHandle }[] = []; + let latest: RouteLatestResponse | null = null; + let routeReady = false; + let stale = false; + let restoring = true; - function onB05_Route_AddCp_Click(): void { - const row = buildPointRow(L("B05_Route_Point_CP")); - const removeBtn = createButton({ - label: L("B05_Route_Btn_RemoveCp"), - variant: "ghost", - onClick: () => { - const idx = cpRows.indexOf(row); - if (idx >= 0) cpRows.splice(idx, 1); - row.root.remove(); - }, + const panel = createRoutePanel({ + onSolve: () => void solve(), + onConfirm: () => void confirm(), + onContourApply: (interval) => void applyContours(interval), + onSurfaceVisible: viewer.setSurfaceVisible, + onContoursVisible: viewer.setContoursVisible, + onAxesVisible: viewer.setAxesVisible, + onView: viewer.setView, + onResetView: () => viewer.setView("top"), + onMovePoint: viewer.beginMoveSelected, + onDeletePoint: viewer.markers.deleteSelected, + onRadiusChange: (radius) => viewer.markers.updateSelected({ radius_m: radius }), + onInputChange: markStale, + }); + + function updateConfirmGate(): void { + panel.setCanConfirm(routeReady && !stale); + } + + function markStale(): void { + if (restoring || !routeReady) return; + stale = true; + panel.setStale(true); + updateConfirmGate(); + } + + viewer.markers.onChange(markStale); + viewer.markers.onSelectionChange(panel.setSelected); + + function restorePanel(next: RouteLatestResponse): void { + const options = next.route_params?.options ?? {}; + panel.restore({ + contourInterval: next.surface_params.contour_interval_m, + algorithm: next.route_params?.algorithm as RoutePanelValues["algorithm"] | undefined, + gradeClass: options.grade_class as RoutePanelValues["gradeClass"] | undefined, + paved: options.paved as boolean | undefined, + minCurveRadius: options.min_curve_radius_m as number | undefined, + maxUphillGrade: options.max_uphill_grade as number | undefined, + maxDownhillGrade: options.max_downhill_grade as number | undefined, + minUphillGrade: options.min_uphill_grade as number | undefined, + minDownhillGrade: options.min_downhill_grade as number | undefined, + allowAvoidPassThrough: options.allow_avoid_pass_through as boolean | undefined, }); - row.root.append(removeBtn); - cpRows.push(row); - cpContainer.append(row.root); + viewer.markers.setPoints(restorePoints(next)); } - const addCpButton = createButton({ - label: L("B05_Route_Btn_AddCp"), - variant: "ghost", - onClick: () => onB05_Route_AddCp_Click(), - }); - pointsGroup.append(pointsLegend, bpRow.root, epRow.root, cpContainer, addCpButton); - - /* ---- 좌측: 기반 지표면 ---- */ - const surfaceGroup = document.createElement("fieldset"); - surfaceGroup.className = "b05-route__group"; - const surfaceLegend = document.createElement("legend"); - surfaceLegend.className = "b05-route__group-legend"; - surfaceLegend.textContent = L("B05_Route_Group_Surface"); - const filterField = createInputField({ label: L("B05_Route_Field_Filter"), type: "text" }); - const methodField = createInputField({ - label: L("B05_Route_Field_Method"), - type: "text", - value: "dtm", - }); - filterField.input.readOnly = true; - methodField.input.readOnly = true; - const confirmedModelInfo = document.createElement("p"); - confirmedModelInfo.className = "b05-route__surface-info"; - confirmedModelInfo.textContent = L("B05_Route_Surface_NotConfirmed"); - surfaceGroup.append(surfaceLegend, confirmedModelInfo, filterField.root, methodField.root); - - /* ---- 좌측: 설계 제약 ---- */ - const constraintGroup = document.createElement("fieldset"); - constraintGroup.className = "b05-route__group"; - const constraintLegend = document.createElement("legend"); - constraintLegend.className = "b05-route__group-legend"; - constraintLegend.textContent = L("B05_Route_Group_Constraints"); - const gradeSelect = createSelectField({ - label: L("B05_Route_Field_GradeClass"), - options: GRADE_CLASSES.map((v) => ({ value: v, text: v })), - }); - const algorithmSelect = createSelectField({ - label: L("B05_Route_Field_Algorithm"), - options: ALGORITHMS.map((v) => ({ value: v, text: v })), - }); - const maxUphillField = createInputField({ - label: L("B05_Route_Field_MaxUphill"), - type: "number", - }); - const maxDownhillField = createInputField({ - label: L("B05_Route_Field_MaxDownhill"), - type: "number", - }); - const minRadiusField = createInputField({ - label: L("B05_Route_Field_MinRadius"), - type: "number", - }); - - const smoothLabel = document.createElement("label"); - smoothLabel.className = "b05-route__check"; - const smoothBox = document.createElement("input"); - smoothBox.type = "checkbox"; - const smoothText = document.createElement("span"); - smoothText.textContent = L("B05_Route_Field_Smooth"); - smoothLabel.append(smoothBox, smoothText); - - constraintGroup.append( - constraintLegend, - gradeSelect.root, - algorithmSelect.root, - maxUphillField.root, - maxDownhillField.root, - minRadiusField.root, - smoothLabel, - ); - - const solveButton = createButton({ - label: L("B05_Route_Btn_Solve"), - variant: "filled", - onClick: () => void onB05_Route_Solve_Click(), - }); - const confirmButton = createButton({ - label: L("B05_Route_Btn_Confirm"), - variant: "ghost", - onClick: () => void onB05_Route_Confirm_Click(), - }); - const actionRow = document.createElement("div"); - actionRow.className = "b05-route__actions"; - actionRow.append(solveButton, confirmButton); - - const leftForm = document.createElement("div"); - leftForm.className = "b05-route__form"; - leftForm.append(pointsGroup, surfaceGroup, constraintGroup, actionRow); - - /* ---- 우측: 결과 ---- */ - const resultTitle = document.createElement("h3"); - resultTitle.className = "b05-route__result-title"; - resultTitle.textContent = L("B05_Route_Result_Title"); - const resultBody = document.createElement("div"); - resultBody.className = "b05-route__result-body"; - - function renderEmptyResult(): void { - resultBody.replaceChildren(); - const empty = document.createElement("p"); - empty.className = "b05-route__empty"; - empty.textContent = L("B05_Route_Result_Empty"); - resultBody.append(empty); - } - - function metricRow(label: string, value: string): HTMLElement { - const row = document.createElement("div"); - row.className = "b05-route__metric"; - const key = document.createElement("span"); - key.className = "b05-route__metric-key"; - key.textContent = label; - const val = document.createElement("span"); - val.className = "b05-route__metric-val"; - val.textContent = value; - row.append(key, val); - return row; - } - - function renderResult(result: RouteSolveResponse): void { - resultBody.replaceChildren(); - const metrics = result.metrics as Record; - const fmt = (v: number | undefined): string => (v === undefined ? "-" : v.toFixed(3)); - resultBody.append( - metricRow(L("B05_Route_Result_Length"), result.total_length_m.toFixed(2)), - metricRow(L("B05_Route_Result_MinSlope"), fmt(metrics.min_slope)), - metricRow(L("B05_Route_Result_MaxSlope"), fmt(metrics.max_slope)), - metricRow(L("B05_Route_Result_MeanSlope"), fmt(metrics.mean_slope)), - metricRow(L("B05_Route_Result_Cost"), fmt(metrics.cost_score)), - metricRow(L("B05_Route_Result_Path"), result.route_data_path), - ); - } - - const resultCard = document.createElement("div"); - resultCard.className = "b05-route__result"; - resultCard.append(resultTitle, resultBody); - - /* ---- 이벤트 핸들러 ---- */ - function getProjectId(): string | null { - const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); - if (!projectId) showToast(L("B05_Route_Error_Project"), "error"); - return projectId; - } - - /** 필수 좌표(BP/EP)와 필터 키를 검증하고, 유효하면 요청 페이로드를 반환. */ - function collectRequest(): { - filter_key: string; - bp: RoutePoint; - ep: RoutePoint; - cp: RoutePoint[]; - } | null { - const bpX = parseNumber(bpRow.x.input.value); - const bpY = parseNumber(bpRow.y.input.value); - const epX = parseNumber(epRow.x.input.value); - const epY = parseNumber(epRow.y.input.value); - if (bpX === null || bpY === null || epX === null || epY === null) { - showToast(L("B05_Route_Error_Points"), "error"); - return null; + function renderLatest(next: RouteLatestResponse): void { + latest = next; + routeReady = Boolean(next.route && next.route_points.length > 1); + stale = false; + panel.setStale(false); + if (next.route) { + const stored = next.route.algorithm_params ?? {}; + const metrics = (stored.metrics as Record | undefined) ?? { + length_m: next.route.total_length_m, + min_slope: next.route.min_slope, + max_slope: next.route.max_slope, + mean_slope: next.route.mean_slope, + cost_score: next.route.cost_score, + }; + panel.renderMetrics(metrics); + viewer.markers.renderRoute( + next.route_points, + panel.values().gradeClass, + (stored.curve_warning_segments as Array<{ + polyline_start_index: number; + polyline_end_index: number; + }>) ?? [], + ); } - const filterKey = filterField.input.value.trim(); - if (!filterKey) { - filterField.setError(L("B05_Route_Error_Filter")); - return null; - } - filterField.setError(); - - const cp: RoutePoint[] = []; - for (const row of cpRows) { - const x = parseNumber(row.x.input.value); - const y = parseNumber(row.y.input.value); - if (x !== null && y !== null) cp.push({ x, y }); - } - return { filter_key: filterKey, bp: { x: bpX, y: bpY }, ep: { x: epX, y: epY }, cp }; + updateConfirmGate(); } - async function onB05_Route_Solve_Click(): Promise { - const projectId = getProjectId(); - if (!projectId) return; - if (!confirmedSurface) { - showToast(L("B05_Route_Surface_NotConfirmed"), "error"); + async function applyContours(interval: number): Promise { + showLoadingOverlay(); + try { + await viewer.reloadContours(interval); + } catch (error) { + showToast(error instanceof Error ? error.message : "등고선 조회에 실패했습니다.", "error"); + } finally { + hideLoadingOverlay(); + } + } + + async function solve(): Promise { + if (!confirmedSurface || !latest) return; + const points = viewer.markers.getPoints(); + if (!points.bp || !points.ep) { + showToast("BP와 EP를 지형에 배치하세요.", "error"); return; } - const base = collectRequest(); - if (!base) return; - + const values = panel.values(); showLoadingOverlay(); try { - const result = await solveRoute(projectId, { - ...base, - method: methodField.input.value.trim() || "dtm", - smooth: smoothBox.checked, + await solveRoute(activeProjectId, { + filter_key: latest.surface_params.source_filter, + method: latest.surface_params.method, + smooth: latest.surface_params.smooth, surface_model_id: confirmedSurface.id, - algorithm: algorithmSelect.select.value, - grade_class: gradeSelect.select.value, - max_uphill_grade: parseNumber(maxUphillField.input.value), - max_downhill_grade: parseNumber(maxDownhillField.input.value), - min_curve_radius_m: parseNumber(minRadiusField.input.value), + algorithm: values.algorithm, + bp: routePoint(points.bp), + ep: routePoint(points.ep), + cp: points.cp.map(routePoint), + ap: points.ap.map(circlePoint), + fp: points.fp.map(circlePoint), + grade_class: values.gradeClass, + paved: values.paved, + min_curve_radius_m: values.minCurveRadius, + max_uphill_grade: values.maxUphillGrade, + max_downhill_grade: values.maxDownhillGrade, + min_uphill_grade: values.minUphillGrade, + min_downhill_grade: values.minDownhillGrade, + allow_avoid_pass_through: values.allowAvoidPassThrough, }); - renderResult(result); - showToast(L("B05_Route_Solve_Success"), "success"); + renderLatest(await fetchLatestRoute(activeProjectId)); + showToast("최적 경로 계산이 완료되었습니다.", "success"); } catch (error) { - const detail = error instanceof Error ? error.message : L("B05_Route_Solve_Failed"); - showToast(`${L("B05_Route_Solve_Failed")} ${detail}`, "error"); + showToast(error instanceof Error ? error.message : "경로 계산에 실패했습니다.", "error"); } finally { hideLoadingOverlay(); } } - async function onB05_Route_Confirm_Click(): Promise { - const projectId = getProjectId(); - if (!projectId) return; + async function confirm(): Promise { + if (!routeReady || stale) return; showLoadingOverlay(); try { - await confirmRoute(projectId); - showToast(L("B05_Route_Confirm_Success"), "success"); + await confirmRoute(activeProjectId); + renderLatest(await fetchLatestRoute(activeProjectId)); + showToast("경로를 확정했습니다.", "success"); } catch (error) { - const detail = error instanceof Error ? error.message : L("B05_Route_Confirm_Failed"); - showToast(`${L("B05_Route_Confirm_Failed")} ${detail}`, "error"); + showToast(error instanceof Error ? error.message : "경로 확정에 실패했습니다.", "error"); } finally { hideLoadingOverlay(); } } - renderEmptyResult(); - - let workflowState: WorkflowState | undefined; - const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); - if (projectId) { - try { - const [nextWorkflowState, models] = await Promise.all([ - fetchWorkflowState(projectId), - listSurfaceModels(projectId), - ]); - workflowState = nextWorkflowState; - confirmedSurface = models.models.find((model) => model.status === "CONFIRMED") ?? null; - if (confirmedSurface) { - const sourceFilter = confirmedSurface.generation_params?.source_filter; - filterField.input.value = typeof sourceFilter === "string" ? sourceFilter : ""; - methodField.input.value = confirmedSurface.model_type; - confirmedModelInfo.textContent = L("B05_Route_Surface_Confirmed") - .replace("{id}", String(confirmedSurface.id)) - .replace("{method}", confirmedSurface.model_type); - } else { - solveButton.disabled = true; - } - } catch { - solveButton.disabled = true; - confirmedModelInfo.textContent = L("B05_Route_Surface_LoadFailed"); - } + const [workflowState, models, latestResponse] = await Promise.all([ + fetchWorkflowState(activeProjectId), + listSurfaceModels(activeProjectId), + fetchLatestRoute(activeProjectId), + ]); + confirmedSurface = models.models.find((model) => model.status === "CONFIRMED") ?? null; + if (!confirmedSurface) { + showToast("확정된 지표면 모델이 없습니다.", "error"); + } else { + const cloud = await fetchSurfacePointCloud( + activeProjectId, + latestResponse.surface_params.source_filter, + ); + restorePanel(latestResponse); + await viewer.loadSurface( + activeProjectId, + confirmedSurface.id, + latestResponse.surface_params.method, + latestResponse.surface_params.smooth, + latestResponse.surface_params.contour_interval_m, + toBounds(cloud.bounds), + ); + renderLatest(latestResponse); } + latest = latestResponse; + restoring = false; const layout = createWorkflowLayout({ - title: L("B05_Route_Title"), + title: "노선 설계", steps: workflowSteps(), activeStep: 2, - leftPanel: leftForm, - mainContent: resultCard, - stages: workflowState?.stages, - currentStage: workflowState?.current_stage, + leftPanel: panel.root, + mainContent: viewer.root, + stages: workflowState.stages, + currentStage: workflowState.current_stage, routes: WORKFLOW_STEP_ROUTES, - onStepClick: (stepIndex) => { - if (projectId) { - goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[stepIndex]); - } - }, + onStepClick: (stepIndex) => goToWorkflowStage(activeProjectId, WORKFLOW_STEP_ROUTES[stepIndex]), }); root.replaceChildren(layout.root); } diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Panel.ts b/B05_wf2_Route/B05_wf2_Route_UI_Panel.ts new file mode 100644 index 00000000..73eae3c6 --- /dev/null +++ b/B05_wf2_Route/B05_wf2_Route_UI_Panel.ts @@ -0,0 +1,294 @@ +import type { PlacedRoutePoint, RoutePointKind } from "./B05_wf2_Route_UI_Markers"; + +export interface RoutePanelValues { + contourInterval: number; + algorithm: "dijkstra" | "ridge_valley"; + gradeClass: "trunk" | "branch" | "work"; + paved: boolean; + minCurveRadius: number | null; + maxUphillGrade: number | null; + maxDownhillGrade: number | null; + minUphillGrade: number | null; + minDownhillGrade: number | null; + allowAvoidPassThrough: boolean; +} + +interface PanelCallbacks { + onSolve: () => void; + onConfirm: () => void; + onContourApply: (interval: number) => void; + onSurfaceVisible: (visible: boolean) => void; + onContoursVisible: (visible: boolean) => void; + onAxesVisible: (visible: boolean) => void; + onView: (view: "iso" | "top" | "front" | "side") => void; + onResetView: () => void; + onMovePoint: () => void; + onDeletePoint: () => void; + onRadiusChange: (radius: number) => void; + onInputChange: () => void; +} + +type WrappedInput = HTMLInputElement & { wrapper: HTMLLabelElement }; + +function section(title: string): { root: HTMLElement; body: HTMLElement } { + const root = document.createElement("section"); + root.className = "b05-route__panel-section"; + const heading = document.createElement("h3"); + heading.textContent = title; + const body = document.createElement("div"); + body.className = "b05-route__panel-body"; + root.append(heading, body); + return { root, body }; +} + +function button(label: string, onClick: () => void, className = ""): HTMLButtonElement { + const element = document.createElement("button"); + element.type = "button"; + element.className = `b05-route__button ${className}`.trim(); + element.textContent = label; + element.addEventListener("click", onClick); + return element; +} + +function numberField(label: string, value = ""): WrappedInput { + const wrapper = document.createElement("label"); + wrapper.className = "b05-route__field"; + const caption = document.createElement("span"); + caption.textContent = label; + const input = document.createElement("input"); + input.type = "number"; + input.step = "0.01"; + input.value = value; + wrapper.append(caption, input); + return Object.assign(input, { wrapper }); +} + +function checkbox(label: string, checked: boolean): WrappedInput { + const wrapper = document.createElement("label"); + wrapper.className = "b05-route__check"; + const input = document.createElement("input"); + input.type = "checkbox"; + input.checked = checked; + wrapper.append(input, document.createTextNode(label)); + return Object.assign(input, { wrapper }); +} + +function parseOptional(input: HTMLInputElement): number | null { + if (!input.value.trim()) return null; + const value = Number(input.value); + return Number.isFinite(value) ? value : null; +} + +export function createRoutePanel(callbacks: PanelCallbacks) { + const root = document.createElement("div"); + root.className = "b05-route__panel"; + + const view = section("뷰 컨트롤"); + const viewButtons = document.createElement("div"); + viewButtons.className = "b05-route__button-grid"; + (["iso", "top", "front", "side"] as const).forEach((preset) => + viewButtons.append(button(preset.toUpperCase(), () => callbacks.onView(preset))), + ); + const surfaceVisible = checkbox("지표면", true); + const contoursVisible = checkbox("등고선", true); + const axesVisible = checkbox("축 표시", false); + surfaceVisible.addEventListener("change", () => + callbacks.onSurfaceVisible(surfaceVisible.checked), + ); + contoursVisible.addEventListener("change", () => + callbacks.onContoursVisible(contoursVisible.checked), + ); + axesVisible.addEventListener("change", () => callbacks.onAxesVisible(axesVisible.checked)); + view.body.append( + viewButtons, + surfaceVisible.wrapper, + contoursVisible.wrapper, + axesVisible.wrapper, + button("뷰 초기화", callbacks.onResetView), + ); + + const contour = section("등고선 간격"); + const contourInterval = numberField("간격 (m)", "1"); + contour.body.append( + contourInterval.wrapper, + button("등고선 재적용", () => callbacks.onContourApply(Number(contourInterval.value) || 1)), + ); + + const palette = section("포인트 팔레트"); + const paletteGrid = document.createElement("div"); + paletteGrid.className = "b05-route__palette"; + const pointLabels: Record = { + bp: "BP 시작점", + ep: "EP 종료점", + cp: "CP 경유점", + ap: "AP 회피구역", + fp: "FP 금지구역", + }; + (Object.keys(pointLabels) as RoutePointKind[]).forEach((kind) => { + const chip = document.createElement("div"); + chip.className = `b05-route__chip is-${kind}`; + chip.draggable = true; + chip.textContent = pointLabels[kind]; + chip.addEventListener("dragstart", (event) => event.dataTransfer?.setData("pointType", kind)); + paletteGrid.append(chip); + }); + palette.body.append(paletteGrid); + + const selected = section("선택 포인트 상세 설정"); + selected.root.hidden = true; + const selectedName = document.createElement("strong"); + const radius = numberField("회피/금지 반경 (m)", "25"); + radius.addEventListener("change", () => callbacks.onRadiusChange(Number(radius.value) || 1)); + const selectedActions = document.createElement("div"); + selectedActions.className = "b05-route__actions"; + selectedActions.append( + button("위치 이동", callbacks.onMovePoint), + button("삭제", callbacks.onDeletePoint, "is-danger"), + ); + selected.body.append(selectedName, radius.wrapper, selectedActions); + + const conditions = section("임도 기준·옵션"); + const algorithm = document.createElement("select"); + algorithm.innerHTML = + ''; + const gradeClass = document.createElement("select"); + gradeClass.innerHTML = + ''; + const algorithmLabel = document.createElement("label"); + algorithmLabel.className = "b05-route__field"; + algorithmLabel.append(document.createTextNode("알고리즘"), algorithm); + const gradeLabel = document.createElement("label"); + gradeLabel.className = "b05-route__field"; + gradeLabel.append(document.createTextNode("임도 등급"), gradeClass); + const minCurveRadius = numberField("최소 곡선반경 (m)"); + const maxUphillGrade = numberField("오르막 경사 상한"); + const maxDownhillGrade = numberField("내리막 경사 상한"); + const minUphillGrade = numberField("오르막 경사 하한"); + const minDownhillGrade = numberField("내리막 경사 하한"); + const paved = checkbox("포장 임도", false); + const avoidPass = checkbox("회피구역 통과 허용", false); + const details = document.createElement("details"); + const summary = document.createElement("summary"); + summary.textContent = "사용한 조건"; + details.append( + summary, + minCurveRadius.wrapper, + maxUphillGrade.wrapper, + maxDownhillGrade.wrapper, + minUphillGrade.wrapper, + minDownhillGrade.wrapper, + ); + const help = document.createElement("details"); + help.innerHTML = + "최적경로란?

배치 포인트와 경사·곡선·회피 조건을 만족하며 비용을 최소화한 경로입니다.

"; + conditions.body.append( + algorithmLabel, + gradeLabel, + paved.wrapper, + avoidPass.wrapper, + details, + help, + ); + + const result = section("경로 설계 산출 결과"); + const stale = document.createElement("span"); + stale.className = "b05-route__stale"; + stale.textContent = "재탐색 필요"; + stale.hidden = true; + const metrics = document.createElement("div"); + metrics.className = "b05-route__metrics"; + metrics.textContent = "경로를 계산하면 결과가 표시됩니다."; + result.body.append(stale, metrics); + + const solveButton = button("최적 경로 계산", callbacks.onSolve, "is-primary"); + const confirmButton = button("경로 확정", callbacks.onConfirm); + confirmButton.disabled = true; + const actionRow = document.createElement("div"); + actionRow.className = "b05-route__actions"; + actionRow.append(solveButton, confirmButton); + + const inputElements = [ + algorithm, + gradeClass, + paved, + avoidPass, + minCurveRadius, + maxUphillGrade, + maxDownhillGrade, + minUphillGrade, + minDownhillGrade, + ]; + inputElements.forEach((input) => input.addEventListener("change", callbacks.onInputChange)); + root.append( + view.root, + contour.root, + palette.root, + selected.root, + conditions.root, + result.root, + actionRow, + ); + + return { + root, + values(): RoutePanelValues { + return { + contourInterval: Number(contourInterval.value) || 1, + algorithm: algorithm.value as RoutePanelValues["algorithm"], + gradeClass: gradeClass.value as RoutePanelValues["gradeClass"], + paved: paved.checked, + minCurveRadius: parseOptional(minCurveRadius), + maxUphillGrade: parseOptional(maxUphillGrade), + maxDownhillGrade: parseOptional(maxDownhillGrade), + minUphillGrade: parseOptional(minUphillGrade), + minDownhillGrade: parseOptional(minDownhillGrade), + allowAvoidPassThrough: avoidPass.checked, + }; + }, + restore(values: Partial) { + if (values.contourInterval != null) contourInterval.value = String(values.contourInterval); + if (values.algorithm) algorithm.value = values.algorithm; + if (values.gradeClass) gradeClass.value = values.gradeClass; + if (values.paved != null) paved.checked = values.paved; + if (values.minCurveRadius != null) minCurveRadius.value = String(values.minCurveRadius); + if (values.maxUphillGrade != null) maxUphillGrade.value = String(values.maxUphillGrade); + if (values.maxDownhillGrade != null) maxDownhillGrade.value = String(values.maxDownhillGrade); + if (values.minUphillGrade != null) minUphillGrade.value = String(values.minUphillGrade); + if (values.minDownhillGrade != null) minDownhillGrade.value = String(values.minDownhillGrade); + if (values.allowAvoidPassThrough != null) avoidPass.checked = values.allowAvoidPassThrough; + }, + setSelected(point: PlacedRoutePoint | null) { + selected.root.hidden = !point; + if (!point) return; + selectedName.textContent = `${point.type.toUpperCase()} (${point.x.toFixed(2)}, ${point.y.toFixed(2)})`; + radius.wrapper.hidden = point.type !== "ap" && point.type !== "fp"; + radius.value = String(point.radius_m ?? 25); + }, + setStale(value: boolean) { + stale.hidden = !value; + confirmButton.disabled = value; + }, + setCanConfirm(value: boolean) { + confirmButton.disabled = !value; + }, + renderMetrics(values: Record) { + const rows = [ + ["총 연장", values.length_m], + ["평균 경사", values.avg_grade_pct ?? values.mean_slope], + ["최대 경사", values.max_grade_pct ?? values.max_slope], + ["비용", values.cost_score], + ["경사 초과", values.slope_violations], + ["곡선반경 미달", values.curve_violations], + ]; + metrics.replaceChildren( + ...rows.map(([label, value]) => { + const row = document.createElement("div"); + row.textContent = `${label}: ${value ?? "-"}`; + return row; + }), + ); + }, + }; +} + +export type RoutePanel = ReturnType; diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Style.css b/B05_wf2_Route/B05_wf2_Route_UI_Style.css index 150614cd..eafd1d92 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Style.css +++ b/B05_wf2_Route/B05_wf2_Route_UI_Style.css @@ -1,139 +1,145 @@ -/* ============================================================================= - * B05_wf2_Route_UI_Style.css - * 2차 워크플로우(경로 설계) 페이지 전용 스타일. - * - * 원칙(frontend.md §1): 하드코딩 색상 금지. theme.css 변수(var(--...))만 참조. - * 공통 컴포넌트 스타일은 ui_template_elements.ts가 주입하므로 여기서는 - * B05 고유 레이아웃(제어점 행/제약 그룹/결과 메트릭)만 정의한다. - * ========================================================================== */ - -/* --- 좌측 입력 폼 --- */ -.b05-route__form { - display: flex; - flex-direction: column; - gap: var(--spacing-16); +.b05-route__viewport { + position: relative; + width: 100%; + height: 100%; + min-height: 560px; + overflow: hidden; + background: var(--color-surface); } -.b05-route__group { +.b05-route__viewport canvas { + display: block; + width: 100%; + height: 100%; +} + +.b05-route__viewer-status { + position: absolute; + inset: var(--spacing-16) auto auto var(--spacing-16); + max-width: 420px; + padding: var(--spacing-8) var(--spacing-16); + border: 1px solid var(--color-border); + border-radius: var(--radius-inputs); + background: var(--color-surface-raised); + color: var(--color-text-body); + font-size: var(--text-caption); + pointer-events: none; +} + +.b05-route__panel { display: flex; flex-direction: column; gap: var(--spacing-16); - margin: 0; + min-width: 300px; + padding-bottom: var(--spacing-24); +} + +.b05-route__panel-section { + display: flex; + flex-direction: column; + gap: var(--spacing-8); padding: var(--spacing-16); border: 1px solid var(--color-border); border-radius: var(--radius-cards); - background-color: var(--color-surface-raised); + background: var(--color-surface-raised); } -.b05-route__group-legend { - padding: 0 var(--spacing-8); - font-size: var(--text-caption); - font-weight: var(--font-weight-medium); - color: var(--color-text-secondary); -} - -/* --- 제어점 (X/Y 쌍) --- */ -.b05-route__point { - display: flex; - flex-direction: column; - gap: var(--spacing-8); -} - -.b05-route__point-label { - font-size: var(--text-body-sm); - font-weight: var(--font-weight-medium); - color: var(--color-text-body); -} - -.b05-route__point-row { - display: grid; - grid-template-columns: 1fr 1fr; - gap: var(--spacing-8); -} - -.b05-route__cp-list { - display: flex; - flex-direction: column; - gap: var(--spacing-16); -} - -/* --- select (지표면/제약) --- */ -.b05-route__select { - appearance: auto; - cursor: pointer; -} - -.b05-route__surface-info { +.b05-route__panel-section h3 { margin: 0; - color: var(--color-text-body); + color: var(--color-text); font-size: var(--text-body-sm); - font-weight: var(--font-weight-medium); } -/* --- 체크박스 --- */ +.b05-route__panel-body, +.b05-route__field, +.b05-route__metrics { + display: flex; + flex-direction: column; + gap: var(--spacing-8); +} + +.b05-route__field, +.b05-route__check, +.b05-route__metrics { + color: var(--color-text-body); + font-size: var(--text-caption); +} + +.b05-route__field input, +.b05-route__field select, +.b05-route__panel-section > select { + width: 100%; + padding: var(--spacing-8); + border: 1px solid var(--color-border); + border-radius: var(--radius-inputs); + background: var(--color-surface); + color: var(--color-text-body); +} + .b05-route__check { display: flex; align-items: center; gap: var(--spacing-8); - font-size: var(--text-body-sm); +} + +.b05-route__button-grid, +.b05-route__palette { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--spacing-8); +} + +.b05-route__button, +.b05-route__chip { + padding: var(--spacing-8); + border: 1px solid var(--color-border); + border-radius: var(--radius-inputs); + background: var(--color-surface); color: var(--color-text-body); + font-size: var(--text-caption); cursor: pointer; } -.b05-route__check input { - accent-color: var(--color-primary); +.b05-route__button:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.b05-route__button.is-primary { + background: var(--color-primary); + color: var(--color-text-on-primary); +} + +.b05-route__button.is-danger, +.b05-route__chip.is-ep, +.b05-route__chip.is-fp { + color: var(--color-danger); +} + +.b05-route__chip.is-bp { + color: var(--color-success); +} + +.b05-route__chip.is-cp, +.b05-route__stale { + color: var(--color-warning); } -/* --- 액션 버튼 행 --- */ .b05-route__actions { display: flex; gap: var(--spacing-8); } -/* --- 우측 결과 --- */ -.b05-route__result { - display: flex; - flex-direction: column; - gap: var(--spacing-16); - padding: var(--spacing-24); +.b05-route__actions > * { + flex: 1; } -.b05-route__result-title { - font-size: var(--text-subheading); - color: var(--color-text); -} - -.b05-route__empty { - color: var(--color-text-muted); - font-size: var(--text-body-sm); -} - -.b05-route__result-body { - display: flex; - flex-direction: column; - gap: var(--spacing-8); - max-width: 480px; -} - -.b05-route__metric { - display: flex; - align-items: baseline; - justify-content: space-between; - gap: var(--spacing-16); - padding: var(--spacing-8) var(--spacing-16); - border-radius: var(--radius-inputs); - background-color: var(--color-surface); -} - -.b05-route__metric-key { +.b05-route__stale { font-size: var(--text-caption); - color: var(--color-text-secondary); + font-weight: var(--font-weight-medium); } -.b05-route__metric-val { - font-family: var(--font-mono); - font-size: var(--text-body-sm); +.b05-route__panel details { color: var(--color-text-body); - word-break: break-all; - text-align: right; + font-size: var(--text-caption); } diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Viewer.ts b/B05_wf2_Route/B05_wf2_Route_UI_Viewer.ts new file mode 100644 index 00000000..6fce9bc3 --- /dev/null +++ b/B05_wf2_Route/B05_wf2_Route_UI_Viewer.ts @@ -0,0 +1,261 @@ +import * as THREE from "three"; +import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; +import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js"; +import { PLYLoader } from "three/examples/jsm/loaders/PLYLoader.js"; +import { API_BASE_URL } from "@config/config_frontend"; +import { + createRouteMarkers, + sceneToModel, + type ModelBounds, + type RouteMarkers, + type RoutePointKind, +} from "./B05_wf2_Route_UI_Markers"; + +function disposeObject(object: THREE.Object3D | null): void { + object?.traverse((child) => { + if ( + child instanceof THREE.Mesh || + child instanceof THREE.Points || + child instanceof THREE.Line + ) { + child.geometry.dispose(); + const materials = Array.isArray(child.material) ? child.material : [child.material]; + materials.forEach((material) => material.dispose()); + } + }); +} + +export interface RouteViewer { + root: HTMLElement; + markers: RouteMarkers; + loadSurface: ( + projectId: string, + modelId: number, + method: string, + smooth: boolean, + interval: number, + bounds: ModelBounds, + ) => Promise; + reloadContours: (interval: number) => Promise; + setSurfaceVisible: (visible: boolean) => void; + setContoursVisible: (visible: boolean) => void; + setAxesVisible: (visible: boolean) => void; + setView: (view: "iso" | "top" | "front" | "side") => void; + beginMoveSelected: () => void; + dispose: () => void; +} + +export function createRouteViewer(): RouteViewer { + const root = document.createElement("div"); + root.className = "b05-route__viewport"; + const status = document.createElement("div"); + status.className = "b05-route__viewer-status"; + status.textContent = "확정 지표면을 불러오는 중입니다."; + const canvas = document.createElement("canvas"); + root.append(canvas, status); + + const scene = new THREE.Scene(); + scene.background = new THREE.Color(0xf5f7fa); + const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 100000); + camera.position.set(100, 120, 100); + const renderer = new THREE.WebGLRenderer({ canvas, antialias: true }); + renderer.setPixelRatio(Math.min(devicePixelRatio, 2)); + const controls = new OrbitControls(camera, canvas); + controls.enableDamping = true; + scene.add(new THREE.HemisphereLight(0xffffff, 0x64748b, 2.2)); + const directional = new THREE.DirectionalLight(0xffffff, 2.2); + directional.position.set(100, 200, 100); + scene.add(directional); + const axes = new THREE.AxesHelper(30); + axes.visible = false; + scene.add(axes); + + let terrain: THREE.Object3D | null = null; + const contours = new THREE.Group(); + scene.add(contours); + let bounds: ModelBounds | null = null; + let current: { projectId: string; modelId: number; smooth: boolean; interval: number } | null = + null; + let movingSelected = false; + const markers = createRouteMarkers(scene, () => bounds); + + function clearContours(): void { + disposeObject(contours); + contours.clear(); + } + + function resize(): void { + const width = Math.max(1, root.clientWidth); + const height = Math.max(1, root.clientHeight); + renderer.setSize(width, height, false); + camera.aspect = width / height; + camera.updateProjectionMatrix(); + } + const resizeObserver = new ResizeObserver(resize); + resizeObserver.observe(root); + + function fit(view: "iso" | "top" | "front" | "side" = "top"): void { + if (!bounds) return; + const width = bounds.x[1] - bounds.x[0]; + const depth = bounds.y[1] - bounds.y[0]; + const distance = Math.max(width, depth, 20) * 1.35; + controls.target.set(0, 0, 0); + const positions = { + iso: [distance, distance, distance], + top: [0, distance, 0.001], + front: [0, distance * 0.25, distance], + side: [distance, distance * 0.25, 0], + } as const; + const [x, y, z] = positions[view]; + camera.position.set(x, y, z); + camera.near = Math.max(0.1, distance / 1000); + camera.far = distance * 10; + camera.updateProjectionMatrix(); + controls.update(); + } + + async function reloadContours(interval: number): Promise { + if (!current || !bounds) return; + current.interval = interval; + const response = await fetch( + `${API_BASE_URL}/projects/${current.projectId}/surface/models/${current.modelId}/contour?interval=${interval}&smooth=${current.smooth}`, + { credentials: "include", cache: "no-store" }, + ); + if (!response.ok) throw new Error("등고선 조회에 실패했습니다."); + const data = (await response.json()) as { + contours: Array<{ level: number; coordinates: [number, number, number][] }>; + }; + clearContours(); + data.contours.forEach((contour) => { + const points = contour.coordinates.map(([x, y, z]) => { + const cx = (bounds!.x[0] + bounds!.x[1]) / 2; + const cy = (bounds!.y[0] + bounds!.y[1]) / 2; + const cz = (bounds!.z[0] + bounds!.z[1]) / 2; + return new THREE.Vector3(x - cx, z - cz + 0.15, -(y - cy)); + }); + if (points.length > 1) { + contours.add( + new THREE.Line( + new THREE.BufferGeometry().setFromPoints(points), + new THREE.LineBasicMaterial({ + color: contour.level % (interval * 5) === 0 ? 0xd97706 : 0xf59e0b, + transparent: true, + opacity: 0.75, + }), + ), + ); + } + }); + } + + function terrainPoint( + event: PointerEvent | DragEvent, + ): { x: number; y: number; z: number } | null { + if (!terrain || !bounds) return null; + const rect = canvas.getBoundingClientRect(); + const pointer = new THREE.Vector2( + ((event.clientX - rect.left) / rect.width) * 2 - 1, + -((event.clientY - rect.top) / rect.height) * 2 + 1, + ); + const raycaster = new THREE.Raycaster(); + raycaster.setFromCamera(pointer, camera); + const hit = raycaster.intersectObject(terrain, true)[0]; + return hit ? sceneToModel(hit.point, bounds) : null; + } + + canvas.addEventListener("dragover", (event) => event.preventDefault()); + canvas.addEventListener("drop", (event) => { + event.preventDefault(); + const kind = event.dataTransfer?.getData("pointType") as RoutePointKind; + const point = terrainPoint(event); + if (point && ["bp", "ep", "cp", "ap", "fp"].includes(kind)) markers.place(kind, point); + }); + canvas.addEventListener("pointerdown", (event) => { + const rect = canvas.getBoundingClientRect(); + const pointer = new THREE.Vector2( + ((event.clientX - rect.left) / rect.width) * 2 - 1, + -((event.clientY - rect.top) / rect.height) * 2 + 1, + ); + const raycaster = new THREE.Raycaster(); + raycaster.setFromCamera(pointer, camera); + const markerHit = raycaster.intersectObject(markers.group, true)[0]; + if (markerHit) { + markers.selectObject(markerHit.object); + return; + } + if (movingSelected) { + const point = terrainPoint(event); + if (point) markers.moveSelected(point); + movingSelected = false; + } else { + markers.selectObject(undefined); + } + }); + + let frame = 0; + function animate(): void { + frame = requestAnimationFrame(animate); + controls.update(); + renderer.render(scene, camera); + } + animate(); + + return { + root, + markers, + async loadSurface(projectId, modelId, method, smooth, interval, nextBounds) { + bounds = nextBounds; + current = { projectId, modelId, smooth, interval }; + if (terrain) { + scene.remove(terrain); + disposeObject(terrain); + } + const url = `${API_BASE_URL}/projects/${projectId}/surface/models/${modelId}/preview?smooth=${smooth}`; + terrain = await new Promise((resolve, reject) => { + if (method === "meshfree") { + new PLYLoader().load( + url, + (geometry) => + resolve(new THREE.Points(geometry, new THREE.PointsMaterial({ size: 0.35 }))), + undefined, + reject, + ); + } else { + new GLTFLoader().load(url, (gltf) => resolve(gltf.scene), undefined, reject); + } + }); + terrain.traverse((child) => { + if (child instanceof THREE.Mesh) child.material.side = THREE.DoubleSide; + }); + scene.add(terrain); + fit("top"); + markers.renderMarkers(); + await reloadContours(interval); + status.textContent = "지형을 클릭하거나 팔레트 포인트를 드래그해 배치하세요."; + }, + reloadContours, + setSurfaceVisible(visible) { + if (terrain) terrain.visible = visible; + }, + setContoursVisible(visible) { + contours.visible = visible; + }, + setAxesVisible(visible) { + axes.visible = visible; + }, + setView: fit, + beginMoveSelected() { + movingSelected = true; + status.textContent = "선택한 포인트를 이동할 지형 위치를 클릭하세요."; + }, + dispose() { + cancelAnimationFrame(frame); + resizeObserver.disconnect(); + markers.dispose(); + clearContours(); + disposeObject(terrain); + controls.dispose(); + renderer.dispose(); + }, + }; +} diff --git a/common_util/common_util_surface_confirmation.py b/common_util/common_util_surface_confirmation.py new file mode 100644 index 00000000..d3a68753 --- /dev/null +++ b/common_util/common_util_surface_confirmation.py @@ -0,0 +1,101 @@ +"""WF1 지표면 확정 선택값의 기본값·DB 스냅샷 처리.""" + +import json +from typing import Any + +import aiomysql + +from config.config_system import ( + SURFACE_CONFIRM_DEFAULT_FILTER, + SURFACE_CONFIRM_DEFAULT_METHOD, + SURFACE_CONFIRM_DEFAULT_SMOOTH, + SURFACE_CONTOUR_INTERVAL_M, +) + +SURFACE_CONFIRM_PARAM_KEYS = ( + "source_filter", + "method", + "smooth", + "contour_interval_m", +) + + +def surface_confirmation_defaults() -> dict[str, Any]: + """현재 config에 설정된 지표면 자동 확정 기본값을 반환한다.""" + return { + "source_filter": SURFACE_CONFIRM_DEFAULT_FILTER, + "method": SURFACE_CONFIRM_DEFAULT_METHOD, + "smooth": SURFACE_CONFIRM_DEFAULT_SMOOTH, + "contour_interval_m": SURFACE_CONTOUR_INTERVAL_M, + } + + +def _decode_params(value: Any) -> dict[str, Any]: + if isinstance(value, dict): + return dict(value) + if isinstance(value, str) and value: + decoded = json.loads(value) + return dict(decoded) if isinstance(decoded, dict) else {} + return {} + + +async def get_surface_confirmation_params( + connection: aiomysql.Connection, + project_id: str, +) -> dict[str, Any]: + """stage 1 스냅샷을 우선하고, 없으면 config 기본값으로 보완한다.""" + async with connection.cursor(aiomysql.DictCursor) as cursor: + await cursor.execute( + """ + SELECT params + FROM project_workflow_stages + WHERE project_id = %s AND stage_no = 1 + """, + (project_id,), + ) + row = await cursor.fetchone() + + resolved = surface_confirmation_defaults() + params = _decode_params(row.get("params") if row else None) + for key in SURFACE_CONFIRM_PARAM_KEYS: + if key in params and params[key] is not None: + resolved[key] = params[key] + return resolved + + +async def merge_surface_confirmation_params( + connection: aiomysql.Connection, + project_id: str, + selection: dict[str, Any], +) -> dict[str, Any]: + """기존 stage 1 params에 확정 선택값 전체를 병합 저장한다.""" + normalized = { + "source_filter": str(selection["source_filter"]), + "method": str(selection["method"]), + "smooth": bool(selection["smooth"]), + "contour_interval_m": float(selection["contour_interval_m"]), + } + async with connection.cursor(aiomysql.DictCursor) as cursor: + await cursor.execute( + """ + SELECT params + FROM project_workflow_stages + WHERE project_id = %s AND stage_no = 1 + FOR UPDATE + """, + (project_id,), + ) + row = await cursor.fetchone() + if row is None: + raise LookupError("WF1 단계 상태를 찾을 수 없습니다.") + params = _decode_params(row.get("params") if row else None) + params.update(normalized) + await cursor.execute( + """ + UPDATE project_workflow_stages + SET params = %s + WHERE project_id = %s AND stage_no = 1 + """, + (json.dumps(params, ensure_ascii=False), project_id), + ) + return normalized diff --git a/config/config_system.py b/config/config_system.py index 0ef0f45c..a9c528dc 100644 --- a/config/config_system.py +++ b/config/config_system.py @@ -149,6 +149,13 @@ SURFACE_SMOOTHING_TIN_TAUBIN_MU = float(os.getenv("SURFACE_SMOOTHING_TIN_TAUBIN_ SURFACE_CONTOUR_INTERVAL_M = float(os.getenv("SURFACE_CONTOUR_INTERVAL_M", "1.0")) SURFACE_CONTOUR_GRID_RESOLUTION_M = float(os.getenv("SURFACE_CONTOUR_GRID_RESOLUTION_M", "1.0")) +# 일반 사용자 WF1 자동 확정 기본값 +SURFACE_CONFIRM_DEFAULT_FILTER = os.getenv("SURFACE_CONFIRM_DEFAULT_FILTER", "csf") +SURFACE_CONFIRM_DEFAULT_METHOD = os.getenv("SURFACE_CONFIRM_DEFAULT_METHOD", "dtm") +SURFACE_CONFIRM_DEFAULT_SMOOTH = ( + os.getenv("SURFACE_CONFIRM_DEFAULT_SMOOTH", "True").lower() == "true" +) + def build_surface_model_config() -> dict: """지표면 모델 파이프라인이 사용하는 config dict를 조립한다.""" diff --git a/db_management/001_create_schema.sql b/db_management/001_create_schema.sql index c9a08ad0..0d2c6401 100644 --- a/db_management/001_create_schema.sql +++ b/db_management/001_create_schema.sql @@ -301,6 +301,9 @@ CREATE TABLE IF NOT EXISTS routes ( CREATE TABLE IF NOT EXISTS route_points ( id INT AUTO_INCREMENT PRIMARY KEY, route_id INT NOT NULL, -- FK는 later + model_x DOUBLE, + model_y DOUBLE, + model_z DOUBLE, chainage_m FLOAT, elevation_m FLOAT, slope_percent FLOAT, diff --git a/db_management/009_route_points_xyz.sql b/db_management/009_route_points_xyz.sql new file mode 100644 index 00000000..ece52eb9 --- /dev/null +++ b/db_management/009_route_points_xyz.sql @@ -0,0 +1,7 @@ +-- B05 최신 경로 API용 모델 좌표 저장 +USE aislo_db; + +ALTER TABLE route_points + ADD COLUMN IF NOT EXISTS model_x DOUBLE NULL AFTER route_id, + ADD COLUMN IF NOT EXISTS model_y DOUBLE NULL AFTER model_x, + ADD COLUMN IF NOT EXISTS model_z DOUBLE NULL AFTER model_y; diff --git a/ui_template/ui_template_workflow_layout.ts b/ui_template/ui_template_workflow_layout.ts index fa2ce562..f062f525 100644 --- a/ui_template/ui_template_workflow_layout.ts +++ b/ui_template/ui_template_workflow_layout.ts @@ -1,6 +1,14 @@ import "./ui_template_workflow_layout.css"; import { t } from "./ui_template_locale"; import { createWorkflowOverlays } from "./ui_template_overlay"; +import { fetchDashboardMe } from "../B01_Dashboard/B01_Dashboard_Api_Fetch"; + +let dashboardRolePromise: Promise | undefined; + +function getDashboardRole(): Promise { + dashboardRolePromise ??= fetchDashboardMe().then((user) => user.role); + return dashboardRolePromise; +} export interface WorkflowStage { stage_no: number; @@ -74,6 +82,18 @@ export function createStepBar( button.classList.toggle("is-enabled", isEnabled); button.disabled = !isEnabled; + if (index === 1) { + void getDashboardRole() + .then((role) => { + if (role !== "SYSTEM_ADMIN") { + button.classList.remove("is-enabled"); + button.disabled = true; + button.title = "시스템 관리자 전용 단계입니다."; + } + }) + .catch(() => undefined); + } + // state 기반 스타일링 (표시 전용) if (stage) { const state = stage.state; From cb86e4b40fd825939c3f141c8292c6278042764c Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 18 Jul 2026 18:31:21 +0900 Subject: [PATCH 2/5] =?UTF-8?q?=EB=B0=B1=EC=97=94=EB=93=9C=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- B05_wf2_Route/B05_wf2_Route_Debug.py | 18 ++++ B05_wf2_Route/B05_wf2_Route_Engine.py | 46 ++++++++- B05_wf2_Route/B05_wf2_Route_Router.py | 124 +++++++++++++++++++---- B05_wf2_Route/B05_wf2_Route_UI_Page.ts | 1 + B05_wf2_Route/B05_wf2_Route_UI_Style.css | 10 +- 5 files changed, 177 insertions(+), 22 deletions(-) create mode 100644 B05_wf2_Route/B05_wf2_Route_Debug.py diff --git a/B05_wf2_Route/B05_wf2_Route_Debug.py b/B05_wf2_Route/B05_wf2_Route_Debug.py new file mode 100644 index 00000000..db96c7bb --- /dev/null +++ b/B05_wf2_Route/B05_wf2_Route_Debug.py @@ -0,0 +1,18 @@ +"""B05 DEBUG 모드 전용 구조화 로그.""" + +import json +import logging +from typing import Any + +from config.config_system import DEBUG + + +def log_b05_debug(logger: logging.Logger, event: str, **payload: Any) -> None: + """DEBUG=True일 때만 B05 계산·DB 저장 정보를 출력한다.""" + if not DEBUG: + return + logger.info( + "[B05 DEBUG] %s | %s", + event, + json.dumps(payload, ensure_ascii=False, default=str), + ) diff --git a/B05_wf2_Route/B05_wf2_Route_Engine.py b/B05_wf2_Route/B05_wf2_Route_Engine.py index 0b751b09..01c73135 100644 --- a/B05_wf2_Route/B05_wf2_Route_Engine.py +++ b/B05_wf2_Route/B05_wf2_Route_Engine.py @@ -5,9 +5,11 @@ GeoJSON으로 저장하며 DB 기록용 데이터(메타·렌더링 샘플·통 라우터에서 asyncio.to_thread로 호출한다. """ +import logging from pathlib import Path from typing import Any +from B05_wf2_Route.B05_wf2_Route_Debug import log_b05_debug from B05_wf2_Route.B05_wf2_Route_Engine_RidgeValley import solve_ridge_valley_route from B05_wf2_Route.B05_wf2_Route_Engine_Solver import solve_optimal_route from common_util.common_util_json import atomic_write_json @@ -15,6 +17,7 @@ from common_util.common_util_json import atomic_write_json _ROUTE_SUBDIR = Path("B05_wf2_Route") / "route" # route_points 테이블에 저장할 렌더링 샘플 최대 개수 _MAX_RENDER_POINTS = 500 +logger = logging.getLogger(__name__) def _route_geojson(polyline: list[list[float]]) -> dict[str, Any]: @@ -87,6 +90,22 @@ def run_route_design( - render_points: route_points 테이블 저장용 샘플 - statistics: route_statistics 저장용 요약 """ + log_b05_debug( + logger, + "calculation.start", + algorithm=algorithm, + filter_key=filter_key, + method=method, + smooth=smooth, + point_counts={ + "bp": 1 if points_data.get("bp") else 0, + "ep": 1 if points_data.get("ep") else 0, + "cp": len(points_data.get("cp", [])), + "ap": len(points_data.get("ap", [])), + "fp": len(points_data.get("fp", [])), + }, + options=options, + ) if algorithm == "ridge_valley": result = solve_ridge_valley_route( project_root, filter_key, smooth, points_data, options, method=method @@ -97,11 +116,27 @@ def run_route_design( ) polyline = result["polyline"] chainage_m = result["chainage_m"] + log_b05_debug( + logger, + "calculation.solver_complete", + algorithm=algorithm, + polyline_count=len(polyline), + segment_count=len(result.get("segments", [])), + metrics=result.get("metrics", {}), + required_points_ok=result.get("required_points_ok"), + warning_count=len(result.get("curve_warning_segments", [])), + ) route_dir = project_root / _ROUTE_SUBDIR route_dir.mkdir(parents=True, exist_ok=True) geojson_path = route_dir / "route_main.geojson" atomic_write_json(geojson_path, _route_geojson(polyline)) + log_b05_debug( + logger, + "calculation.geojson_saved", + path=geojson_path.relative_to(project_root).as_posix(), + coordinate_count=len(polyline), + ) # 통계 요약 (solver 메트릭에서 파생) metrics = result["metrics"] @@ -112,10 +147,19 @@ def run_route_design( "cost_score": None, } + render_points = _sample_render_points(polyline, chainage_m, _MAX_RENDER_POINTS) + log_b05_debug( + logger, + "calculation.output_prepared", + render_point_count=len(render_points), + first_render_point=render_points[0] if render_points else None, + last_render_point=render_points[-1] if render_points else None, + statistics=statistics, + ) return { "route_data_path": geojson_path.relative_to(project_root).as_posix(), "solver_result": result, - "render_points": _sample_render_points(polyline, chainage_m, _MAX_RENDER_POINTS), + "render_points": render_points, "statistics": statistics, "grade_percent": [seg.get("max_grade_pct") for seg in result.get("segments", [])], "constraints": result.get("conditions_snapshot", {}), diff --git a/B05_wf2_Route/B05_wf2_Route_Router.py b/B05_wf2_Route/B05_wf2_Route_Router.py index 2b3aa4f7..755ff91a 100644 --- a/B05_wf2_Route/B05_wf2_Route_Router.py +++ b/B05_wf2_Route/B05_wf2_Route_Router.py @@ -10,6 +10,7 @@ from fastapi import APIRouter from fastapi.responses import JSONResponse from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path +from B05_wf2_Route.B05_wf2_Route_Debug import log_b05_debug from B05_wf2_Route.B05_wf2_Route_Engine import run_route_design from B05_wf2_Route.B05_wf2_Route_Repository import ( confirm_route, @@ -55,10 +56,23 @@ async def solve_route( "algorithm": request.algorithm, "surface_model_id": request.surface_model_id, } + log_b05_debug( + logger, + "request.solve", + project_id=str(project_id), + workflow_stage_params=params, + ) async with pool.acquire() as connection: async with connection.cursor() as cursor: await start_stage(cursor, str(project_id), 2, params) await connection.commit() + log_b05_debug( + logger, + "db.project_workflow_stages.start_stage_committed", + project_id=str(project_id), + stage_no=2, + params=params, + ) stored_path = await get_project_storage_relative_path(connection, project_id) project_root = Path(resolve_stored_project_path(stored_path)) @@ -76,38 +90,88 @@ async def solve_route( ) solver = design["solver_result"] metrics = solver["metrics"] + log_b05_debug( + logger, + "calculation.complete", + project_id=str(project_id), + metrics=metrics, + required_points_ok=solver.get("required_points_ok"), + route_data_path=design["route_data_path"], + ) await connection.begin() try: - route_id = await create_route( - connection, - project_id=project_id, - surface_model_id=request.surface_model_id, - total_length_m=metrics.get("length_m"), - start_chainage_m=0.0, - end_chainage_m=metrics.get("length_m"), - grade_percent=design["grade_percent"], - constraints=design["constraints"], - algorithm_params={ + route_record = { + "project_id": str(project_id), + "surface_model_id": request.surface_model_id, + "status": "DRAFT", + "start_chainage_m": 0.0, + "end_chainage_m": metrics.get("length_m"), + "total_length_m": metrics.get("length_m"), + "grade_percent": design["grade_percent"], + "constraints": design["constraints"], + "algorithm_params": { **design["algorithm_params"], "metrics": metrics, "curve_warning_segments": solver.get("curve_warning_segments", []), }, - route_data_path=design["route_data_path"], + "route_data_path": design["route_data_path"], + } + log_b05_debug(logger, "db.routes.insert", record=route_record) + route_id = await create_route( + connection, + project_id=project_id, + surface_model_id=route_record["surface_model_id"], + total_length_m=route_record["total_length_m"], + start_chainage_m=route_record["start_chainage_m"], + end_chainage_m=route_record["end_chainage_m"], + grade_percent=route_record["grade_percent"], + constraints=route_record["constraints"], + algorithm_params=route_record["algorithm_params"], + route_data_path=route_record["route_data_path"], ) - await insert_route_points(connection, route_id, design["render_points"]) + render_points = design["render_points"] + log_b05_debug( + logger, + "db.route_points.insert_many", + route_id=route_id, + row_count=len(render_points), + first_row=render_points[0] if render_points else None, + last_row=render_points[-1] if render_points else None, + ) + await insert_route_points(connection, route_id, render_points) stats = design["statistics"] + statistics_record = { + "route_id": route_id, + "min_slope": stats["min_slope"], + "max_slope": stats["max_slope"], + "mean_slope": stats["mean_slope"], + "cost_score": stats["cost_score"], + } + log_b05_debug( + logger, + "db.route_statistics.insert", + record=statistics_record, + ) await create_route_statistics( connection, - route_id=route_id, - min_slope=stats["min_slope"], - max_slope=stats["max_slope"], - mean_slope=stats["mean_slope"], - cost_score=stats["cost_score"], + **statistics_record, ) await connection.commit() - except Exception: + log_b05_debug( + logger, + "db.route_transaction.committed", + project_id=str(project_id), + route_id=route_id, + ) + except Exception as exc: await connection.rollback() + log_b05_debug( + logger, + "db.route_transaction.rolled_back", + project_id=str(project_id), + reason=str(exc), + ) raise return RouteSolveResponse( @@ -188,12 +252,34 @@ async def confirm_latest_route(project_id: UUID) -> RouteConfirmResponse | JSONR ) await connection.begin() try: + log_b05_debug( + logger, + "db.routes.confirm", + project_id=str(project_id), + route_id=latest["id"], + previous_status=latest["status"], + next_status="CONFIRMED", + ) await confirm_route(connection, latest["id"]) async with connection.cursor() as cursor: await complete_stage(cursor, str(project_id), 2) await connection.commit() - except Exception: + log_b05_debug( + logger, + "db.route_confirmation.committed", + project_id=str(project_id), + route_id=latest["id"], + completed_stage=2, + ) + except Exception as exc: await connection.rollback() + log_b05_debug( + logger, + "db.route_confirmation.rolled_back", + project_id=str(project_id), + route_id=latest["id"], + reason=str(exc), + ) raise return RouteConfirmResponse(project_id=str(project_id), route_id=latest["id"]) except Exception: diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Page.ts b/B05_wf2_Route/B05_wf2_Route_UI_Page.ts index 2f4eb8a3..e079d58f 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Page.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Page.ts @@ -224,6 +224,7 @@ export async function renderB05Route(root: HTMLElement): Promise { await confirmRoute(activeProjectId); renderLatest(await fetchLatestRoute(activeProjectId)); showToast("경로를 확정했습니다.", "success"); + goToWorkflowStage(activeProjectId, WORKFLOW_STEP_ROUTES[3]); } catch (error) { showToast(error instanceof Error ? error.message : "경로 확정에 실패했습니다.", "error"); } finally { diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Style.css b/B05_wf2_Route/B05_wf2_Route_UI_Style.css index eafd1d92..8146a94c 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Style.css +++ b/B05_wf2_Route/B05_wf2_Route_UI_Style.css @@ -29,16 +29,19 @@ .b05-route__panel { display: flex; flex-direction: column; + width: 100%; + min-width: 0; gap: var(--spacing-16); - min-width: 300px; padding-bottom: var(--spacing-24); } .b05-route__panel-section { display: flex; flex-direction: column; + width: 100%; + min-width: 0; gap: var(--spacing-8); - padding: var(--spacing-16); + padding: calc(var(--spacing-8) + var(--spacing-4)); border: 1px solid var(--color-border); border-radius: var(--radius-cards); background: var(--color-surface-raised); @@ -55,6 +58,7 @@ .b05-route__metrics { display: flex; flex-direction: column; + min-width: 0; gap: var(--spacing-8); } @@ -69,6 +73,7 @@ .b05-route__field select, .b05-route__panel-section > select { width: 100%; + min-width: 0; padding: var(--spacing-8); border: 1px solid var(--color-border); border-radius: var(--radius-inputs); @@ -86,6 +91,7 @@ .b05-route__palette { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); + min-width: 0; gap: var(--spacing-8); } From babf558d372a3c966e8238504855b4f4fdee0530 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 18 Jul 2026 18:49:08 +0900 Subject: [PATCH 3/5] 260718_3 --- B05_wf2_Route/B05_wf2_Route_UI_Page.ts | 1 + B05_wf2_Route/B05_wf2_Route_UI_Style.css | 19 ++++++++++++++++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Page.ts b/B05_wf2_Route/B05_wf2_Route_UI_Page.ts index e079d58f..b54b1eee 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Page.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Page.ts @@ -270,5 +270,6 @@ export async function renderB05Route(root: HTMLElement): Promise { routes: WORKFLOW_STEP_ROUTES, onStepClick: (stepIndex) => goToWorkflowStage(activeProjectId, WORKFLOW_STEP_ROUTES[stepIndex]), }); + layout.root.classList.add("b05-route-layout"); root.replaceChildren(layout.root); } diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Style.css b/B05_wf2_Route/B05_wf2_Route_UI_Style.css index 8146a94c..a88f8da6 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Style.css +++ b/B05_wf2_Route/B05_wf2_Route_UI_Style.css @@ -1,8 +1,25 @@ +.b05-route-layout { + height: calc(100vh - var(--spacing-64)); + height: calc(100dvh - var(--spacing-64)); + min-height: 0; + overflow: hidden; +} + +.b05-route-layout .ui-workflow-layout__body, +.b05-route-layout .ui-workflow-layout__main { + height: 100%; + min-height: 0; +} + +.b05-route-layout .ui-workflow-layout__main { + overflow: hidden; +} + .b05-route__viewport { position: relative; width: 100%; height: 100%; - min-height: 560px; + min-height: 0; overflow: hidden; background: var(--color-surface); } From 50da12c4914e65ea7994d9d58e15101ca7dd28d5 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 18 Jul 2026 18:57:24 +0900 Subject: [PATCH 4/5] 260718_4 --- A00_Common/app_shell.ts | 15 +++++++++++---- A00_Common/main.ts | 15 +++++++++++++-- 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/A00_Common/app_shell.ts b/A00_Common/app_shell.ts index 99356d58..cd37da17 100644 --- a/A00_Common/app_shell.ts +++ b/A00_Common/app_shell.ts @@ -184,16 +184,20 @@ function buildFooter(): HTMLElement { } /* ----------------------------------------------------------------------------- - * 셸 렌더 — #app 안에 헤더 + outlet + 푸터를 구성하고 outlet 반환 + * 셸 렌더 — #app 안에 헤더 + outlet 및 선택적 푸터를 구성하고 outlet 반환 * -------------------------------------------------------------------------- */ -export function renderShell(appRoot: HTMLElement): HTMLElement { +export function renderShell(appRoot: HTMLElement, showFooter = true): HTMLElement { appRoot.innerHTML = ""; const header = buildHeader(); const outlet = document.createElement("main"); outlet.className = "app-outlet"; outlet.id = "app-outlet"; - const footer = buildFooter(); - appRoot.append(header, outlet, footer); + appRoot.append(header, outlet); + if (showFooter) { + appRoot.append(buildFooter()); + } else { + outlet.classList.add("app-outlet--without-footer"); + } return outlet; } @@ -250,6 +254,9 @@ const SHELL_CSS = ` .app-outlet { min-height: calc(100vh - 64px - 56px); } +.app-outlet--without-footer { + min-height: calc(100vh - 64px); +} .app-footer { height: 56px; display: flex; diff --git a/A00_Common/main.ts b/A00_Common/main.ts index 33baaa29..90c3e00b 100644 --- a/A00_Common/main.ts +++ b/A00_Common/main.ts @@ -10,8 +10,18 @@ import "@ui/ui_template_theme.css"; import { injectBaseStyles } from "@ui/ui_template_elements"; +import { ROUTES, type RoutePath } from "@config/config_frontend"; import { initThemeAndLang, injectShellStyles, renderShell } from "./app_shell"; -import { renderCurrentRoute, ensureInitialHash } from "./router"; +import { currentRoute, renderCurrentRoute, ensureInitialHash } from "./router"; + +const FOOTERLESS_ROUTES: readonly RoutePath[] = [ + ROUTES.B04_WF1_SURFACE, + ROUTES.B05_WF2_ROUTE, + ROUTES.B06_WF3_PROFILE_CROSS, + ROUTES.B07_WF4_DESIGN_DETAIL, + ROUTES.B08_WF5_QUANTITY, + ROUTES.B09_WF6_ESTIMATION, +]; function bootstrap(): void { const appRoot = document.getElementById("app"); @@ -28,7 +38,8 @@ function bootstrap(): void { // 3. 셸 렌더 + 현재 라우트 렌더를 함께 수행하는 헬퍼 const renderAll = (): void => { - const outlet = renderShell(appRoot); // 헤더/푸터(로그인·언어 반영) 갱신 + const showFooter = !FOOTERLESS_ROUTES.includes(currentRoute()); + const outlet = renderShell(appRoot, showFooter); // 헤더/푸터(로그인·언어 반영) 갱신 void renderCurrentRoute(outlet); // 아울렛에 페이지 렌더 }; From 3402e69828e311c5eac70209e73d41719e4a0a25 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 18 Jul 2026 19:04:30 +0900 Subject: [PATCH 5/5] 260718_6 --- A00_Common/main.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/A00_Common/main.ts b/A00_Common/main.ts index 90c3e00b..5c4b9ed8 100644 --- a/A00_Common/main.ts +++ b/A00_Common/main.ts @@ -15,12 +15,17 @@ import { initThemeAndLang, injectShellStyles, renderShell } from "./app_shell"; import { currentRoute, renderCurrentRoute, ensureInitialHash } from "./router"; const FOOTERLESS_ROUTES: readonly RoutePath[] = [ + ROUTES.B01_ACCOUNT, + ROUTES.B02_PROJ_REGISTER, + ROUTES.B03_FILE_INPUT, ROUTES.B04_WF1_SURFACE, ROUTES.B05_WF2_ROUTE, ROUTES.B06_WF3_PROFILE_CROSS, ROUTES.B07_WF4_DESIGN_DETAIL, ROUTES.B08_WF5_QUANTITY, ROUTES.B09_WF6_ESTIMATION, + ROUTES.B10_PAYMENT, + ROUTES.B11_STATUS, ]; function bootstrap(): void {