feat(B03/B04): LAS 없이 도엽등고선으로 3D 서피스를 만들어 설계한다

- B03: 'LAS 없이 설계' 토글 — LAS 필수카드 비활성화, las_free 플래그로
  업로드·완료 검증 면제, 계획노선 CSV를 WF1 분석 입력으로 사용
- B04: 신규 Engine_SheetSurface — 도엽_등고선.geojson을 노선 bbox+300m
  직사각형으로 절취, 배수유역 엔진의 등고선 정점구름·Delaunay TIN 보간을
  재사용해 dtm_sheet.npz(1m 격자, LAS DTM과 동일 형식) + 프리뷰 glb 생성.
  build_surface_sampler('sheet','dtm')로 종·횡단·배수 하류 계산 무수정 동작
- WF1: las_free면 run_sheet_surface_analysis로 분기, sheet/dtm 자동 확정.
  VWorld·도엽 확보 블록을 download_geodata()로 추출해 두 경로가 공유
- LAS가 있어도 도엽 서피스를 함께 생성·등록(참고용), B04에
  '도엽등고 3D 서피스' 별도 컨테이너로 표시

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-30 14:27:41 +09:00
co-authored by Claude Opus 5
parent 6d8fd46d87
commit 3572694f73
14 changed files with 1047 additions and 200 deletions
+86 -18
View File
@@ -9,7 +9,10 @@ import {
} from "@ui/ui_template_elements";
import { createWorkflowLayout } from "@ui/ui_template_workflow_layout";
import { fetchDashboardMe } from "../B01_Dashboard/B01_Dashboard_Api_Fetch";
import { clearPreloadMark, purgeOtherProjects } from "../A00_Common/b_asset_cache";
import {
clearPreloadMark,
purgeOtherProjects,
} from "../A00_Common/b_asset_cache";
import { clearRouteLatestCache } from "../B05_Profile/B05_Profile_Api_Fetch";
import { workflowSteps } from "../A00_Common/b_page_scaffold";
import {
@@ -105,11 +108,17 @@ export async function renderB04Surface(root: HTMLElement): Promise<void> {
if (guardedProjectId) {
const user = await fetchDashboardMe();
if (user.role !== "SYSTEM_ADMIN") {
const workflowState = await fetchWorkflowState(guardedProjectId).catch(() => undefined);
const surfaceStage = workflowState?.stages.find((stage) => stage.stage_no === 1);
const workflowState = await fetchWorkflowState(guardedProjectId).catch(
() => undefined,
);
const surfaceStage = workflowState?.stages.find(
(stage) => stage.stage_no === 1,
);
goToWorkflowStage(
guardedProjectId,
surfaceStage?.state === "COMPLETE" ? ROUTES.B05_PROFILE : ROUTES.B03_FILE_INPUT,
surfaceStage?.state === "COMPLETE"
? ROUTES.B05_PROFILE
: ROUTES.B03_FILE_INPUT,
);
return;
}
@@ -140,6 +149,16 @@ export async function renderB04Surface(root: HTMLElement): Promise<void> {
const viewer = createSurfacePointCloudViewer();
const terrainViewer = createSurfaceTerrainViewer();
const mapViewer = createSurfaceMapViewer();
// 도엽등고 3D 서피스 — 전처리에서 함께 생성되는 참고 서피스(LAS 없는 설계의 지형 원천,
// 2026-08-30). 모델 목록에 sheet/dtm이 있을 때만 별도 컨테이너로 보여준다.
const sheetViewer = createSurfaceTerrainViewer();
const sheetSection = document.createElement("section");
sheetSection.className = "b04-surface__sheet-section ui-sidebar-section";
const sheetTitle = document.createElement("h3");
sheetTitle.className = "b04-surface__panel-title";
sheetTitle.textContent = L("B04_Surface_SheetSurface");
sheetSection.append(sheetTitle, sheetViewer.root);
sheetSection.hidden = true;
let syncingCamera = false;
viewer.onCameraChange((state) => {
@@ -200,14 +219,20 @@ export async function renderB04Surface(root: HTMLElement): Promise<void> {
const actionRow = document.createElement("div");
actionRow.className = "ui-sidebar-actions";
actionRow.append(confirmButton, resetButton);
panel.append(inputGroup, analysisGroup, displayGroup, viewer.controlsGroup, actionRow);
panel.append(
inputGroup,
analysisGroup,
displayGroup,
viewer.controlsGroup,
actionRow,
);
const viewers = document.createElement("div");
viewers.className = "b04-surface__viewers";
viewers.append(viewer.root, terrainViewer.root);
const workspace = document.createElement("div");
workspace.className = "b04-surface__workspace";
workspace.append(statusBox, viewers, mapViewer.root);
workspace.append(statusBox, viewers, sheetSection, mapViewer.root);
let workflowState: WorkflowState | undefined;
const layoutProjectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
@@ -229,7 +254,8 @@ export async function renderB04Surface(root: HTMLElement): Promise<void> {
currentStage: workflowState?.current_stage,
routes: WORKFLOW_STEP_ROUTES,
onStepClick: (stepIndex) => {
if (layoutProjectId) goToWorkflowStage(layoutProjectId, WORKFLOW_STEP_ROUTES[stepIndex]);
if (layoutProjectId)
goToWorkflowStage(layoutProjectId, WORKFLOW_STEP_ROUTES[stepIndex]);
},
});
@@ -263,7 +289,11 @@ export async function renderB04Surface(root: HTMLElement): Promise<void> {
return;
}
const variant =
status.status === "completed" ? "success" : status.status === "failed" ? "danger" : "warning";
status.status === "completed"
? "success"
: status.status === "failed"
? "danger"
: "warning";
statusBox.append(
createTag(`${status.progress_percent}%`, variant),
document.createTextNode(status.message),
@@ -280,7 +310,9 @@ export async function renderB04Surface(root: HTMLElement): Promise<void> {
inputInfo.append(
buildInfoLine(
"좌표계",
selectedInputFile.crs_epsg ? `EPSG:${selectedInputFile.crs_epsg}` : null,
selectedInputFile.crs_epsg
? `EPSG:${selectedInputFile.crs_epsg}`
: null,
),
buildInfoLine(
"크기",
@@ -289,7 +321,10 @@ export async function renderB04Surface(root: HTMLElement): Promise<void> {
: `${selectedInputFile.file_size_mb.toFixed(2)} MB`,
),
buildInfoLine("포인트 수", pointCloud?.point_count.toLocaleString()),
buildInfoLine("표시 포인트 수", pointCloud?.sampled_count.toLocaleString()),
buildInfoLine(
"표시 포인트 수",
pointCloud?.sampled_count.toLocaleString(),
),
buildInfoLine("높이 범위", heightRange),
);
}
@@ -328,7 +363,10 @@ export async function renderB04Surface(root: HTMLElement): Promise<void> {
function updateSelectedModel(): void {
const projectId = getProjectId();
if (!projectId) return;
terrainViewer.setSelection(filterGroup.select.value, methodGroup.select.value);
terrainViewer.setSelection(
filterGroup.select.value,
methodGroup.select.value,
);
terrainViewer.render(projectId, models);
confirmButton.disabled = !findSelectedModel();
}
@@ -339,14 +377,20 @@ export async function renderB04Surface(root: HTMLElement): Promise<void> {
showLoadingOverlay();
viewer.setLoading("포인트 데이터 로딩 중…");
try {
pointCloud = await fetchSurfacePointCloud(projectId, filterGroup.select.value);
pointCloud = await fetchSurfacePointCloud(
projectId,
filterGroup.select.value,
);
terrainViewer.setReferenceBounds(pointCloud.bounds);
viewer.render(pointCloud);
renderInputInfo();
} catch (error) {
pointCloud = null;
viewer.render(null);
const detail = error instanceof Error ? error.message : "지면 포인트 조회에 실패했습니다.";
const detail =
error instanceof Error
? error.message
: "지면 포인트 조회에 실패했습니다.";
showToast(detail, "error");
} finally {
hideLoadingOverlay();
@@ -366,7 +410,8 @@ export async function renderB04Surface(root: HTMLElement): Promise<void> {
// 확정본과 같은 조합에서 시작해야 B05와 같은 파일을 보고, 보관함도 한 벌만 쓴다.
// 확정 이력이 없을 때만 개발 기본값(csf·dtm)으로 둔다.
if (confirmed.model_id) {
if (confirmed.source_filter) filterGroup.select.value = confirmed.source_filter;
if (confirmed.source_filter)
filterGroup.select.value = confirmed.source_filter;
if (confirmed.method) methodGroup.select.value = confirmed.method;
terrainViewer.setSmoothing(confirmed.smooth ?? false);
}
@@ -376,7 +421,10 @@ export async function renderB04Surface(root: HTMLElement): Promise<void> {
renderStatus(status);
viewer.setLoading("포인트 데이터 로딩 중…");
try {
pointCloud = await fetchSurfacePointCloud(projectId, filterGroup.select.value);
pointCloud = await fetchSurfacePointCloud(
projectId,
filterGroup.select.value,
);
terrainViewer.setReferenceBounds(pointCloud.bounds);
viewer.render(pointCloud);
// 지도(2D)는 계획노선 기준으로 연다 — 라이다 범위와 다루는 범위가 다르다.
@@ -388,6 +436,19 @@ export async function renderB04Surface(root: HTMLElement): Promise<void> {
}
renderInputInfo();
updateSelectedModel();
// 도엽등고 3D 서피스 — sheet/dtm 모델이 있으면 별도 컨테이너로 보여준다.
const hasSheetModel = models.some(
(model) =>
model.model_type.toLowerCase() === "dtm" &&
getModelFilter(model) === "sheet",
);
sheetSection.hidden = !hasSheetModel;
if (hasSheetModel) {
sheetViewer.setSelection("sheet", "dtm");
sheetViewer.setSmoothing(false);
sheetViewer.render(projectId, models);
}
}
async function onB04_Surface_Confirm_Click(): Promise<void> {
@@ -414,14 +475,20 @@ export async function renderB04Surface(root: HTMLElement): Promise<void> {
L("B04_Surface_Confirm_Success")
.replace("{filter}", filterGroup.select.value)
.replace("{method}", methodGroup.select.value)
.replace("{smoothing}", terrainViewer.isSmoothingEnabled() ? "ON" : "OFF"),
.replace(
"{smoothing}",
terrainViewer.isSmoothingEnabled() ? "ON" : "OFF",
),
"success",
);
await loadProjectData(projectId);
enableRouteStep(projectId);
goToWorkflowStage(projectId, ROUTE_STAGE);
} catch (error) {
const detail = error instanceof Error ? error.message : L("B04_Surface_Confirm_Failed");
const detail =
error instanceof Error
? error.message
: L("B04_Surface_Confirm_Failed");
showToast(`${L("B04_Surface_Confirm_Failed")} ${detail}`, "error");
} finally {
hideLoadingOverlay();
@@ -446,7 +513,8 @@ export async function renderB04Surface(root: HTMLElement): Promise<void> {
}
inputSelect.addEventListener("change", () => {
selectedInputFile = inputFiles.find((file) => String(file.id) === inputSelect.value) ?? null;
selectedInputFile =
inputFiles.find((file) => String(file.id) === inputSelect.value) ?? null;
renderInputInfo();
});
filterGroup.select.addEventListener("change", () => {