This commit is contained in:
2026-07-10 18:12:17 +09:00
parent 50d75fcfcd
commit e3d66e717c
28 changed files with 2542 additions and 99 deletions
@@ -40,6 +40,47 @@ export interface SurfaceModelSummary {
created_at: string | null;
}
export interface SurfaceInputFileSummary {
id: number;
file_type: string;
original_filename: string;
raw_file_path: string;
file_size_mb: number | null;
crs_epsg: number | null;
status: string | null;
created_at: string | null;
}
export interface SurfaceInputFileListResponse {
status: string;
project_id: string;
files: SurfaceInputFileSummary[];
}
export interface SurfacePointCloudSampleResponse {
status: string;
project_id: string;
point_count: number;
sampled_count: number;
bounds: Record<string, number>;
points: [number, number, number][];
}
export interface SurfaceGroundStatsResponse {
status: string;
project_id: string;
filters: Record<string, Record<string, unknown>>;
}
export interface SurfaceStatusResponse {
project_id: string;
status: "pending" | "in_progress" | "completed" | "failed";
model_count: number;
progress_percent: number;
current_stage: string;
message: string;
}
/** 지표면 모델 목록 응답 (SurfaceModelListResponse) */
export interface SurfaceModelListResponse {
status: string;
@@ -88,3 +129,36 @@ export async function listSurfaceModels(projectId: string): Promise<SurfaceModel
method: "GET",
});
}
export async function listSurfaceInputFiles(
projectId: string,
): Promise<SurfaceInputFileListResponse> {
return requestJson<SurfaceInputFileListResponse>(`/projects/${projectId}/surface/input-files`, {
method: "GET",
});
}
export async function fetchSurfacePointCloud(
projectId: string,
): Promise<SurfacePointCloudSampleResponse> {
return requestJson<SurfacePointCloudSampleResponse>(
`/projects/${projectId}/surface/point-cloud`,
{
method: "GET",
},
);
}
export async function fetchSurfaceGroundStats(
projectId: string,
): Promise<SurfaceGroundStatsResponse> {
return requestJson<SurfaceGroundStatsResponse>(`/projects/${projectId}/surface/ground-stats`, {
method: "GET",
});
}
export async function fetchSurfaceStatus(projectId: string): Promise<SurfaceStatusResponse> {
return requestJson<SurfaceStatusResponse>(`/projects/${projectId}/surface/status`, {
method: "GET",
});
}
@@ -198,6 +198,38 @@ async def get_input_file(
}
async def list_project_point_cloud_inputs(
connection: aiomysql.Connection, project_id: UUID
) -> list[dict[str, Any]]:
"""프로젝트의 LAS/LAZ 원본 입력 파일 목록을 최신순으로 조회한다."""
async with connection.cursor() as cursor:
await cursor.execute(
"""
SELECT id, file_type, original_filename, raw_file_path, file_size_mb,
crs_epsg, status, created_at
FROM input_files
WHERE project_id = %s AND file_type IN ('las', 'laz')
ORDER BY created_at DESC, id DESC
""",
(str(project_id),),
)
rows = await cursor.fetchall()
return [
{
"id": int(row[0]),
"file_type": row[1],
"original_filename": row[2],
"raw_file_path": row[3],
"file_size_mb": float(row[4]) if row[4] is not None else None,
"crs_epsg": row[5],
"status": row[6],
"created_at": row[7].isoformat() if row[7] else None,
}
for row in rows
]
async def list_surface_models(
connection: aiomysql.Connection, project_id: UUID
) -> list[dict[str, Any]]:
+167 -4
View File
@@ -1,12 +1,14 @@
"""B04 지표면 분석 FastAPI 라우터."""
import asyncio
import json
import logging
from pathlib import Path
from typing import Any
from uuid import UUID
import aiomysql
import numpy as np
from fastapi import APIRouter
from fastapi.responses import JSONResponse
@@ -17,19 +19,39 @@ from B04_wf1_Surface.B04_wf1_Surface_Repository import (
create_surface_model,
create_terrain_layer,
get_input_file,
list_project_point_cloud_inputs,
list_surface_models,
)
from B04_wf1_Surface.B04_wf1_Surface_Schema import (
SurfaceAnalyzeRequest,
SurfaceAnalyzeResponse,
SurfaceGroundStatsResponse,
SurfaceInputFileListResponse,
SurfaceInputFileSummary,
SurfaceModelListResponse,
SurfaceModelSummary,
SurfacePointCloudSampleResponse,
)
from common_util.common_util_storage import resolve_stored_project_path
from config.config_db import get_db_pool
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/projects", tags=["B04 Surface Analysis"])
POINT_CLOUD_SAMPLE_LIMIT = 100_000
async def update_project_status(
connection: aiomysql.Connection, project_id: UUID, status: str
) -> None:
async with connection.cursor() as cursor:
await cursor.execute(
"""
UPDATE projects
SET status = %s, updated_at = NOW()
WHERE id = %s AND deleted_at IS NULL
""",
(status, str(project_id)),
)
async def save_surface_analysis_to_db(
@@ -103,6 +125,8 @@ async def analyze_surface(
pool = get_db_pool()
try:
async with pool.acquire() as connection:
await update_project_status(connection, project_id, "WF1_ANALYZING")
await connection.commit()
stored_path = await get_project_storage_relative_path(connection, project_id)
project_root = Path(resolve_stored_project_path(stored_path))
input_file = await get_input_file(connection, project_id, request.input_file_id)
@@ -133,6 +157,7 @@ async def analyze_surface(
analysis_result=result,
source_filters=source_filters,
)
await update_project_status(connection, project_id, "WF1_COMPLETE")
await connection.commit()
except Exception:
await connection.rollback()
@@ -147,9 +172,15 @@ async def analyze_surface(
except LookupError as exc:
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
except (OSError, ValueError) as exc:
async with pool.acquire() as connection:
await update_project_status(connection, project_id, "WF1_FAILED")
await connection.commit()
return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)})
except Exception:
logger.exception("B04 지표면 분석 실패: project_id=%s", project_id)
async with pool.acquire() as connection:
await update_project_status(connection, project_id, "WF1_FAILED")
await connection.commit()
return JSONResponse(
status_code=500,
content={"status": "error", "message": "지표면 분석 처리 중 오류가 발생했습니다."},
@@ -175,6 +206,108 @@ async def get_surface_models(project_id: UUID) -> SurfaceModelListResponse | JSO
)
@router.get("/{project_id}/surface/input-files", response_model=SurfaceInputFileListResponse)
async def get_surface_input_files(project_id: UUID) -> SurfaceInputFileListResponse | JSONResponse:
"""프로젝트의 WF1 분석 대상 LAS/LAZ 입력 파일 목록을 조회한다."""
pool = get_db_pool()
try:
async with pool.acquire() as connection:
files = await list_project_point_cloud_inputs(connection, project_id)
return SurfaceInputFileListResponse(
project_id=str(project_id),
files=[SurfaceInputFileSummary(**item) for item in files],
)
except Exception:
logger.exception("B04 입력 파일 목록 조회 실패: project_id=%s", project_id)
return JSONResponse(
status_code=500,
content={"status": "error", "message": "입력 파일 목록 조회 중 오류가 발생했습니다."},
)
@router.get("/{project_id}/surface/point-cloud", response_model=SurfacePointCloudSampleResponse)
async def get_surface_point_cloud(
project_id: UUID,
) -> SurfacePointCloudSampleResponse | JSONResponse:
"""구조화된 LAS 결과에서 B04 3D 미리보기용 포인트 샘플을 반환한다."""
pool = get_db_pool()
try:
async with pool.acquire() as connection:
stored_path = await get_project_storage_relative_path(connection, project_id)
project_root = Path(resolve_stored_project_path(stored_path))
structured_path = project_root / "B04_wf1_Surface" / "processed" / "structured.npz"
if not structured_path.is_file():
return JSONResponse(
status_code=404,
content={"status": "error", "message": "구조화된 포인트클라우드가 없습니다."},
)
with np.load(structured_path) as structured:
xyz = np.asarray(structured["xyz"], dtype=np.float32)
bounds = np.asarray(structured["bounds"], dtype=np.float64)
point_count = int(len(xyz))
if point_count > POINT_CLOUD_SAMPLE_LIMIT:
rng = np.random.default_rng(20260710)
indexes = rng.choice(point_count, POINT_CLOUD_SAMPLE_LIMIT, replace=False)
sample = xyz[indexes]
else:
sample = xyz
return SurfacePointCloudSampleResponse(
project_id=str(project_id),
point_count=point_count,
sampled_count=int(len(sample)),
bounds={
"x_min": float(bounds[0, 0]),
"x_max": float(bounds[0, 1]),
"y_min": float(bounds[1, 0]),
"y_max": float(bounds[1, 1]),
"z_min": float(bounds[2, 0]),
"z_max": float(bounds[2, 1]),
},
points=sample.astype(float).tolist(),
)
except LookupError as exc:
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
except Exception:
logger.exception("B04 포인트클라우드 샘플 조회 실패: project_id=%s", project_id)
return JSONResponse(
status_code=500,
content={"status": "error", "message": "포인트클라우드 조회 중 오류가 발생했습니다."},
)
@router.get("/{project_id}/surface/ground-stats", response_model=SurfaceGroundStatsResponse)
async def get_surface_ground_stats(project_id: UUID) -> SurfaceGroundStatsResponse | JSONResponse:
"""manifest에서 필터별 지면 포인트 통계를 반환한다."""
pool = get_db_pool()
try:
async with pool.acquire() as connection:
stored_path = await get_project_storage_relative_path(connection, project_id)
project_root = Path(resolve_stored_project_path(stored_path))
manifest_path = project_root / "B04_wf1_Surface" / "models" / "manifest.json"
if not manifest_path.is_file():
return SurfaceGroundStatsResponse(project_id=str(project_id), filters={})
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
filters = {
key: {
"source_point_count": value.get("source_point_count"),
"methods": value.get("methods", {}),
}
for key, value in manifest.get("source_filters", {}).items()
if isinstance(value, dict)
}
return SurfaceGroundStatsResponse(project_id=str(project_id), filters=filters)
except LookupError as exc:
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
except Exception:
logger.exception("B04 지면 통계 조회 실패: project_id=%s", project_id)
return JSONResponse(
status_code=500,
content={"status": "error", "message": "지면 통계 조회 중 오류가 발생했습니다."},
)
@router.get("/{project_id}/surface/status")
async def get_wf1_analysis_status(project_id: UUID) -> dict:
"""WF1 분석 상태를 조회한다."""
@@ -184,20 +317,50 @@ async def get_wf1_analysis_status(project_id: UUID) -> dict:
async with connection.cursor(aiomysql.DictCursor) as cursor:
await cursor.execute(
"""
SELECT COUNT(*) as model_count
FROM surface_models
WHERE project_id = %s
SELECT p.status as project_status, COUNT(sm.id) as model_count
FROM projects p
LEFT JOIN surface_models sm ON sm.project_id = p.id
WHERE p.id = %s AND p.deleted_at IS NULL
GROUP BY p.id, p.status
""",
(str(project_id),),
)
row = await cursor.fetchone()
if not row:
return JSONResponse(
status_code=404,
content={"status": "error", "message": "프로젝트를 찾을 수 없습니다."},
)
model_count = int(row["model_count"]) if row else 0
status = "completed" if model_count > 0 else "in_progress"
project_status = str(row.get("project_status") or "NEW")
if project_status == "WF1_FAILED":
status = "failed"
progress_percent = 0
current_stage = "failed"
message = "WF1 분석에 실패했습니다."
elif model_count > 0 or project_status == "WF1_COMPLETE":
status = "completed"
progress_percent = 100
current_stage = "completed"
message = "WF1 분석이 완료되었습니다."
elif project_status == "WF1_ANALYZING":
status = "in_progress"
progress_percent = 30
current_stage = "surface_analysis"
message = "WF1 분석이 진행 중입니다."
else:
status = "pending"
progress_percent = 0
current_stage = "pending"
message = "WF1 분석 대기 중입니다."
return {
"project_id": str(project_id),
"status": status,
"model_count": model_count,
"progress_percent": progress_percent,
"current_stage": current_stage,
"message": message,
}
except Exception:
logger.exception("WF1 분석 상태 조회 실패: project_id=%s", project_id)
+40
View File
@@ -53,6 +53,46 @@ class SurfaceModelSummary(BaseModel):
created_at: str | None = None
class SurfaceInputFileSummary(BaseModel):
"""WF1 분석 대상으로 선택 가능한 LAS/LAZ 입력 파일 요약."""
id: int
file_type: str
original_filename: str
raw_file_path: str
file_size_mb: float | None = None
crs_epsg: int | None = None
status: str | None = None
created_at: str | None = None
class SurfaceInputFileListResponse(BaseModel):
"""프로젝트 지표면 분석 입력 파일 목록 응답."""
status: str = "success"
project_id: str
files: list[SurfaceInputFileSummary]
class SurfacePointCloudSampleResponse(BaseModel):
"""B04 3D 미리보기용 포인트클라우드 샘플."""
status: str = "success"
project_id: str
point_count: int
sampled_count: int
bounds: dict[str, float]
points: list[list[float]]
class SurfaceGroundStatsResponse(BaseModel):
"""필터별 지면 포인트 통계 응답."""
status: str = "success"
project_id: str
filters: dict[str, dict[str, Any]]
class SurfaceAnalyzeResponse(BaseModel):
"""지표면 분석 실행 결과."""
+289 -4
View File
@@ -25,9 +25,17 @@ import {
import { workflowSteps } from "../A00_Common/b_page_scaffold";
import {
analyzeSurface,
fetchSurfaceGroundStats,
fetchSurfacePointCloud,
fetchSurfaceStatus,
listSurfaceInputFiles,
listSurfaceModels,
type SurfaceInputFileSummary,
type SurfaceModelSummary,
type SurfaceStatusResponse,
} from "./B04_wf1_Surface_Api_Fetch";
import { createWorkflowLayout } from "@ui/ui_template_workflow_layout";
import { createSurfacePointCloudViewer } from "./B04_wf1_Surface_UI_Viewer";
import "./B04_wf1_Surface_UI_Style.css";
/** locale 헬퍼 */
@@ -73,7 +81,7 @@ function buildCheckboxGroup(
return { root, selected };
}
export function renderB04Surface(root: HTMLElement): void {
export function renderB04SurfaceLegacy(root: HTMLElement): void {
const shell = createWorkflowShell({
title: L("B04_Surface_Title"),
steps: workflowSteps(),
@@ -166,9 +174,7 @@ export function renderB04Surface(root: HTMLElement): void {
const meta = document.createElement("div");
meta.className = "b04-surface__model-meta";
const resolution = document.createElement("span");
resolution.textContent = `${L("B04_Surface_Model_Resolution")}: ${
model.resolution_m ?? "-"
}`;
resolution.textContent = `${L("B04_Surface_Model_Resolution")}: ${model.resolution_m ?? "-"}`;
const path = document.createElement("span");
path.textContent = `${L("B04_Surface_Model_Path")}: ${model.model_file_path ?? "-"}`;
meta.append(resolution, path);
@@ -248,3 +254,282 @@ export function renderB04Surface(root: HTMLElement): void {
const initialProjectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
if (initialProjectId) void loadModels(initialProjectId);
}
function buildInfoLine(label: string, value: unknown): HTMLElement {
const row = document.createElement("div");
row.className = "b04-surface__line";
const key = document.createElement("span");
key.textContent = label;
const val = document.createElement("strong");
val.textContent = value == null || value === "" ? "-" : String(value);
row.append(key, val);
return row;
}
export function renderB04Surface(root: HTMLElement): void {
let selectedInputFile: SurfaceInputFileSummary | null = null;
let inputFiles: SurfaceInputFileSummary[] = [];
const inputSelect = document.createElement("select");
inputSelect.className = "b04-surface__select";
const inputInfo = document.createElement("div");
inputInfo.className = "b04-surface__input-info";
const statusBox = document.createElement("div");
statusBox.className = "b04-surface__status";
const modelList = document.createElement("div");
modelList.className = "b04-surface__models";
const statsList = document.createElement("div");
statsList.className = "b04-surface__stats";
const viewer = createSurfacePointCloudViewer();
const filterGroup = buildCheckboxGroup(L("B04_Surface_Group_Filters"), SOURCE_FILTERS, [
"grid_min_z",
"csf",
"pmf",
]);
const methodGroup = buildCheckboxGroup(L("B04_Surface_Group_Methods"), MODEL_METHODS, [
"dtm",
"tin",
]);
const forceLabel = document.createElement("label");
forceLabel.className = "b04-surface__check";
const forceBox = document.createElement("input");
forceBox.type = "checkbox";
const forceText = document.createElement("span");
forceText.textContent = L("B04_Surface_Field_Force");
forceLabel.append(forceBox, forceText);
const analyzeButton = createButton({
label: L("B04_Surface_Btn_Analyze"),
variant: "filled",
onClick: () => void onB04_Surface_Analyze_Click(),
});
const refreshButton = createButton({
label: L("B04_Surface_Btn_Refresh"),
variant: "ghost",
onClick: () => void onB04_Surface_Refresh_Click(),
});
const panel = document.createElement("div");
panel.className = "b04-surface__form";
const inputGroup = document.createElement("section");
inputGroup.className = "b04-surface__group";
const inputTitle = document.createElement("h3");
inputTitle.className = "b04-surface__panel-title";
inputTitle.textContent = L("B04_Surface_InputFiles");
inputGroup.append(inputTitle, inputSelect, inputInfo);
panel.append(
inputGroup,
filterGroup.root,
methodGroup.root,
forceLabel,
analyzeButton,
refreshButton,
);
const workspace = document.createElement("div");
workspace.className = "b04-surface__workspace";
const topbar = document.createElement("div");
topbar.className = "b04-surface__topbar";
const title = document.createElement("h3");
title.textContent = L("B04_Surface_PointCloud_Title");
topbar.append(title, 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);
const layout = createWorkflowLayout({
title: L("B04_Surface_Title"),
steps: workflowSteps(),
activeStep: 1,
leftPanel: panel,
mainContent: workspace,
});
function getProjectId(): string | null {
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
if (!projectId) showToast(L("B04_Surface_Error_Project"), "error");
return projectId;
}
function renderStatus(status: SurfaceStatusResponse | null): void {
statusBox.replaceChildren();
if (!status) {
statusBox.append(createTag(L("B04_Surface_Status_Unknown"), "neutral"));
return;
}
const variant =
status.status === "completed" ? "success" : status.status === "failed" ? "danger" : "warning";
statusBox.append(
createTag(`${status.progress_percent}%`, variant),
document.createTextNode(status.message),
);
}
function renderInputFiles(files: readonly SurfaceInputFileSummary[]): void {
inputFiles = [...files];
inputSelect.replaceChildren();
if (inputFiles.length === 0) {
selectedInputFile = null;
const option = document.createElement("option");
option.value = "";
option.textContent = L("B04_Surface_InputFiles_Empty");
inputSelect.append(option);
inputInfo.replaceChildren();
analyzeButton.disabled = true;
return;
}
selectedInputFile = inputFiles[0];
for (const file of inputFiles) {
const option = document.createElement("option");
option.value = String(file.id);
option.textContent = `${file.original_filename} (#${file.id})`;
inputSelect.append(option);
}
inputSelect.value = String(selectedInputFile.id);
analyzeButton.disabled = false;
renderInputInfo();
}
function renderInputInfo(): void {
inputInfo.replaceChildren();
if (!selectedInputFile) return;
inputInfo.append(
buildInfoLine(L("B04_Surface_Input_FileName"), selectedInputFile.original_filename),
buildInfoLine(L("B04_Surface_Input_Crs"), selectedInputFile.crs_epsg),
buildInfoLine(L("B04_Surface_Input_Size"), selectedInputFile.file_size_mb?.toFixed(2)),
);
}
function renderModels(models: readonly SurfaceModelSummary[]): void {
modelList.replaceChildren();
if (models.length === 0) {
const empty = document.createElement("p");
empty.className = "b04-surface__empty";
empty.textContent = L("B04_Surface_Result_Empty");
modelList.append(empty);
return;
}
for (const model of models) {
const card = document.createElement("article");
card.className = "b04-surface__model-card";
const head = document.createElement("div");
head.className = "b04-surface__model-head";
const type = document.createElement("strong");
type.textContent = model.model_type;
const variant =
model.status === "CONFIRMED" || model.status === "COMPLETE" ? "success" : "neutral";
head.append(type, createTag(model.status, variant));
const meta = document.createElement("div");
meta.className = "b04-surface__model-meta";
meta.append(
buildInfoLine(L("B04_Surface_Model_Resolution"), model.resolution_m),
buildInfoLine(L("B04_Surface_Model_Path"), model.model_file_path),
);
card.append(head, meta);
modelList.append(card);
}
}
function renderGroundStats(filters: Record<string, Record<string, unknown>>): void {
statsList.replaceChildren();
const entries = Object.entries(filters);
if (entries.length === 0) {
const empty = document.createElement("p");
empty.className = "b04-surface__empty";
empty.textContent = L("B04_Surface_GroundStats_Empty");
statsList.append(empty);
return;
}
for (const [name, value] of entries) {
const item = document.createElement("article");
item.className = "b04-surface__stat-card";
item.append(
buildInfoLine(L("B04_Surface_GroundStats_Filter"), name),
buildInfoLine(L("B04_Surface_GroundStats_SourcePoints"), value.source_point_count),
);
statsList.append(item);
}
}
async function loadProjectData(projectId: string): Promise<void> {
const [inputs, status, models, stats] = await Promise.all([
listSurfaceInputFiles(projectId),
fetchSurfaceStatus(projectId),
listSurfaceModels(projectId),
fetchSurfaceGroundStats(projectId),
]);
renderInputFiles(inputs.files);
renderStatus(status);
renderModels(models.models);
renderGroundStats(stats.filters);
try {
viewer.render(await fetchSurfacePointCloud(projectId));
} catch {
viewer.render(null);
}
}
async function onB04_Surface_Analyze_Click(): Promise<void> {
const projectId = getProjectId();
if (!projectId || !selectedInputFile) return;
if (filterGroup.selected.size === 0 || methodGroup.selected.size === 0) {
showToast(L("B04_Surface_Error_Selection"), "error");
return;
}
showLoadingOverlay();
try {
const response = await analyzeSurface(projectId, {
input_file_id: selectedInputFile.id,
source_filters: [...filterGroup.selected],
methods: [...methodGroup.selected],
force: forceBox.checked,
});
showToast(
`${L("B04_Surface_Analyze_Success")} (${response.surface_model_ids.length})`,
"success",
);
await loadProjectData(projectId);
} catch (error) {
const detail = error instanceof Error ? error.message : L("B04_Surface_Analyze_Failed");
showToast(`${L("B04_Surface_Analyze_Failed")} ${detail}`, "error");
} finally {
hideLoadingOverlay();
}
}
async function onB04_Surface_Refresh_Click(): Promise<void> {
const projectId = getProjectId();
if (!projectId) return;
showLoadingOverlay();
try {
await loadProjectData(projectId);
} catch {
showToast(L("B04_Surface_Load_Failed"), "error");
} finally {
hideLoadingOverlay();
}
}
inputSelect.addEventListener("change", () => {
selectedInputFile = inputFiles.find((file) => String(file.id) === inputSelect.value) ?? null;
renderInputInfo();
});
root.replaceChildren(layout.root);
const projectId = getProjectId();
if (projectId) void onB04_Surface_Refresh_Click();
}
@@ -12,6 +12,7 @@
display: flex;
flex-direction: column;
gap: var(--spacing-16);
padding: var(--spacing-16);
}
.b04-surface__group {
@@ -45,7 +46,99 @@
accent-color: var(--color-primary);
}
.b04-surface__panel-title {
font-size: var(--text-body-sm);
color: var(--color-text);
}
.b04-surface__select {
width: 100%;
min-height: 38px;
border: 1px solid var(--color-border);
border-radius: var(--radius-inputs);
background: var(--color-canvas);
color: var(--color-text-body);
padding: var(--spacing-8);
}
.b04-surface__input-info,
.b04-surface__model-meta,
.b04-surface__stats {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
}
.b04-surface__line {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--spacing-16);
color: var(--color-text-secondary);
font-size: var(--text-caption);
}
.b04-surface__line strong {
color: var(--color-text-body);
text-align: right;
word-break: break-word;
}
/* --- 우측 결과 영역 --- */
.b04-surface__workspace {
display: grid;
grid-template-rows: auto minmax(360px, 1fr) auto;
min-height: calc(100vh - var(--wf-header-height));
}
.b04-surface__topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-16);
padding: var(--spacing-16) var(--spacing-24);
border-bottom: 1px solid var(--color-border);
}
.b04-surface__topbar h3,
.b04-surface__section h3 {
font-size: var(--text-body);
}
.b04-surface__status {
display: flex;
align-items: center;
gap: var(--spacing-8);
color: var(--color-text-secondary);
font-size: var(--text-body-sm);
}
.b04-surface-viewer {
min-height: 360px;
height: 100%;
}
.b04-surface-viewer__canvas {
display: block;
width: 100%;
height: 100%;
}
.b04-surface__bottom {
display: grid;
grid-template-columns: minmax(240px, 320px) minmax(0, 1fr);
gap: var(--spacing-16);
padding: var(--spacing-16) var(--spacing-24) var(--spacing-24);
border-top: 1px solid var(--color-border);
}
.b04-surface__section {
display: flex;
flex-direction: column;
gap: var(--spacing-16);
min-width: 0;
}
.b04-surface__result {
display: flex;
flex-direction: column;
@@ -86,6 +179,16 @@
box-shadow: var(--shadow-sm);
}
.b04-surface__stat-card {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
padding: var(--spacing-16);
border: 1px solid var(--color-border);
border-radius: var(--radius-cards);
background-color: var(--color-surface-raised);
}
.b04-surface__model-head {
display: flex;
align-items: center;
@@ -107,3 +210,9 @@
color: var(--color-text-secondary);
word-break: break-all;
}
@media (max-width: 980px) {
.b04-surface__bottom {
grid-template-columns: 1fr;
}
}
@@ -0,0 +1,121 @@
import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import { RENDER_OPTIONS } from "@config/config_frontend";
import type { SurfacePointCloudSampleResponse } from "./B04_wf1_Surface_Api_Fetch";
export interface SurfacePointCloudViewer {
root: HTMLElement;
render: (data: SurfacePointCloudSampleResponse | null) => void;
dispose: () => void;
}
export function createSurfacePointCloudViewer(): SurfacePointCloudViewer {
const root = document.createElement("div");
root.className = "b04-surface-viewer";
const canvas = document.createElement("canvas");
canvas.className = "b04-surface-viewer__canvas";
root.append(canvas);
const renderer = new THREE.WebGLRenderer({
canvas,
antialias: RENDER_OPTIONS.antialias,
});
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,
);
const controls = new OrbitControls(camera, canvas);
controls.enableDamping = true;
const grid = new THREE.GridHelper(
200,
20,
RENDER_OPTIONS.gridCenterColor,
RENDER_OPTIONS.gridLineColor,
);
scene.add(grid);
let pointsObject: THREE.Points | null = null;
let animationFrame = 0;
function resize(): void {
const rect = root.getBoundingClientRect();
const width = Math.max(1, Math.floor(rect.width));
const height = Math.max(1, Math.floor(rect.height));
renderer.setSize(width, height, false);
camera.aspect = width / height;
camera.updateProjectionMatrix();
}
function animate(): void {
resize();
controls.update();
renderer.render(scene, camera);
animationFrame = requestAnimationFrame(animate);
}
function clearPoints(): void {
if (!pointsObject) return;
pointsObject.geometry.dispose();
const material = pointsObject.material;
if (Array.isArray(material)) material.forEach((item) => item.dispose());
else material.dispose();
scene.remove(pointsObject);
pointsObject = null;
}
function render(data: SurfacePointCloudSampleResponse | null): void {
clearPoints();
if (!data || 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 geometry = new THREE.BufferGeometry();
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
geometry.computeBoundingSphere();
const material = new THREE.PointsMaterial({
color: RENDER_OPTIONS.pointColor,
size: 1.6,
sizeAttenuation: false,
});
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();
}
animationFrame = requestAnimationFrame(animate);
return {
root,
render,
dispose: () => {
cancelAnimationFrame(animationFrame);
clearPoints();
controls.dispose();
renderer.dispose();
},
};
}