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
+169 -45
View File
@@ -9,8 +9,14 @@ import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
import { createButton, createTag, showToast } from "@ui/ui_template_elements";
import { createGeneralLayout } from "@ui/ui_template_general_layout";
import { createWorkflowOverlays } from "@ui/ui_template_overlay";
import { createStepBar, WORKFLOW_STEP_ICONS } from "@ui/ui_template_workflow_layout";
import { fetchUploadOverview, type UploadedFileResult } from "./B03_FileInput_Api_Fetch";
import {
createStepBar,
WORKFLOW_STEP_ICONS,
} from "@ui/ui_template_workflow_layout";
import {
fetchUploadOverview,
type UploadedFileResult,
} from "./B03_FileInput_Api_Fetch";
import { clearPreloadMark } from "../A00_Common/b_asset_cache";
import { navigateTo } from "../A00_Common/router";
import { attachTempBatch } from "../B01_Dashboard/B01_Dashboard_Api_Temp";
@@ -66,6 +72,10 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
// 단계로 넘어가는 것을 막는다(2026-08-08 사용자 지시).
let pageRoot: HTMLElement | null = null;
let isUploading = false;
// LAS 없는 설계(도엽등고선 기반, 2026-08-30) — 프로젝트별로 기억한다.
let lasFreeDesign = activeProjectId
? localStorage.getItem(`b03_las_free_${activeProjectId}`) === "1"
: false;
function clearDerivedCaches(projectId: string): void {
clearRouteLatestCache(projectId);
@@ -114,7 +124,10 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
return Array.from(slots.values()).filter((state) => state.file);
}
function setCardState(slot: FileSlot, stateName: "empty" | "selected" | UploadStatus): void {
function setCardState(
slot: FileSlot,
stateName: "empty" | "selected" | UploadStatus,
): void {
const card = cardMap.get(slot);
if (!card) return;
card.classList.remove(
@@ -128,7 +141,9 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
const cssState = stateName === "failed" ? "error" : stateName;
card.classList.add(`b03-file__card--${cssState}`);
const badgeContainer = card.querySelector<HTMLDivElement>(".b03-file__card-badge-container");
const badgeContainer = card.querySelector<HTMLDivElement>(
".b03-file__card-badge-container",
);
if (badgeContainer) {
badgeContainer.replaceChildren();
if (stateName === "empty") {
@@ -183,18 +198,38 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
const card = cardMap.get(slot);
if (!state || !card) return;
const fileName = card.querySelector<HTMLSpanElement>(".b03-file__file-name");
const fileSize = card.querySelector<HTMLSpanElement>(".b03-file__file-size");
const progress = card.querySelector<HTMLDivElement>(".b03-file__progress-bar");
const progressBytes = card.querySelector<HTMLSpanElement>(".b03-file__progress-bytes");
const progressSpeed = card.querySelector<HTMLSpanElement>(".b03-file__progress-speed");
const progressEta = card.querySelector<HTMLSpanElement>(".b03-file__progress-eta");
const error = card.querySelector<HTMLDivElement>(".b03-file__error-message");
const remove = card.querySelector<HTMLButtonElement>(".b03-file__card-remove");
const fileName = card.querySelector<HTMLSpanElement>(
".b03-file__file-name",
);
const fileSize = card.querySelector<HTMLSpanElement>(
".b03-file__file-size",
);
const progress = card.querySelector<HTMLDivElement>(
".b03-file__progress-bar",
);
const progressBytes = card.querySelector<HTMLSpanElement>(
".b03-file__progress-bytes",
);
const progressSpeed = card.querySelector<HTMLSpanElement>(
".b03-file__progress-speed",
);
const progressEta = card.querySelector<HTMLSpanElement>(
".b03-file__progress-eta",
);
const error = card.querySelector<HTMLDivElement>(
".b03-file__error-message",
);
const remove = card.querySelector<HTMLButtonElement>(
".b03-file__card-remove",
);
const percent = state.file ? Math.min(100, (state.progressBytes / state.file.size) * 100) : 0;
const percent = state.file
? Math.min(100, (state.progressBytes / state.file.size) * 100)
: 0;
// 로컬 파일이 없어도 서버에 업로드된 파일이 있으면 그 정보(정본)를 보여준다.
if (fileName) fileName.textContent = state.file?.name ?? state.serverUploaded?.name ?? "";
if (fileName)
fileName.textContent =
state.file?.name ?? state.serverUploaded?.name ?? "";
if (fileSize) {
fileSize.textContent = state.file
? formatBytes(state.file.size)
@@ -220,8 +255,13 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
if (remove) remove.hidden = !state.file;
if (state.error) setCardState(slot, "failed");
else if (!state.file) setCardState(slot, state.serverUploaded ? "completed" : "empty");
else setCardState(slot, state.uploadStatus === "pending" ? "selected" : state.uploadStatus);
else if (!state.file)
setCardState(slot, state.serverUploaded ? "completed" : "empty");
else
setCardState(
slot,
state.uploadStatus === "pending" ? "selected" : state.uploadStatus,
);
updateUploadButton();
}
@@ -233,19 +273,29 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
renderSlot(slot);
}
function validateFileForSlot(file: File, state: FileSlotState): string | null {
function validateFileForSlot(
file: File,
state: FileSlotState,
): string | null {
const extension = getExtension(file.name);
const maxBytes = UPLOAD_MAX_MB * 1024 * 1024;
if (!state.extensions.includes(extension)) return L("B03_File_Error_SlotType");
if (file.size === 0 || file.size > maxBytes) return L("B03_File_Error_Size");
if (!state.extensions.includes(extension))
return L("B03_File_Error_SlotType");
if (file.size === 0 || file.size > maxBytes)
return L("B03_File_Error_Size");
return null;
}
async function assignFileToSlot(file: File, targetSlot?: FileSlot): Promise<void> {
async function assignFileToSlot(
file: File,
targetSlot?: FileSlot,
): Promise<void> {
const extension = getExtension(file.name);
const state = targetSlot
? slots.get(targetSlot)
: Array.from(slots.values()).find((candidate) => candidate.extensions.includes(extension));
: Array.from(slots.values()).find((candidate) =>
candidate.extensions.includes(extension),
);
if (!state) {
pageError.textContent = `${L("B03_File_Error_Extension")} ${file.name}`;
return;
@@ -256,13 +306,19 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
return;
}
if (!targetSlot && state.file && state.file.name !== file.name) {
showErrorMessage(state.slot, `${L("B03_File_Error_DuplicateSlot")} ${file.name}`);
showErrorMessage(
state.slot,
`${L("B03_File_Error_DuplicateSlot")} ${file.name}`,
);
return;
}
// 서버에 이미 완료된 슬롯이면 교체 확인을 받는다(2026-08-04 사용자 지시). 이어올리기로
// 같은 파일을 다시 고르는 경우는 업로드가 미완료라 serverUploaded가 없어 묻지 않는다.
if (state.serverUploaded) {
const accepted = await confirmReplaceUpload(L(state.labelKey), state.serverUploaded.name);
const accepted = await confirmReplaceUpload(
L(state.labelKey),
state.serverUploaded.name,
);
if (!accepted) return;
}
@@ -286,8 +342,9 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
const extension = getExtension(file.name);
const slot =
targetSlot ??
Array.from(slots.values()).find((candidate) => candidate.extensions.includes(extension))
?.slot;
Array.from(slots.values()).find((candidate) =>
candidate.extensions.includes(extension),
)?.slot;
if (slot) occupied.add(slot);
}
if (occupied.size > UPLOAD_MAX_FILES) {
@@ -325,11 +382,19 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
// 필수 슬롯은 로컬 선택이 없어도 **서버에 이미 올라간 파일**이 있으면 충족이다 —
// 재접속 후 파일 하나만 교체 업로드하는 흐름을 막지 않는다(2026-08-04 사용자 지시).
const missingRequired = Array.from(slots.values()).some(
(state) => state.isRequired && !state.file && !state.serverUploaded,
(state) =>
state.isRequired &&
!state.file &&
!state.serverUploaded &&
// LAS 없는 설계면 포인트클라우드 카드는 필수에서 뺀다.
!(lasFreeDesign && state.slot === "las_laz"),
);
if (missingRequired) return L("B03_File_Error_RequiredSlots");
const lasState = slots.get("las_laz");
if (!lasState?.file && !lasState?.serverUploaded) return L("B03_File_Error_Las");
if (!lasFreeDesign) {
const lasState = slots.get("las_laz");
if (!lasState?.file && !lasState?.serverUploaded)
return L("B03_File_Error_Las");
}
for (const state of selected) {
if (state.error) return state.error;
const validation = validateFileForSlot(state.file!, state);
@@ -358,7 +423,10 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
candidate.extensions.includes(extension),
);
if (state) {
state.serverUploaded = { name: file.original_filename, sizeMb: file.file_size_mb };
state.serverUploaded = {
name: file.original_filename,
sizeMb: file.file_size_mb,
};
}
}
for (const slot of slots.keys()) renderSlot(slot);
@@ -393,22 +461,30 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
if (!card) throw new Error("file-card-template is invalid");
card.dataset.slotId = state.slot;
card.querySelector(".b03-file__card-icon")!.textContent = state.icon;
card.querySelector(".b03-file__card-label")!.textContent = L(state.labelKey);
card.querySelector(".b03-file__card-label")!.textContent = L(
state.labelKey,
);
// 지형 래스터만 선택 항목이라 확장자 옆에 표시해 둔다.
const extLabel = state.extensions.join(", ");
card.querySelector(".b03-file__card-ext")!.textContent = state.isRequired
? extLabel
: `${extLabel} · ${L("B03_File_Card_Optional")}`;
const input = card.querySelector<HTMLInputElement>(".b03-file__slot-input")!;
const input = card.querySelector<HTMLInputElement>(
".b03-file__slot-input",
)!;
input.accept = state.extensions.join(",");
const select = card.querySelector<HTMLButtonElement>(".b03-file__card-select")!;
const select = card.querySelector<HTMLButtonElement>(
".b03-file__card-select",
)!;
select.textContent = L("B03_File_Card_Select");
select.addEventListener("click", () => input.click());
input.addEventListener("change", () => {
onFileSelected(input.files ? Array.from(input.files) : [], state.slot);
input.value = "";
});
const remove = card.querySelector<HTMLButtonElement>(".b03-file__card-remove")!;
const remove = card.querySelector<HTMLButtonElement>(
".b03-file__card-remove",
)!;
remove.textContent = "×";
remove.title = L("B03_File_Card_Remove");
remove.setAttribute("aria-label", L("B03_File_Card_Remove"));
@@ -417,7 +493,10 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
return card;
}
function createCardGroup(title: string, groupSlots: readonly FileSlot[]): HTMLElement {
function createCardGroup(
title: string,
groupSlots: readonly FileSlot[],
): HTMLElement {
const group = document.createElement("section");
group.className = "b03-file__group";
if (title) {
@@ -443,7 +522,9 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
if (!activeProjectId) return;
for (const state of selectedStates()) {
const stored = localStorage.getItem(makeSessionKey(activeProjectId, state.file!));
const stored = localStorage.getItem(
makeSessionKey(activeProjectId, state.file!),
);
if (!stored) continue;
const session = JSON.parse(stored) as StoredUploadSession;
state.uploadSessionId = session.uploadSessionId;
@@ -513,7 +594,8 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
showToast(L("B03_File_Analysis_StillRunning"), "warning");
}
} catch (error) {
const detail = error instanceof Error ? error.message : L("B03_Temp_Attach_Failed");
const detail =
error instanceof Error ? error.message : L("B03_Temp_Attach_Failed");
pageError.textContent = `${L("B03_Temp_Attach_Failed")} ${detail}`;
showToast(L("B03_Temp_Attach_Failed"), "error");
}
@@ -542,7 +624,9 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
}
}
async function startChunkedUpload(targetStates = selectedStates()): Promise<void> {
async function startChunkedUpload(
targetStates = selectedStates(),
): Promise<void> {
if (isUploading) return;
// 보관함에서 불러온 자료가 지정돼 있으면 그것을 옮기는 것이 이 버튼의 동작이다.
if (tempPicker.selected()) {
@@ -568,8 +652,12 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
for (let index = 0; index < targetStates.length; index += 1) {
const state = targetStates[index];
uploaded.push(
...(await uploadOneFile(activeProjectId, state, index === targetStates.length - 1, () =>
renderSlot(state.slot),
...(await uploadOneFile(
activeProjectId,
state,
index === targetStates.length - 1,
() => renderSlot(state.slot),
lasFreeDesign,
)),
);
}
@@ -586,8 +674,11 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
showToast(L("B03_File_Analysis_StillRunning"), "warning");
}
} catch (error) {
const failed = targetStates.find((state) => state.uploadStatus === "uploading");
const detail = error instanceof Error ? error.message : L("B03_File_Upload_Failed");
const failed = targetStates.find(
(state) => state.uploadStatus === "uploading",
);
const detail =
error instanceof Error ? error.message : L("B03_File_Upload_Failed");
if (failed) showErrorMessage(failed.slot, detail);
pageError.textContent = `${L("B03_File_Upload_Failed")} ${detail}`;
showToast(L("B03_File_Upload_Failed"), "error");
@@ -621,7 +712,9 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
function onB03_File_Drop(event: DragEvent): void {
event.preventDefault();
dropzone.classList.remove("is-dragging");
onFileSelected(event.dataTransfer?.files ? Array.from(event.dataTransfer.files) : []);
onFileSelected(
event.dataTransfer?.files ? Array.from(event.dataTransfer.files) : [],
);
}
fileInput.addEventListener("change", onB03_File_Select_Change);
@@ -633,7 +726,9 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
event.preventDefault();
dropzone.classList.add("is-dragging");
});
dropzone.addEventListener("dragleave", () => dropzone.classList.remove("is-dragging"));
dropzone.addEventListener("dragleave", () =>
dropzone.classList.remove("is-dragging"),
);
dropzone.addEventListener("drop", onB03_File_Drop);
uploadButton = createButton({
@@ -667,9 +762,37 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
"tif",
]);
// LAS 없는 설계 토글 — 켜면 포인트클라우드 카드를 비활성화하고 필수에서 뺀다.
const lasFreeRow = document.createElement("label");
lasFreeRow.className = "b03-file__lasfree";
lasFreeRow.title = L("B03_File_LasFree_Hint");
const lasFreeCheck = document.createElement("input");
lasFreeCheck.type = "checkbox";
const lasFreeText = document.createElement("span");
lasFreeText.textContent = L("B03_File_LasFree_Toggle");
lasFreeRow.append(lasFreeCheck, lasFreeText);
function applyLasFreeState(): void {
lasFreeCheck.checked = lasFreeDesign;
cardMap
.get("las_laz")
?.classList.toggle("b03-file__card--disabled", lasFreeDesign);
}
lasFreeCheck.addEventListener("change", () => {
lasFreeDesign = lasFreeCheck.checked;
if (activeProjectId) {
localStorage.setItem(
`b03_las_free_${activeProjectId}`,
lasFreeDesign ? "1" : "0",
);
}
applyLasFreeState();
pageError.textContent = "";
});
const cardsContainer = document.createElement("div");
cardsContainer.className = "b03-file__control-panel b03-file__cards-container-panel";
cardsContainer.append(inputsGroup);
cardsContainer.className =
"b03-file__control-panel b03-file__cards-container-panel";
cardsContainer.append(lasFreeRow, inputsGroup);
const workflowState = activeProjectId
? await fetchWorkflowState(activeProjectId).catch(() => undefined)
@@ -705,6 +828,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
root.replaceChildren(layout.root);
for (const slot of slots.keys()) renderSlot(slot);
applyLasFreeState();
void relockWhileInitialPipelineRuns();
void registerB03ServiceWorker();
void applyUploadOverview();