From 6b1583103f2e12aa121d320fe53b1b4df3198aa6 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 10 Jul 2026 19:52:06 +0900 Subject: [PATCH] 260710_1 --- A00_Common/app_shell.ts | 53 +- A00_Common/b_page_scaffold.ts | 6 +- B01_Dashboard/B01_Dashboard_UI_Page.ts | 4 +- B01_Dashboard/B01_Dashboard_UI_Style.css | 6 +- B04_wf1_Surface/B04_wf1_Surface_Api_Fetch.ts | 1 + B04_wf1_Surface/B04_wf1_Surface_Router.py | 170 ++++- B04_wf1_Surface/B04_wf1_Surface_Schema.py | 1 + B04_wf1_Surface/B04_wf1_Surface_UI_Page.ts | 23 +- B04_wf1_Surface/B04_wf1_Surface_UI_Style.css | 113 +++ .../B04_wf1_Surface_UI_TerrainViewer.ts | 664 ++++++++++++++++++ B04_wf1_Surface/B04_wf1_Surface_UI_Viewer.ts | 351 ++++++++- B05_wf2_Route/B05_wf2_Route_UI_Page.ts | 37 +- .../B06_wf3_ProfileCross_UI_Page.ts | 37 +- .../B07_wf4_DesignDetail_UI_Page.ts | 2 +- B08_wf5_Quantity/B08_wf5_Quantity_UI_Page.ts | 2 +- .../B09_wf6_Estimation_UI_Page.ts | 2 +- scratch_inspect.py | 28 +- ui_template/ui_template_workflow_layout.css | 36 + ui_template/ui_template_workflow_layout.ts | 77 +- 19 files changed, 1515 insertions(+), 98 deletions(-) create mode 100644 B04_wf1_Surface/B04_wf1_Surface_UI_TerrainViewer.ts diff --git a/A00_Common/app_shell.ts b/A00_Common/app_shell.ts index 1189fa8..99356d5 100644 --- a/A00_Common/app_shell.ts +++ b/A00_Common/app_shell.ts @@ -126,24 +126,41 @@ function buildHeader(): HTMLElement { /** 서버 세션 상태에 따라 로그인/로그아웃 버튼을 채운다. */ async function fillAuthActions(slot: HTMLElement): Promise { - const loggedIn = await isAuthenticated(); - slot.innerHTML = ""; - if (loggedIn) { - const { fetchSessionUser } = await import("../A06_Login/A06_Login_Api_Fetch"); - const user = await fetchSessionUser(); - slot.append( - createButton({ - label: user?.name?.trim() || L("Nav_MyAccount"), - variant: "ghost", - onClick: () => navigateTo(ROUTES.B01_ACCOUNT), - }), - createButton({ - label: L("Nav_Logout"), - variant: "pill", - onClick: onApp_Logout_Click, - }), - ); - } else { + try { + const loggedIn = await isAuthenticated(); + slot.innerHTML = ""; + if (loggedIn) { + const { fetchSessionUser } = await import("../A06_Login/A06_Login_Api_Fetch"); + const user = await fetchSessionUser(); + slot.append( + createButton({ + label: user?.name?.trim() || L("Nav_MyAccount"), + variant: "ghost", + onClick: () => navigateTo(ROUTES.B01_ACCOUNT), + }), + createButton({ + label: L("Nav_Logout"), + variant: "pill", + onClick: onApp_Logout_Click, + }), + ); + } else { + slot.append( + createButton({ + label: L("Nav_Login"), + variant: "ghost", + onClick: () => navigateTo(ROUTES.A06_LOGIN), + }), + createButton({ + label: L("Nav_Register"), + variant: "filled", + onClick: () => navigateTo(ROUTES.A07_REGISTER), + }), + ); + } + } catch (error) { + console.error("[app_shell] fillAuthActions error:", error); + slot.innerHTML = ""; slot.append( createButton({ label: L("Nav_Login"), diff --git a/A00_Common/b_page_scaffold.ts b/A00_Common/b_page_scaffold.ts index d8b4e98..2a3ff1e 100644 --- a/A00_Common/b_page_scaffold.ts +++ b/A00_Common/b_page_scaffold.ts @@ -93,10 +93,12 @@ export function renderPendingWorkflow(root: HTMLElement, opts: PendingWorkflowOp root.append(shell.root); } -/** 워크플로우 스텝 라벨 6종 (locale 결과) — B04~B09 공용. - * 기존 WF_Step_* 키를 재사용 (중복 등록 방지, frontend.md §3). */ +/** 워크플로우 스텝 라벨 7종 (locale 결과) — B03~B09 공용. + * 파일 입력(B03)부터 견적/문서(B09)까지 대시보드 워크플로우와 동일한 순서. + * 기존 WF_Step_* / B03_File_Title 키를 재사용 (중복 등록 방지, frontend.md §3). */ export function workflowSteps(): string[] { return [ + L("B03_File_Title"), L("WF_Step_Surface"), L("WF_Step_Route"), L("WF_Step_ProfileCross"), diff --git a/B01_Dashboard/B01_Dashboard_UI_Page.ts b/B01_Dashboard/B01_Dashboard_UI_Page.ts index bcfc7ab..b338b36 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Page.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Page.ts @@ -1,6 +1,7 @@ import { CURRENT_PROJECT_ID_KEY, ROUTES, type RoutePath } from "@config/config_frontend"; import { isBlank } from "@util/common_util_validate"; import { navigateTo } from "../A00_Common/router"; +import { workflowSteps } from "../A00_Common/b_page_scaffold"; import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; import { createButton, @@ -382,12 +383,13 @@ function workflow(project: ProjectItem): HTMLElement { box.className = "b01-dashboard__workflow"; const stages = project.workflow_state?.stages; + const stepLabels = workflowSteps(); routes.forEach((route, index) => { const button = document.createElement("button"); button.type = "button"; button.className = "b01-dashboard__step"; - button.textContent = `B${String(index + 3).padStart(2, "0")}`; + button.textContent = stepLabels[index] ?? `B${String(index + 3).padStart(2, "0")}`; let enabled = false; if (stages && stages.length > 0) { diff --git a/B01_Dashboard/B01_Dashboard_UI_Style.css b/B01_Dashboard/B01_Dashboard_UI_Style.css index 79f5a76..e18d4cf 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Style.css +++ b/B01_Dashboard/B01_Dashboard_UI_Style.css @@ -83,17 +83,19 @@ .b01-dashboard__workflow { display: flex; + flex-wrap: wrap; gap: var(--spacing-4); } .b01-dashboard__step { - width: 28px; height: 28px; + padding: 0 var(--spacing-12); border: 1px solid var(--color-border); - border-radius: var(--radius-icons); + border-radius: var(--radius-pills); background: var(--color-surface); color: var(--color-text-muted); font-size: var(--text-caption); + white-space: nowrap; cursor: default; } diff --git a/B04_wf1_Surface/B04_wf1_Surface_Api_Fetch.ts b/B04_wf1_Surface/B04_wf1_Surface_Api_Fetch.ts index f226c75..19c12c3 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_Api_Fetch.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_Api_Fetch.ts @@ -64,6 +64,7 @@ export interface SurfacePointCloudSampleResponse { sampled_count: number; bounds: Record; points: [number, number, number][]; + rgb?: [number, number, number][]; } export interface SurfaceGroundStatsResponse { diff --git a/B04_wf1_Surface/B04_wf1_Surface_Router.py b/B04_wf1_Surface/B04_wf1_Surface_Router.py index fd4373e..3df369b 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_Router.py +++ b/B04_wf1_Surface/B04_wf1_Surface_Router.py @@ -10,7 +10,7 @@ from uuid import UUID import aiomysql import numpy as np from fastapi import APIRouter -from fastapi.responses import JSONResponse +from fastapi.responses import FileResponse, JSONResponse from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path from B04_wf1_Surface.B04_wf1_Surface_Engine import run_surface_analysis @@ -304,6 +304,15 @@ async def get_surface_point_cloud( else: sample = xyz + rgb_sample = None + if "rgb" in structured: + rgb_arr = np.asarray(structured["rgb"]) + if rgb_arr.ndim > 0 and len(rgb_arr) == point_count: + if point_count > POINT_CLOUD_SAMPLE_LIMIT: + rgb_sample = rgb_arr[indexes] + else: + rgb_sample = rgb_arr + return SurfacePointCloudSampleResponse( project_id=str(project_id), point_count=point_count, @@ -317,6 +326,7 @@ async def get_surface_point_cloud( "z_max": float(bounds[2, 1]), }, points=sample.astype(float).tolist(), + rgb=rgb_sample.astype(int).tolist() if rgb_sample is not None else None, ) except LookupError as exc: return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)}) @@ -469,3 +479,161 @@ async def get_wf1_analysis_status(project_id: UUID) -> dict: status_code=500, content={"status": "error", "message": "분석 상태 조회 중 오류가 발생했습니다."}, ) + + +@router.get("/{project_id}/surface/models/{model_id}/preview", response_model=None) +async def get_surface_model_preview( + project_id: UUID, + model_id: int, + smooth: bool = False, +) -> FileResponse | JSONResponse: + """지표면 모델의 3D 프리뷰 파일(GLB/PLY)을 반환한다.""" + pool = get_db_pool() + try: + async with pool.acquire() as connection: + stored_path = await get_project_storage_relative_path(connection, project_id) + async with connection.cursor() as cursor: + await cursor.execute( + """ + SELECT model_type, model_file_path + FROM surface_models + WHERE id = %s AND project_id = %s + """, + (model_id, str(project_id)), + ) + row = await cursor.fetchone() + if not row: + return JSONResponse( + status_code=404, + content={"status": "error", "message": "해당 모델을 찾을 수 없습니다."}, + ) + model_type, model_file_path = row[0], row[1] + + project_root = Path(resolve_stored_project_path(stored_path)) + if not model_file_path: + return JSONResponse( + status_code=404, + content={"status": "error", "message": "모델 파일 경로가 없습니다."}, + ) + + model_path = project_root / model_file_path + models_dir = model_path.parent + stem = model_path.stem + + ext = "ply" if model_type == "meshfree" else "glb" + if smooth and model_type in ("dtm", "tin"): + preview_filename = f"{stem}_smooth_preview.glb" + else: + preview_filename = f"{stem}_preview.{ext}" + + preview_path = models_dir / preview_filename + if not preview_path.is_file(): + return JSONResponse( + status_code=404, + content={ + "status": "error", + "message": "프리뷰 파일이 생성되지 않았거나 존재하지 않습니다.", + }, + ) + + media_type = "application/octet-stream" + if ext == "glb": + media_type = "model/gltf-binary" + elif ext == "ply": + media_type = "application/ply" + + return FileResponse(preview_path, media_type=media_type, filename=preview_filename) + + except Exception: + logger.exception( + "지표면 모델 프리뷰 조회 실패: project_id=%s, model_id=%s", project_id, model_id + ) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "프리뷰 파일 조회 중 오류가 발생했습니다."}, + ) + + +@router.get("/{project_id}/surface/models/{model_id}/contour", response_model=None) +async def get_surface_model_contour( + project_id: UUID, + model_id: int, + interval: float = 5.0, + smooth: bool = False, +) -> FileResponse | JSONResponse: + """지표면 모델의 등고선 JSON 파일을 반환한다.""" + pool = get_db_pool() + try: + async with pool.acquire() as connection: + stored_path = await get_project_storage_relative_path(connection, project_id) + async with connection.cursor() as cursor: + await cursor.execute( + """ + SELECT model_type, model_file_path + FROM surface_models + WHERE id = %s AND project_id = %s + """, + (model_id, str(project_id)), + ) + row = await cursor.fetchone() + if not row: + return JSONResponse( + status_code=404, + content={"status": "error", "message": "해당 모델을 찾을 수 없습니다."}, + ) + model_type, model_file_path = row[0], row[1] + + project_root = Path(resolve_stored_project_path(stored_path)) + if not model_file_path: + return JSONResponse( + status_code=404, + content={"status": "error", "message": "모델 파일 경로가 없습니다."}, + ) + + model_path = project_root / model_file_path + models_dir = model_path.parent + stem = model_path.stem + + parts = stem.split("_") + if len(parts) >= 2: + method = parts[0] + filter_key = "_".join(parts[1:]) + else: + method = model_type + filter_key = "csf" + + if smooth and method in ("dtm", "tin"): + contour_filename = f"contour_{filter_key}_{method}_smooth_{interval}m.json" + else: + contour_filename = f"contour_{filter_key}_{method}_{interval}m.json" + + contour_path = models_dir / contour_filename + + if not contour_path.is_file(): + fallback_files = list(models_dir.glob(f"contour_{filter_key}_{method}*.json")) + if smooth and method in ("dtm", "tin"): + fallback_files = list( + models_dir.glob(f"contour_{filter_key}_{method}_smooth_*.json") + ) + if fallback_files: + contour_path = fallback_files[0] + + if not contour_path.is_file(): + return JSONResponse( + status_code=404, + content={ + "status": "error", + "message": "등고선 파일이 생성되지 않았거나 존재하지 않습니다.", + }, + ) + + return FileResponse(contour_path, media_type="application/json", filename=contour_filename) + + except Exception: + logger.exception( + "지표면 모델 등고선 조회 실패: project_id=%s, model_id=%s", project_id, model_id + ) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "등고선 파일 조회 중 오류가 발생했습니다."}, + ) diff --git a/B04_wf1_Surface/B04_wf1_Surface_Schema.py b/B04_wf1_Surface/B04_wf1_Surface_Schema.py index 4c63c3b..690ff11 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_Schema.py +++ b/B04_wf1_Surface/B04_wf1_Surface_Schema.py @@ -83,6 +83,7 @@ class SurfacePointCloudSampleResponse(BaseModel): sampled_count: int bounds: dict[str, float] points: list[list[float]] + rgb: list[list[int]] | None = None class SurfaceGroundStatsResponse(BaseModel): diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_Page.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_Page.ts index 4c603b5..7678109 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_Page.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_Page.ts @@ -34,6 +34,7 @@ import { } from "./B04_wf1_Surface_Api_Fetch"; import { createWorkflowLayout } from "@ui/ui_template_workflow_layout"; import { createSurfacePointCloudViewer } from "./B04_wf1_Surface_UI_Viewer"; +import { createSurfaceTerrainViewer } from "./B04_wf1_Surface_UI_TerrainViewer"; import "./B04_wf1_Surface_UI_Style.css"; /** locale 헬퍼 */ @@ -105,6 +106,7 @@ export function renderB04Surface(root: HTMLElement): void { const statsList = document.createElement("div"); statsList.className = "b04-surface__stats"; const viewer = createSurfacePointCloudViewer(); + const terrainViewer = createSurfaceTerrainViewer(); const filterGroup = buildCheckboxGroup(L("B04_Surface_Group_Filters"), SOURCE_FILTERS, [ "grid_min_z", @@ -148,6 +150,8 @@ export function renderB04Surface(root: HTMLElement): void { filterGroup.root, methodGroup.root, forceLabel, + viewer.controlsGroup, + viewer.optionsGroup, analyzeButton, refreshButton, ); @@ -156,24 +160,35 @@ export function renderB04Surface(root: HTMLElement): void { workspace.className = "b04-surface__workspace"; const topbar = document.createElement("div"); topbar.className = "b04-surface__topbar"; + + const titleWrap = document.createElement("div"); + titleWrap.style.display = "flex"; + titleWrap.style.alignItems = "baseline"; + titleWrap.style.gap = "var(--spacing-12)"; + const title = document.createElement("h3"); title.textContent = L("B04_Surface_PointCloud_Title"); - topbar.append(title, statusBox); + titleWrap.append(title, viewer.statusSpan); + + topbar.append(titleWrap, statusBox); const bottom = document.createElement("div"); bottom.className = "b04-surface__bottom"; + const statsSection = document.createElement("section"); statsSection.className = "b04-surface__section"; const statsTitle = document.createElement("h3"); statsTitle.textContent = L("B04_Surface_GroundStats_Title"); statsSection.append(statsTitle, statsList); + const modelsSection = document.createElement("section"); modelsSection.className = "b04-surface__section"; const modelsTitle = document.createElement("h3"); modelsTitle.textContent = L("B04_Surface_Result_Title"); modelsSection.append(modelsTitle, modelList); + bottom.append(statsSection, modelsSection); - workspace.append(topbar, viewer.root, bottom); + workspace.append(topbar, viewer.root, terrainViewer.root, bottom); const layout = createWorkflowLayout({ title: L("B04_Surface_Title"), @@ -240,6 +255,10 @@ export function renderB04Surface(root: HTMLElement): void { function renderModels(models: readonly SurfaceModelSummary[]): void { modelList.replaceChildren(); + const projectId = getProjectId(); + if (projectId) { + terrainViewer.render(projectId, models); + } if (models.length === 0) { const empty = document.createElement("p"); empty.className = "b04-surface__empty"; diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_Style.css b/B04_wf1_Surface/B04_wf1_Surface_UI_Style.css index 411244d..47d23c2 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_Style.css +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_Style.css @@ -116,12 +116,15 @@ .b04-surface-viewer { min-height: 360px; height: 100%; + padding: 0 var(--spacing-24) var(--spacing-24); + box-sizing: border-box; } .b04-surface-viewer__canvas { display: block; width: 100%; height: 100%; + border-radius: var(--radius-cards); } .b04-surface__bottom { @@ -216,3 +219,113 @@ grid-template-columns: 1fr; } } + +.point-viewer { + display: flex; + flex-direction: column; + border: 1px solid var(--color-border); + border-radius: var(--radius-cards); + overflow: hidden; + background: var(--color-surface); +} + +.point-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--spacing-12); + padding: var(--spacing-12) var(--spacing-16); + border-bottom: 1px solid var(--color-border); + background: var(--color-surface-raised); +} + +.point-toolbar--stacked { + align-items: flex-start; + flex-wrap: wrap; +} + +.point-toolbar div { + display: flex; + align-items: baseline; + gap: var(--spacing-8); + flex-wrap: wrap; +} + +.point-toolbar span { + color: var(--color-text-secondary); + font-size: var(--text-caption); +} + +.viewer-controls { + display: flex; + gap: var(--spacing-8); + flex-wrap: wrap; + justify-content: flex-end; +} + +.viewer-controls button { + min-height: 32px; + padding: 0 var(--spacing-12); + font-size: var(--text-caption); + border: 1px solid var(--color-border); + border-radius: var(--radius-buttons); + background: var(--color-canvas); + color: var(--color-text); + cursor: pointer; +} + +.viewer-controls button:hover { + border-color: var(--color-accent); + background: var(--color-mist-violet); +} + +.toggle-label { + display: inline-flex; + align-items: center; + gap: var(--spacing-4); + min-height: 32px; + font-size: var(--text-caption); + color: var(--color-text-secondary); + cursor: pointer; +} + +.viewer-options { + display: flex; + align-items: center; + gap: var(--spacing-16); + flex-wrap: wrap; + padding: var(--spacing-12) var(--spacing-16); + border-bottom: 1px solid var(--color-border); + background: var(--color-surface-raised); +} + +.viewer-options label { + display: inline-flex; + align-items: center; + gap: var(--spacing-8); + font-size: var(--text-caption); + color: var(--color-text-secondary); +} + +.viewer-option-val { + font-variant-numeric: tabular-nums; + min-width: 38px; + display: inline-block; + color: var(--color-text); + font-weight: var(--font-weight-semibold); +} + +.subtle-notice { + color: var(--color-text-muted); + background: var(--color-surface-raised); + border-bottom: 1px solid var(--color-border); + padding: var(--spacing-8) var(--spacing-16); + font-size: var(--text-caption); +} + +.three-viewer { + position: relative; + width: 100%; + height: 560px; + background: var(--color-canvas); +} diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_TerrainViewer.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_TerrainViewer.ts new file mode 100644 index 0000000..62d4b47 --- /dev/null +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_TerrainViewer.ts @@ -0,0 +1,664 @@ +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 type { SurfaceModelSummary } from "./B04_wf1_Surface_Api_Fetch"; +import { API_BASE_URL } from "@config/config_frontend"; + +export interface SurfaceTerrainViewer { + root: HTMLElement; + render: (projectId: string, models: readonly SurfaceModelSummary[]) => void; + dispose: () => void; +} + +export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { + const root = document.createElement("div"); + root.className = "terrain-model-group panel panel--no-bottom"; + root.style.marginTop = "var(--spacing-24)"; + + // Header Title + const header = document.createElement("div"); + header.className = "panel-title"; + header.style.display = "flex"; + header.style.justifyContent = "space-between"; + header.style.alignItems = "center"; + + const titleSpan = document.createElement("span"); + titleSpan.innerHTML = "🏕️ 지면 필터별 5가지 지표면 모델 비교"; + + const statusSpan = document.createElement("span"); + statusSpan.className = "terrain-status"; + statusSpan.style.fontSize = "var(--text-caption)"; + statusSpan.style.color = "var(--color-text-secondary)"; + statusSpan.textContent = "모델 선택 대기 중..."; + + header.append(titleSpan, statusSpan); + root.append(header); + + // Filters and Methods selector bars + const selectorBar = document.createElement("div"); + selectorBar.className = "filter-selector-bar terrain-selector-bar"; + selectorBar.style.padding = "var(--spacing-12) var(--spacing-16)"; + selectorBar.style.background = "var(--color-bg-light)"; + selectorBar.style.borderBottom = "1px solid var(--color-border)"; + selectorBar.style.display = "flex"; + selectorBar.style.flexDirection = "column"; + selectorBar.style.gap = "var(--spacing-12)"; + + // 1. Source Filter row + const filterRow = document.createElement("div"); + filterRow.style.display = "flex"; + filterRow.style.alignItems = "center"; + filterRow.style.gap = "var(--spacing-12)"; + + const filterLabel = document.createElement("span"); + filterLabel.className = "filter-bar-label"; + filterLabel.textContent = "서피스 기준 데이터:"; + filterLabel.style.fontWeight = "bold"; + filterLabel.style.minWidth = "120px"; + + const filterSegmented = document.createElement("div"); + filterSegmented.className = "segmented"; + + const filters = [ + { key: "csf", label: "CSF" }, + { key: "pmf", label: "PMF" }, + { key: "grid_min_z", label: "Grid Min-Z" }, + ]; + + let activeFilter = "csf"; + const filterButtons = filters.map((f) => { + const btn = document.createElement("button"); + btn.type = "button"; + btn.textContent = f.label; + btn.className = f.key === activeFilter ? "active" : ""; + btn.addEventListener("click", () => { + filterButtons.forEach((b) => b.classList.remove("active")); + btn.classList.add("active"); + activeFilter = f.key; + updateSelectedModel(); + }); + filterSegmented.append(btn); + return btn; + }); + filterRow.append(filterLabel, filterSegmented); + + // 2. Method row + const methodRow = document.createElement("div"); + methodRow.style.display = "flex"; + methodRow.style.alignItems = "center"; + methodRow.style.gap = "var(--spacing-12)"; + + const methodLabel = document.createElement("span"); + methodLabel.className = "filter-bar-label"; + methodLabel.textContent = "지표면 표현 방식:"; + methodLabel.style.fontWeight = "bold"; + methodLabel.style.minWidth = "120px"; + + const methodSegmented = document.createElement("div"); + methodSegmented.className = "segmented"; + + const methods = [ + { key: "tin", label: "TIN" }, + { key: "dtm", label: "DTM (Grid)" }, + { key: "nurbs", label: "NURBS" }, + { key: "implicit", label: "Implicit" }, + { key: "meshfree", label: "Meshfree" }, + ]; + + let activeMethod = "dtm"; + const methodButtons = methods.map((m) => { + const btn = document.createElement("button"); + btn.type = "button"; + btn.textContent = m.label; + btn.className = m.key === activeMethod ? "active" : ""; + btn.addEventListener("click", () => { + methodButtons.forEach((b) => b.classList.remove("active")); + btn.classList.add("active"); + activeMethod = m.key; + updateSelectedModel(); + }); + methodSegmented.append(btn); + return btn; + }); + methodRow.append(methodLabel, methodSegmented); + selectorBar.append(filterRow, methodRow); + root.append(selectorBar); + + // Viewer Controls toolbar & checkbox options + const toolbar = document.createElement("div"); + toolbar.className = "point-toolbar"; + toolbar.style.display = "flex"; + toolbar.style.justifyContent = "space-between"; + toolbar.style.alignItems = "center"; + toolbar.style.padding = "var(--spacing-12) var(--spacing-16)"; + toolbar.style.background = "var(--color-bg-card)"; + toolbar.style.borderBottom = "1px solid var(--color-border)"; + + const leftControls = document.createElement("div"); + leftControls.className = "viewer-controls"; + + const btnIso = document.createElement("button"); + btnIso.type = "button"; + btnIso.innerHTML = "🔍 사시도"; + const btnTop = document.createElement("button"); + btnTop.type = "button"; + btnTop.textContent = "상단"; + const btnFront = document.createElement("button"); + btnFront.type = "button"; + btnFront.textContent = "정면"; + const btnSide = document.createElement("button"); + btnSide.type = "button"; + btnSide.textContent = "측면"; + const btnReset = document.createElement("button"); + btnReset.type = "button"; + btnReset.innerHTML = "🔄 리셋"; + + const toggleAxesLabel = document.createElement("label"); + toggleAxesLabel.className = "toggle-label"; + const axesCheck = document.createElement("input"); + axesCheck.type = "checkbox"; + axesCheck.checked = true; + toggleAxesLabel.append(axesCheck, document.createTextNode(" 축")); + + leftControls.append(btnIso, btnTop, btnFront, btnSide, btnReset, toggleAxesLabel); + + const rightControls = document.createElement("div"); + rightControls.style.display = "flex"; + rightControls.style.alignItems = "center"; + rightControls.style.gap = "var(--spacing-16)"; + + // Surface Toggle + const surfLabel = document.createElement("label"); + surfLabel.className = "toggle-label"; + const surfCheck = document.createElement("input"); + surfCheck.type = "checkbox"; + surfCheck.checked = true; + surfLabel.append(surfCheck, document.createTextNode(" 서피스")); + + // Smooth Toggle (for tin/dtm) + const smoothLabel = document.createElement("label"); + smoothLabel.className = "toggle-label"; + const smoothCheck = document.createElement("input"); + smoothCheck.type = "checkbox"; + smoothCheck.checked = true; + smoothLabel.append(smoothCheck, document.createTextNode(" 스무딩")); + + // Contour Toggle + const contourLabel = document.createElement("label"); + contourLabel.className = "toggle-label"; + const contourCheck = document.createElement("input"); + contourCheck.type = "checkbox"; + contourCheck.checked = true; + contourLabel.append(contourCheck, document.createTextNode(" 등고선")); + + // Contour Interval input form + const intervalForm = document.createElement("form"); + intervalForm.style.display = "flex"; + intervalForm.style.alignItems = "center"; + intervalForm.style.gap = "var(--spacing-4)"; + + const intervalInput = document.createElement("input"); + intervalInput.type = "number"; + intervalInput.value = "5.0"; + intervalInput.step = "0.5"; + intervalInput.min = "0.5"; + intervalInput.style.width = "60px"; + intervalInput.style.padding = "var(--spacing-4) var(--spacing-8)"; + intervalInput.style.border = "1px solid var(--color-border)"; + intervalInput.style.borderRadius = "var(--radius-normal)"; + + const intervalSubmit = document.createElement("button"); + intervalSubmit.type = "submit"; + intervalSubmit.textContent = "적용"; + intervalSubmit.style.padding = "var(--spacing-4) var(--spacing-12)"; + + intervalForm.append( + document.createTextNode("간격 "), + intervalInput, + document.createTextNode("m "), + intervalSubmit, + ); + + rightControls.append(surfLabel, smoothLabel, contourLabel, intervalForm); + toolbar.append(leftControls, rightControls); + root.append(toolbar); + + // 3D View container + const viewerArea = document.createElement("div"); + viewerArea.className = "three-viewer"; + viewerArea.style.height = "520px"; + viewerArea.style.position = "relative"; + viewerArea.style.borderRadius = "0 0 var(--radius-cards) var(--radius-cards)"; + viewerArea.style.overflow = "hidden"; + + const canvas = document.createElement("canvas"); + canvas.className = "b04-surface-viewer__canvas"; + viewerArea.append(canvas); + root.append(viewerArea); + + // Scale bar overlay + const scaleBar = document.createElement("div"); + scaleBar.style.position = "absolute"; + scaleBar.style.bottom = "16px"; + scaleBar.style.left = "16px"; + scaleBar.style.background = "rgba(255, 255, 255, 0.9)"; + scaleBar.style.border = "1.5px solid #1e293b"; + scaleBar.style.borderTop = "none"; + scaleBar.style.height = "8px"; + scaleBar.style.width = "100px"; + scaleBar.style.zIndex = "10"; + scaleBar.style.display = "none"; + scaleBar.style.flexDirection = "column"; + scaleBar.style.alignItems = "center"; + scaleBar.style.justifyContent = "flex-end"; + + const scaleLabel = document.createElement("span"); + scaleLabel.style.fontSize = "10px"; + scaleLabel.style.fontWeight = "bold"; + scaleLabel.style.color = "#1e293b"; + scaleLabel.style.position = "absolute"; + scaleLabel.style.bottom = "10px"; + scaleLabel.style.whiteSpace = "nowrap"; + scaleBar.append(scaleLabel); + viewerArea.append(scaleBar); + + // Three.js context variables + let currentProjectId = ""; + let currentModelsList: readonly SurfaceModelSummary[] = []; + + const scene = new THREE.Scene(); + scene.background = new THREE.Color(0xf5f7f9); + + const camera = new THREE.PerspectiveCamera(50, 1, 0.01, 100000); + const renderer = new THREE.WebGLRenderer({ canvas, antialias: true }); + renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)); + + const controls = new OrbitControls(camera, renderer.domElement); + controls.enableDamping = true; + controls.dampingFactor = 0.08; + controls.screenSpacePanning = true; + + scene.add(new THREE.HemisphereLight(0xffffff, 0x445566, 1.8)); + const directional = new THREE.DirectionalLight(0xffffff, 1.5); + directional.position.set(100, 180, 120); + scene.add(directional); + + const axes = new THREE.AxesHelper(25); + axes.visible = axesCheck.checked; + scene.add(axes); + + const contourGroup = new THREE.Group(); + contourGroup.visible = contourCheck.checked; + scene.add(contourGroup); + + let terrainMesh: THREE.Object3D | null = null; + const labelElements: HTMLDivElement[] = []; + + function disposeObject(obj: THREE.Object3D) { + obj.traverse((child) => { + const renderable = child as THREE.Mesh | THREE.Points | THREE.LineSegments; + renderable.geometry?.dispose(); + const material = renderable.material; + if (Array.isArray(material)) material.forEach((item) => item.dispose()); + else material?.dispose(); + }); + } + + function clearMesh() { + if (terrainMesh) { + scene.remove(terrainMesh); + disposeObject(terrainMesh); + terrainMesh = null; + } + } + + function clearContours() { + while (contourGroup.children.length > 0) { + const child = contourGroup.children[0]; + contourGroup.remove(child); + if (child instanceof THREE.LineSegments) { + child.geometry.dispose(); + (child.material as THREE.Material).dispose(); + } + } + labelElements.forEach((el) => el.remove()); + labelElements.length = 0; + } + + const getFitParams = (object: THREE.Object3D) => { + const box = new THREE.Box3().setFromObject(object); + const center = box.getCenter(new THREE.Vector3()); + const size = box.getSize(new THREE.Vector3()); + const span = Math.max(size.x, size.y, size.z, 1); + return { center, span }; + }; + + const fitCamera = (object: THREE.Object3D) => { + const { center, span } = getFitParams(object); + controls.target.copy(center); + camera.position.set(center.x, center.y + span * 1.2, center.z + 0.001); + camera.near = Math.max(span / 10000, 0.01); + camera.far = span * 100; + camera.updateProjectionMatrix(); + controls.update(); + }; + + function setCameraView(view: "iso" | "top" | "front" | "side") { + if (!terrainMesh) return; + const { center, span } = getFitParams(terrainMesh); + controls.target.copy(center); + if (view === "iso") { + camera.position.set(center.x + span * 0.8, center.y + span * 0.65, center.z + span * 0.9); + } else if (view === "top") { + camera.position.set(center.x, center.y + span * 1.2, center.z + 0.001); + } else if (view === "front") { + camera.position.set(center.x, center.y, center.z + span * 1.2); + } else if (view === "side") { + camera.position.set(center.x + span * 1.2, center.y, center.z); + } + camera.updateProjectionMatrix(); + controls.update(); + } + + // Load mesh and contours + async function updateSelectedModel() { + if (!currentProjectId || currentModelsList.length === 0) return; + + clearMesh(); + clearContours(); + scaleBar.style.display = "none"; + statusSpan.textContent = "모델 조회 중..."; + + // 1. Find matching model in list + // model_type is TIN / DTM / NURBS / Implicit / Meshfree (we match activeMethod) + // model_file_path contains the activeFilter (e.g. csf, pmf, grid_min_z) + const match = currentModelsList.find((m) => { + const typeMatches = m.model_type.toLowerCase() === activeMethod.toLowerCase(); + const pathContainsFilter = + m.model_file_path && + m.model_file_path.toLowerCase().includes(activeFilter.toLowerCase()); + return typeMatches && pathContainsFilter; + }); + + if (!match) { + statusSpan.textContent = "일치하는 완성된 모델을 찾을 수 없습니다."; + return; + } + + const modelId = match.id; + const isSmooth = (activeMethod === "tin" || activeMethod === "dtm") && smoothCheck.checked; + + statusSpan.textContent = "3D 메쉬 파일 다운로드 중..."; + const previewUrl = `${API_BASE_URL}/projects/${currentProjectId}/surface/models/${modelId}/preview?smooth=${isSmooth}`; + + try { + if (activeMethod === "meshfree") { + new PLYLoader().load( + previewUrl, + (geometry) => { + geometry.computeBoundingSphere(); + const material = new THREE.PointsMaterial({ + size: 0.35, + vertexColors: geometry.hasAttribute("color"), + sizeAttenuation: true, + }); + const points = new THREE.Points(geometry, material); + points.visible = surfCheck.checked; + terrainMesh = points; + scene.add(points); + fitCamera(points); + statusSpan.textContent = `${activeFilter.toUpperCase()} · ${activeMethod.toUpperCase()} 표시 중`; + loadContourLines(modelId, isSmooth); + }, + undefined, + () => { + statusSpan.textContent = "3D 파일 로드에 실패했습니다."; + }, + ); + } else { + new GLTFLoader().load( + previewUrl, + (gltf) => { + gltf.scene.traverse((child) => { + if (child instanceof THREE.Mesh) { + child.material.side = THREE.DoubleSide; + child.material.vertexColors = child.geometry.hasAttribute("color"); + } + }); + gltf.scene.visible = surfCheck.checked; + terrainMesh = gltf.scene; + scene.add(gltf.scene); + fitCamera(gltf.scene); + statusSpan.textContent = `${activeFilter.toUpperCase()} · ${activeMethod.toUpperCase()} 표시 중`; + loadContourLines(modelId, isSmooth); + }, + undefined, + () => { + statusSpan.textContent = "3D 메쉬 파일이 없거나 로드할 수 없습니다."; + }, + ); + } + } catch (e) { + statusSpan.textContent = "에러 발생"; + } + } + + async function loadContourLines(modelId: number, isSmooth: boolean) { + const interval = parseFloat(intervalInput.value) || 5.0; + const contourUrl = `${API_BASE_URL}/projects/${currentProjectId}/surface/models/${modelId}/contour?interval=${interval}&smooth=${isSmooth}`; + + try { + const res = await fetch(contourUrl); + if (!res.ok) throw new Error("등고선 조회 실패"); + const data = await res.json(); + + clearContours(); + + const bounds = data.bounds; + if (!bounds) return; + + 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; + + const transform = (coords: [number, number, number][]) => { + return coords.map(([x_model, y_model, z_model]) => { + const x_scene = x_model - cx; + const y_scene = z_model - cz; + const z_scene = -(y_model - cy); + return new THREE.Vector3(x_scene, y_scene, z_scene); + }); + }; + + data.contours.forEach((c: any) => { + const points = transform(c.coordinates); + if (points.length < 2) return; + + const linePoints: THREE.Vector3[] = []; + for (let i = 0; i < points.length - 1; i++) { + linePoints.push(points[i], points[i + 1]); + } + + const geometry = new THREE.BufferGeometry().setFromPoints(linePoints); + const isMajor = c.level % (interval * 5) === 0; + const material = new THREE.LineBasicMaterial({ + color: isMajor ? 0xd97706 : 0xf59e0b, + linewidth: isMajor ? 2 : 1, + transparent: true, + opacity: 0.8, + }); + + const segments = new THREE.LineSegments(geometry, material); + contourGroup.add(segments); + + if (isMajor && points.length > 4) { + const labelPos = points[Math.floor(points.length / 2)]; + const labelDiv = document.createElement("div"); + labelDiv.className = "contour-label"; + labelDiv.innerText = `${Math.round(c.level)}m`; + labelDiv.style.position = "absolute"; + labelDiv.style.background = "rgba(255, 255, 255, 0.85)"; + labelDiv.style.border = "1px solid #d97706"; + labelDiv.style.color = "#b45309"; + labelDiv.style.padding = "1px 4px"; + labelDiv.style.borderRadius = "3px"; + labelDiv.style.fontSize = "9px"; + labelDiv.style.fontWeight = "bold"; + labelDiv.style.pointerEvents = "none"; + labelDiv.style.zIndex = "5"; + labelDiv.style.transform = "translate(-50%, -50%)"; + + (labelDiv as any).__updateLabelPos = () => { + if (!contourCheck.checked) { + labelDiv.style.display = "none"; + return; + } + const proj = labelPos.clone().project(camera); + const x = (proj.x * 0.5 + 0.5) * viewerArea.clientWidth; + const y = (-(proj.y * 0.5) + 0.5) * viewerArea.clientHeight; + + if (proj.z > 1) { + labelDiv.style.display = "none"; + } else { + labelDiv.style.display = "block"; + labelDiv.style.left = `${x}px`; + labelDiv.style.top = `${y}px`; + } + }; + + viewerArea.appendChild(labelDiv); + labelElements.push(labelDiv); + } + }); + } catch (e) { + // Contour loading failed or silent fail + } + } + + // Animation render loop + let animationFrameId = 0; + function animate() { + if (!root.isConnected) { + cancelAnimationFrame(animationFrameId); + clearMesh(); + clearContours(); + controls.dispose(); + renderer.dispose(); + return; + } + controls.update(); + + // Render scale bar dynamically + if (terrainMesh && terrainMesh.visible) { + scaleBar.style.display = "flex"; + const dist = camera.position.distanceTo(controls.target); + const metersPerPixel = + (2 * Math.tan((camera.fov * Math.PI) / 360) * dist) / viewerArea.clientWidth; + const roughMeters = 100 * metersPerPixel; + const prettyMeters = + roughMeters < 5 + ? 2 + : roughMeters < 15 + ? 10 + : roughMeters < 35 + ? 20 + : roughMeters < 75 + ? 50 + : roughMeters < 150 + ? 100 + : roughMeters < 350 + ? 200 + : roughMeters < 750 + ? 500 + : roughMeters < 1500 + ? 1000 + : roughMeters < 3500 + ? 2000 + : roughMeters < 7500 + ? 5000 + : 10000; + scaleBar.style.width = `${prettyMeters / metersPerPixel}px`; + scaleLabel.textContent = + prettyMeters >= 1000 ? `${(prettyMeters / 1000).toFixed(0)} km` : `${prettyMeters} m`; + } else { + scaleBar.style.display = "none"; + } + + // Update labels position + labelElements.forEach((label) => { + if (typeof (label as any).__updateLabelPos === "function") { + (label as any).__updateLabelPos(); + } + }); + + renderer.render(scene, camera); + animationFrameId = requestAnimationFrame(animate); + } + + // Event Listeners + btnIso.addEventListener("click", () => setCameraView("iso")); + btnTop.addEventListener("click", () => setCameraView("top")); + btnFront.addEventListener("click", () => setCameraView("front")); + btnSide.addEventListener("click", () => setCameraView("side")); + btnReset.addEventListener("click", () => { + if (terrainMesh) fitCamera(terrainMesh); + }); + + axesCheck.addEventListener("change", () => { + axes.visible = axesCheck.checked; + }); + + surfCheck.addEventListener("change", () => { + if (terrainMesh) { + terrainMesh.visible = surfCheck.checked; + } + }); + + smoothCheck.addEventListener("change", () => { + updateSelectedModel(); + }); + + contourCheck.addEventListener("change", () => { + contourGroup.visible = contourCheck.checked; + labelElements.forEach((el) => { + el.style.display = contourCheck.checked ? "block" : "none"; + }); + }); + + intervalForm.addEventListener("submit", (e) => { + e.preventDefault(); + updateSelectedModel(); + }); + + // Resize handler + const resizeObserver = new ResizeObserver((entries) => { + for (const entry of entries) { + const width = entry.contentRect.width || 800; + const height = entry.contentRect.height || 520; + camera.aspect = width / height; + camera.updateProjectionMatrix(); + renderer.setSize(width, height); + } + }); + resizeObserver.observe(viewerArea); + + animationFrameId = requestAnimationFrame(animate); + + return { + root, + render(projectId, models) { + currentProjectId = projectId; + currentModelsList = models; + updateSelectedModel(); + }, + dispose() { + cancelAnimationFrame(animationFrameId); + resizeObserver.disconnect(); + clearMesh(); + clearContours(); + controls.dispose(); + renderer.dispose(); + }, + }; +} diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_Viewer.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_Viewer.ts index b34e273..2ac9fb0 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_Viewer.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_Viewer.ts @@ -5,18 +5,132 @@ import type { SurfacePointCloudSampleResponse } from "./B04_wf1_Surface_Api_Fetc export interface SurfacePointCloudViewer { root: HTMLElement; + controlsGroup: HTMLElement; + optionsGroup: HTMLElement; + statusSpan: HTMLElement; render: (data: SurfacePointCloudSampleResponse | null) => void; dispose: () => void; } export function createSurfacePointCloudViewer(): SurfacePointCloudViewer { const root = document.createElement("div"); - root.className = "b04-surface-viewer"; + root.className = "point-viewer"; + + const statusSpan = document.createElement("span"); + statusSpan.className = "b04-surface__status-info"; + statusSpan.style.color = "var(--color-text-secondary)"; + statusSpan.style.fontSize = "var(--text-caption)"; + statusSpan.textContent = "포인트 데이터 로딩 중..."; + + const controlsDiv = document.createElement("div"); + controlsDiv.className = "viewer-controls"; + + const btnIso = document.createElement("button"); + btnIso.type = "button"; + btnIso.innerHTML = "🔍 사시도"; + + const btnTop = document.createElement("button"); + btnTop.type = "button"; + btnTop.textContent = "상단"; + + const btnFront = document.createElement("button"); + btnFront.type = "button"; + btnFront.textContent = "정면"; + + const btnSide = document.createElement("button"); + btnSide.type = "button"; + btnSide.textContent = "측면"; + + const btnReset = document.createElement("button"); + btnReset.type = "button"; + btnReset.innerHTML = "🔄 리셋"; + + const toggleLabel = document.createElement("label"); + toggleLabel.className = "toggle-label"; + const axesCheck = document.createElement("input"); + axesCheck.type = "checkbox"; + axesCheck.checked = true; + toggleLabel.append(axesCheck, document.createTextNode(" 축")); + + controlsDiv.append(btnIso, btnTop, btnFront, btnSide, btnReset, toggleLabel); + + const controlsGroup = document.createElement("section"); + controlsGroup.className = "b04-surface__group"; + const controlsTitle = document.createElement("h3"); + controlsTitle.className = "b04-surface__panel-title"; + controlsTitle.textContent = "뷰어 시점 제어"; + controlsGroup.append(controlsTitle, controlsDiv); + + // 2. Options Sliders + const optionsDiv = document.createElement("div"); + optionsDiv.className = "viewer-options"; + + const sizeLabel = document.createElement("label"); + sizeLabel.textContent = "점 크기 "; + const sizeInput = document.createElement("input"); + sizeInput.type = "range"; + sizeInput.min = "0.1"; + sizeInput.max = "2.5"; + sizeInput.step = "0.01"; + sizeInput.value = "0.42"; + sizeLabel.append(sizeInput); + + const densityLabel = document.createElement("label"); + densityLabel.innerHTML = '밀도 100%'; + const densityInput = document.createElement("input"); + densityInput.type = "range"; + densityInput.min = "1"; + densityInput.max = "10"; + densityInput.step = "1"; + densityInput.value = "10"; + densityLabel.append(densityInput); + + optionsDiv.append(sizeLabel, densityLabel); + + const optionsGroup = document.createElement("section"); + optionsGroup.className = "b04-surface__group"; + const optionsTitle = document.createElement("h3"); + optionsTitle.className = "b04-surface__panel-title"; + optionsTitle.textContent = "표시 옵션"; + optionsGroup.append(optionsTitle, optionsDiv); + + // 4. Viewer Area & Canvas + const viewerArea = document.createElement("div"); + viewerArea.className = "three-viewer"; + viewerArea.style.height = "520px"; const canvas = document.createElement("canvas"); canvas.className = "b04-surface-viewer__canvas"; - root.append(canvas); + viewerArea.append(canvas); + root.append(viewerArea); + // 5. Scale Bar Overlay + const scaleBar = document.createElement("div"); + scaleBar.style.position = "absolute"; + scaleBar.style.bottom = "16px"; + scaleBar.style.left = "16px"; + scaleBar.style.background = "rgba(255, 255, 255, 0.9)"; + scaleBar.style.border = "1.5px solid #1e293b"; + scaleBar.style.borderTop = "none"; + scaleBar.style.height = "8px"; + scaleBar.style.zIndex = "10"; + scaleBar.style.display = "none"; + scaleBar.style.flexDirection = "column"; + scaleBar.style.alignItems = "center"; + scaleBar.style.justifyContent = "flex-end"; + scaleBar.style.pointerEvents = "none"; + + const scaleText = document.createElement("span"); + scaleText.style.fontSize = "10px"; + scaleText.style.fontWeight = "bold"; + scaleText.style.color = "#1e293b"; + scaleText.style.position = "absolute"; + scaleText.style.bottom = "10px"; + scaleText.style.whiteSpace = "nowrap"; + scaleBar.append(scaleText); + viewerArea.append(scaleBar); + + // 6. ThreeJS Setup const renderer = new THREE.WebGLRenderer({ canvas, antialias: RENDER_OPTIONS.antialias, @@ -24,29 +138,24 @@ export function createSurfacePointCloudViewer(): SurfacePointCloudViewer { renderer.setPixelRatio(Math.min(window.devicePixelRatio, RENDER_OPTIONS.maxPixelRatio)); const scene = new THREE.Scene(); - scene.background = new THREE.Color(RENDER_OPTIONS.canvasClearColorLight); - const camera = new THREE.PerspectiveCamera( - RENDER_OPTIONS.cameraFov, - 1, - RENDER_OPTIONS.cameraNear, - RENDER_OPTIONS.cameraFar, - ); + scene.background = new THREE.Color(0xf5f7f9); + + const camera = new THREE.PerspectiveCamera(55, 1, 0.1, 22000); const controls = new OrbitControls(camera, canvas); controls.enableDamping = true; + controls.dampingFactor = 0.08; + controls.screenSpacePanning = true; - const grid = new THREE.GridHelper( - 200, - 20, - RENDER_OPTIONS.gridCenterColor, - RENDER_OPTIONS.gridLineColor, - ); - scene.add(grid); + const axes = new THREE.AxesHelper(45); + axes.visible = axesCheck.checked; + scene.add(axes); let pointsObject: THREE.Points | null = null; let animationFrame = 0; + let currentData: SurfacePointCloudSampleResponse | null = null; function resize(): void { - const rect = root.getBoundingClientRect(); + const rect = viewerArea.getBoundingClientRect(); const width = Math.max(1, Math.floor(rect.width)); const height = Math.max(1, Math.floor(rect.height)); renderer.setSize(width, height, false); @@ -54,10 +163,85 @@ export function createSurfacePointCloudViewer(): SurfacePointCloudViewer { camera.updateProjectionMatrix(); } + function setCameraView(view: "iso" | "top" | "front" | "side"): void { + const positions: Record = { + iso: [120, 95, 135], + top: [0, 190, 0.001], + front: [0, 60, 240], + side: [240, 60, 0], + }; + camera.position.set(...positions[view]); + camera.lookAt(0, 0, 0); + controls.target.set(0, 0, 0); + controls.update(); + } + function animate(): void { + if (!root.isConnected) { + cancelAnimationFrame(animationFrame); + clearPoints(); + controls.dispose(); + renderer.dispose(); + return; + } resize(); controls.update(); renderer.render(scene, camera); + + // Update scale bar overlay + if (currentData) { + const bounds = currentData.bounds; + const xMin = bounds.x_min ?? 0; + const xMax = bounds.x_max ?? 0; + const yMin = bounds.y_min ?? 0; + const yMax = bounds.y_max ?? 0; + const zMin = bounds.z_min ?? 0; + const zMax = bounds.z_max ?? 0; + const xSpan = xMax - xMin; + const ySpan = yMax - yMin; + const zSpan = zMax - zMin; + const maxSpan = Math.max(xSpan, ySpan, zSpan); + const internalScale = 180 / maxSpan; + + const dist = camera.position.distanceTo(controls.target); + const rect = viewerArea.getBoundingClientRect(); + const clientWidth = rect.width || 1200; + const sceneUnitsPerPixel = (2 * Math.tan((camera.fov * Math.PI) / 360) * dist) / clientWidth; + const metersPerPixel = sceneUnitsPerPixel / internalScale; + + const roughMeters = 100 * metersPerPixel; + const prettyMeters = + roughMeters < 5 + ? 2 + : roughMeters < 15 + ? 10 + : roughMeters < 35 + ? 20 + : roughMeters < 75 + ? 50 + : roughMeters < 150 + ? 100 + : roughMeters < 350 + ? 200 + : roughMeters < 750 + ? 500 + : roughMeters < 1500 + ? 1000 + : roughMeters < 3500 + ? 2000 + : roughMeters < 7500 + ? 5000 + : 10000; + + const pixels = (prettyMeters * internalScale) / sceneUnitsPerPixel; + scaleBar.style.display = "flex"; + scaleBar.style.width = `${pixels}px`; + scaleText.textContent = + prettyMeters >= 1000 ? `${(prettyMeters / 1000).toFixed(0)} km` : `${prettyMeters} m`; + } else { + scaleBar.style.display = "none"; + } + animationFrame = requestAnimationFrame(animate); } @@ -71,45 +255,138 @@ export function createSurfacePointCloudViewer(): SurfacePointCloudViewer { pointsObject = null; } - function render(data: SurfacePointCloudSampleResponse | null): void { + function renderPointCloudInternal(data: SurfacePointCloudSampleResponse): void { clearPoints(); - if (!data || data.points.length === 0) return; + if (data.points.length === 0) return; const bounds = data.bounds; - const cx = ((bounds.x_min ?? 0) + (bounds.x_max ?? 0)) / 2; - const cy = ((bounds.y_min ?? 0) + (bounds.y_max ?? 0)) / 2; - const cz = ((bounds.z_min ?? 0) + (bounds.z_max ?? 0)) / 2; - const positions = new Float32Array(data.points.length * 3); - data.points.forEach((point, index) => { - positions[index * 3] = point[0] - cx; - positions[index * 3 + 1] = point[2] - cz; - positions[index * 3 + 2] = point[1] - cy; + const xMin = bounds.x_min ?? 0; + const xMax = bounds.x_max ?? 0; + const yMin = bounds.y_min ?? 0; + const yMax = bounds.y_max ?? 0; + const zMin = bounds.z_min ?? 0; + const zMax = bounds.z_max ?? 0; + + const xMid = (xMin + xMax) / 2; + const yMid = (yMin + yMax) / 2; + const zMid = (zMin + zMax) / 2; + + const xSpan = Math.max(xMax - xMin, 1e-9); + const ySpan = Math.max(yMax - yMin, 1e-9); + const zSpan = Math.max(zMax - zMin, 1e-9); + const scale = 180 / Math.max(xSpan, ySpan, zSpan); + + const hasRgb = data.rgb && data.rgb.length === data.points.length; + const densityVal = parseInt(densityInput.value, 10); + const step = Math.max(1, Math.ceil(10 / densityVal)); + const renderPoints: typeof data.points = []; + const renderRgb: [number, number, number][] = []; + for (let i = 0; i < data.points.length; i += step) { + renderPoints.push(data.points[i]); + if (hasRgb && data.rgb) { + renderRgb.push(data.rgb[i]); + } + } + + const positions = new Float32Array(renderPoints.length * 3); + const colors = new Float32Array(renderPoints.length * 3); + + renderPoints.forEach((point, index) => { + const x = point[0]; + const y = point[1]; + const z = point[2]; + + positions[index * 3] = (x - xMid) * scale; + positions[index * 3 + 1] = (z - zMid) * scale; + positions[index * 3 + 2] = -(y - yMid) * scale; + + let r = 0; + let g = 0; + let b = 0; + if (hasRgb && renderRgb[index]) { + const rgbPoint = renderRgb[index]; + const maxVal = Math.max(rgbPoint[0], rgbPoint[1], rgbPoint[2]); + const divisor = maxVal > 255 ? 65535 : 255; + r = rgbPoint[0] / divisor; + g = rgbPoint[1] / divisor; + b = rgbPoint[2] / divisor; + } else { + const t = Math.max(0, Math.min(1, (z - zMin) / zSpan)); + r = (36 + t * 190) / 255; + g = (86 + Math.sin(t * Math.PI) * 95) / 255; + b = (128 - t * 80) / 255; + } + + colors[index * 3] = r; + colors[index * 3 + 1] = g; + colors[index * 3 + 2] = b; }); const geometry = new THREE.BufferGeometry(); geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3)); + geometry.setAttribute("color", new THREE.BufferAttribute(colors, 3)); geometry.computeBoundingSphere(); + + const sizeVal = parseFloat(sizeInput.value); const material = new THREE.PointsMaterial({ - color: RENDER_OPTIONS.pointColor, - size: 1.6, - sizeAttenuation: false, + size: sizeVal, + vertexColors: true, + sizeAttenuation: true, }); + pointsObject = new THREE.Points(geometry, material); scene.add(pointsObject); - const radius = geometry.boundingSphere?.radius ?? 100; - camera.position.set(radius * 0.9, radius * 0.7, radius * 1.4); - camera.near = Math.max(RENDER_OPTIONS.cameraNear, radius / 10000); - camera.far = Math.max(RENDER_OPTIONS.cameraFar, radius * 8); - camera.updateProjectionMatrix(); - controls.target.set(0, 0, 0); - controls.update(); + // Update status text + const nearestMin10 = Math.round(zMin / 10) * 10; + const nearestMax10 = Math.round(zMax / 10) * 10; + statusSpan.textContent = ` ${renderPoints.length.toLocaleString()}개 점 표시 중 [높이범위: ~${nearestMin10}m ... ${nearestMax10}m~]`; } + function render(data: SurfacePointCloudSampleResponse | null): void { + currentData = data; + clearPoints(); + if (!data) { + statusSpan.textContent = "데이터가 존재하지 않습니다."; + return; + } + renderPointCloudInternal(data); + setCameraView("top"); + } + + // Event Listeners + btnIso.addEventListener("click", () => setCameraView("iso")); + btnTop.addEventListener("click", () => setCameraView("top")); + btnFront.addEventListener("click", () => setCameraView("front")); + btnSide.addEventListener("click", () => setCameraView("side")); + btnReset.addEventListener("click", () => setCameraView("iso")); + + axesCheck.addEventListener("change", () => { + axes.visible = axesCheck.checked; + }); + + sizeInput.addEventListener("input", () => { + if (pointsObject) { + (pointsObject.material as THREE.PointsMaterial).size = parseFloat(sizeInput.value); + } + }); + + densityInput.addEventListener("input", () => { + const val = parseInt(densityInput.value, 10); + const valSpan = densityLabel.querySelector(".viewer-option-val"); + if (valSpan) valSpan.textContent = `${val * 10}%`; + if (currentData) { + renderPointCloudInternal(currentData); + } + }); + animationFrame = requestAnimationFrame(animate); return { root, + controlsGroup, + optionsGroup, + statusSpan, render, dispose: () => { cancelAnimationFrame(animationFrame); diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Page.ts b/B05_wf2_Route/B05_wf2_Route_UI_Page.ts index 2fec686..d63257f 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Page.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Page.ts @@ -11,7 +11,7 @@ * 텍스트는 ui_template_locale에 선(先) 등록 후 참조 (frontend.md §3). * ========================================================================== */ -import { CURRENT_PROJECT_ID_KEY } from "@config/config_frontend"; +import { CURRENT_PROJECT_ID_KEY, ROUTES, type RoutePath } from "@config/config_frontend"; import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import { createButton, @@ -22,8 +22,9 @@ import { showToast, type InputFieldHandle, } from "@ui/ui_template_elements"; -import { createWorkflowLayout } from "@ui/ui_template_workflow_layout"; +import { createWorkflowLayout, type WorkflowStage } from "@ui/ui_template_workflow_layout"; import { workflowSteps } from "../A00_Common/b_page_scaffold"; +import { navigateTo } from "../A00_Common/router"; import { confirmRoute, solveRoute, @@ -318,12 +319,44 @@ export function renderB05Route(root: HTMLElement): void { } renderEmptyResult(); + + const workflowRoutes = [ + 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, + ]; + + let workflowStages: WorkflowStage[] = []; + const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); + if (projectId) { + fetch(`/api/projects/${projectId}/workflow-state`) + .then((res) => res.json()) + .then((data) => { + workflowStages = data.stages || []; + }) + .catch(() => { + /* silently fail */ + }); + } + const layout = createWorkflowLayout({ title: L("B05_Route_Title"), steps: workflowSteps(), activeStep: 2, leftPanel: leftForm, mainContent: resultCard, + stages: workflowStages, + routes: workflowRoutes, + onStepClick: (_stepIndex, route) => { + if (projectId) { + localStorage.setItem(CURRENT_PROJECT_ID_KEY, projectId); + navigateTo(route as RoutePath); + } + }, }); root.replaceChildren(layout.root); } diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts index dd87e0a..190b759 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts @@ -11,7 +11,7 @@ * 텍스트는 ui_template_locale에 선(先) 등록 후 참조 (frontend.md §3). * ========================================================================== */ -import { CURRENT_PROJECT_ID_KEY } from "@config/config_frontend"; +import { CURRENT_PROJECT_ID_KEY, ROUTES, type RoutePath } from "@config/config_frontend"; import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import { createButton, @@ -20,8 +20,9 @@ import { showLoadingOverlay, showToast, } from "@ui/ui_template_elements"; -import { createWorkflowLayout } from "@ui/ui_template_workflow_layout"; +import { createWorkflowLayout, type WorkflowStage } from "@ui/ui_template_workflow_layout"; import { workflowSteps } from "../A00_Common/b_page_scaffold"; +import { navigateTo } from "../A00_Common/router"; import { confirmSections, generateSections, @@ -240,12 +241,44 @@ export function renderB06ProfileCross(root: HTMLElement): void { } renderEmptyResult(); + + const workflowRoutes = [ + 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, + ]; + + let workflowStages: WorkflowStage[] = []; + const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); + if (projectId) { + fetch(`/api/projects/${projectId}/workflow-state`) + .then((res) => res.json()) + .then((data) => { + workflowStages = data.stages || []; + }) + .catch(() => { + /* silently fail */ + }); + } + const layout = createWorkflowLayout({ title: L("B06_Profile_Title"), steps: workflowSteps(), activeStep: 3, leftPanel: leftForm, mainContent: resultCard, + stages: workflowStages, + routes: workflowRoutes, + onStepClick: (_stepIndex, route) => { + if (projectId) { + localStorage.setItem(CURRENT_PROJECT_ID_KEY, projectId); + navigateTo(route as RoutePath); + } + }, }); root.replaceChildren(layout.root); } diff --git a/B07_wf4_DesignDetail/B07_wf4_DesignDetail_UI_Page.ts b/B07_wf4_DesignDetail/B07_wf4_DesignDetail_UI_Page.ts index 1a02bf7..6915ac0 100644 --- a/B07_wf4_DesignDetail/B07_wf4_DesignDetail_UI_Page.ts +++ b/B07_wf4_DesignDetail/B07_wf4_DesignDetail_UI_Page.ts @@ -21,6 +21,6 @@ export function renderB07DesignDetail(root: HTMLElement): void { renderPendingWorkflow(root, { title: L("B07_Design_Title"), steps: workflowSteps(), - activeStep: 3, + activeStep: 4, }); } diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_UI_Page.ts b/B08_wf5_Quantity/B08_wf5_Quantity_UI_Page.ts index c69673f..5128a6c 100644 --- a/B08_wf5_Quantity/B08_wf5_Quantity_UI_Page.ts +++ b/B08_wf5_Quantity/B08_wf5_Quantity_UI_Page.ts @@ -21,6 +21,6 @@ export function renderB08Quantity(root: HTMLElement): void { renderPendingWorkflow(root, { title: L("B08_Quantity_Title"), steps: workflowSteps(), - activeStep: 4, + activeStep: 5, }); } diff --git a/B09_wf6_Estimation/B09_wf6_Estimation_UI_Page.ts b/B09_wf6_Estimation/B09_wf6_Estimation_UI_Page.ts index d80b323..5e530f7 100644 --- a/B09_wf6_Estimation/B09_wf6_Estimation_UI_Page.ts +++ b/B09_wf6_Estimation/B09_wf6_Estimation_UI_Page.ts @@ -21,6 +21,6 @@ export function renderB09Estimation(root: HTMLElement): void { renderPendingWorkflow(root, { title: L("B09_Estimation_Title"), steps: workflowSteps(), - activeStep: 5, + activeStep: 6, }); } diff --git a/scratch_inspect.py b/scratch_inspect.py index 24c1916..a4a9797 100644 --- a/scratch_inspect.py +++ b/scratch_inspect.py @@ -1,23 +1,9 @@ -import asyncio -import sys -import aiomysql -from dotenv import load_dotenv -load_dotenv(".env") +import re -from config.config_db import init_db_pool, close_db_pool +path = "D:/02_Software_Prog/임도설계 및 견적자동화 프로그램 개발/0_old/main.py" +with open(path, "r", encoding="utf-8") as f: + code = f.read() -async def main(): - await init_db_pool() - from config.config_db import get_db_pool - from B01_Dashboard.B01_Dashboard_Repository import get_system_resources - res = await get_system_resources(30) - print("Current:", res["current"]) - print("History count:", len(res["history"])) - for h in res["history"][:5]: - print(h) - except Exception as e: - print("Error:", e) - await close_db_pool() - -if __name__ == "__main__": - asyncio.run(main()) +print("--- Endpoints ---") +for m in re.finditer(r'@app\.(get|post)\("([^"]+)"\)', code): + print(m.group(0)) diff --git a/ui_template/ui_template_workflow_layout.css b/ui_template/ui_template_workflow_layout.css index 7228e82..0f20568 100644 --- a/ui_template/ui_template_workflow_layout.css +++ b/ui_template/ui_template_workflow_layout.css @@ -100,6 +100,20 @@ background: var(--color-paper); color: var(--color-text-secondary); font-size: var(--text-caption); + border: 1px solid transparent; + cursor: default; + transition: + background-color var(--transition-base), + color var(--transition-base), + cursor var(--transition-base); +} + +.ui-workflow-layout__step.is-enabled { + cursor: pointer; +} + +.ui-workflow-layout__step.is-enabled:hover { + background: var(--color-paper-hover, var(--color-paper)); } .ui-workflow-layout__step.is-active { @@ -107,6 +121,28 @@ color: var(--color-accent); } +.ui-workflow-layout__step:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.ui-workflow-layout__step.state-complete { + border-color: var(--color-success); +} + +.ui-workflow-layout__step.state-in_progress { + border-color: var(--color-warning); +} + +.ui-workflow-layout__step.state-stale { + border-color: var(--color-muted); + opacity: 0.7; +} + +.ui-workflow-layout__step.state-failed { + border-color: var(--color-danger); +} + .ui-workflow-layout__body { position: relative; display: block; diff --git a/ui_template/ui_template_workflow_layout.ts b/ui_template/ui_template_workflow_layout.ts index 969656c..fd96719 100644 --- a/ui_template/ui_template_workflow_layout.ts +++ b/ui_template/ui_template_workflow_layout.ts @@ -1,11 +1,20 @@ import "./ui_template_workflow_layout.css"; +export interface WorkflowStage { + stage_no: number; + stage_key: string; + state: string; +} + export interface WorkflowLayoutOptions { title: string; steps: string[]; activeStep: number; leftPanel: HTMLElement; mainContent: HTMLElement; + stages?: WorkflowStage[]; + routes?: string[]; + onStepClick?: (stepIndex: number, route: string) => void; onMenuToggle?: (isOpen: boolean) => void; onHeaderToggle?: (isVisible: boolean) => void; } @@ -16,15 +25,62 @@ export interface WorkflowLayoutHandle { setHeaderVisible: (isVisible: boolean) => void; } -function createStepBar(steps: readonly string[], activeStep: number): HTMLElement { +function createStepBar( + steps: readonly string[], + activeStep: number, + options?: { + stages?: WorkflowStage[]; + routes?: string[]; + onStepClick?: (stepIndex: number, route: string) => void; + }, +): HTMLElement { const bar = document.createElement("div"); bar.className = "ui-workflow-layout__steps"; steps.forEach((step, index) => { - const item = document.createElement("span"); - item.className = "ui-workflow-layout__step"; - if (index === activeStep) item.classList.add("is-active"); - item.textContent = step; - bar.append(item); + const button = document.createElement("button"); + button.type = "button"; + button.className = "ui-workflow-layout__step"; + if (index === activeStep) button.classList.add("is-active"); + button.textContent = step; + + // DB state 기반 활성화 로직 + let enabled = false; + if (options?.stages && options.stages.length > 0) { + enabled = index === 0 || options.stages[index - 1]?.state === "COMPLETE"; + } else { + enabled = index <= activeStep; + } + + button.disabled = !enabled; + if (enabled) { + button.classList.add("is-enabled"); + } + + // state 기반 스타일링 + if (options?.stages && options.stages[index]) { + const state = options.stages[index].state; + button.classList.add(`state-${state.toLowerCase()}`); + if (state === "STALE") { + button.title = "Stale (하위 단계 변경으로 무효화됨)"; + } else if (state === "FAILED") { + button.title = "Failed (실패)"; + } else if (state === "COMPLETE") { + button.title = "Complete (완료)"; + } else if (state === "IN_PROGRESS") { + button.title = "In Progress (진행 중)"; + } else { + button.title = "Not Started (미실행)"; + } + } + + // 클릭 이벤트 + if (enabled && options?.routes && options?.routes[index]) { + button.addEventListener("click", () => { + options.onStepClick?.(index, options.routes![index]); + }); + } + + bar.append(button); }); return bar; } @@ -47,7 +103,14 @@ export function createWorkflowLayout(options: WorkflowLayoutOptions): WorkflowLa const title = document.createElement("h2"); title.className = "ui-workflow-layout__title"; title.textContent = options.title; - titleWrap.append(title, createStepBar(options.steps, options.activeStep)); + titleWrap.append( + title, + createStepBar(options.steps, options.activeStep, { + stages: options.stages, + routes: options.routes, + onStepClick: options.onStepClick, + }), + ); const headerHideButton = document.createElement("button"); headerHideButton.type = "button";