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:
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user