B05 페이지 초안
This commit is contained in:
@@ -7,12 +7,10 @@ from typing import Any
|
|||||||
from uuid import UUID, uuid4
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
import aiomysql
|
import aiomysql
|
||||||
from fastapi import APIRouter, File, Form, UploadFile
|
from fastapi import APIRouter, Depends, File, Form, UploadFile
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
from B03_FileInput.B03_FileInput_Email import (
|
from B03_FileInput.B03_FileInput_Email import (
|
||||||
send_analysis_completion_email,
|
|
||||||
send_analysis_error_email,
|
|
||||||
send_file_upload_complete_email,
|
send_file_upload_complete_email,
|
||||||
)
|
)
|
||||||
from B03_FileInput.B03_FileInput_Engine import (
|
from B03_FileInput.B03_FileInput_Engine import (
|
||||||
@@ -44,20 +42,17 @@ from B03_FileInput.B03_FileInput_Schema import (
|
|||||||
UploadFinalizeRequest,
|
UploadFinalizeRequest,
|
||||||
UploadStatusResponse,
|
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_json import atomic_write_json
|
||||||
from common_util.common_util_storage import resolve_stored_project_path
|
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 import load_project_workflow
|
||||||
from common_util.common_util_workflow_state import (
|
from common_util.common_util_workflow_state import (
|
||||||
complete_stage,
|
complete_stage,
|
||||||
fail_stage,
|
|
||||||
get_workflow_state,
|
get_workflow_state,
|
||||||
start_stage,
|
|
||||||
)
|
)
|
||||||
from config.config_db import get_db_pool
|
from config.config_db import get_db_pool
|
||||||
from config.config_system import (
|
from config.config_system import (
|
||||||
SEND_ANALYSIS_COMPLETION_EMAIL,
|
|
||||||
SURFACE_MODEL_PRECOMPUTE,
|
|
||||||
SURFACE_MODEL_SOURCE_FILTERS,
|
|
||||||
UPLOAD_CHUNK_SIZE_BYTES,
|
UPLOAD_CHUNK_SIZE_BYTES,
|
||||||
UPLOAD_MAX_FILES,
|
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)
|
@router.post("/{project_id}/files", response_model=FileUploadResponse)
|
||||||
async def upload_project_files(
|
async def upload_project_files(
|
||||||
project_id: UUID,
|
project_id: UUID,
|
||||||
files: list[UploadFile] = File(...),
|
files: list[UploadFile] = File(...),
|
||||||
|
session: dict[str, Any] = Depends(verify_session),
|
||||||
) -> FileUploadResponse | JSONResponse:
|
) -> FileUploadResponse | JSONResponse:
|
||||||
"""프로젝트 입력 파일을 저장·분석하고 DB 메타데이터를 기록한다."""
|
"""프로젝트 입력 파일을 저장·분석하고 DB 메타데이터를 기록한다."""
|
||||||
if not files or len(files) > UPLOAD_MAX_FILES:
|
if not files or len(files) > UPLOAD_MAX_FILES:
|
||||||
@@ -391,6 +250,7 @@ async def upload_project_files(
|
|||||||
trigger_wf1_analysis_and_email(
|
trigger_wf1_analysis_and_email(
|
||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
input_file_id=point_cloud_result.input_file_id,
|
input_file_id=point_cloud_result.input_file_id,
|
||||||
|
user_role=str(session["role"]),
|
||||||
),
|
),
|
||||||
task_name=f"b04-wf1-auto-{project_id}",
|
task_name=f"b04-wf1-auto-{project_id}",
|
||||||
)
|
)
|
||||||
@@ -525,6 +385,7 @@ async def upload_project_chunk(
|
|||||||
async def finalize_project_upload(
|
async def finalize_project_upload(
|
||||||
project_id: UUID,
|
project_id: UUID,
|
||||||
payload: UploadFinalizeRequest,
|
payload: UploadFinalizeRequest,
|
||||||
|
session: dict[str, Any] = Depends(verify_session),
|
||||||
) -> FileUploadResponse | JSONResponse:
|
) -> FileUploadResponse | JSONResponse:
|
||||||
"""청크 업로드를 최종 병합하고 input_files 메타데이터를 기록한다."""
|
"""청크 업로드를 최종 병합하고 input_files 메타데이터를 기록한다."""
|
||||||
pool = get_db_pool()
|
pool = get_db_pool()
|
||||||
@@ -617,6 +478,7 @@ async def finalize_project_upload(
|
|||||||
trigger_wf1_analysis_and_email(
|
trigger_wf1_analysis_and_email(
|
||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
input_file_id=result.input_file_id,
|
input_file_id=result.input_file_id,
|
||||||
|
user_role=str(session["role"]),
|
||||||
),
|
),
|
||||||
task_name=f"b04-wf1-auto-{project_id}",
|
task_name=f"b04-wf1-auto-{project_id}",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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),
|
||||||
|
)
|
||||||
@@ -12,6 +12,7 @@ import { createButton, createTag, showToast } from "@ui/ui_template_elements";
|
|||||||
import { createGeneralLayout } from "@ui/ui_template_general_layout";
|
import { createGeneralLayout } from "@ui/ui_template_general_layout";
|
||||||
import { createWorkflowOverlays } from "@ui/ui_template_overlay";
|
import { createWorkflowOverlays } from "@ui/ui_template_overlay";
|
||||||
import { createStepBar, WORKFLOW_STEP_ICONS } from "@ui/ui_template_workflow_layout";
|
import { createStepBar, WORKFLOW_STEP_ICONS } from "@ui/ui_template_workflow_layout";
|
||||||
|
import { fetchDashboardMe } from "../B01_Dashboard/B01_Dashboard_Api_Fetch";
|
||||||
import {
|
import {
|
||||||
checkWF1AnalysisStatus,
|
checkWF1AnalysisStatus,
|
||||||
createUploadSession,
|
createUploadSession,
|
||||||
@@ -50,6 +51,9 @@ function L(key: keyof typeof ui_locales): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||||
|
const dashboardUser = await fetchDashboardMe();
|
||||||
|
const completionRoute =
|
||||||
|
dashboardUser.role === "SYSTEM_ADMIN" ? ROUTES.B04_WF1_SURFACE : ROUTES.B05_WF2_ROUTE;
|
||||||
const slots = initializeSlots();
|
const slots = initializeSlots();
|
||||||
const cardMap = new Map<FileSlot, HTMLElement>();
|
const cardMap = new Map<FileSlot, HTMLElement>();
|
||||||
const resultList = document.createElement("ul");
|
const resultList = document.createElement("ul");
|
||||||
@@ -441,6 +445,14 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
|||||||
if (status.status === "completed") {
|
if (status.status === "completed") {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
status.current_stage === "awaiting_confirmation" &&
|
||||||
|
status.message.includes("자동 확정 보류")
|
||||||
|
) {
|
||||||
|
pageError.textContent = status.message;
|
||||||
|
showToast(status.message, "warning");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
await new Promise((resolve) => setTimeout(resolve, 5000));
|
await new Promise((resolve) => setTimeout(resolve, 5000));
|
||||||
}
|
}
|
||||||
@@ -468,7 +480,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
|||||||
const analysisComplete = await pollWF1Analysis(activeProjectId);
|
const analysisComplete = await pollWF1Analysis(activeProjectId);
|
||||||
|
|
||||||
if (analysisComplete) {
|
if (analysisComplete) {
|
||||||
navigateTo(ROUTES.B04_WF1_SURFACE);
|
navigateTo(completionRoute);
|
||||||
} else {
|
} else {
|
||||||
showToast(L("B03_File_Analysis_StillRunning"), "warning");
|
showToast(L("B03_File_Analysis_StillRunning"), "warning");
|
||||||
}
|
}
|
||||||
@@ -576,7 +588,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
|||||||
label: L("B03_File_Restore_State"),
|
label: L("B03_File_Restore_State"),
|
||||||
container: resumeBanner,
|
container: resumeBanner,
|
||||||
poll: pollWF1Analysis,
|
poll: pollWF1Analysis,
|
||||||
onComplete: () => navigateTo(ROUTES.B04_WF1_SURFACE),
|
onComplete: () => navigateTo(completionRoute),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,11 @@ export interface SurfaceConfirmResponse {
|
|||||||
confirmed: boolean;
|
confirmed: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SurfaceConfirmOptions {
|
||||||
|
smooth: boolean;
|
||||||
|
contour_interval_m: number;
|
||||||
|
}
|
||||||
|
|
||||||
/** 저장된 지표면 모델 요약 (SurfaceModelSummary) */
|
/** 저장된 지표면 모델 요약 (SurfaceModelSummary) */
|
||||||
export interface SurfaceModelSummary {
|
export interface SurfaceModelSummary {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -152,10 +157,11 @@ export async function listSurfaceModels(projectId: string): Promise<SurfaceModel
|
|||||||
export async function confirmSurfaceModel(
|
export async function confirmSurfaceModel(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
modelId: number,
|
modelId: number,
|
||||||
|
options: SurfaceConfirmOptions,
|
||||||
): Promise<SurfaceConfirmResponse> {
|
): Promise<SurfaceConfirmResponse> {
|
||||||
return requestJson<SurfaceConfirmResponse>(`/projects/${projectId}/surface/confirm`, {
|
return requestJson<SurfaceConfirmResponse>(`/projects/${projectId}/surface/confirm`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ model_id: modelId }),
|
body: JSON.stringify({ model_id: modelId, ...options }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from uuid import UUID
|
|||||||
|
|
||||||
import aiomysql
|
import aiomysql
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter, Depends
|
||||||
from fastapi.responses import FileResponse, JSONResponse
|
from fastapi.responses import FileResponse, JSONResponse
|
||||||
|
|
||||||
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
|
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 (
|
from B04_wf1_Surface.B04_wf1_Surface_Repository import (
|
||||||
clear_confirmed_surface_models,
|
clear_confirmed_surface_models,
|
||||||
confirm_surface_model,
|
|
||||||
get_input_file,
|
get_input_file,
|
||||||
list_project_point_cloud_inputs,
|
list_project_point_cloud_inputs,
|
||||||
list_surface_models,
|
list_surface_models,
|
||||||
@@ -38,10 +37,12 @@ from B04_wf1_Surface.B04_wf1_Surface_Schema import (
|
|||||||
SurfaceModelSummary,
|
SurfaceModelSummary,
|
||||||
SurfacePointCloudSampleResponse,
|
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_json import atomic_write_json
|
||||||
from common_util.common_util_storage import resolve_stored_project_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 (
|
from common_util.common_util_workflow_state import (
|
||||||
complete_stage,
|
|
||||||
fail_stage,
|
fail_stage,
|
||||||
start_stage,
|
start_stage,
|
||||||
update_stage_progress,
|
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)
|
@router.post("/{project_id}/surface/confirm", response_model=SurfaceConfirmResponse)
|
||||||
async def confirm_surface(
|
async def confirm_surface(
|
||||||
project_id: UUID, request: SurfaceConfirmRequest
|
project_id: UUID,
|
||||||
|
request: SurfaceConfirmRequest,
|
||||||
|
_session: dict[str, Any] = Depends(require_system_admin),
|
||||||
) -> SurfaceConfirmResponse | JSONResponse:
|
) -> SurfaceConfirmResponse | JSONResponse:
|
||||||
"""사용자가 선택한 지표면 모델을 확정하고 WF1을 완료한다."""
|
"""사용자가 선택한 지표면 모델을 확정하고 WF1을 완료한다."""
|
||||||
pool = get_db_pool()
|
pool = get_db_pool()
|
||||||
@@ -213,9 +216,39 @@ async def confirm_surface(
|
|||||||
async with pool.acquire() as connection:
|
async with pool.acquire() as connection:
|
||||||
await connection.begin()
|
await connection.begin()
|
||||||
try:
|
try:
|
||||||
await confirm_surface_model(connection, project_id, request.model_id)
|
models = await list_surface_models(connection, project_id)
|
||||||
async with connection.cursor() as cursor:
|
selected_model = next(
|
||||||
await complete_stage(cursor, str(project_id), 1)
|
(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()
|
await connection.commit()
|
||||||
except Exception:
|
except Exception:
|
||||||
await connection.rollback()
|
await connection.rollback()
|
||||||
|
|||||||
@@ -48,6 +48,12 @@ class SurfaceConfirmRequest(BaseModel):
|
|||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
model_id: int = Field(gt=0, description="확정할 surface_models.id")
|
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):
|
class SurfaceConfirmResponse(BaseModel):
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
showToast,
|
showToast,
|
||||||
} from "@ui/ui_template_elements";
|
} from "@ui/ui_template_elements";
|
||||||
import { createWorkflowLayout } from "@ui/ui_template_workflow_layout";
|
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 { workflowSteps } from "../A00_Common/b_page_scaffold";
|
||||||
import {
|
import {
|
||||||
fetchWorkflowState,
|
fetchWorkflowState,
|
||||||
@@ -83,6 +84,20 @@ function getModelFilter(model: SurfaceModelSummary): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function renderB04Surface(root: HTMLElement): Promise<void> {
|
export async function renderB04Surface(root: HTMLElement): Promise<void> {
|
||||||
|
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 selectedInputFile: SurfaceInputFileSummary | null = null;
|
||||||
let inputFiles: SurfaceInputFileSummary[] = [];
|
let inputFiles: SurfaceInputFileSummary[] = [];
|
||||||
let models: SurfaceModelSummary[] = [];
|
let models: SurfaceModelSummary[] = [];
|
||||||
@@ -332,7 +347,10 @@ export async function renderB04Surface(root: HTMLElement): Promise<void> {
|
|||||||
}
|
}
|
||||||
showLoadingOverlay();
|
showLoadingOverlay();
|
||||||
try {
|
try {
|
||||||
await confirmSurfaceModel(projectId, model.id);
|
await confirmSurfaceModel(projectId, model.id, {
|
||||||
|
smooth: terrainViewer.isSmoothingEnabled(),
|
||||||
|
contour_interval_m: terrainViewer.getContourInterval(),
|
||||||
|
});
|
||||||
showToast(
|
showToast(
|
||||||
L("B04_Surface_Confirm_Success")
|
L("B04_Surface_Confirm_Success")
|
||||||
.replace("{filter}", filterGroup.select.value)
|
.replace("{filter}", filterGroup.select.value)
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export interface SurfaceTerrainViewer {
|
|||||||
onCameraChange: (listener: (state: SurfaceCameraState) => void) => void;
|
onCameraChange: (listener: (state: SurfaceCameraState) => void) => void;
|
||||||
onAxesVisibilityChange: (listener: (visible: boolean) => void) => void;
|
onAxesVisibilityChange: (listener: (visible: boolean) => void) => void;
|
||||||
isSmoothingEnabled: () => boolean;
|
isSmoothingEnabled: () => boolean;
|
||||||
|
getContourInterval: () => number;
|
||||||
resetOptions: () => void;
|
resetOptions: () => void;
|
||||||
dispose: () => void;
|
dispose: () => void;
|
||||||
}
|
}
|
||||||
@@ -650,6 +651,9 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
|
|||||||
isSmoothingEnabled() {
|
isSmoothingEnabled() {
|
||||||
return !smoothCheck.disabled && smoothCheck.checked;
|
return !smoothCheck.disabled && smoothCheck.checked;
|
||||||
},
|
},
|
||||||
|
getContourInterval() {
|
||||||
|
return Number.parseFloat(intervalInput.value);
|
||||||
|
},
|
||||||
resetOptions() {
|
resetOptions() {
|
||||||
axesCheck.checked = false;
|
axesCheck.checked = false;
|
||||||
axes.visible = false;
|
axes.visible = false;
|
||||||
|
|||||||
@@ -17,9 +17,14 @@ import { API_BASE_URL, API_TIMEOUT_MS } from "@config/config_frontend";
|
|||||||
export interface RoutePoint {
|
export interface RoutePoint {
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
|
z?: number;
|
||||||
order?: number;
|
order?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CirclePoint extends RoutePoint {
|
||||||
|
radius_m: number;
|
||||||
|
}
|
||||||
|
|
||||||
/** 경로 탐색 실행 요청 (RouteSolveRequest) */
|
/** 경로 탐색 실행 요청 (RouteSolveRequest) */
|
||||||
export interface RouteSolveRequest {
|
export interface RouteSolveRequest {
|
||||||
filter_key: string;
|
filter_key: string;
|
||||||
@@ -30,10 +35,16 @@ export interface RouteSolveRequest {
|
|||||||
bp: RoutePoint;
|
bp: RoutePoint;
|
||||||
ep: RoutePoint;
|
ep: RoutePoint;
|
||||||
cp?: RoutePoint[];
|
cp?: RoutePoint[];
|
||||||
|
ap?: CirclePoint[];
|
||||||
|
fp?: CirclePoint[];
|
||||||
grade_class?: string;
|
grade_class?: string;
|
||||||
|
paved?: boolean;
|
||||||
min_curve_radius_m?: number | null;
|
min_curve_radius_m?: number | null;
|
||||||
max_uphill_grade?: number | null;
|
max_uphill_grade?: number | null;
|
||||||
max_downhill_grade?: number | null;
|
max_downhill_grade?: number | null;
|
||||||
|
min_uphill_grade?: number | null;
|
||||||
|
min_downhill_grade?: number | null;
|
||||||
|
allow_avoid_pass_through?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 경로 탐색 실행 결과 (RouteSolveResponse) */
|
/** 경로 탐색 실행 결과 (RouteSolveResponse) */
|
||||||
@@ -55,6 +66,40 @@ export interface RouteConfirmResponse {
|
|||||||
confirmed: boolean;
|
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<string, unknown> | null;
|
||||||
|
} | null;
|
||||||
|
route_points: Array<RoutePoint & { chainage_m?: number; slope_percent?: number }>;
|
||||||
|
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<string, unknown>;
|
||||||
|
algorithm?: string;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
/** 공통 fetch 헬퍼: 타임아웃 + 인증 헤더 + 오류 응답 변환. */
|
/** 공통 fetch 헬퍼: 타임아웃 + 인증 헤더 + 오류 응답 변환. */
|
||||||
async function requestJson<T>(path: string, init: RequestInit): Promise<T> {
|
async function requestJson<T>(path: string, init: RequestInit): Promise<T> {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
@@ -96,3 +141,9 @@ export async function confirmRoute(projectId: string): Promise<RouteConfirmRespo
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchLatestRoute(projectId: string): Promise<RouteLatestResponse> {
|
||||||
|
return requestJson<RouteLatestResponse>(`/projects/${projectId}/route/latest`, {
|
||||||
|
method: "GET",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ def _sample_render_points(
|
|||||||
points: list[dict[str, Any]] = []
|
points: list[dict[str, Any]] = []
|
||||||
for seq, idx in enumerate(indices):
|
for seq, idx in enumerate(indices):
|
||||||
idx = min(idx, n - 1)
|
idx = min(idx, n - 1)
|
||||||
_, _, z = polyline[idx]
|
x, y, z = polyline[idx]
|
||||||
# 국소 경사(%) — 직전 정점과의 차이
|
# 국소 경사(%) — 직전 정점과의 차이
|
||||||
slope_pct = 0.0
|
slope_pct = 0.0
|
||||||
if idx > 0:
|
if idx > 0:
|
||||||
@@ -56,6 +56,9 @@ def _sample_render_points(
|
|||||||
slope_pct = abs(z1 - z0) / h * 100.0
|
slope_pct = abs(z1 - z0) / h * 100.0
|
||||||
points.append(
|
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,
|
"chainage_m": round(chainage_m[idx], 3) if idx < len(chainage_m) else None,
|
||||||
"elevation_m": round(z, 3),
|
"elevation_m": round(z, 3),
|
||||||
"slope_percent": round(slope_pct, 3),
|
"slope_percent": round(slope_pct, 3),
|
||||||
|
|||||||
@@ -79,13 +79,16 @@ async def insert_route_points(
|
|||||||
) -> int:
|
) -> 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:
|
if not points:
|
||||||
return 0
|
return 0
|
||||||
rows = [
|
rows = [
|
||||||
(
|
(
|
||||||
route_id,
|
route_id,
|
||||||
|
point.get("x"),
|
||||||
|
point.get("y"),
|
||||||
|
point.get("z"),
|
||||||
point.get("chainage_m"),
|
point.get("chainage_m"),
|
||||||
point.get("elevation_m"),
|
point.get("elevation_m"),
|
||||||
point.get("slope_percent"),
|
point.get("slope_percent"),
|
||||||
@@ -97,9 +100,10 @@ async def insert_route_points(
|
|||||||
await cursor.executemany(
|
await cursor.executemany(
|
||||||
"""
|
"""
|
||||||
INSERT INTO route_points (
|
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,
|
rows,
|
||||||
)
|
)
|
||||||
@@ -149,13 +153,16 @@ async def get_latest_route(
|
|||||||
connection: aiomysql.Connection, project_id: UUID
|
connection: aiomysql.Connection, project_id: UUID
|
||||||
) -> dict[str, Any] | None:
|
) -> dict[str, Any] | None:
|
||||||
"""프로젝트의 최신 경로를 조회한다 (없으면 None)."""
|
"""프로젝트의 최신 경로를 조회한다 (없으면 None)."""
|
||||||
async with connection.cursor() as cursor:
|
async with connection.cursor(aiomysql.DictCursor) as cursor:
|
||||||
await cursor.execute(
|
await cursor.execute(
|
||||||
"""
|
"""
|
||||||
SELECT id, status, total_length_m, route_data_path, computed_at
|
SELECT r.id, r.status, r.surface_model_id, r.total_length_m,
|
||||||
FROM routes
|
r.route_data_path, r.constraints, r.algorithm_params, r.computed_at,
|
||||||
WHERE project_id = %s
|
rs.min_slope, rs.max_slope, rs.mean_slope, rs.cost_score
|
||||||
ORDER BY computed_at DESC, id DESC
|
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
|
LIMIT 1
|
||||||
""",
|
""",
|
||||||
(str(project_id),),
|
(str(project_id),),
|
||||||
@@ -163,13 +170,33 @@ async def get_latest_route(
|
|||||||
row = await cursor.fetchone()
|
row = await cursor.fetchone()
|
||||||
if not row:
|
if not row:
|
||||||
return None
|
return None
|
||||||
return {
|
result = dict(row)
|
||||||
"id": int(row[0]),
|
result["id"] = int(result["id"])
|
||||||
"status": row[1],
|
result["computed_at"] = result["computed_at"].isoformat() if result["computed_at"] else None
|
||||||
"total_length_m": row[2],
|
for key in ("constraints", "algorithm_params"):
|
||||||
"route_data_path": row[3],
|
if isinstance(result.get(key), str):
|
||||||
"computed_at": row[4].isoformat() if row[4] else None,
|
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:
|
async def confirm_route(connection: aiomysql.Connection, route_id: int) -> None:
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import logging
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
|
import aiomysql
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
@@ -15,15 +16,23 @@ from B05_wf2_Route.B05_wf2_Route_Repository import (
|
|||||||
create_route,
|
create_route,
|
||||||
create_route_statistics,
|
create_route_statistics,
|
||||||
get_latest_route,
|
get_latest_route,
|
||||||
|
get_route_points,
|
||||||
insert_route_points,
|
insert_route_points,
|
||||||
)
|
)
|
||||||
from B05_wf2_Route.B05_wf2_Route_Schema import (
|
from B05_wf2_Route.B05_wf2_Route_Schema import (
|
||||||
RouteConfirmResponse,
|
RouteConfirmResponse,
|
||||||
|
RouteLatestResponse,
|
||||||
RouteSolveRequest,
|
RouteSolveRequest,
|
||||||
RouteSolveResponse,
|
RouteSolveResponse,
|
||||||
)
|
)
|
||||||
from common_util.common_util_storage import resolve_stored_project_path
|
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
|
from config.config_db import get_db_pool
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -41,7 +50,7 @@ async def solve_route(
|
|||||||
"filter_key": request.filter_key,
|
"filter_key": request.filter_key,
|
||||||
"method": request.method,
|
"method": request.method,
|
||||||
"smooth": request.smooth,
|
"smooth": request.smooth,
|
||||||
"points": request.points,
|
"points": request.points_data(),
|
||||||
"options": request.options(),
|
"options": request.options(),
|
||||||
"algorithm": request.algorithm,
|
"algorithm": request.algorithm,
|
||||||
"surface_model_id": request.surface_model_id,
|
"surface_model_id": request.surface_model_id,
|
||||||
@@ -79,7 +88,11 @@ async def solve_route(
|
|||||||
end_chainage_m=metrics.get("length_m"),
|
end_chainage_m=metrics.get("length_m"),
|
||||||
grade_percent=design["grade_percent"],
|
grade_percent=design["grade_percent"],
|
||||||
constraints=design["constraints"],
|
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"],
|
route_data_path=design["route_data_path"],
|
||||||
)
|
)
|
||||||
await insert_route_points(connection, route_id, design["render_points"])
|
await insert_route_points(connection, route_id, design["render_points"])
|
||||||
@@ -92,8 +105,6 @@ async def solve_route(
|
|||||||
mean_slope=stats["mean_slope"],
|
mean_slope=stats["mean_slope"],
|
||||||
cost_score=stats["cost_score"],
|
cost_score=stats["cost_score"],
|
||||||
)
|
)
|
||||||
async with connection.cursor() as cursor:
|
|
||||||
await complete_stage(cursor, str(project_id), 2)
|
|
||||||
await connection.commit()
|
await connection.commit()
|
||||||
except Exception:
|
except Exception:
|
||||||
await connection.rollback()
|
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)
|
@router.post("/{project_id}/route/confirm", response_model=RouteConfirmResponse)
|
||||||
async def confirm_latest_route(project_id: UUID) -> RouteConfirmResponse | JSONResponse:
|
async def confirm_latest_route(project_id: UUID) -> RouteConfirmResponse | JSONResponse:
|
||||||
"""프로젝트의 최신 경로를 확정(CONFIRMED)한다."""
|
"""프로젝트의 최신 경로를 확정(CONFIRMED)한다."""
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ class RoutePoint(BaseModel):
|
|||||||
|
|
||||||
x: float
|
x: float
|
||||||
y: float
|
y: float
|
||||||
|
z: float | None = None
|
||||||
order: int | None = None
|
order: int | None = None
|
||||||
|
|
||||||
|
|
||||||
@@ -49,6 +50,8 @@ class RouteSolveRequest(BaseModel):
|
|||||||
min_curve_radius_m: float | None = None
|
min_curve_radius_m: float | None = None
|
||||||
max_uphill_grade: float | None = None
|
max_uphill_grade: float | None = None
|
||||||
max_downhill_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
|
weights: dict[str, float] | None = None
|
||||||
allow_avoid_pass_through: bool = Field(default=False)
|
allow_avoid_pass_through: bool = Field(default=False)
|
||||||
|
|
||||||
@@ -76,6 +79,8 @@ class RouteSolveRequest(BaseModel):
|
|||||||
"min_curve_radius_m": self.min_curve_radius_m,
|
"min_curve_radius_m": self.min_curve_radius_m,
|
||||||
"max_uphill_grade": self.max_uphill_grade,
|
"max_uphill_grade": self.max_uphill_grade,
|
||||||
"max_downhill_grade": self.max_downhill_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,
|
"weights": self.weights,
|
||||||
"allow_avoid_pass_through": self.allow_avoid_pass_through,
|
"allow_avoid_pass_through": self.allow_avoid_pass_through,
|
||||||
}
|
}
|
||||||
@@ -100,3 +105,14 @@ class RouteConfirmResponse(BaseModel):
|
|||||||
project_id: str
|
project_id: str
|
||||||
route_id: int
|
route_id: int
|
||||||
confirmed: bool = True
|
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
|
||||||
|
|||||||
@@ -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<RoutePointKind, number> = {
|
||||||
|
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<PlacedRoutePoint, "x" | "y" | "z">, 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<PlacedRoutePoint>): 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<typeof createRouteMarkers>;
|
||||||
@@ -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 { CURRENT_PROJECT_ID_KEY } from "@config/config_frontend";
|
||||||
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
|
import { hideLoadingOverlay, showLoadingOverlay, showToast } from "@ui/ui_template_elements";
|
||||||
import {
|
|
||||||
createButton,
|
|
||||||
createInputField,
|
|
||||||
createSelectField,
|
|
||||||
hideLoadingOverlay,
|
|
||||||
showLoadingOverlay,
|
|
||||||
showToast,
|
|
||||||
type InputFieldHandle,
|
|
||||||
} from "@ui/ui_template_elements";
|
|
||||||
import { createWorkflowLayout } from "@ui/ui_template_workflow_layout";
|
import { createWorkflowLayout } from "@ui/ui_template_workflow_layout";
|
||||||
import { workflowSteps } from "../A00_Common/b_page_scaffold";
|
import { workflowSteps } from "../A00_Common/b_page_scaffold";
|
||||||
import {
|
import {
|
||||||
fetchWorkflowState,
|
fetchWorkflowState,
|
||||||
goToWorkflowStage,
|
goToWorkflowStage,
|
||||||
WORKFLOW_STEP_ROUTES,
|
WORKFLOW_STEP_ROUTES,
|
||||||
type WorkflowState,
|
|
||||||
} from "../A00_Common/b_workflow_nav";
|
} from "../A00_Common/b_workflow_nav";
|
||||||
import {
|
import {
|
||||||
confirmRoute,
|
fetchSurfacePointCloud,
|
||||||
solveRoute,
|
|
||||||
type RoutePoint,
|
|
||||||
type RouteSolveResponse,
|
|
||||||
} from "./B05_wf2_Route_Api_Fetch";
|
|
||||||
import {
|
|
||||||
listSurfaceModels,
|
listSurfaceModels,
|
||||||
type SurfaceModelSummary,
|
type SurfaceModelSummary,
|
||||||
} from "../B04_wf1_Surface/B04_wf1_Surface_Api_Fetch";
|
} 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";
|
import "./B05_wf2_Route_UI_Style.css";
|
||||||
|
|
||||||
/** locale 헬퍼 */
|
function toBounds(bounds: {
|
||||||
function L(key: keyof typeof ui_locales): string {
|
x_min: number;
|
||||||
return ui_locales[key][currentLanguageIndex];
|
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) */
|
function placed(
|
||||||
const GRADE_CLASSES = ["trunk", "branch", "work"] as const;
|
type: RoutePointKind,
|
||||||
/** 경로 알고리즘 (Schema.algorithm 허용값) */
|
point: RoutePoint | CirclePoint,
|
||||||
const ALGORITHMS = ["dijkstra", "ridge_valley"] as const;
|
index = 0,
|
||||||
|
): PlacedRoutePoint {
|
||||||
/** X/Y 좌표 한 쌍 입력 행 생성. */
|
return {
|
||||||
function buildPointRow(label: string): {
|
id: `${type}-restored-${index}`,
|
||||||
root: HTMLElement;
|
type,
|
||||||
x: InputFieldHandle;
|
x: point.x,
|
||||||
y: InputFieldHandle;
|
y: point.y,
|
||||||
} {
|
z: point.z ?? 0,
|
||||||
const root = document.createElement("div");
|
...(type === "ap" || type === "fp" ? { radius_m: (point as CirclePoint).radius_m ?? 25 } : {}),
|
||||||
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 };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 숫자 입력값을 파싱. 빈 값이면 null. */
|
function restorePoints(latest: RouteLatestResponse): RouteDesignPoints {
|
||||||
function parseNumber(value: string): number | null {
|
const points = latest.route_params?.points;
|
||||||
const trimmed = value.trim();
|
return {
|
||||||
if (!trimmed) return null;
|
bp: points?.bp ? placed("bp", points.bp) : null,
|
||||||
const parsed = Number(trimmed);
|
ep: points?.ep ? placed("ep", points.ep) : null,
|
||||||
return Number.isFinite(parsed) ? parsed : 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<void> {
|
export async function renderB05Route(root: HTMLElement): Promise<void> {
|
||||||
|
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;
|
let confirmedSurface: SurfaceModelSummary | null = null;
|
||||||
/* ---- 좌측: 경로 제어점 ---- */
|
let latest: RouteLatestResponse | null = null;
|
||||||
const pointsGroup = document.createElement("fieldset");
|
let routeReady = false;
|
||||||
pointsGroup.className = "b05-route__group";
|
let stale = false;
|
||||||
const pointsLegend = document.createElement("legend");
|
let restoring = true;
|
||||||
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 }[] = [];
|
|
||||||
|
|
||||||
function onB05_Route_AddCp_Click(): void {
|
const panel = createRoutePanel({
|
||||||
const row = buildPointRow(L("B05_Route_Point_CP"));
|
onSolve: () => void solve(),
|
||||||
const removeBtn = createButton({
|
onConfirm: () => void confirm(),
|
||||||
label: L("B05_Route_Btn_RemoveCp"),
|
onContourApply: (interval) => void applyContours(interval),
|
||||||
variant: "ghost",
|
onSurfaceVisible: viewer.setSurfaceVisible,
|
||||||
onClick: () => {
|
onContoursVisible: viewer.setContoursVisible,
|
||||||
const idx = cpRows.indexOf(row);
|
onAxesVisible: viewer.setAxesVisible,
|
||||||
if (idx >= 0) cpRows.splice(idx, 1);
|
onView: viewer.setView,
|
||||||
row.root.remove();
|
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);
|
viewer.markers.setPoints(restorePoints(next));
|
||||||
cpRows.push(row);
|
|
||||||
cpContainer.append(row.root);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const addCpButton = createButton({
|
function renderLatest(next: RouteLatestResponse): void {
|
||||||
label: L("B05_Route_Btn_AddCp"),
|
latest = next;
|
||||||
variant: "ghost",
|
routeReady = Boolean(next.route && next.route_points.length > 1);
|
||||||
onClick: () => onB05_Route_AddCp_Click(),
|
stale = false;
|
||||||
});
|
panel.setStale(false);
|
||||||
pointsGroup.append(pointsLegend, bpRow.root, epRow.root, cpContainer, addCpButton);
|
if (next.route) {
|
||||||
|
const stored = next.route.algorithm_params ?? {};
|
||||||
/* ---- 좌측: 기반 지표면 ---- */
|
const metrics = (stored.metrics as Record<string, unknown> | undefined) ?? {
|
||||||
const surfaceGroup = document.createElement("fieldset");
|
length_m: next.route.total_length_m,
|
||||||
surfaceGroup.className = "b05-route__group";
|
min_slope: next.route.min_slope,
|
||||||
const surfaceLegend = document.createElement("legend");
|
max_slope: next.route.max_slope,
|
||||||
surfaceLegend.className = "b05-route__group-legend";
|
mean_slope: next.route.mean_slope,
|
||||||
surfaceLegend.textContent = L("B05_Route_Group_Surface");
|
cost_score: next.route.cost_score,
|
||||||
const filterField = createInputField({ label: L("B05_Route_Field_Filter"), type: "text" });
|
};
|
||||||
const methodField = createInputField({
|
panel.renderMetrics(metrics);
|
||||||
label: L("B05_Route_Field_Method"),
|
viewer.markers.renderRoute(
|
||||||
type: "text",
|
next.route_points,
|
||||||
value: "dtm",
|
panel.values().gradeClass,
|
||||||
});
|
(stored.curve_warning_segments as Array<{
|
||||||
filterField.input.readOnly = true;
|
polyline_start_index: number;
|
||||||
methodField.input.readOnly = true;
|
polyline_end_index: number;
|
||||||
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<string, number | undefined>;
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
const filterKey = filterField.input.value.trim();
|
updateConfirmGate();
|
||||||
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 };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onB05_Route_Solve_Click(): Promise<void> {
|
async function applyContours(interval: number): Promise<void> {
|
||||||
const projectId = getProjectId();
|
showLoadingOverlay();
|
||||||
if (!projectId) return;
|
try {
|
||||||
if (!confirmedSurface) {
|
await viewer.reloadContours(interval);
|
||||||
showToast(L("B05_Route_Surface_NotConfirmed"), "error");
|
} catch (error) {
|
||||||
|
showToast(error instanceof Error ? error.message : "등고선 조회에 실패했습니다.", "error");
|
||||||
|
} finally {
|
||||||
|
hideLoadingOverlay();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function solve(): Promise<void> {
|
||||||
|
if (!confirmedSurface || !latest) return;
|
||||||
|
const points = viewer.markers.getPoints();
|
||||||
|
if (!points.bp || !points.ep) {
|
||||||
|
showToast("BP와 EP를 지형에 배치하세요.", "error");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const base = collectRequest();
|
const values = panel.values();
|
||||||
if (!base) return;
|
|
||||||
|
|
||||||
showLoadingOverlay();
|
showLoadingOverlay();
|
||||||
try {
|
try {
|
||||||
const result = await solveRoute(projectId, {
|
await solveRoute(activeProjectId, {
|
||||||
...base,
|
filter_key: latest.surface_params.source_filter,
|
||||||
method: methodField.input.value.trim() || "dtm",
|
method: latest.surface_params.method,
|
||||||
smooth: smoothBox.checked,
|
smooth: latest.surface_params.smooth,
|
||||||
surface_model_id: confirmedSurface.id,
|
surface_model_id: confirmedSurface.id,
|
||||||
algorithm: algorithmSelect.select.value,
|
algorithm: values.algorithm,
|
||||||
grade_class: gradeSelect.select.value,
|
bp: routePoint(points.bp),
|
||||||
max_uphill_grade: parseNumber(maxUphillField.input.value),
|
ep: routePoint(points.ep),
|
||||||
max_downhill_grade: parseNumber(maxDownhillField.input.value),
|
cp: points.cp.map(routePoint),
|
||||||
min_curve_radius_m: parseNumber(minRadiusField.input.value),
|
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);
|
renderLatest(await fetchLatestRoute(activeProjectId));
|
||||||
showToast(L("B05_Route_Solve_Success"), "success");
|
showToast("최적 경로 계산이 완료되었습니다.", "success");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const detail = error instanceof Error ? error.message : L("B05_Route_Solve_Failed");
|
showToast(error instanceof Error ? error.message : "경로 계산에 실패했습니다.", "error");
|
||||||
showToast(`${L("B05_Route_Solve_Failed")} ${detail}`, "error");
|
|
||||||
} finally {
|
} finally {
|
||||||
hideLoadingOverlay();
|
hideLoadingOverlay();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onB05_Route_Confirm_Click(): Promise<void> {
|
async function confirm(): Promise<void> {
|
||||||
const projectId = getProjectId();
|
if (!routeReady || stale) return;
|
||||||
if (!projectId) return;
|
|
||||||
showLoadingOverlay();
|
showLoadingOverlay();
|
||||||
try {
|
try {
|
||||||
await confirmRoute(projectId);
|
await confirmRoute(activeProjectId);
|
||||||
showToast(L("B05_Route_Confirm_Success"), "success");
|
renderLatest(await fetchLatestRoute(activeProjectId));
|
||||||
|
showToast("경로를 확정했습니다.", "success");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const detail = error instanceof Error ? error.message : L("B05_Route_Confirm_Failed");
|
showToast(error instanceof Error ? error.message : "경로 확정에 실패했습니다.", "error");
|
||||||
showToast(`${L("B05_Route_Confirm_Failed")} ${detail}`, "error");
|
|
||||||
} finally {
|
} finally {
|
||||||
hideLoadingOverlay();
|
hideLoadingOverlay();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
renderEmptyResult();
|
const [workflowState, models, latestResponse] = await Promise.all([
|
||||||
|
fetchWorkflowState(activeProjectId),
|
||||||
let workflowState: WorkflowState | undefined;
|
listSurfaceModels(activeProjectId),
|
||||||
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
|
fetchLatestRoute(activeProjectId),
|
||||||
if (projectId) {
|
]);
|
||||||
try {
|
confirmedSurface = models.models.find((model) => model.status === "CONFIRMED") ?? null;
|
||||||
const [nextWorkflowState, models] = await Promise.all([
|
if (!confirmedSurface) {
|
||||||
fetchWorkflowState(projectId),
|
showToast("확정된 지표면 모델이 없습니다.", "error");
|
||||||
listSurfaceModels(projectId),
|
} else {
|
||||||
]);
|
const cloud = await fetchSurfacePointCloud(
|
||||||
workflowState = nextWorkflowState;
|
activeProjectId,
|
||||||
confirmedSurface = models.models.find((model) => model.status === "CONFIRMED") ?? null;
|
latestResponse.surface_params.source_filter,
|
||||||
if (confirmedSurface) {
|
);
|
||||||
const sourceFilter = confirmedSurface.generation_params?.source_filter;
|
restorePanel(latestResponse);
|
||||||
filterField.input.value = typeof sourceFilter === "string" ? sourceFilter : "";
|
await viewer.loadSurface(
|
||||||
methodField.input.value = confirmedSurface.model_type;
|
activeProjectId,
|
||||||
confirmedModelInfo.textContent = L("B05_Route_Surface_Confirmed")
|
confirmedSurface.id,
|
||||||
.replace("{id}", String(confirmedSurface.id))
|
latestResponse.surface_params.method,
|
||||||
.replace("{method}", confirmedSurface.model_type);
|
latestResponse.surface_params.smooth,
|
||||||
} else {
|
latestResponse.surface_params.contour_interval_m,
|
||||||
solveButton.disabled = true;
|
toBounds(cloud.bounds),
|
||||||
}
|
);
|
||||||
} catch {
|
renderLatest(latestResponse);
|
||||||
solveButton.disabled = true;
|
|
||||||
confirmedModelInfo.textContent = L("B05_Route_Surface_LoadFailed");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
latest = latestResponse;
|
||||||
|
restoring = false;
|
||||||
|
|
||||||
const layout = createWorkflowLayout({
|
const layout = createWorkflowLayout({
|
||||||
title: L("B05_Route_Title"),
|
title: "노선 설계",
|
||||||
steps: workflowSteps(),
|
steps: workflowSteps(),
|
||||||
activeStep: 2,
|
activeStep: 2,
|
||||||
leftPanel: leftForm,
|
leftPanel: panel.root,
|
||||||
mainContent: resultCard,
|
mainContent: viewer.root,
|
||||||
stages: workflowState?.stages,
|
stages: workflowState.stages,
|
||||||
currentStage: workflowState?.current_stage,
|
currentStage: workflowState.current_stage,
|
||||||
routes: WORKFLOW_STEP_ROUTES,
|
routes: WORKFLOW_STEP_ROUTES,
|
||||||
onStepClick: (stepIndex) => {
|
onStepClick: (stepIndex) => goToWorkflowStage(activeProjectId, WORKFLOW_STEP_ROUTES[stepIndex]),
|
||||||
if (projectId) {
|
|
||||||
goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[stepIndex]);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
root.replaceChildren(layout.root);
|
root.replaceChildren(layout.root);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<RoutePointKind, string> = {
|
||||||
|
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 =
|
||||||
|
'<option value="dijkstra">Dijkstra</option><option value="ridge_valley">능선·계곡</option>';
|
||||||
|
const gradeClass = document.createElement("select");
|
||||||
|
gradeClass.innerHTML =
|
||||||
|
'<option value="trunk">간선</option><option value="branch">지선</option><option value="work">작업</option>';
|
||||||
|
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 =
|
||||||
|
"<summary>최적경로란?</summary><p>배치 포인트와 경사·곡선·회피 조건을 만족하며 비용을 최소화한 경로입니다.</p>";
|
||||||
|
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<RoutePanelValues>) {
|
||||||
|
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<string, unknown>) {
|
||||||
|
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<typeof createRoutePanel>;
|
||||||
@@ -1,139 +1,145 @@
|
|||||||
/* =============================================================================
|
.b05-route__viewport {
|
||||||
* B05_wf2_Route_UI_Style.css
|
position: relative;
|
||||||
* 2차 워크플로우(경로 설계) 페이지 전용 스타일.
|
width: 100%;
|
||||||
*
|
height: 100%;
|
||||||
* 원칙(frontend.md §1): 하드코딩 색상 금지. theme.css 변수(var(--...))만 참조.
|
min-height: 560px;
|
||||||
* 공통 컴포넌트 스타일은 ui_template_elements.ts가 주입하므로 여기서는
|
overflow: hidden;
|
||||||
* B05 고유 레이아웃(제어점 행/제약 그룹/결과 메트릭)만 정의한다.
|
background: var(--color-surface);
|
||||||
* ========================================================================== */
|
|
||||||
|
|
||||||
/* --- 좌측 입력 폼 --- */
|
|
||||||
.b05-route__form {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--spacing-16);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.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;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--spacing-16);
|
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);
|
padding: var(--spacing-16);
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: var(--radius-cards);
|
border-radius: var(--radius-cards);
|
||||||
background-color: var(--color-surface-raised);
|
background: var(--color-surface-raised);
|
||||||
}
|
}
|
||||||
|
|
||||||
.b05-route__group-legend {
|
.b05-route__panel-section h3 {
|
||||||
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 {
|
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--color-text-body);
|
color: var(--color-text);
|
||||||
font-size: var(--text-body-sm);
|
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 {
|
.b05-route__check {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--spacing-8);
|
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);
|
color: var(--color-text-body);
|
||||||
|
font-size: var(--text-caption);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.b05-route__check input {
|
.b05-route__button:disabled {
|
||||||
accent-color: var(--color-primary);
|
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 {
|
.b05-route__actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: var(--spacing-8);
|
gap: var(--spacing-8);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- 우측 결과 --- */
|
.b05-route__actions > * {
|
||||||
.b05-route__result {
|
flex: 1;
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--spacing-16);
|
|
||||||
padding: var(--spacing-24);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.b05-route__result-title {
|
.b05-route__stale {
|
||||||
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 {
|
|
||||||
font-size: var(--text-caption);
|
font-size: var(--text-caption);
|
||||||
color: var(--color-text-secondary);
|
font-weight: var(--font-weight-medium);
|
||||||
}
|
}
|
||||||
|
|
||||||
.b05-route__metric-val {
|
.b05-route__panel details {
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: var(--text-body-sm);
|
|
||||||
color: var(--color-text-body);
|
color: var(--color-text-body);
|
||||||
word-break: break-all;
|
font-size: var(--text-caption);
|
||||||
text-align: right;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<void>;
|
||||||
|
reloadContours: (interval: number) => Promise<void>;
|
||||||
|
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<void> {
|
||||||
|
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<THREE.Object3D>((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();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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_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"))
|
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:
|
def build_surface_model_config() -> dict:
|
||||||
"""지표면 모델 파이프라인이 사용하는 config dict를 조립한다."""
|
"""지표면 모델 파이프라인이 사용하는 config dict를 조립한다."""
|
||||||
|
|||||||
@@ -301,6 +301,9 @@ CREATE TABLE IF NOT EXISTS routes (
|
|||||||
CREATE TABLE IF NOT EXISTS route_points (
|
CREATE TABLE IF NOT EXISTS route_points (
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
route_id INT NOT NULL, -- FK는 later
|
route_id INT NOT NULL, -- FK는 later
|
||||||
|
model_x DOUBLE,
|
||||||
|
model_y DOUBLE,
|
||||||
|
model_z DOUBLE,
|
||||||
chainage_m FLOAT,
|
chainage_m FLOAT,
|
||||||
elevation_m FLOAT,
|
elevation_m FLOAT,
|
||||||
slope_percent FLOAT,
|
slope_percent FLOAT,
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -1,6 +1,14 @@
|
|||||||
import "./ui_template_workflow_layout.css";
|
import "./ui_template_workflow_layout.css";
|
||||||
import { t } from "./ui_template_locale";
|
import { t } from "./ui_template_locale";
|
||||||
import { createWorkflowOverlays } from "./ui_template_overlay";
|
import { createWorkflowOverlays } from "./ui_template_overlay";
|
||||||
|
import { fetchDashboardMe } from "../B01_Dashboard/B01_Dashboard_Api_Fetch";
|
||||||
|
|
||||||
|
let dashboardRolePromise: Promise<string> | undefined;
|
||||||
|
|
||||||
|
function getDashboardRole(): Promise<string> {
|
||||||
|
dashboardRolePromise ??= fetchDashboardMe().then((user) => user.role);
|
||||||
|
return dashboardRolePromise;
|
||||||
|
}
|
||||||
|
|
||||||
export interface WorkflowStage {
|
export interface WorkflowStage {
|
||||||
stage_no: number;
|
stage_no: number;
|
||||||
@@ -74,6 +82,18 @@ export function createStepBar(
|
|||||||
button.classList.toggle("is-enabled", isEnabled);
|
button.classList.toggle("is-enabled", isEnabled);
|
||||||
button.disabled = !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 기반 스타일링 (표시 전용)
|
// state 기반 스타일링 (표시 전용)
|
||||||
if (stage) {
|
if (stage) {
|
||||||
const state = stage.state;
|
const state = stage.state;
|
||||||
|
|||||||
Reference in New Issue
Block a user