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
+49 -12
View File
@@ -6,8 +6,14 @@
* 갱신하고, 화면 갱신은 호출측이 넘긴 콜백으로만 한다 — 이 파일은 DOM 구조를 모른다.
* ========================================================================== */
import { PROGRESS_UPDATE_INTERVAL_MS, UPLOAD_CHUNK_SIZE_MB } from "@config/config_frontend";
import { fetchWorkflowState, type WorkflowState } from "../A00_Common/b_workflow_nav";
import {
PROGRESS_UPDATE_INTERVAL_MS,
UPLOAD_CHUNK_SIZE_MB,
} from "@config/config_frontend";
import {
fetchWorkflowState,
type WorkflowState,
} from "../A00_Common/b_workflow_nav";
import { createButton } from "@ui/ui_template_elements";
import { fileFingerprint } from "./B03_FileInput_Fingerprint";
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
@@ -18,7 +24,10 @@ import {
uploadFileChunk,
type UploadedFileResult,
} from "./B03_FileInput_Api_Fetch";
import { saveB03UploadedFile, updateB03AnalysisState } from "./B03_FileInput_State";
import {
saveB03UploadedFile,
updateB03AnalysisState,
} from "./B03_FileInput_State";
import {
makeSessionKey,
type FileSlotState,
@@ -33,7 +42,10 @@ function L(key: keyof typeof ui_locales): string {
* 완료된 슬롯 재업로드 확인 모달 — 기존 파일·분석 결과가 교체된다는 경고에 사용자의
* 명시적 확인을 받는다(2026-08-04 사용자 지시). 확인 시에만 resolve(true).
*/
export function confirmReplaceUpload(slotLabel: string, fileName: string): Promise<boolean> {
export function confirmReplaceUpload(
slotLabel: string,
fileName: string,
): Promise<boolean> {
return new Promise((resolve) => {
const backdrop = document.createElement("div");
backdrop.className = "b03-file__modal-backdrop";
@@ -98,6 +110,7 @@ export async function uploadOneFile(
state: FileSlotState,
completeUpload: boolean,
onProgress: () => void,
lasFree = false,
): Promise<UploadedFileResult[]> {
const file = state.file;
if (!file) return [];
@@ -107,7 +120,9 @@ export async function uploadOneFile(
const chunkSizeBytes = UPLOAD_CHUNK_SIZE_MB * 1024 * 1024;
// 같은 파일을 다시 고른 경우 전송을 통째로 건너뛴다 — 라이다는 한 번에 몇 분씩 걸린다.
const fingerprint = state.uploadSessionId ? null : await fileFingerprint(file);
const fingerprint = state.uploadSessionId
? null
: await fileFingerprint(file);
let session = state.uploadSessionId;
if (!session) {
const created = await createUploadSession(
@@ -116,6 +131,7 @@ export async function uploadOneFile(
chunkSizeBytes,
fingerprint,
completeUpload,
lasFree,
);
if (created.already_uploaded) {
state.progressBytes = file.size;
@@ -140,11 +156,22 @@ export async function uploadOneFile(
const start = chunkIndex * chunkSizeBytes;
const end = Math.min(file.size, start + chunkSizeBytes);
const chunkStartedAt = performance.now();
await uploadFileChunk(projectId, session, chunkIndex, file.slice(start, end));
const elapsedSec = Math.max(0.001, (performance.now() - chunkStartedAt) / 1000);
await uploadFileChunk(
projectId,
session,
chunkIndex,
file.slice(start, end),
);
const elapsedSec = Math.max(
0.001,
(performance.now() - chunkStartedAt) / 1000,
);
state.progressBytes = end;
state.speedMbs = (end - start) / 1024 / 1024 / elapsedSec;
state.etaSeconds = state.speedMbs > 0 ? (file.size - end) / 1024 / 1024 / state.speedMbs : null;
state.etaSeconds =
state.speedMbs > 0
? (file.size - end) / 1024 / 1024 / state.speedMbs
: null;
const stored: StoredUploadSession = {
key: storageKey,
@@ -161,7 +188,10 @@ export async function uploadOneFile(
localStorage.setItem(storageKey, JSON.stringify(stored));
const now = performance.now();
if (now - lastPaintAt > PROGRESS_UPDATE_INTERVAL_MS || chunkIndex === totalChunks - 1) {
if (
now - lastPaintAt > PROGRESS_UPDATE_INTERVAL_MS ||
chunkIndex === totalChunks - 1
) {
lastPaintAt = now;
onProgress();
}
@@ -173,6 +203,7 @@ export async function uploadOneFile(
totalChunks,
completeUpload,
fingerprint,
lasFree,
);
localStorage.removeItem(storageKey);
saveB03UploadedFile(projectId, {
@@ -182,7 +213,10 @@ export async function uploadOneFile(
});
state.progressBytes = file.size;
state.speedMbs =
file.size / 1024 / 1024 / Math.max(0.001, (performance.now() - startedAt) / 1000);
file.size /
1024 /
1024 /
Math.max(0.001, (performance.now() - startedAt) / 1000);
state.etaSeconds = 0;
state.uploadStatus = "completed";
onProgress();
@@ -203,9 +237,12 @@ export async function uploadOneFile(
*
* 전처리가 실패했으면 더 기다릴 게 없으므로 잠금을 푼다.
*/
export function isInitialPipelineRunning(state: WorkflowState | undefined): boolean {
export function isInitialPipelineRunning(
state: WorkflowState | undefined,
): boolean {
if (!state?.stages?.length) return false;
const stageAt = (stageNo: number) => state.stages.find((stage) => stage.stage_no === stageNo);
const stageAt = (stageNo: number) =>
state.stages.find((stage) => stage.stage_no === stageNo);
const fileInput = stageAt(0);
const preprocess = stageAt(1);
const section = stageAt(3);