Merge pull request 'Feature/b04 페이지 개선' (#2) from feature/B04-페이지-개선 into main

Reviewed-on: #2
This commit was merged in pull request #2.
This commit is contained in:
2026-07-18 19:04:59 +09:00
27 changed files with 1968 additions and 623 deletions
+11 -4
View File
@@ -184,16 +184,20 @@ function buildFooter(): HTMLElement {
}
/* -----------------------------------------------------------------------------
* 셸 렌더 — #app 안에 헤더 + outlet + 푸터를 구성하고 outlet 반환
* 셸 렌더 — #app 안에 헤더 + outlet 및 선택적 푸터를 구성하고 outlet 반환
* -------------------------------------------------------------------------- */
export function renderShell(appRoot: HTMLElement): HTMLElement {
export function renderShell(appRoot: HTMLElement, showFooter = true): HTMLElement {
appRoot.innerHTML = "";
const header = buildHeader();
const outlet = document.createElement("main");
outlet.className = "app-outlet";
outlet.id = "app-outlet";
const footer = buildFooter();
appRoot.append(header, outlet, footer);
appRoot.append(header, outlet);
if (showFooter) {
appRoot.append(buildFooter());
} else {
outlet.classList.add("app-outlet--without-footer");
}
return outlet;
}
@@ -250,6 +254,9 @@ const SHELL_CSS = `
.app-outlet {
min-height: calc(100vh - 64px - 56px);
}
.app-outlet--without-footer {
min-height: calc(100vh - 64px);
}
.app-footer {
height: 56px;
display: flex;
+18 -2
View File
@@ -10,8 +10,23 @@
import "@ui/ui_template_theme.css";
import { injectBaseStyles } from "@ui/ui_template_elements";
import { ROUTES, type RoutePath } from "@config/config_frontend";
import { initThemeAndLang, injectShellStyles, renderShell } from "./app_shell";
import { renderCurrentRoute, ensureInitialHash } from "./router";
import { currentRoute, renderCurrentRoute, ensureInitialHash } from "./router";
const FOOTERLESS_ROUTES: readonly RoutePath[] = [
ROUTES.B01_ACCOUNT,
ROUTES.B02_PROJ_REGISTER,
ROUTES.B03_FILE_INPUT,
ROUTES.B04_WF1_SURFACE,
ROUTES.B05_WF2_ROUTE,
ROUTES.B06_WF3_PROFILE_CROSS,
ROUTES.B07_WF4_DESIGN_DETAIL,
ROUTES.B08_WF5_QUANTITY,
ROUTES.B09_WF6_ESTIMATION,
ROUTES.B10_PAYMENT,
ROUTES.B11_STATUS,
];
function bootstrap(): void {
const appRoot = document.getElementById("app");
@@ -28,7 +43,8 @@ function bootstrap(): void {
// 3. 셸 렌더 + 현재 라우트 렌더를 함께 수행하는 헬퍼
const renderAll = (): void => {
const outlet = renderShell(appRoot); // 헤더/푸터(로그인·언어 반영) 갱신
const showFooter = !FOOTERLESS_ROUTES.includes(currentRoute());
const outlet = renderShell(appRoot, showFooter); // 헤더/푸터(로그인·언어 반영) 갱신
void renderCurrentRoute(outlet); // 아울렛에 페이지 렌더
};
+7 -145
View File
@@ -7,12 +7,10 @@ from typing import Any
from uuid import UUID, uuid4
import aiomysql
from fastapi import APIRouter, File, Form, UploadFile
from fastapi import APIRouter, Depends, File, Form, UploadFile
from fastapi.responses import JSONResponse
from B03_FileInput.B03_FileInput_Email import (
send_analysis_completion_email,
send_analysis_error_email,
send_file_upload_complete_email,
)
from B03_FileInput.B03_FileInput_Engine import (
@@ -44,20 +42,17 @@ from B03_FileInput.B03_FileInput_Schema import (
UploadFinalizeRequest,
UploadStatusResponse,
)
from B03_FileInput.B03_FileInput_Service_WF1 import trigger_wf1_analysis_and_email
from common_util.common_util_auth import verify_session
from common_util.common_util_json import atomic_write_json
from common_util.common_util_storage import resolve_stored_project_path
from common_util.common_util_workflow import load_project_workflow
from common_util.common_util_workflow_state import (
complete_stage,
fail_stage,
get_workflow_state,
start_stage,
)
from config.config_db import get_db_pool
from config.config_system import (
SEND_ANALYSIS_COMPLETION_EMAIL,
SURFACE_MODEL_PRECOMPUTE,
SURFACE_MODEL_SOURCE_FILTERS,
UPLOAD_CHUNK_SIZE_BYTES,
UPLOAD_MAX_FILES,
)
@@ -144,147 +139,11 @@ async def _send_upload_complete_notification(
)
async def trigger_wf1_analysis_and_email(
*,
project_id: UUID,
input_file_id: int,
) -> None:
"""WF1 분석 실행, DB 저장, 완료/오류 이메일 발송을 백그라운드에서 수행한다.
실행 흐름:
1. 프로젝트 저장 경로와 입력 파일 정보를 조회한다.
2. 무거운 WF1 분석은 워커 스레드에서 실행한다.
3. 분석 결과를 단일 DB 트랜잭션으로 저장한다.
4. 완료 이메일은 SEND_ANALYSIS_COMPLETION_EMAIL 설정이 켜진 경우에만 발송한다.
5. 분석 또는 DB 저장 실패 시 오류 이메일을 발송한다.
"""
pool = get_db_pool()
project_info: dict[str, Any] | None = None
try:
source_filters = list(SURFACE_MODEL_SOURCE_FILTERS)
methods = list(SURFACE_MODEL_PRECOMPUTE)
params = {
"input_file_id": str(input_file_id),
"source_filters": source_filters,
"methods": methods,
"force": False,
}
async with pool.acquire() as connection:
async with connection.cursor() as cursor:
await start_stage(cursor, str(project_id), 1, params)
await connection.commit()
stored_path = await get_project_storage_relative_path(connection, project_id)
project_info = await _get_project_notification_info(connection, project_id)
if not project_info or not project_info.get("user_email"):
logger.warning("WF1 분석 이메일 수신자 없음: project_id=%s", project_id)
from B04_wf1_Surface.B04_wf1_Surface_Repository import get_input_file
input_file = await get_input_file(connection, project_id, input_file_id)
project_root = Path(resolve_stored_project_path(stored_path))
las_path = project_root / Path(str(input_file["raw_file_path"]))
if not las_path.is_file():
raise FileNotFoundError("원본 LAS/LAZ 파일을 찾을 수 없습니다.")
logger.info(
"WF1 백그라운드 분석 시작: project_id=%s input_file_id=%s",
project_id,
input_file_id,
)
from B04_wf1_Surface.B04_wf1_Surface_Engine import run_surface_analysis
from B04_wf1_Surface.B04_wf1_Surface_Router import (
save_surface_analysis_to_db,
write_surface_progress,
)
# 업로드 자동 분석도 progress.json에 진행률을 기록한다 (PLAN C-4)
write_surface_progress(project_root, 5, "analyzing", "WF1 분석을 시작합니다.")
def _on_progress(percent: int, stage: str, message: str) -> None:
write_surface_progress(project_root, percent, stage, message)
logger.info("WF1 분석 엔진 시작: las_path=%s", las_path)
analysis_result = await asyncio.to_thread(
run_surface_analysis,
project_root,
las_path,
source_filters=source_filters,
methods=methods,
force=False,
on_progress=_on_progress,
)
logger.info("WF1 분석 엔진 완료: 모델 %d개 생성됨", len(analysis_result.get("models", [])))
logger.info("WF1 분석 결과 DB 저장 시작: project_id=%s", project_id)
async with pool.acquire() as connection:
await connection.begin()
try:
await save_surface_analysis_to_db(
connection,
project_id=project_id,
input_file_id=input_file_id,
analysis_result=analysis_result,
source_filters=source_filters,
)
async with connection.cursor() as cursor:
await complete_stage(cursor, str(project_id), 1)
await connection.commit()
logger.info("WF1 분석 결과 DB 저장 완료: project_id=%s", project_id)
except Exception as e:
logger.exception("WF1 분석 결과 DB 저장 실패: %s", e)
await connection.rollback()
raise
write_surface_progress(
project_root,
100,
"awaiting_confirmation",
"WF1 분석이 완료되었습니다. 사용할 모델을 확정하세요.",
)
logger.info(
"SEND_ANALYSIS_COMPLETION_EMAIL=%s, project_info=%s",
SEND_ANALYSIS_COMPLETION_EMAIL,
bool(project_info),
)
if SEND_ANALYSIS_COMPLETION_EMAIL and project_info and project_info.get("user_email"):
logger.info("WF1 완료 이메일 발송 시작: to=%s", project_info["user_email"])
await send_analysis_completion_email(
project_id=project_id,
project_name=str(project_info.get("project_name") or project_id),
to_email=str(project_info["user_email"]),
analysis_result=analysis_result,
)
logger.info("WF1 완료 이메일 발송 완료: project_id=%s", project_id)
else:
logger.info(
"WF1 완료 이메일 발송 스킵: SEND_ANALYSIS_COMPLETION_EMAIL=%s, has_email=%s",
SEND_ANALYSIS_COMPLETION_EMAIL,
project_info and project_info.get("user_email") is not None,
)
logger.info("WF1 백그라운드 분석 완료: project_id=%s", project_id)
except Exception as exc:
logger.exception("WF1 백그라운드 분석 실패: project_id=%s", project_id)
async with pool.acquire() as connection, connection.cursor() as cursor:
await fail_stage(cursor, str(project_id), 1, str(exc))
await connection.commit()
if project_info and project_info.get("user_email"):
await send_analysis_error_email(
project_id=project_id,
project_name=str(project_info.get("project_name") or project_id),
to_email=str(project_info["user_email"]),
error_message=str(exc),
)
@router.post("/{project_id}/files", response_model=FileUploadResponse)
async def upload_project_files(
project_id: UUID,
files: list[UploadFile] = File(...),
session: dict[str, Any] = Depends(verify_session),
) -> FileUploadResponse | JSONResponse:
"""프로젝트 입력 파일을 저장·분석하고 DB 메타데이터를 기록한다."""
if not files or len(files) > UPLOAD_MAX_FILES:
@@ -391,6 +250,7 @@ async def upload_project_files(
trigger_wf1_analysis_and_email(
project_id=project_id,
input_file_id=point_cloud_result.input_file_id,
user_role=str(session["role"]),
),
task_name=f"b04-wf1-auto-{project_id}",
)
@@ -525,6 +385,7 @@ async def upload_project_chunk(
async def finalize_project_upload(
project_id: UUID,
payload: UploadFinalizeRequest,
session: dict[str, Any] = Depends(verify_session),
) -> FileUploadResponse | JSONResponse:
"""청크 업로드를 최종 병합하고 input_files 메타데이터를 기록한다."""
pool = get_db_pool()
@@ -617,6 +478,7 @@ async def finalize_project_upload(
trigger_wf1_analysis_and_email(
project_id=project_id,
input_file_id=result.input_file_id,
user_role=str(session["role"]),
),
task_name=f"b04-wf1-auto-{project_id}",
)
+166
View File
@@ -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),
)
+14 -2
View File
@@ -12,6 +12,7 @@ import { createButton, createTag, showToast } from "@ui/ui_template_elements";
import { createGeneralLayout } from "@ui/ui_template_general_layout";
import { createWorkflowOverlays } from "@ui/ui_template_overlay";
import { createStepBar, WORKFLOW_STEP_ICONS } from "@ui/ui_template_workflow_layout";
import { fetchDashboardMe } from "../B01_Dashboard/B01_Dashboard_Api_Fetch";
import {
checkWF1AnalysisStatus,
createUploadSession,
@@ -50,6 +51,9 @@ function L(key: keyof typeof ui_locales): string {
}
export async function renderB03FileInput(root: HTMLElement): Promise<void> {
const dashboardUser = await fetchDashboardMe();
const completionRoute =
dashboardUser.role === "SYSTEM_ADMIN" ? ROUTES.B04_WF1_SURFACE : ROUTES.B05_WF2_ROUTE;
const slots = initializeSlots();
const cardMap = new Map<FileSlot, HTMLElement>();
const resultList = document.createElement("ul");
@@ -441,6 +445,14 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
if (status.status === "completed") {
return true;
}
if (
status.current_stage === "awaiting_confirmation" &&
status.message.includes("자동 확정 보류")
) {
pageError.textContent = status.message;
showToast(status.message, "warning");
return false;
}
} catch {}
await new Promise((resolve) => setTimeout(resolve, 5000));
}
@@ -468,7 +480,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
const analysisComplete = await pollWF1Analysis(activeProjectId);
if (analysisComplete) {
navigateTo(ROUTES.B04_WF1_SURFACE);
navigateTo(completionRoute);
} else {
showToast(L("B03_File_Analysis_StillRunning"), "warning");
}
@@ -576,7 +588,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
label: L("B03_File_Restore_State"),
container: resumeBanner,
poll: pollWF1Analysis,
onComplete: () => navigateTo(ROUTES.B04_WF1_SURFACE),
onComplete: () => navigateTo(completionRoute),
});
}
}
+7 -1
View File
@@ -37,6 +37,11 @@ export interface SurfaceConfirmResponse {
confirmed: boolean;
}
export interface SurfaceConfirmOptions {
smooth: boolean;
contour_interval_m: number;
}
/** 저장된 지표면 모델 요약 (SurfaceModelSummary) */
export interface SurfaceModelSummary {
id: number;
@@ -152,10 +157,11 @@ export async function listSurfaceModels(projectId: string): Promise<SurfaceModel
export async function confirmSurfaceModel(
projectId: string,
modelId: number,
options: SurfaceConfirmOptions,
): Promise<SurfaceConfirmResponse> {
return requestJson<SurfaceConfirmResponse>(`/projects/${projectId}/surface/confirm`, {
method: "POST",
body: JSON.stringify({ model_id: modelId }),
body: JSON.stringify({ model_id: modelId, ...options }),
});
}
+40 -7
View File
@@ -9,7 +9,7 @@ from uuid import UUID
import aiomysql
import numpy as np
from fastapi import APIRouter
from fastapi import APIRouter, Depends
from fastapi.responses import FileResponse, JSONResponse
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
@@ -20,7 +20,6 @@ from B04_wf1_Surface.B04_wf1_Surface_Engine import (
)
from B04_wf1_Surface.B04_wf1_Surface_Repository import (
clear_confirmed_surface_models,
confirm_surface_model,
get_input_file,
list_project_point_cloud_inputs,
list_surface_models,
@@ -38,10 +37,12 @@ from B04_wf1_Surface.B04_wf1_Surface_Schema import (
SurfaceModelSummary,
SurfacePointCloudSampleResponse,
)
from B04_wf1_Surface.B04_wf1_Surface_Service import confirm_surface_selection
from common_util.common_util_auth import require_system_admin
from common_util.common_util_json import atomic_write_json
from common_util.common_util_storage import resolve_stored_project_path
from common_util.common_util_surface_confirmation import surface_confirmation_defaults
from common_util.common_util_workflow_state import (
complete_stage,
fail_stage,
start_stage,
update_stage_progress,
@@ -205,7 +206,9 @@ async def get_surface_models(project_id: UUID) -> SurfaceModelListResponse | JSO
@router.post("/{project_id}/surface/confirm", response_model=SurfaceConfirmResponse)
async def confirm_surface(
project_id: UUID, request: SurfaceConfirmRequest
project_id: UUID,
request: SurfaceConfirmRequest,
_session: dict[str, Any] = Depends(require_system_admin),
) -> SurfaceConfirmResponse | JSONResponse:
"""사용자가 선택한 지표면 모델을 확정하고 WF1을 완료한다."""
pool = get_db_pool()
@@ -213,9 +216,39 @@ async def confirm_surface(
async with pool.acquire() as connection:
await connection.begin()
try:
await confirm_surface_model(connection, project_id, request.model_id)
async with connection.cursor() as cursor:
await complete_stage(cursor, str(project_id), 1)
models = await list_surface_models(connection, project_id)
selected_model = next(
(model for model in models if model["id"] == request.model_id),
None,
)
if selected_model is None:
raise LookupError("확정할 지표면 모델을 찾을 수 없습니다.")
generation_params = selected_model.get("generation_params") or {}
source_filter = generation_params.get("source_filter")
if not source_filter:
raise LookupError("선택한 모델의 지면 필터 정보를 찾을 수 없습니다.")
selection = surface_confirmation_defaults()
selection.update(
{
"source_filter": str(source_filter),
"method": str(selected_model["model_type"]),
"smooth": (
request.smooth if request.smooth is not None else selection["smooth"]
),
"contour_interval_m": (
request.contour_interval_m
if request.contour_interval_m is not None
else selection["contour_interval_m"]
),
}
)
await confirm_surface_selection(
connection,
project_id,
request.model_id,
selection,
)
await connection.commit()
except Exception:
await connection.rollback()
@@ -48,6 +48,12 @@ class SurfaceConfirmRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
model_id: int = Field(gt=0, description="확정할 surface_models.id")
smooth: bool | None = Field(default=None, description="확정 시 적용한 스무딩 여부")
contour_interval_m: float | None = Field(
default=None,
gt=0,
description="확정 시 적용한 등고선 간격(m)",
)
class SurfaceConfirmResponse(BaseModel):
@@ -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
+19 -1
View File
@@ -8,6 +8,7 @@ import {
showToast,
} from "@ui/ui_template_elements";
import { createWorkflowLayout } from "@ui/ui_template_workflow_layout";
import { fetchDashboardMe } from "../B01_Dashboard/B01_Dashboard_Api_Fetch";
import { workflowSteps } from "../A00_Common/b_page_scaffold";
import {
fetchWorkflowState,
@@ -83,6 +84,20 @@ function getModelFilter(model: SurfaceModelSummary): string {
}
export async function renderB04Surface(root: HTMLElement): Promise<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 inputFiles: SurfaceInputFileSummary[] = [];
let models: SurfaceModelSummary[] = [];
@@ -332,7 +347,10 @@ export async function renderB04Surface(root: HTMLElement): Promise<void> {
}
showLoadingOverlay();
try {
await confirmSurfaceModel(projectId, model.id);
await confirmSurfaceModel(projectId, model.id, {
smooth: terrainViewer.isSmoothingEnabled(),
contour_interval_m: terrainViewer.getContourInterval(),
});
showToast(
L("B04_Surface_Confirm_Success")
.replace("{filter}", filterGroup.select.value)
@@ -22,6 +22,7 @@ export interface SurfaceTerrainViewer {
onCameraChange: (listener: (state: SurfaceCameraState) => void) => void;
onAxesVisibilityChange: (listener: (visible: boolean) => void) => void;
isSmoothingEnabled: () => boolean;
getContourInterval: () => number;
resetOptions: () => void;
dispose: () => void;
}
@@ -650,6 +651,9 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
isSmoothingEnabled() {
return !smoothCheck.disabled && smoothCheck.checked;
},
getContourInterval() {
return Number.parseFloat(intervalInput.value);
},
resetOptions() {
axesCheck.checked = false;
axes.visible = false;
+51
View File
@@ -17,9 +17,14 @@ import { API_BASE_URL, API_TIMEOUT_MS } from "@config/config_frontend";
export interface RoutePoint {
x: number;
y: number;
z?: number;
order?: number;
}
export interface CirclePoint extends RoutePoint {
radius_m: number;
}
/** 경로 탐색 실행 요청 (RouteSolveRequest) */
export interface RouteSolveRequest {
filter_key: string;
@@ -30,10 +35,16 @@ export interface RouteSolveRequest {
bp: RoutePoint;
ep: RoutePoint;
cp?: RoutePoint[];
ap?: CirclePoint[];
fp?: CirclePoint[];
grade_class?: string;
paved?: boolean;
min_curve_radius_m?: number | null;
max_uphill_grade?: number | null;
max_downhill_grade?: number | null;
min_uphill_grade?: number | null;
min_downhill_grade?: number | null;
allow_avoid_pass_through?: boolean;
}
/** 경로 탐색 실행 결과 (RouteSolveResponse) */
@@ -55,6 +66,40 @@ export interface RouteConfirmResponse {
confirmed: boolean;
}
export interface RouteLatestResponse {
status: string;
project_id: string;
route: {
id: number;
status: string;
surface_model_id: number | null;
total_length_m: number | null;
min_slope: number | null;
max_slope: number | null;
mean_slope: number | null;
cost_score: number | null;
algorithm_params?: Record<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 헬퍼: 타임아웃 + 인증 헤더 + 오류 응답 변환. */
async function requestJson<T>(path: string, init: RequestInit): Promise<T> {
const controller = new AbortController();
@@ -96,3 +141,9 @@ export async function confirmRoute(projectId: string): Promise<RouteConfirmRespo
method: "POST",
});
}
export async function fetchLatestRoute(projectId: string): Promise<RouteLatestResponse> {
return requestJson<RouteLatestResponse>(`/projects/${projectId}/route/latest`, {
method: "GET",
});
}
+18
View File
@@ -0,0 +1,18 @@
"""B05 DEBUG 모드 전용 구조화 로그."""
import json
import logging
from typing import Any
from config.config_system import DEBUG
def log_b05_debug(logger: logging.Logger, event: str, **payload: Any) -> None:
"""DEBUG=True일 때만 B05 계산·DB 저장 정보를 출력한다."""
if not DEBUG:
return
logger.info(
"[B05 DEBUG] %s | %s",
event,
json.dumps(payload, ensure_ascii=False, default=str),
)
+49 -2
View File
@@ -5,9 +5,11 @@ GeoJSON으로 저장하며 DB 기록용 데이터(메타·렌더링 샘플·통
라우터에서 asyncio.to_thread로 호출한다.
"""
import logging
from pathlib import Path
from typing import Any
from B05_wf2_Route.B05_wf2_Route_Debug import log_b05_debug
from B05_wf2_Route.B05_wf2_Route_Engine_RidgeValley import solve_ridge_valley_route
from B05_wf2_Route.B05_wf2_Route_Engine_Solver import solve_optimal_route
from common_util.common_util_json import atomic_write_json
@@ -15,6 +17,7 @@ from common_util.common_util_json import atomic_write_json
_ROUTE_SUBDIR = Path("B05_wf2_Route") / "route"
# route_points 테이블에 저장할 렌더링 샘플 최대 개수
_MAX_RENDER_POINTS = 500
logger = logging.getLogger(__name__)
def _route_geojson(polyline: list[list[float]]) -> dict[str, Any]:
@@ -45,7 +48,7 @@ def _sample_render_points(
points: list[dict[str, Any]] = []
for seq, idx in enumerate(indices):
idx = min(idx, n - 1)
_, _, z = polyline[idx]
x, y, z = polyline[idx]
# 국소 경사(%) — 직전 정점과의 차이
slope_pct = 0.0
if idx > 0:
@@ -56,6 +59,9 @@ def _sample_render_points(
slope_pct = abs(z1 - z0) / h * 100.0
points.append(
{
"x": round(x, 3),
"y": round(y, 3),
"z": round(z, 3),
"chainage_m": round(chainage_m[idx], 3) if idx < len(chainage_m) else None,
"elevation_m": round(z, 3),
"slope_percent": round(slope_pct, 3),
@@ -84,6 +90,22 @@ def run_route_design(
- render_points: route_points 테이블 저장용 샘플
- statistics: route_statistics 저장용 요약
"""
log_b05_debug(
logger,
"calculation.start",
algorithm=algorithm,
filter_key=filter_key,
method=method,
smooth=smooth,
point_counts={
"bp": 1 if points_data.get("bp") else 0,
"ep": 1 if points_data.get("ep") else 0,
"cp": len(points_data.get("cp", [])),
"ap": len(points_data.get("ap", [])),
"fp": len(points_data.get("fp", [])),
},
options=options,
)
if algorithm == "ridge_valley":
result = solve_ridge_valley_route(
project_root, filter_key, smooth, points_data, options, method=method
@@ -94,11 +116,27 @@ def run_route_design(
)
polyline = result["polyline"]
chainage_m = result["chainage_m"]
log_b05_debug(
logger,
"calculation.solver_complete",
algorithm=algorithm,
polyline_count=len(polyline),
segment_count=len(result.get("segments", [])),
metrics=result.get("metrics", {}),
required_points_ok=result.get("required_points_ok"),
warning_count=len(result.get("curve_warning_segments", [])),
)
route_dir = project_root / _ROUTE_SUBDIR
route_dir.mkdir(parents=True, exist_ok=True)
geojson_path = route_dir / "route_main.geojson"
atomic_write_json(geojson_path, _route_geojson(polyline))
log_b05_debug(
logger,
"calculation.geojson_saved",
path=geojson_path.relative_to(project_root).as_posix(),
coordinate_count=len(polyline),
)
# 통계 요약 (solver 메트릭에서 파생)
metrics = result["metrics"]
@@ -109,10 +147,19 @@ def run_route_design(
"cost_score": None,
}
render_points = _sample_render_points(polyline, chainage_m, _MAX_RENDER_POINTS)
log_b05_debug(
logger,
"calculation.output_prepared",
render_point_count=len(render_points),
first_render_point=render_points[0] if render_points else None,
last_render_point=render_points[-1] if render_points else None,
statistics=statistics,
)
return {
"route_data_path": geojson_path.relative_to(project_root).as_posix(),
"solver_result": result,
"render_points": _sample_render_points(polyline, chainage_m, _MAX_RENDER_POINTS),
"render_points": render_points,
"statistics": statistics,
"grade_percent": [seg.get("max_grade_pct") for seg in result.get("segments", [])],
"constraints": result.get("conditions_snapshot", {}),
+42 -15
View File
@@ -79,13 +79,16 @@ async def insert_route_points(
) -> int:
"""경로 렌더링 샘플 포인트를 일괄 저장하고 저장 건수를 반환한다.
각 point dict: {chainage_m, elevation_m, slope_percent, sequence_num}
각 point dict: {x, y, z, chainage_m, elevation_m, slope_percent, sequence_num}
"""
if not points:
return 0
rows = [
(
route_id,
point.get("x"),
point.get("y"),
point.get("z"),
point.get("chainage_m"),
point.get("elevation_m"),
point.get("slope_percent"),
@@ -97,9 +100,10 @@ async def insert_route_points(
await cursor.executemany(
"""
INSERT INTO route_points (
route_id, chainage_m, elevation_m, slope_percent, sequence_num
route_id, model_x, model_y, model_z,
chainage_m, elevation_m, slope_percent, sequence_num
)
VALUES (%s, %s, %s, %s, %s)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
""",
rows,
)
@@ -149,13 +153,16 @@ async def get_latest_route(
connection: aiomysql.Connection, project_id: UUID
) -> dict[str, Any] | None:
"""프로젝트의 최신 경로를 조회한다 (없으면 None)."""
async with connection.cursor() as cursor:
async with connection.cursor(aiomysql.DictCursor) as cursor:
await cursor.execute(
"""
SELECT id, status, total_length_m, route_data_path, computed_at
FROM routes
WHERE project_id = %s
ORDER BY computed_at DESC, id DESC
SELECT r.id, r.status, r.surface_model_id, r.total_length_m,
r.route_data_path, r.constraints, r.algorithm_params, r.computed_at,
rs.min_slope, rs.max_slope, rs.mean_slope, rs.cost_score
FROM routes r
LEFT JOIN route_statistics rs ON rs.route_id = r.id
WHERE r.project_id = %s
ORDER BY r.computed_at DESC, r.id DESC
LIMIT 1
""",
(str(project_id),),
@@ -163,13 +170,33 @@ async def get_latest_route(
row = await cursor.fetchone()
if not row:
return None
return {
"id": int(row[0]),
"status": row[1],
"total_length_m": row[2],
"route_data_path": row[3],
"computed_at": row[4].isoformat() if row[4] else None,
}
result = dict(row)
result["id"] = int(result["id"])
result["computed_at"] = result["computed_at"].isoformat() if result["computed_at"] else None
for key in ("constraints", "algorithm_params"):
if isinstance(result.get(key), str):
result[key] = json.loads(result[key])
return result
async def get_route_points(
connection: aiomysql.Connection,
route_id: int,
) -> list[dict[str, Any]]:
"""최신 경로의 DB 렌더 좌표를 순서대로 조회한다."""
async with connection.cursor(aiomysql.DictCursor) as cursor:
await cursor.execute(
"""
SELECT model_x AS x, model_y AS y, model_z AS z,
chainage_m, elevation_m, slope_percent, sequence_num
FROM route_points
WHERE route_id = %s
ORDER BY sequence_num ASC, id ASC
""",
(route_id,),
)
rows = await cursor.fetchall()
return [dict(row) for row in rows]
async def confirm_route(connection: aiomysql.Connection, route_id: int) -> None:
+147 -20
View File
@@ -5,25 +5,35 @@ import logging
from pathlib import Path
from uuid import UUID
import aiomysql
from fastapi import APIRouter
from fastapi.responses import JSONResponse
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
from B05_wf2_Route.B05_wf2_Route_Debug import log_b05_debug
from B05_wf2_Route.B05_wf2_Route_Engine import run_route_design
from B05_wf2_Route.B05_wf2_Route_Repository import (
confirm_route,
create_route,
create_route_statistics,
get_latest_route,
get_route_points,
insert_route_points,
)
from B05_wf2_Route.B05_wf2_Route_Schema import (
RouteConfirmResponse,
RouteLatestResponse,
RouteSolveRequest,
RouteSolveResponse,
)
from common_util.common_util_storage import resolve_stored_project_path
from common_util.common_util_workflow_state import complete_stage, fail_stage, start_stage
from common_util.common_util_surface_confirmation import get_surface_confirmation_params
from common_util.common_util_workflow_state import (
complete_stage,
fail_stage,
get_workflow_state,
start_stage,
)
from config.config_db import get_db_pool
logger = logging.getLogger(__name__)
@@ -41,15 +51,28 @@ async def solve_route(
"filter_key": request.filter_key,
"method": request.method,
"smooth": request.smooth,
"points": request.points,
"points": request.points_data(),
"options": request.options(),
"algorithm": request.algorithm,
"surface_model_id": request.surface_model_id,
}
log_b05_debug(
logger,
"request.solve",
project_id=str(project_id),
workflow_stage_params=params,
)
async with pool.acquire() as connection:
async with connection.cursor() as cursor:
await start_stage(cursor, str(project_id), 2, params)
await connection.commit()
log_b05_debug(
logger,
"db.project_workflow_stages.start_stage_committed",
project_id=str(project_id),
stage_no=2,
params=params,
)
stored_path = await get_project_storage_relative_path(connection, project_id)
project_root = Path(resolve_stored_project_path(stored_path))
@@ -67,36 +90,88 @@ async def solve_route(
)
solver = design["solver_result"]
metrics = solver["metrics"]
log_b05_debug(
logger,
"calculation.complete",
project_id=str(project_id),
metrics=metrics,
required_points_ok=solver.get("required_points_ok"),
route_data_path=design["route_data_path"],
)
await connection.begin()
try:
route_record = {
"project_id": str(project_id),
"surface_model_id": request.surface_model_id,
"status": "DRAFT",
"start_chainage_m": 0.0,
"end_chainage_m": metrics.get("length_m"),
"total_length_m": metrics.get("length_m"),
"grade_percent": design["grade_percent"],
"constraints": design["constraints"],
"algorithm_params": {
**design["algorithm_params"],
"metrics": metrics,
"curve_warning_segments": solver.get("curve_warning_segments", []),
},
"route_data_path": design["route_data_path"],
}
log_b05_debug(logger, "db.routes.insert", record=route_record)
route_id = await create_route(
connection,
project_id=project_id,
surface_model_id=request.surface_model_id,
total_length_m=metrics.get("length_m"),
start_chainage_m=0.0,
end_chainage_m=metrics.get("length_m"),
grade_percent=design["grade_percent"],
constraints=design["constraints"],
algorithm_params=design["algorithm_params"],
route_data_path=design["route_data_path"],
surface_model_id=route_record["surface_model_id"],
total_length_m=route_record["total_length_m"],
start_chainage_m=route_record["start_chainage_m"],
end_chainage_m=route_record["end_chainage_m"],
grade_percent=route_record["grade_percent"],
constraints=route_record["constraints"],
algorithm_params=route_record["algorithm_params"],
route_data_path=route_record["route_data_path"],
)
await insert_route_points(connection, route_id, design["render_points"])
render_points = design["render_points"]
log_b05_debug(
logger,
"db.route_points.insert_many",
route_id=route_id,
row_count=len(render_points),
first_row=render_points[0] if render_points else None,
last_row=render_points[-1] if render_points else None,
)
await insert_route_points(connection, route_id, render_points)
stats = design["statistics"]
statistics_record = {
"route_id": route_id,
"min_slope": stats["min_slope"],
"max_slope": stats["max_slope"],
"mean_slope": stats["mean_slope"],
"cost_score": stats["cost_score"],
}
log_b05_debug(
logger,
"db.route_statistics.insert",
record=statistics_record,
)
await create_route_statistics(
connection,
route_id=route_id,
min_slope=stats["min_slope"],
max_slope=stats["max_slope"],
mean_slope=stats["mean_slope"],
cost_score=stats["cost_score"],
**statistics_record,
)
async with connection.cursor() as cursor:
await complete_stage(cursor, str(project_id), 2)
await connection.commit()
except Exception:
log_b05_debug(
logger,
"db.route_transaction.committed",
project_id=str(project_id),
route_id=route_id,
)
except Exception as exc:
await connection.rollback()
log_b05_debug(
logger,
"db.route_transaction.rolled_back",
project_id=str(project_id),
reason=str(exc),
)
raise
return RouteSolveResponse(
@@ -133,6 +208,36 @@ async def solve_route(
)
@router.get("/{project_id}/route/latest", response_model=RouteLatestResponse)
async def read_latest_route(project_id: UUID) -> RouteLatestResponse | JSONResponse:
"""최신 경로와 DB 렌더 좌표, WF1/WF2 입력 스냅샷을 반환한다."""
pool = get_db_pool()
try:
async with pool.acquire() as connection:
latest = await get_latest_route(connection, project_id)
route_points = await get_route_points(connection, latest["id"]) if latest else []
surface_params = await get_surface_confirmation_params(connection, str(project_id))
async with connection.cursor(aiomysql.DictCursor) as cursor:
workflow = await get_workflow_state(cursor, str(project_id))
route_stage = next(
(stage for stage in workflow["stages"] if stage["stage_no"] == 2),
None,
)
return RouteLatestResponse(
project_id=str(project_id),
route=latest,
route_points=route_points,
surface_params=surface_params,
route_params=route_stage.get("params") if route_stage else None,
)
except Exception:
logger.exception("B05 최신 경로 조회 실패: project_id=%s", project_id)
return JSONResponse(
status_code=500,
content={"status": "error", "message": "최신 경로 조회 중 오류가 발생했습니다."},
)
@router.post("/{project_id}/route/confirm", response_model=RouteConfirmResponse)
async def confirm_latest_route(project_id: UUID) -> RouteConfirmResponse | JSONResponse:
"""프로젝트의 최신 경로를 확정(CONFIRMED)한다."""
@@ -147,12 +252,34 @@ async def confirm_latest_route(project_id: UUID) -> RouteConfirmResponse | JSONR
)
await connection.begin()
try:
log_b05_debug(
logger,
"db.routes.confirm",
project_id=str(project_id),
route_id=latest["id"],
previous_status=latest["status"],
next_status="CONFIRMED",
)
await confirm_route(connection, latest["id"])
async with connection.cursor() as cursor:
await complete_stage(cursor, str(project_id), 2)
await connection.commit()
except Exception:
log_b05_debug(
logger,
"db.route_confirmation.committed",
project_id=str(project_id),
route_id=latest["id"],
completed_stage=2,
)
except Exception as exc:
await connection.rollback()
log_b05_debug(
logger,
"db.route_confirmation.rolled_back",
project_id=str(project_id),
route_id=latest["id"],
reason=str(exc),
)
raise
return RouteConfirmResponse(project_id=str(project_id), route_id=latest["id"])
except Exception:
+16
View File
@@ -14,6 +14,7 @@ class RoutePoint(BaseModel):
x: float
y: float
z: float | None = None
order: int | None = None
@@ -49,6 +50,8 @@ class RouteSolveRequest(BaseModel):
min_curve_radius_m: float | None = None
max_uphill_grade: float | None = None
max_downhill_grade: float | None = None
min_uphill_grade: float | None = None
min_downhill_grade: float | None = None
weights: dict[str, float] | None = None
allow_avoid_pass_through: bool = Field(default=False)
@@ -76,6 +79,8 @@ class RouteSolveRequest(BaseModel):
"min_curve_radius_m": self.min_curve_radius_m,
"max_uphill_grade": self.max_uphill_grade,
"max_downhill_grade": self.max_downhill_grade,
"min_uphill_grade": self.min_uphill_grade,
"min_downhill_grade": self.min_downhill_grade,
"weights": self.weights,
"allow_avoid_pass_through": self.allow_avoid_pass_through,
}
@@ -100,3 +105,14 @@ class RouteConfirmResponse(BaseModel):
project_id: str
route_id: int
confirmed: bool = True
class RouteLatestResponse(BaseModel):
"""새로고침 복원을 위한 최신 경로·입력·통계 응답."""
status: str = "success"
project_id: str
route: dict[str, Any] | None = None
route_points: list[dict[str, Any]] = Field(default_factory=list)
surface_params: dict[str, Any]
route_params: dict[str, Any] | None = None
+256
View File
@@ -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>;
+217 -320
View File
@@ -1,378 +1,275 @@
/* =============================================================================
* B05_wf2_Route_UI_Page.ts
* 로그인 후 05: 2차 워크플로우 (경로 설계)
*
* 3단 레이아웃 (frontend.md §2):
* 상단: 페이지 타이틀 + 진행 단계 스텝바 (createWorkflowLayout)
* 좌측: 경로 제어점(BP/EP/CP) 좌표 + 기반 지표면 + 설계 제약 폼
* 우측: 경로 탐색 결과(연장·경사·비용) 카드
*
* 이벤트 핸들러 명명 (frontend.md §4): onB05_Route_[기능]_[액션]
* 텍스트는 ui_template_locale에 선(先) 등록 후 참조 (frontend.md §3).
* ========================================================================== */
import { CURRENT_PROJECT_ID_KEY } from "@config/config_frontend";
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
import {
createButton,
createInputField,
createSelectField,
hideLoadingOverlay,
showLoadingOverlay,
showToast,
type InputFieldHandle,
} from "@ui/ui_template_elements";
import { hideLoadingOverlay, showLoadingOverlay, showToast } from "@ui/ui_template_elements";
import { createWorkflowLayout } from "@ui/ui_template_workflow_layout";
import { workflowSteps } from "../A00_Common/b_page_scaffold";
import {
fetchWorkflowState,
goToWorkflowStage,
WORKFLOW_STEP_ROUTES,
type WorkflowState,
} from "../A00_Common/b_workflow_nav";
import {
confirmRoute,
solveRoute,
type RoutePoint,
type RouteSolveResponse,
} from "./B05_wf2_Route_Api_Fetch";
import {
fetchSurfacePointCloud,
listSurfaceModels,
type SurfaceModelSummary,
} from "../B04_wf1_Surface/B04_wf1_Surface_Api_Fetch";
import {
confirmRoute,
fetchLatestRoute,
solveRoute,
type CirclePoint,
type RouteLatestResponse,
type RoutePoint,
} from "./B05_wf2_Route_Api_Fetch";
import {
type ModelBounds,
type PlacedRoutePoint,
type RouteDesignPoints,
type RoutePointKind,
} from "./B05_wf2_Route_UI_Markers";
import { createRoutePanel, type RoutePanelValues } from "./B05_wf2_Route_UI_Panel";
import { createRouteViewer } from "./B05_wf2_Route_UI_Viewer";
import "./B05_wf2_Route_UI_Style.css";
/** locale 헬퍼 */
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
function toBounds(bounds: {
x_min: number;
x_max: number;
y_min: number;
y_max: number;
z_min: number;
z_max: number;
}): ModelBounds {
return {
x: [bounds.x_min, bounds.x_max],
y: [bounds.y_min, bounds.y_max],
z: [bounds.z_min, bounds.z_max],
};
}
/** 임도 등급 (config_system.ROUTE_GRADE_CLASSES) */
const GRADE_CLASSES = ["trunk", "branch", "work"] as const;
/** 경로 알고리즘 (Schema.algorithm 허용값) */
const ALGORITHMS = ["dijkstra", "ridge_valley"] as const;
/** X/Y 좌표 한 쌍 입력 행 생성. */
function buildPointRow(label: string): {
root: HTMLElement;
x: InputFieldHandle;
y: InputFieldHandle;
} {
const root = document.createElement("div");
root.className = "b05-route__point";
const caption = document.createElement("span");
caption.className = "b05-route__point-label";
caption.textContent = label;
const x = createInputField({ label: L("B05_Route_Field_X"), type: "number" });
const y = createInputField({ label: L("B05_Route_Field_Y"), type: "number" });
const row = document.createElement("div");
row.className = "b05-route__point-row";
row.append(x.root, y.root);
root.append(caption, row);
return { root, x, y };
function placed(
type: RoutePointKind,
point: RoutePoint | CirclePoint,
index = 0,
): PlacedRoutePoint {
return {
id: `${type}-restored-${index}`,
type,
x: point.x,
y: point.y,
z: point.z ?? 0,
...(type === "ap" || type === "fp" ? { radius_m: (point as CirclePoint).radius_m ?? 25 } : {}),
};
}
/** 숫자 입력값을 파싱. 빈 값이면 null. */
function parseNumber(value: string): number | null {
const trimmed = value.trim();
if (!trimmed) return null;
const parsed = Number(trimmed);
return Number.isFinite(parsed) ? parsed : null;
function restorePoints(latest: RouteLatestResponse): RouteDesignPoints {
const points = latest.route_params?.points;
return {
bp: points?.bp ? placed("bp", points.bp) : null,
ep: points?.ep ? placed("ep", points.ep) : null,
cp: (points?.cp ?? []).map((point, index) => placed("cp", point, index)),
ap: (points?.ap ?? []).map((point, index) => placed("ap", point, index)),
fp: (points?.fp ?? []).map((point, index) => placed("fp", point, index)),
};
}
function routePoint(point: PlacedRoutePoint): RoutePoint {
return { x: point.x, y: point.y, z: point.z };
}
function circlePoint(point: PlacedRoutePoint): CirclePoint {
return { ...routePoint(point), radius_m: point.radius_m ?? 25 };
}
export async function renderB05Route(root: HTMLElement): Promise<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;
/* ---- 좌측: 경로 제어점 ---- */
const pointsGroup = document.createElement("fieldset");
pointsGroup.className = "b05-route__group";
const pointsLegend = document.createElement("legend");
pointsLegend.className = "b05-route__group-legend";
pointsLegend.textContent = L("B05_Route_Group_Points");
const bpRow = buildPointRow(L("B05_Route_Point_BP"));
const epRow = buildPointRow(L("B05_Route_Point_EP"));
const cpContainer = document.createElement("div");
cpContainer.className = "b05-route__cp-list";
const cpRows: { root: HTMLElement; x: InputFieldHandle; y: InputFieldHandle }[] = [];
let latest: RouteLatestResponse | null = null;
let routeReady = false;
let stale = false;
let restoring = true;
function onB05_Route_AddCp_Click(): void {
const row = buildPointRow(L("B05_Route_Point_CP"));
const removeBtn = createButton({
label: L("B05_Route_Btn_RemoveCp"),
variant: "ghost",
onClick: () => {
const idx = cpRows.indexOf(row);
if (idx >= 0) cpRows.splice(idx, 1);
row.root.remove();
},
const panel = createRoutePanel({
onSolve: () => void solve(),
onConfirm: () => void confirm(),
onContourApply: (interval) => void applyContours(interval),
onSurfaceVisible: viewer.setSurfaceVisible,
onContoursVisible: viewer.setContoursVisible,
onAxesVisible: viewer.setAxesVisible,
onView: viewer.setView,
onResetView: () => viewer.setView("top"),
onMovePoint: viewer.beginMoveSelected,
onDeletePoint: viewer.markers.deleteSelected,
onRadiusChange: (radius) => viewer.markers.updateSelected({ radius_m: radius }),
onInputChange: markStale,
});
function updateConfirmGate(): void {
panel.setCanConfirm(routeReady && !stale);
}
function markStale(): void {
if (restoring || !routeReady) return;
stale = true;
panel.setStale(true);
updateConfirmGate();
}
viewer.markers.onChange(markStale);
viewer.markers.onSelectionChange(panel.setSelected);
function restorePanel(next: RouteLatestResponse): void {
const options = next.route_params?.options ?? {};
panel.restore({
contourInterval: next.surface_params.contour_interval_m,
algorithm: next.route_params?.algorithm as RoutePanelValues["algorithm"] | undefined,
gradeClass: options.grade_class as RoutePanelValues["gradeClass"] | undefined,
paved: options.paved as boolean | undefined,
minCurveRadius: options.min_curve_radius_m as number | undefined,
maxUphillGrade: options.max_uphill_grade as number | undefined,
maxDownhillGrade: options.max_downhill_grade as number | undefined,
minUphillGrade: options.min_uphill_grade as number | undefined,
minDownhillGrade: options.min_downhill_grade as number | undefined,
allowAvoidPassThrough: options.allow_avoid_pass_through as boolean | undefined,
});
row.root.append(removeBtn);
cpRows.push(row);
cpContainer.append(row.root);
viewer.markers.setPoints(restorePoints(next));
}
const addCpButton = createButton({
label: L("B05_Route_Btn_AddCp"),
variant: "ghost",
onClick: () => onB05_Route_AddCp_Click(),
});
pointsGroup.append(pointsLegend, bpRow.root, epRow.root, cpContainer, addCpButton);
/* ---- 좌측: 기반 지표면 ---- */
const surfaceGroup = document.createElement("fieldset");
surfaceGroup.className = "b05-route__group";
const surfaceLegend = document.createElement("legend");
surfaceLegend.className = "b05-route__group-legend";
surfaceLegend.textContent = L("B05_Route_Group_Surface");
const filterField = createInputField({ label: L("B05_Route_Field_Filter"), type: "text" });
const methodField = createInputField({
label: L("B05_Route_Field_Method"),
type: "text",
value: "dtm",
});
filterField.input.readOnly = true;
methodField.input.readOnly = true;
const confirmedModelInfo = document.createElement("p");
confirmedModelInfo.className = "b05-route__surface-info";
confirmedModelInfo.textContent = L("B05_Route_Surface_NotConfirmed");
surfaceGroup.append(surfaceLegend, confirmedModelInfo, filterField.root, methodField.root);
/* ---- 좌측: 설계 제약 ---- */
const constraintGroup = document.createElement("fieldset");
constraintGroup.className = "b05-route__group";
const constraintLegend = document.createElement("legend");
constraintLegend.className = "b05-route__group-legend";
constraintLegend.textContent = L("B05_Route_Group_Constraints");
const gradeSelect = createSelectField({
label: L("B05_Route_Field_GradeClass"),
options: GRADE_CLASSES.map((v) => ({ value: v, text: v })),
});
const algorithmSelect = createSelectField({
label: L("B05_Route_Field_Algorithm"),
options: ALGORITHMS.map((v) => ({ value: v, text: v })),
});
const maxUphillField = createInputField({
label: L("B05_Route_Field_MaxUphill"),
type: "number",
});
const maxDownhillField = createInputField({
label: L("B05_Route_Field_MaxDownhill"),
type: "number",
});
const minRadiusField = createInputField({
label: L("B05_Route_Field_MinRadius"),
type: "number",
});
const smoothLabel = document.createElement("label");
smoothLabel.className = "b05-route__check";
const smoothBox = document.createElement("input");
smoothBox.type = "checkbox";
const smoothText = document.createElement("span");
smoothText.textContent = L("B05_Route_Field_Smooth");
smoothLabel.append(smoothBox, smoothText);
constraintGroup.append(
constraintLegend,
gradeSelect.root,
algorithmSelect.root,
maxUphillField.root,
maxDownhillField.root,
minRadiusField.root,
smoothLabel,
);
const solveButton = createButton({
label: L("B05_Route_Btn_Solve"),
variant: "filled",
onClick: () => void onB05_Route_Solve_Click(),
});
const confirmButton = createButton({
label: L("B05_Route_Btn_Confirm"),
variant: "ghost",
onClick: () => void onB05_Route_Confirm_Click(),
});
const actionRow = document.createElement("div");
actionRow.className = "b05-route__actions";
actionRow.append(solveButton, confirmButton);
const leftForm = document.createElement("div");
leftForm.className = "b05-route__form";
leftForm.append(pointsGroup, surfaceGroup, constraintGroup, actionRow);
/* ---- 우측: 결과 ---- */
const resultTitle = document.createElement("h3");
resultTitle.className = "b05-route__result-title";
resultTitle.textContent = L("B05_Route_Result_Title");
const resultBody = document.createElement("div");
resultBody.className = "b05-route__result-body";
function renderEmptyResult(): void {
resultBody.replaceChildren();
const empty = document.createElement("p");
empty.className = "b05-route__empty";
empty.textContent = L("B05_Route_Result_Empty");
resultBody.append(empty);
}
function metricRow(label: string, value: string): HTMLElement {
const row = document.createElement("div");
row.className = "b05-route__metric";
const key = document.createElement("span");
key.className = "b05-route__metric-key";
key.textContent = label;
const val = document.createElement("span");
val.className = "b05-route__metric-val";
val.textContent = value;
row.append(key, val);
return row;
}
function renderResult(result: RouteSolveResponse): void {
resultBody.replaceChildren();
const metrics = result.metrics as Record<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;
function renderLatest(next: RouteLatestResponse): void {
latest = next;
routeReady = Boolean(next.route && next.route_points.length > 1);
stale = false;
panel.setStale(false);
if (next.route) {
const stored = next.route.algorithm_params ?? {};
const metrics = (stored.metrics as Record<string, unknown> | undefined) ?? {
length_m: next.route.total_length_m,
min_slope: next.route.min_slope,
max_slope: next.route.max_slope,
mean_slope: next.route.mean_slope,
cost_score: next.route.cost_score,
};
panel.renderMetrics(metrics);
viewer.markers.renderRoute(
next.route_points,
panel.values().gradeClass,
(stored.curve_warning_segments as Array<{
polyline_start_index: number;
polyline_end_index: number;
}>) ?? [],
);
}
const filterKey = filterField.input.value.trim();
if (!filterKey) {
filterField.setError(L("B05_Route_Error_Filter"));
return null;
}
filterField.setError();
const cp: RoutePoint[] = [];
for (const row of cpRows) {
const x = parseNumber(row.x.input.value);
const y = parseNumber(row.y.input.value);
if (x !== null && y !== null) cp.push({ x, y });
}
return { filter_key: filterKey, bp: { x: bpX, y: bpY }, ep: { x: epX, y: epY }, cp };
updateConfirmGate();
}
async function onB05_Route_Solve_Click(): Promise<void> {
const projectId = getProjectId();
if (!projectId) return;
if (!confirmedSurface) {
showToast(L("B05_Route_Surface_NotConfirmed"), "error");
async function applyContours(interval: number): Promise<void> {
showLoadingOverlay();
try {
await viewer.reloadContours(interval);
} 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;
}
const base = collectRequest();
if (!base) return;
const values = panel.values();
showLoadingOverlay();
try {
const result = await solveRoute(projectId, {
...base,
method: methodField.input.value.trim() || "dtm",
smooth: smoothBox.checked,
await solveRoute(activeProjectId, {
filter_key: latest.surface_params.source_filter,
method: latest.surface_params.method,
smooth: latest.surface_params.smooth,
surface_model_id: confirmedSurface.id,
algorithm: algorithmSelect.select.value,
grade_class: gradeSelect.select.value,
max_uphill_grade: parseNumber(maxUphillField.input.value),
max_downhill_grade: parseNumber(maxDownhillField.input.value),
min_curve_radius_m: parseNumber(minRadiusField.input.value),
algorithm: values.algorithm,
bp: routePoint(points.bp),
ep: routePoint(points.ep),
cp: points.cp.map(routePoint),
ap: points.ap.map(circlePoint),
fp: points.fp.map(circlePoint),
grade_class: values.gradeClass,
paved: values.paved,
min_curve_radius_m: values.minCurveRadius,
max_uphill_grade: values.maxUphillGrade,
max_downhill_grade: values.maxDownhillGrade,
min_uphill_grade: values.minUphillGrade,
min_downhill_grade: values.minDownhillGrade,
allow_avoid_pass_through: values.allowAvoidPassThrough,
});
renderResult(result);
showToast(L("B05_Route_Solve_Success"), "success");
renderLatest(await fetchLatestRoute(activeProjectId));
showToast("최적 경로 계산이 완료되었습니다.", "success");
} catch (error) {
const detail = error instanceof Error ? error.message : L("B05_Route_Solve_Failed");
showToast(`${L("B05_Route_Solve_Failed")} ${detail}`, "error");
showToast(error instanceof Error ? error.message : "경로 계산에 실패했습니다.", "error");
} finally {
hideLoadingOverlay();
}
}
async function onB05_Route_Confirm_Click(): Promise<void> {
const projectId = getProjectId();
if (!projectId) return;
async function confirm(): Promise<void> {
if (!routeReady || stale) return;
showLoadingOverlay();
try {
await confirmRoute(projectId);
showToast(L("B05_Route_Confirm_Success"), "success");
await confirmRoute(activeProjectId);
renderLatest(await fetchLatestRoute(activeProjectId));
showToast("경로를 확정했습니다.", "success");
goToWorkflowStage(activeProjectId, WORKFLOW_STEP_ROUTES[3]);
} catch (error) {
const detail = error instanceof Error ? error.message : L("B05_Route_Confirm_Failed");
showToast(`${L("B05_Route_Confirm_Failed")} ${detail}`, "error");
showToast(error instanceof Error ? error.message : "경로 확정에 실패했습니다.", "error");
} finally {
hideLoadingOverlay();
}
}
renderEmptyResult();
let workflowState: WorkflowState | undefined;
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
if (projectId) {
try {
const [nextWorkflowState, models] = await Promise.all([
fetchWorkflowState(projectId),
listSurfaceModels(projectId),
]);
workflowState = nextWorkflowState;
confirmedSurface = models.models.find((model) => model.status === "CONFIRMED") ?? null;
if (confirmedSurface) {
const sourceFilter = confirmedSurface.generation_params?.source_filter;
filterField.input.value = typeof sourceFilter === "string" ? sourceFilter : "";
methodField.input.value = confirmedSurface.model_type;
confirmedModelInfo.textContent = L("B05_Route_Surface_Confirmed")
.replace("{id}", String(confirmedSurface.id))
.replace("{method}", confirmedSurface.model_type);
} else {
solveButton.disabled = true;
}
} catch {
solveButton.disabled = true;
confirmedModelInfo.textContent = L("B05_Route_Surface_LoadFailed");
}
const [workflowState, models, latestResponse] = await Promise.all([
fetchWorkflowState(activeProjectId),
listSurfaceModels(activeProjectId),
fetchLatestRoute(activeProjectId),
]);
confirmedSurface = models.models.find((model) => model.status === "CONFIRMED") ?? null;
if (!confirmedSurface) {
showToast("확정된 지표면 모델이 없습니다.", "error");
} else {
const cloud = await fetchSurfacePointCloud(
activeProjectId,
latestResponse.surface_params.source_filter,
);
restorePanel(latestResponse);
await viewer.loadSurface(
activeProjectId,
confirmedSurface.id,
latestResponse.surface_params.method,
latestResponse.surface_params.smooth,
latestResponse.surface_params.contour_interval_m,
toBounds(cloud.bounds),
);
renderLatest(latestResponse);
}
latest = latestResponse;
restoring = false;
const layout = createWorkflowLayout({
title: L("B05_Route_Title"),
title: "노선 설계",
steps: workflowSteps(),
activeStep: 2,
leftPanel: leftForm,
mainContent: resultCard,
stages: workflowState?.stages,
currentStage: workflowState?.current_stage,
leftPanel: panel.root,
mainContent: viewer.root,
stages: workflowState.stages,
currentStage: workflowState.current_stage,
routes: WORKFLOW_STEP_ROUTES,
onStepClick: (stepIndex) => {
if (projectId) {
goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[stepIndex]);
}
},
onStepClick: (stepIndex) => goToWorkflowStage(activeProjectId, WORKFLOW_STEP_ROUTES[stepIndex]),
});
layout.root.classList.add("b05-route-layout");
root.replaceChildren(layout.root);
}
+294
View File
@@ -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>;
+133 -104
View File
@@ -1,139 +1,168 @@
/* =============================================================================
* B05_wf2_Route_UI_Style.css
* 2차 워크플로우(경로 설계) 페이지 전용 스타일.
*
* 원칙(frontend.md §1): 하드코딩 색상 금지. theme.css 변수(var(--...))만 참조.
* 공통 컴포넌트 스타일은 ui_template_elements.ts가 주입하므로 여기서는
* B05 고유 레이아웃(제어점 행/제약 그룹/결과 메트릭)만 정의한다.
* ========================================================================== */
/* --- 좌측 입력 폼 --- */
.b05-route__form {
display: flex;
flex-direction: column;
gap: var(--spacing-16);
.b05-route-layout {
height: calc(100vh - var(--spacing-64));
height: calc(100dvh - var(--spacing-64));
min-height: 0;
overflow: hidden;
}
.b05-route__group {
.b05-route-layout .ui-workflow-layout__body,
.b05-route-layout .ui-workflow-layout__main {
height: 100%;
min-height: 0;
}
.b05-route-layout .ui-workflow-layout__main {
overflow: hidden;
}
.b05-route__viewport {
position: relative;
width: 100%;
height: 100%;
min-height: 0;
overflow: hidden;
background: var(--color-surface);
}
.b05-route__viewport canvas {
display: block;
width: 100%;
height: 100%;
}
.b05-route__viewer-status {
position: absolute;
inset: var(--spacing-16) auto auto var(--spacing-16);
max-width: 420px;
padding: var(--spacing-8) var(--spacing-16);
border: 1px solid var(--color-border);
border-radius: var(--radius-inputs);
background: var(--color-surface-raised);
color: var(--color-text-body);
font-size: var(--text-caption);
pointer-events: none;
}
.b05-route__panel {
display: flex;
flex-direction: column;
width: 100%;
min-width: 0;
gap: var(--spacing-16);
margin: 0;
padding: var(--spacing-16);
padding-bottom: var(--spacing-24);
}
.b05-route__panel-section {
display: flex;
flex-direction: column;
width: 100%;
min-width: 0;
gap: var(--spacing-8);
padding: calc(var(--spacing-8) + var(--spacing-4));
border: 1px solid var(--color-border);
border-radius: var(--radius-cards);
background-color: var(--color-surface-raised);
background: var(--color-surface-raised);
}
.b05-route__group-legend {
padding: 0 var(--spacing-8);
font-size: var(--text-caption);
font-weight: var(--font-weight-medium);
color: var(--color-text-secondary);
}
/* --- 제어점 (X/Y 쌍) --- */
.b05-route__point {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
}
.b05-route__point-label {
font-size: var(--text-body-sm);
font-weight: var(--font-weight-medium);
color: var(--color-text-body);
}
.b05-route__point-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--spacing-8);
}
.b05-route__cp-list {
display: flex;
flex-direction: column;
gap: var(--spacing-16);
}
/* --- select (지표면/제약) --- */
.b05-route__select {
appearance: auto;
cursor: pointer;
}
.b05-route__surface-info {
.b05-route__panel-section h3 {
margin: 0;
color: var(--color-text-body);
color: var(--color-text);
font-size: var(--text-body-sm);
font-weight: var(--font-weight-medium);
}
/* --- 체크박스 --- */
.b05-route__panel-body,
.b05-route__field,
.b05-route__metrics {
display: flex;
flex-direction: column;
min-width: 0;
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%;
min-width: 0;
padding: var(--spacing-8);
border: 1px solid var(--color-border);
border-radius: var(--radius-inputs);
background: var(--color-surface);
color: var(--color-text-body);
}
.b05-route__check {
display: flex;
align-items: center;
gap: var(--spacing-8);
font-size: var(--text-body-sm);
}
.b05-route__button-grid,
.b05-route__palette {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
min-width: 0;
gap: var(--spacing-8);
}
.b05-route__button,
.b05-route__chip {
padding: var(--spacing-8);
border: 1px solid var(--color-border);
border-radius: var(--radius-inputs);
background: var(--color-surface);
color: var(--color-text-body);
font-size: var(--text-caption);
cursor: pointer;
}
.b05-route__check input {
accent-color: var(--color-primary);
.b05-route__button:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.b05-route__button.is-primary {
background: var(--color-primary);
color: var(--color-text-on-primary);
}
.b05-route__button.is-danger,
.b05-route__chip.is-ep,
.b05-route__chip.is-fp {
color: var(--color-danger);
}
.b05-route__chip.is-bp {
color: var(--color-success);
}
.b05-route__chip.is-cp,
.b05-route__stale {
color: var(--color-warning);
}
/* --- 액션 버튼 행 --- */
.b05-route__actions {
display: flex;
gap: var(--spacing-8);
}
/* --- 우측 결과 --- */
.b05-route__result {
display: flex;
flex-direction: column;
gap: var(--spacing-16);
padding: var(--spacing-24);
.b05-route__actions > * {
flex: 1;
}
.b05-route__result-title {
font-size: var(--text-subheading);
color: var(--color-text);
}
.b05-route__empty {
color: var(--color-text-muted);
font-size: var(--text-body-sm);
}
.b05-route__result-body {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
max-width: 480px;
}
.b05-route__metric {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--spacing-16);
padding: var(--spacing-8) var(--spacing-16);
border-radius: var(--radius-inputs);
background-color: var(--color-surface);
}
.b05-route__metric-key {
.b05-route__stale {
font-size: var(--text-caption);
color: var(--color-text-secondary);
font-weight: var(--font-weight-medium);
}
.b05-route__metric-val {
font-family: var(--font-mono);
font-size: var(--text-body-sm);
.b05-route__panel details {
color: var(--color-text-body);
word-break: break-all;
text-align: right;
font-size: var(--text-caption);
}
+261
View File
@@ -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
+7
View File
@@ -149,6 +149,13 @@ SURFACE_SMOOTHING_TIN_TAUBIN_MU = float(os.getenv("SURFACE_SMOOTHING_TIN_TAUBIN_
SURFACE_CONTOUR_INTERVAL_M = float(os.getenv("SURFACE_CONTOUR_INTERVAL_M", "1.0"))
SURFACE_CONTOUR_GRID_RESOLUTION_M = float(os.getenv("SURFACE_CONTOUR_GRID_RESOLUTION_M", "1.0"))
# 일반 사용자 WF1 자동 확정 기본값
SURFACE_CONFIRM_DEFAULT_FILTER = os.getenv("SURFACE_CONFIRM_DEFAULT_FILTER", "csf")
SURFACE_CONFIRM_DEFAULT_METHOD = os.getenv("SURFACE_CONFIRM_DEFAULT_METHOD", "dtm")
SURFACE_CONFIRM_DEFAULT_SMOOTH = (
os.getenv("SURFACE_CONFIRM_DEFAULT_SMOOTH", "True").lower() == "true"
)
def build_surface_model_config() -> dict:
"""지표면 모델 파이프라인이 사용하는 config dict를 조립한다."""
+3
View File
@@ -301,6 +301,9 @@ CREATE TABLE IF NOT EXISTS routes (
CREATE TABLE IF NOT EXISTS route_points (
id INT AUTO_INCREMENT PRIMARY KEY,
route_id INT NOT NULL, -- FK는 later
model_x DOUBLE,
model_y DOUBLE,
model_z DOUBLE,
chainage_m FLOAT,
elevation_m FLOAT,
slope_percent FLOAT,
+7
View File
@@ -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 { t } from "./ui_template_locale";
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 {
stage_no: number;
@@ -74,6 +82,18 @@ export function createStepBar(
button.classList.toggle("is-enabled", isEnabled);
button.disabled = !isEnabled;
if (index === 1) {
void getDashboardRole()
.then((role) => {
if (role !== "SYSTEM_ADMIN") {
button.classList.remove("is-enabled");
button.disabled = true;
button.title = "시스템 관리자 전용 단계입니다.";
}
})
.catch(() => undefined);
}
// state 기반 스타일링 (표시 전용)
if (stage) {
const state = stage.state;