This commit is contained in:
2026-07-10 19:52:06 +09:00
parent b98affbf99
commit 6b1583103f
19 changed files with 1515 additions and 98 deletions
+17
View File
@@ -126,6 +126,7 @@ function buildHeader(): HTMLElement {
/** 서버 세션 상태에 따라 로그인/로그아웃 버튼을 채운다. */ /** 서버 세션 상태에 따라 로그인/로그아웃 버튼을 채운다. */
async function fillAuthActions(slot: HTMLElement): Promise<void> { async function fillAuthActions(slot: HTMLElement): Promise<void> {
try {
const loggedIn = await isAuthenticated(); const loggedIn = await isAuthenticated();
slot.innerHTML = ""; slot.innerHTML = "";
if (loggedIn) { if (loggedIn) {
@@ -157,6 +158,22 @@ async function fillAuthActions(slot: HTMLElement): Promise<void> {
}), }),
); );
} }
} catch (error) {
console.error("[app_shell] fillAuthActions error:", error);
slot.innerHTML = "";
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),
}),
);
}
} }
function buildFooter(): HTMLElement { function buildFooter(): HTMLElement {
+4 -2
View File
@@ -93,10 +93,12 @@ export function renderPendingWorkflow(root: HTMLElement, opts: PendingWorkflowOp
root.append(shell.root); root.append(shell.root);
} }
/** 워크플로우 스텝 라벨 6종 (locale 결과) — B04~B09 공용. /** 워크플로우 스텝 라벨 7종 (locale 결과) — B03~B09 공용.
* 기존 WF_Step_* 키를 재사용 (중복 등록 방지, frontend.md §3). */ * 파일 입력(B03)부터 견적/문서(B09)까지 대시보드 워크플로우와 동일한 순서.
* 기존 WF_Step_* / B03_File_Title 키를 재사용 (중복 등록 방지, frontend.md §3). */
export function workflowSteps(): string[] { export function workflowSteps(): string[] {
return [ return [
L("B03_File_Title"),
L("WF_Step_Surface"), L("WF_Step_Surface"),
L("WF_Step_Route"), L("WF_Step_Route"),
L("WF_Step_ProfileCross"), L("WF_Step_ProfileCross"),
+3 -1
View File
@@ -1,6 +1,7 @@
import { CURRENT_PROJECT_ID_KEY, ROUTES, type RoutePath } from "@config/config_frontend"; import { CURRENT_PROJECT_ID_KEY, ROUTES, type RoutePath } from "@config/config_frontend";
import { isBlank } from "@util/common_util_validate"; import { isBlank } from "@util/common_util_validate";
import { navigateTo } from "../A00_Common/router"; import { navigateTo } from "../A00_Common/router";
import { workflowSteps } from "../A00_Common/b_page_scaffold";
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
import { import {
createButton, createButton,
@@ -382,12 +383,13 @@ function workflow(project: ProjectItem): HTMLElement {
box.className = "b01-dashboard__workflow"; box.className = "b01-dashboard__workflow";
const stages = project.workflow_state?.stages; const stages = project.workflow_state?.stages;
const stepLabels = workflowSteps();
routes.forEach((route, index) => { routes.forEach((route, index) => {
const button = document.createElement("button"); const button = document.createElement("button");
button.type = "button"; button.type = "button";
button.className = "b01-dashboard__step"; 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; let enabled = false;
if (stages && stages.length > 0) { if (stages && stages.length > 0) {
+4 -2
View File
@@ -83,17 +83,19 @@
.b01-dashboard__workflow { .b01-dashboard__workflow {
display: flex; display: flex;
flex-wrap: wrap;
gap: var(--spacing-4); gap: var(--spacing-4);
} }
.b01-dashboard__step { .b01-dashboard__step {
width: 28px;
height: 28px; height: 28px;
padding: 0 var(--spacing-12);
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: var(--radius-icons); border-radius: var(--radius-pills);
background: var(--color-surface); background: var(--color-surface);
color: var(--color-text-muted); color: var(--color-text-muted);
font-size: var(--text-caption); font-size: var(--text-caption);
white-space: nowrap;
cursor: default; cursor: default;
} }
@@ -64,6 +64,7 @@ export interface SurfacePointCloudSampleResponse {
sampled_count: number; sampled_count: number;
bounds: Record<string, number>; bounds: Record<string, number>;
points: [number, number, number][]; points: [number, number, number][];
rgb?: [number, number, number][];
} }
export interface SurfaceGroundStatsResponse { export interface SurfaceGroundStatsResponse {
+169 -1
View File
@@ -10,7 +10,7 @@ from uuid import UUID
import aiomysql import aiomysql
import numpy as np import numpy as np
from fastapi import APIRouter from fastapi import APIRouter
from fastapi.responses import JSONResponse from fastapi.responses import FileResponse, JSONResponse
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
from B04_wf1_Surface.B04_wf1_Surface_Engine import run_surface_analysis from B04_wf1_Surface.B04_wf1_Surface_Engine import run_surface_analysis
@@ -304,6 +304,15 @@ async def get_surface_point_cloud(
else: else:
sample = xyz 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( return SurfacePointCloudSampleResponse(
project_id=str(project_id), project_id=str(project_id),
point_count=point_count, point_count=point_count,
@@ -317,6 +326,7 @@ async def get_surface_point_cloud(
"z_max": float(bounds[2, 1]), "z_max": float(bounds[2, 1]),
}, },
points=sample.astype(float).tolist(), points=sample.astype(float).tolist(),
rgb=rgb_sample.astype(int).tolist() if rgb_sample is not None else None,
) )
except LookupError as exc: except LookupError as exc:
return JSONResponse(status_code=404, content={"status": "error", "message": str(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, status_code=500,
content={"status": "error", "message": "분석 상태 조회 중 오류가 발생했습니다."}, 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": "등고선 파일 조회 중 오류가 발생했습니다."},
)
@@ -83,6 +83,7 @@ class SurfacePointCloudSampleResponse(BaseModel):
sampled_count: int sampled_count: int
bounds: dict[str, float] bounds: dict[str, float]
points: list[list[float]] points: list[list[float]]
rgb: list[list[int]] | None = None
class SurfaceGroundStatsResponse(BaseModel): class SurfaceGroundStatsResponse(BaseModel):
+21 -2
View File
@@ -34,6 +34,7 @@ import {
} from "./B04_wf1_Surface_Api_Fetch"; } from "./B04_wf1_Surface_Api_Fetch";
import { createWorkflowLayout } from "@ui/ui_template_workflow_layout"; import { createWorkflowLayout } from "@ui/ui_template_workflow_layout";
import { createSurfacePointCloudViewer } from "./B04_wf1_Surface_UI_Viewer"; import { createSurfacePointCloudViewer } from "./B04_wf1_Surface_UI_Viewer";
import { createSurfaceTerrainViewer } from "./B04_wf1_Surface_UI_TerrainViewer";
import "./B04_wf1_Surface_UI_Style.css"; import "./B04_wf1_Surface_UI_Style.css";
/** locale 헬퍼 */ /** locale 헬퍼 */
@@ -105,6 +106,7 @@ export function renderB04Surface(root: HTMLElement): void {
const statsList = document.createElement("div"); const statsList = document.createElement("div");
statsList.className = "b04-surface__stats"; statsList.className = "b04-surface__stats";
const viewer = createSurfacePointCloudViewer(); const viewer = createSurfacePointCloudViewer();
const terrainViewer = createSurfaceTerrainViewer();
const filterGroup = buildCheckboxGroup(L("B04_Surface_Group_Filters"), SOURCE_FILTERS, [ const filterGroup = buildCheckboxGroup(L("B04_Surface_Group_Filters"), SOURCE_FILTERS, [
"grid_min_z", "grid_min_z",
@@ -148,6 +150,8 @@ export function renderB04Surface(root: HTMLElement): void {
filterGroup.root, filterGroup.root,
methodGroup.root, methodGroup.root,
forceLabel, forceLabel,
viewer.controlsGroup,
viewer.optionsGroup,
analyzeButton, analyzeButton,
refreshButton, refreshButton,
); );
@@ -156,24 +160,35 @@ export function renderB04Surface(root: HTMLElement): void {
workspace.className = "b04-surface__workspace"; workspace.className = "b04-surface__workspace";
const topbar = document.createElement("div"); const topbar = document.createElement("div");
topbar.className = "b04-surface__topbar"; 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"); const title = document.createElement("h3");
title.textContent = L("B04_Surface_PointCloud_Title"); 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"); const bottom = document.createElement("div");
bottom.className = "b04-surface__bottom"; bottom.className = "b04-surface__bottom";
const statsSection = document.createElement("section"); const statsSection = document.createElement("section");
statsSection.className = "b04-surface__section"; statsSection.className = "b04-surface__section";
const statsTitle = document.createElement("h3"); const statsTitle = document.createElement("h3");
statsTitle.textContent = L("B04_Surface_GroundStats_Title"); statsTitle.textContent = L("B04_Surface_GroundStats_Title");
statsSection.append(statsTitle, statsList); statsSection.append(statsTitle, statsList);
const modelsSection = document.createElement("section"); const modelsSection = document.createElement("section");
modelsSection.className = "b04-surface__section"; modelsSection.className = "b04-surface__section";
const modelsTitle = document.createElement("h3"); const modelsTitle = document.createElement("h3");
modelsTitle.textContent = L("B04_Surface_Result_Title"); modelsTitle.textContent = L("B04_Surface_Result_Title");
modelsSection.append(modelsTitle, modelList); modelsSection.append(modelsTitle, modelList);
bottom.append(statsSection, modelsSection); bottom.append(statsSection, modelsSection);
workspace.append(topbar, viewer.root, bottom); workspace.append(topbar, viewer.root, terrainViewer.root, bottom);
const layout = createWorkflowLayout({ const layout = createWorkflowLayout({
title: L("B04_Surface_Title"), title: L("B04_Surface_Title"),
@@ -240,6 +255,10 @@ export function renderB04Surface(root: HTMLElement): void {
function renderModels(models: readonly SurfaceModelSummary[]): void { function renderModels(models: readonly SurfaceModelSummary[]): void {
modelList.replaceChildren(); modelList.replaceChildren();
const projectId = getProjectId();
if (projectId) {
terrainViewer.render(projectId, models);
}
if (models.length === 0) { if (models.length === 0) {
const empty = document.createElement("p"); const empty = document.createElement("p");
empty.className = "b04-surface__empty"; empty.className = "b04-surface__empty";
@@ -116,12 +116,15 @@
.b04-surface-viewer { .b04-surface-viewer {
min-height: 360px; min-height: 360px;
height: 100%; height: 100%;
padding: 0 var(--spacing-24) var(--spacing-24);
box-sizing: border-box;
} }
.b04-surface-viewer__canvas { .b04-surface-viewer__canvas {
display: block; display: block;
width: 100%; width: 100%;
height: 100%; height: 100%;
border-radius: var(--radius-cards);
} }
.b04-surface__bottom { .b04-surface__bottom {
@@ -216,3 +219,113 @@
grid-template-columns: 1fr; 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);
}
@@ -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();
},
};
}
+314 -37
View File
@@ -5,18 +5,132 @@ import type { SurfacePointCloudSampleResponse } from "./B04_wf1_Surface_Api_Fetc
export interface SurfacePointCloudViewer { export interface SurfacePointCloudViewer {
root: HTMLElement; root: HTMLElement;
controlsGroup: HTMLElement;
optionsGroup: HTMLElement;
statusSpan: HTMLElement;
render: (data: SurfacePointCloudSampleResponse | null) => void; render: (data: SurfacePointCloudSampleResponse | null) => void;
dispose: () => void; dispose: () => void;
} }
export function createSurfacePointCloudViewer(): SurfacePointCloudViewer { export function createSurfacePointCloudViewer(): SurfacePointCloudViewer {
const root = document.createElement("div"); 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 = '밀도&nbsp;<span class="viewer-option-val">100%</span>';
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"); const canvas = document.createElement("canvas");
canvas.className = "b04-surface-viewer__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({ const renderer = new THREE.WebGLRenderer({
canvas, canvas,
antialias: RENDER_OPTIONS.antialias, antialias: RENDER_OPTIONS.antialias,
@@ -24,29 +138,24 @@ export function createSurfacePointCloudViewer(): SurfacePointCloudViewer {
renderer.setPixelRatio(Math.min(window.devicePixelRatio, RENDER_OPTIONS.maxPixelRatio)); renderer.setPixelRatio(Math.min(window.devicePixelRatio, RENDER_OPTIONS.maxPixelRatio));
const scene = new THREE.Scene(); const scene = new THREE.Scene();
scene.background = new THREE.Color(RENDER_OPTIONS.canvasClearColorLight); scene.background = new THREE.Color(0xf5f7f9);
const camera = new THREE.PerspectiveCamera(
RENDER_OPTIONS.cameraFov, const camera = new THREE.PerspectiveCamera(55, 1, 0.1, 22000);
1,
RENDER_OPTIONS.cameraNear,
RENDER_OPTIONS.cameraFar,
);
const controls = new OrbitControls(camera, canvas); const controls = new OrbitControls(camera, canvas);
controls.enableDamping = true; controls.enableDamping = true;
controls.dampingFactor = 0.08;
controls.screenSpacePanning = true;
const grid = new THREE.GridHelper( const axes = new THREE.AxesHelper(45);
200, axes.visible = axesCheck.checked;
20, scene.add(axes);
RENDER_OPTIONS.gridCenterColor,
RENDER_OPTIONS.gridLineColor,
);
scene.add(grid);
let pointsObject: THREE.Points | null = null; let pointsObject: THREE.Points | null = null;
let animationFrame = 0; let animationFrame = 0;
let currentData: SurfacePointCloudSampleResponse | null = null;
function resize(): void { function resize(): void {
const rect = root.getBoundingClientRect(); const rect = viewerArea.getBoundingClientRect();
const width = Math.max(1, Math.floor(rect.width)); const width = Math.max(1, Math.floor(rect.width));
const height = Math.max(1, Math.floor(rect.height)); const height = Math.max(1, Math.floor(rect.height));
renderer.setSize(width, height, false); renderer.setSize(width, height, false);
@@ -54,10 +163,85 @@ export function createSurfacePointCloudViewer(): SurfacePointCloudViewer {
camera.updateProjectionMatrix(); camera.updateProjectionMatrix();
} }
function setCameraView(view: "iso" | "top" | "front" | "side"): void {
const positions: Record<string, [number, number, number]> = {
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 { function animate(): void {
if (!root.isConnected) {
cancelAnimationFrame(animationFrame);
clearPoints();
controls.dispose();
renderer.dispose();
return;
}
resize(); resize();
controls.update(); controls.update();
renderer.render(scene, camera); 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); animationFrame = requestAnimationFrame(animate);
} }
@@ -71,45 +255,138 @@ export function createSurfacePointCloudViewer(): SurfacePointCloudViewer {
pointsObject = null; pointsObject = null;
} }
function render(data: SurfacePointCloudSampleResponse | null): void { function renderPointCloudInternal(data: SurfacePointCloudSampleResponse): void {
clearPoints(); clearPoints();
if (!data || data.points.length === 0) return; if (data.points.length === 0) return;
const bounds = data.bounds; const bounds = data.bounds;
const cx = ((bounds.x_min ?? 0) + (bounds.x_max ?? 0)) / 2; const xMin = bounds.x_min ?? 0;
const cy = ((bounds.y_min ?? 0) + (bounds.y_max ?? 0)) / 2; const xMax = bounds.x_max ?? 0;
const cz = ((bounds.z_min ?? 0) + (bounds.z_max ?? 0)) / 2; const yMin = bounds.y_min ?? 0;
const positions = new Float32Array(data.points.length * 3); const yMax = bounds.y_max ?? 0;
data.points.forEach((point, index) => { const zMin = bounds.z_min ?? 0;
positions[index * 3] = point[0] - cx; const zMax = bounds.z_max ?? 0;
positions[index * 3 + 1] = point[2] - cz;
positions[index * 3 + 2] = point[1] - cy; 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(); const geometry = new THREE.BufferGeometry();
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3)); geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
geometry.setAttribute("color", new THREE.BufferAttribute(colors, 3));
geometry.computeBoundingSphere(); geometry.computeBoundingSphere();
const sizeVal = parseFloat(sizeInput.value);
const material = new THREE.PointsMaterial({ const material = new THREE.PointsMaterial({
color: RENDER_OPTIONS.pointColor, size: sizeVal,
size: 1.6, vertexColors: true,
sizeAttenuation: false, sizeAttenuation: true,
}); });
pointsObject = new THREE.Points(geometry, material); pointsObject = new THREE.Points(geometry, material);
scene.add(pointsObject); scene.add(pointsObject);
const radius = geometry.boundingSphere?.radius ?? 100; // Update status text
camera.position.set(radius * 0.9, radius * 0.7, radius * 1.4); const nearestMin10 = Math.round(zMin / 10) * 10;
camera.near = Math.max(RENDER_OPTIONS.cameraNear, radius / 10000); const nearestMax10 = Math.round(zMax / 10) * 10;
camera.far = Math.max(RENDER_OPTIONS.cameraFar, radius * 8); statusSpan.textContent = ` ${renderPoints.length.toLocaleString()}개 점 표시 중 [높이범위: ~${nearestMin10}m ... ${nearestMax10}m~]`;
camera.updateProjectionMatrix();
controls.target.set(0, 0, 0);
controls.update();
} }
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); animationFrame = requestAnimationFrame(animate);
return { return {
root, root,
controlsGroup,
optionsGroup,
statusSpan,
render, render,
dispose: () => { dispose: () => {
cancelAnimationFrame(animationFrame); cancelAnimationFrame(animationFrame);
+35 -2
View File
@@ -11,7 +11,7 @@
* ui_template_locale에 () (frontend.md §3). * 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 { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
import { import {
createButton, createButton,
@@ -22,8 +22,9 @@ import {
showToast, showToast,
type InputFieldHandle, type InputFieldHandle,
} from "@ui/ui_template_elements"; } 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 { workflowSteps } from "../A00_Common/b_page_scaffold";
import { navigateTo } from "../A00_Common/router";
import { import {
confirmRoute, confirmRoute,
solveRoute, solveRoute,
@@ -318,12 +319,44 @@ export function renderB05Route(root: HTMLElement): void {
} }
renderEmptyResult(); 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({ const layout = createWorkflowLayout({
title: L("B05_Route_Title"), title: L("B05_Route_Title"),
steps: workflowSteps(), steps: workflowSteps(),
activeStep: 2, activeStep: 2,
leftPanel: leftForm, leftPanel: leftForm,
mainContent: resultCard, 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); root.replaceChildren(layout.root);
} }
@@ -11,7 +11,7 @@
* ui_template_locale에 () (frontend.md §3). * 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 { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
import { import {
createButton, createButton,
@@ -20,8 +20,9 @@ import {
showLoadingOverlay, showLoadingOverlay,
showToast, showToast,
} from "@ui/ui_template_elements"; } 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 { workflowSteps } from "../A00_Common/b_page_scaffold";
import { navigateTo } from "../A00_Common/router";
import { import {
confirmSections, confirmSections,
generateSections, generateSections,
@@ -240,12 +241,44 @@ export function renderB06ProfileCross(root: HTMLElement): void {
} }
renderEmptyResult(); 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({ const layout = createWorkflowLayout({
title: L("B06_Profile_Title"), title: L("B06_Profile_Title"),
steps: workflowSteps(), steps: workflowSteps(),
activeStep: 3, activeStep: 3,
leftPanel: leftForm, leftPanel: leftForm,
mainContent: resultCard, 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); root.replaceChildren(layout.root);
} }
@@ -21,6 +21,6 @@ export function renderB07DesignDetail(root: HTMLElement): void {
renderPendingWorkflow(root, { renderPendingWorkflow(root, {
title: L("B07_Design_Title"), title: L("B07_Design_Title"),
steps: workflowSteps(), steps: workflowSteps(),
activeStep: 3, activeStep: 4,
}); });
} }
+1 -1
View File
@@ -21,6 +21,6 @@ export function renderB08Quantity(root: HTMLElement): void {
renderPendingWorkflow(root, { renderPendingWorkflow(root, {
title: L("B08_Quantity_Title"), title: L("B08_Quantity_Title"),
steps: workflowSteps(), steps: workflowSteps(),
activeStep: 4, activeStep: 5,
}); });
} }
@@ -21,6 +21,6 @@ export function renderB09Estimation(root: HTMLElement): void {
renderPendingWorkflow(root, { renderPendingWorkflow(root, {
title: L("B09_Estimation_Title"), title: L("B09_Estimation_Title"),
steps: workflowSteps(), steps: workflowSteps(),
activeStep: 5, activeStep: 6,
}); });
} }
+7 -21
View File
@@ -1,23 +1,9 @@
import asyncio import re
import sys
import aiomysql
from dotenv import load_dotenv
load_dotenv(".env")
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(): print("--- Endpoints ---")
await init_db_pool() for m in re.finditer(r'@app\.(get|post)\("([^"]+)"\)', code):
from config.config_db import get_db_pool print(m.group(0))
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())
@@ -100,6 +100,20 @@
background: var(--color-paper); background: var(--color-paper);
color: var(--color-text-secondary); color: var(--color-text-secondary);
font-size: var(--text-caption); 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 { .ui-workflow-layout__step.is-active {
@@ -107,6 +121,28 @@
color: var(--color-accent); 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 { .ui-workflow-layout__body {
position: relative; position: relative;
display: block; display: block;
+70 -7
View File
@@ -1,11 +1,20 @@
import "./ui_template_workflow_layout.css"; import "./ui_template_workflow_layout.css";
export interface WorkflowStage {
stage_no: number;
stage_key: string;
state: string;
}
export interface WorkflowLayoutOptions { export interface WorkflowLayoutOptions {
title: string; title: string;
steps: string[]; steps: string[];
activeStep: number; activeStep: number;
leftPanel: HTMLElement; leftPanel: HTMLElement;
mainContent: HTMLElement; mainContent: HTMLElement;
stages?: WorkflowStage[];
routes?: string[];
onStepClick?: (stepIndex: number, route: string) => void;
onMenuToggle?: (isOpen: boolean) => void; onMenuToggle?: (isOpen: boolean) => void;
onHeaderToggle?: (isVisible: boolean) => void; onHeaderToggle?: (isVisible: boolean) => void;
} }
@@ -16,15 +25,62 @@ export interface WorkflowLayoutHandle {
setHeaderVisible: (isVisible: boolean) => void; 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"); const bar = document.createElement("div");
bar.className = "ui-workflow-layout__steps"; bar.className = "ui-workflow-layout__steps";
steps.forEach((step, index) => { steps.forEach((step, index) => {
const item = document.createElement("span"); const button = document.createElement("button");
item.className = "ui-workflow-layout__step"; button.type = "button";
if (index === activeStep) item.classList.add("is-active"); button.className = "ui-workflow-layout__step";
item.textContent = step; if (index === activeStep) button.classList.add("is-active");
bar.append(item); 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; return bar;
} }
@@ -47,7 +103,14 @@ export function createWorkflowLayout(options: WorkflowLayoutOptions): WorkflowLa
const title = document.createElement("h2"); const title = document.createElement("h2");
title.className = "ui-workflow-layout__title"; title.className = "ui-workflow-layout__title";
title.textContent = options.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"); const headerHideButton = document.createElement("button");
headerHideButton.type = "button"; headerHideButton.type = "button";