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
+69 -43
View File
@@ -69,12 +69,15 @@ export async function uploadProjectFiles(
const controller = new AbortController();
const timeoutId = window.setTimeout(() => controller.abort(), API_TIMEOUT_MS);
try {
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/files`, {
method: "POST",
credentials: "include",
body: formData,
signal: controller.signal,
});
const response = await fetch(
`${API_BASE_URL}/projects/${projectId}/files`,
{
method: "POST",
credentials: "include",
body: formData,
signal: controller.signal,
},
);
return await readJsonOrThrow<FileUploadResponse>(response);
} finally {
window.clearTimeout(timeoutId);
@@ -87,19 +90,24 @@ export async function createUploadSession(
chunkSizeBytes: number,
fingerprint?: string | null,
completeUpload = false,
lasFree = false,
): Promise<ChunkSessionCreateResponse> {
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/upload-sessions`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
original_filename: file.name,
size_bytes: file.size,
chunk_size_bytes: chunkSizeBytes,
fingerprint: fingerprint ?? null,
complete_upload: completeUpload,
}),
});
const response = await fetch(
`${API_BASE_URL}/projects/${projectId}/upload-sessions`,
{
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
original_filename: file.name,
size_bytes: file.size,
chunk_size_bytes: chunkSizeBytes,
fingerprint: fingerprint ?? null,
complete_upload: completeUpload,
las_free: lasFree,
}),
},
);
return await readJsonOrThrow<ChunkSessionCreateResponse>(response);
}
@@ -128,18 +136,23 @@ export async function finalizeUploadSession(
totalChunks: number,
completeUpload: boolean,
fingerprint?: string | null,
lasFree = false,
): Promise<FileUploadResponse> {
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/finalize`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
session_id: sessionId,
total_chunks: totalChunks,
complete_upload: completeUpload,
fingerprint: fingerprint ?? null,
}),
});
const response = await fetch(
`${API_BASE_URL}/projects/${projectId}/finalize`,
{
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
session_id: sessionId,
total_chunks: totalChunks,
complete_upload: completeUpload,
fingerprint: fingerprint ?? null,
las_free: lasFree,
}),
},
);
return await readJsonOrThrow<FileUploadResponse>(response);
}
@@ -147,10 +160,13 @@ export async function fetchUploadStatus(
projectId: string,
sessionId: string,
): Promise<UploadStatusResponse> {
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/upload-status/${sessionId}`, {
method: "GET",
credentials: "include",
});
const response = await fetch(
`${API_BASE_URL}/projects/${projectId}/upload-status/${sessionId}`,
{
method: "GET",
credentials: "include",
},
);
return await readJsonOrThrow<UploadStatusResponse>(response);
}
@@ -182,11 +198,16 @@ export interface UploadOverviewResponse {
}
/** 재접속 시 업로드 현황(정본) — 완료 파일 목록 + 중단 세션 + 완료 여부. */
export async function fetchUploadOverview(projectId: string): Promise<UploadOverviewResponse> {
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/upload-overview`, {
method: "GET",
credentials: "include",
});
export async function fetchUploadOverview(
projectId: string,
): Promise<UploadOverviewResponse> {
const response = await fetch(
`${API_BASE_URL}/projects/${projectId}/upload-overview`,
{
method: "GET",
credentials: "include",
},
);
return await readJsonOrThrow<UploadOverviewResponse>(response);
}
@@ -200,10 +221,15 @@ export interface WF1AnalysisStatus {
error?: string;
}
export async function checkWF1AnalysisStatus(projectId: string): Promise<WF1AnalysisStatus> {
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/surface/status`, {
method: "GET",
credentials: "include",
});
export async function checkWF1AnalysisStatus(
projectId: string,
): Promise<WF1AnalysisStatus> {
const response = await fetch(
`${API_BASE_URL}/projects/${projectId}/surface/status`,
{
method: "GET",
credentials: "include",
},
);
return await readJsonOrThrow<WF1AnalysisStatus>(response);
}
+8 -3
View File
@@ -64,8 +64,8 @@ async def create_input_file(
async def get_project_input_readiness(
connection: aiomysql.Connection,
project_id: UUID,
) -> tuple[set[str], int | None]:
"""현재 업로드 파일 유형과 최신 포인트클라우드 입력 ID를 반환한다."""
) -> tuple[set[str], int | None, int | None]:
"""업로드 파일 유형, 최신 포인트클라우드 입력 ID, 최신 계획노선 CSV 입력 ID를 반환한다."""
async with connection.cursor(aiomysql.DictCursor) as cursor:
await cursor.execute(
"""
@@ -83,7 +83,12 @@ async def get_project_input_readiness(
(int(row["id"]) for row in rows if str(row.get("file_type") or "") in {"las", "laz"}),
None,
)
return file_types, point_cloud_id
# LAS 없는 설계(2026-08-30)의 WF1 입력 — 계획노선 CSV가 분석 원천이 된다.
route_csv_id = next(
(int(row["id"]) for row in rows if str(row.get("file_type") or "") == "csv"),
None,
)
return file_types, point_cloud_id, route_csv_id
async def get_project_storage_relative_path(
+35 -13
View File
@@ -93,15 +93,16 @@ def _is_point_cloud_result(result: UploadedFileResult) -> bool:
return result.file_type.lower() in _POINT_CLOUD_FILE_TYPES
def _missing_required_file_types(file_types: set[str]) -> list[str]:
def _missing_required_file_types(file_types: set[str], las_free: bool = False) -> list[str]:
missing = sorted(_REQUIRED_FILE_TYPES - file_types)
if not file_types.intersection(_POINT_CLOUD_FILE_TYPES):
# LAS 없는 설계(도엽등고선 기반, 2026-08-30)는 LAS 필수를 면제한다.
if not las_free and not file_types.intersection(_POINT_CLOUD_FILE_TYPES):
missing.append("las/laz")
return missing
def _require_complete_file_set(file_types: set[str]) -> None:
missing = _missing_required_file_types(file_types)
def _require_complete_file_set(file_types: set[str], las_free: bool = False) -> None:
missing = _missing_required_file_types(file_types, las_free)
if missing:
raise ValueError(f"B03 필수 입력 파일이 없습니다: {', '.join(missing)}")
@@ -153,11 +154,17 @@ async def _already_uploaded(
async def _complete_file_input_if_ready(
connection: aiomysql.Connection,
project_id: UUID,
las_free: bool = False,
) -> int:
file_types, point_cloud_input_id = await get_project_input_readiness(connection, project_id)
_require_complete_file_set(file_types)
file_types, point_cloud_input_id, route_csv_input_id = await get_project_input_readiness(
connection, project_id
)
_require_complete_file_set(file_types, las_free)
if point_cloud_input_id is None:
raise ValueError("LAS 또는 LAZ 입력 파일을 찾을 수 없습니다.")
if not las_free:
raise ValueError("LAS 또는 LAZ 입력 파일을 찾을 수 없습니다.")
if route_csv_input_id is None:
raise ValueError("계획 노선 CSV 입력 파일을 찾을 수 없습니다.")
# 자료가 갈렸으니 옛 계산 결과(파일 + DB)를 지우고 진행 표시도 되돌린다. 남겨 두면
# 아직 다시 만들어지지 않은 뒷단계 화면이 옛 결과를 새 자료 것인 양 보여준다.
stored_path = await get_project_storage_relative_path(connection, project_id)
@@ -168,7 +175,8 @@ async def _complete_file_input_if_ready(
async with connection.cursor(aiomysql.DictCursor) as cursor:
await reset_stages_after_input_change(cursor, str(project_id))
await complete_stage(cursor, str(project_id), 0)
return point_cloud_input_id
# LAS가 있으면 LAS, 없으면(las_free) 계획노선 CSV가 WF1 분석 입력이다.
return point_cloud_input_id if point_cloud_input_id is not None else int(route_csv_input_id)
def _write_stage_metadata(
@@ -289,6 +297,7 @@ async def _send_upload_complete_notification(
async def upload_project_files(
project_id: UUID,
files: list[UploadFile] = File(...),
las_free: bool = Form(False),
session: dict[str, Any] = Depends(verify_session),
) -> FileUploadResponse | JSONResponse:
"""프로젝트 입력 파일을 저장·분석하고 DB 메타데이터를 기록한다."""
@@ -308,7 +317,8 @@ async def upload_project_files(
content={"status": "error", "message": "동일한 파일명을 중복 업로드할 수 없습니다."},
)
las_count = sum(Path(filename).suffix.lower() in {".las", ".laz"} for filename in filenames)
if las_count != 1:
# LAS 없는 설계(las_free)는 LAS 0개를 허용한다 — 올렸다면 정상 경로로 취급.
if las_count != 1 and not (las_free and las_count == 0):
return JSONResponse(
status_code=400,
content={
@@ -326,7 +336,7 @@ async def upload_project_files(
},
)
request_file_types = {Path(filename).suffix.lower().lstrip(".") for filename in filenames}
missing_required = _missing_required_file_types(request_file_types)
missing_required = _missing_required_file_types(request_file_types, las_free)
if missing_required:
return JSONResponse(
status_code=400,
@@ -385,7 +395,9 @@ async def upload_project_files(
metadata=metadata,
)
)
point_cloud_input_id = await _complete_file_input_if_ready(connection, project_id)
point_cloud_input_id = await _complete_file_input_if_ready(
connection, project_id, las_free
)
await connection.commit()
except Exception:
await connection.rollback()
@@ -460,6 +472,7 @@ async def create_project_upload_session(
point_cloud_input_id = await _complete_file_input_if_ready(
connection,
project_id,
payload.las_free,
)
await connection.commit()
except Exception:
@@ -649,6 +662,7 @@ async def finalize_project_upload(
point_cloud_input_id = await _complete_file_input_if_ready(
connection,
project_id,
payload.las_free,
)
await connection.commit()
except Exception:
@@ -753,7 +767,9 @@ async def get_project_upload_overview(
async with pool.acquire() as connection:
files = await list_project_input_files(connection, project_id)
sessions = await list_incomplete_upload_sessions(connection, project_id)
file_types, point_cloud_id = await get_project_input_readiness(connection, project_id)
file_types, point_cloud_id, _route_csv_id = await get_project_input_readiness(
connection, project_id
)
async with connection.cursor(aiomysql.DictCursor) as cursor:
state = await get_workflow_state(cursor, str(project_id))
stages = (state or {}).get("stages") or []
@@ -761,6 +777,11 @@ async def get_project_upload_overview(
int(stage.get("stage_no", -1)) == 1 and str(stage.get("state")) == "COMPLETE"
for stage in stages
)
# LAS 없는 설계로 stage 0을 마친 프로젝트는 LAS가 없어도 필수 충족으로 본다.
stage0_complete = any(
int(stage.get("stage_no", -1)) == 0 and str(stage.get("state")) == "COMPLETE"
for stage in stages
)
return UploadOverviewResponse(
files=[
UploadOverviewFile(
@@ -787,7 +808,8 @@ async def get_project_upload_overview(
)
for row in sessions
],
required_complete=_REQUIRED_FILE_TYPES <= file_types and point_cloud_id is not None,
required_complete=_REQUIRED_FILE_TYPES <= file_types
and (point_cloud_id is not None or stage0_complete),
analysis_complete=analysis_complete,
)
except Exception:
+4
View File
@@ -57,6 +57,8 @@ class ChunkSessionCreateRequest(FileUploadDescriptor):
chunk_size_bytes: int = Field(default=UPLOAD_CHUNK_SIZE_BYTES, gt=0)
complete_upload: bool = False
# LAS 없는 설계(도엽등고선 기반, 2026-08-30) — 완료 판정에서 LAS 필수를 면제한다.
las_free: bool = False
# 파일 지문 — 같은 이름으로 **같은 내용**이 다시 올라오는지 전송 전에 가린다.
# 화면이 파일 크기 + 앞·중간·끝 조각으로 만든다([[fileFingerprint]]).
fingerprint: str | None = Field(default=None, max_length=128)
@@ -96,6 +98,8 @@ class UploadFinalizeRequest(BaseModel):
session_id: str = Field(min_length=1, max_length=36)
total_chunks: int = Field(gt=0)
complete_upload: bool = True
# LAS 없는 설계(도엽등고선 기반, 2026-08-30) — 완료 판정에서 LAS 필수를 면제한다.
las_free: bool = False
# 세션 생성 때 쓴 지문을 그대로 다시 받아 입력 파일에 남긴다. 다음에 같은 파일이
# 올라오면 이 값으로 전송을 건너뛴다(upload_sessions에 컬럼을 더하지 않으려는 선택).
fingerprint: str | None = Field(default=None, max_length=128)
+42 -13
View File
@@ -20,6 +20,7 @@ from config.config_db import get_db_pool
from config.config_system import (
AUTO_DESIGN_CHAIN_ENABLED,
SEND_ANALYSIS_COMPLETION_EMAIL,
SURFACE_CONTOUR_INTERVAL_M,
SURFACE_MODEL_PRECOMPUTE,
SURFACE_MODEL_SOURCE_FILTERS,
)
@@ -79,9 +80,15 @@ async def trigger_wf1_analysis_and_email(
input_file = await get_input_file(connection, project_id, input_file_id)
project_root = Path(resolve_stored_project_path(stored_path))
las_path = project_root / Path(str(input_file["raw_file_path"]))
if not las_path.is_file():
raise FileNotFoundError("원본 LAS/LAZ 파일을 찾을 수 없습니다.")
source_path = project_root / Path(str(input_file["raw_file_path"]))
# LAS 없는 설계(2026-08-30): 입력이 계획노선 CSV면 도엽등고선 서피스 분석으로 간다.
las_free = str(input_file.get("file_type") or "").lower() not in {"las", "laz"}
if not source_path.is_file():
raise FileNotFoundError(
"계획 노선 파일을 찾을 수 없습니다."
if las_free
else "원본 LAS/LAZ 파일을 찾을 수 없습니다."
)
from B04_PreProcess.B04_PreProcess_Engine import run_surface_analysis
from B04_PreProcess.B04_PreProcess_Repository import save_surface_analysis_to_db
@@ -92,15 +99,27 @@ async def trigger_wf1_analysis_and_email(
def _on_progress(percent: int, stage: str, message: str) -> None:
write_surface_progress(project_root, percent, stage, message)
analysis_result = await asyncio.to_thread(
run_surface_analysis,
project_root,
las_path,
source_filters=source_filters,
methods=methods,
force=False,
on_progress=_on_progress,
)
if las_free:
from B04_PreProcess.B04_PreProcess_Engine_SheetSurface import (
run_sheet_surface_analysis,
)
analysis_result = await asyncio.to_thread(
run_sheet_surface_analysis,
project_root,
source_path,
on_progress=_on_progress,
)
else:
analysis_result = await asyncio.to_thread(
run_surface_analysis,
project_root,
source_path,
source_filters=source_filters,
methods=methods,
force=False,
on_progress=_on_progress,
)
auto_confirmation_error: str | None = None
auto_confirmed = False
@@ -124,7 +143,17 @@ async def trigger_wf1_analysis_and_email(
find_surface_model_for_selection,
)
selection = surface_confirmation_defaults()
# LAS 없는 설계는 도엽 서피스 모델(sheet/dtm)로 확정한다.
selection = (
{
"source_filter": "sheet",
"method": "dtm",
"smooth": False,
"contour_interval_m": SURFACE_CONTOUR_INTERVAL_M,
}
if las_free
else surface_confirmation_defaults()
)
try:
model_id = await find_surface_model_for_selection(
connection, project_id, selection
+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();
+23 -2
View File
@@ -277,7 +277,9 @@
color: var(--color-royal-amethyst, #3e0079);
background: var(--color-mist-violet, #edecff);
font-size: var(--text-body-sm, 14px);
margin-right: var(--spacing-8); /* 아이콘 우측 마진 추가 (아이콘 좌측 여유 확대 효과) */
margin-right: var(
--spacing-8
); /* 아이콘 우측 마진 추가 (아이콘 좌측 여유 확대 효과) */
}
.b03-file__card-heading {
@@ -321,7 +323,9 @@
font-size: var(--text-body-sm, 14px);
line-height: 1;
padding: 0;
margin-left: var(--spacing-8); /* 취소 버튼 좌측 여유 추가 (취소 버튼 우측 여유 확보) */
margin-left: var(
--spacing-8
); /* 취소 버튼 좌측 여유 추가 (취소 버튼 우측 여유 확보) */
transition: all var(--transition-base, 0.2s);
}
@@ -474,3 +478,20 @@
grid-template-columns: 1fr; /* 모바일에서는 1행 1열 구조 */
}
}
/* LAS 없는 설계(도엽등고선 기반) 토글 — 2026-08-30 */
.b03-file__lasfree {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 2px;
font-size: var(--text-body);
color: var(--color-text-body);
cursor: pointer;
user-select: none;
}
.b03-file__card--disabled {
opacity: 0.45;
pointer-events: none;
}
+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);
+74 -11
View File
@@ -223,11 +223,64 @@ def run_surface_analysis(
time.monotonic() - step_started,
)
# 3-2. VWorld 지도 및 국가 GIS 벡터 다운로드 (기존 산출물이 있으면 스킵)
# 3-2·3-3. VWorld 지도·국가 GIS 벡터·수치지형도 도엽 (공용 블록 — 도엽 서피스도 사용)
_report(90, "download_maps", "VWorld 지도 및 GIS 벡터 데이터 다운로드 중")
las_bounds_dict = {
"x": [float(bounds[0, 0]), float(bounds[0, 1])],
"y": [float(bounds[1, 0]), float(bounds[1, 1])],
"z": [float(bounds[2, 0]), float(bounds[2, 1])],
}
download_geodata(
project_root, processed_dir, las_bounds_dict, las_path.parent, rebuild, report=_report
)
# 3-4. 도엽등고선 3D 서피스 — LAS가 있어도 참고용으로 같이 만들어 영구저장한다
# (2026-08-30 사용자 확정). 실패해도 분석은 계속한다.
_report(94, "surface_model", "도엽등고선 3D 서피스 생성 중")
sheet_model: dict[str, Any] | None = None
try:
# 입력 LAS와 같은 폴더의 .prj를 우선 사용 (PLAN E-1: 전체 재귀 glob 제거)
prj_candidates = sorted(las_path.parent.glob("*.prj")) or sorted(
from B04_PreProcess.B04_PreProcess_Engine_SheetSurface import (
build_sheet_surface_from_route,
)
sheet_model = build_sheet_surface_from_route(project_root, processed_dir, models_dir)
except Exception as exc:
logger.warning("도엽등고선 서피스 생성 실패: %s", exc)
_report(95, "saving", "결과 저장 중")
return _collect_analysis_result(
project_root,
models_dir,
structured_path,
bounds_dict,
stats,
total_points,
ground_summary,
manifest,
sheet_model,
total_started,
)
def download_geodata(
project_root: Path,
processed_dir: Path,
las_bounds_dict: dict[str, list[float]],
prj_search_dir: Path,
rebuild: bool,
*,
default_epsg: str = "EPSG:5186",
report: Any = None,
) -> None:
"""VWorld 지도·국가 GIS 벡터·수치지형도 도엽 확보 (공용 블록).
LAS 분석(run_surface_analysis)과 LAS 없는 도엽 서피스 분석이 같이 쓴다.
실패해도 예외를 밖으로 던지지 않는다 — 분석 본체를 막지 않는다.
"""
try:
# 입력 파일과 같은 폴더의 .prj를 우선 사용 (PLAN E-1: 전체 재귀 glob 제거)
prj_candidates = sorted(prj_search_dir.glob("*.prj")) or sorted(
project_root.glob("B03_FileInput/**/*.prj")
)
prj_path = prj_candidates[0] if prj_candidates else project_root / "result.prj"
@@ -243,12 +296,7 @@ def run_surface_analysis(
get_epsg_from_prj,
)
las_bounds_dict = {
"x": [float(bounds[0, 0]), float(bounds[0, 1])],
"y": [float(bounds[1, 0]), float(bounds[1, 1])],
"z": [float(bounds[2, 0]), float(bounds[2, 1])],
}
project_epsg = "EPSG:5186"
project_epsg = default_epsg
if prj_path.exists():
project_epsg = get_epsg_from_prj(prj_path.read_text(encoding="utf-8", errors="ignore"))
# 국가 GIS 벡터는 라이다∪계획노선 범위로 받는다.
@@ -305,7 +353,8 @@ def run_surface_analysis(
# 3-3. 1:5,000 수치지형도 도엽 확보 → 프로젝트 영구저장소
# 기준은 계획노선 시점·종점 (같은 도엽이면 9매, 이웃 도엽에 걸치면 12매).
# (실패해도 분석은 계속 — 폴백은 수동 다운로드 + 인제스트)
_report(92, "download_maps", "수치지형도 도엽 확보 중")
if report is not None:
report(92, "download_maps", "수치지형도 도엽 확보 중")
try:
from B04_PreProcess.B04_PreProcess_Engine_Extent import sheet_reference_points_wgs84
from B04_PreProcess.B04_PreProcess_Engine_MapSheet import neighbors_for_points
@@ -356,8 +405,20 @@ def run_surface_analysis(
except Exception as exc:
logger.warning("B04 지도·GIS 다운로드 단계 실패: %s", exc)
_report(95, "saving", "결과 저장 중")
def _collect_analysis_result(
project_root: Path,
models_dir: Path,
structured_path: Path,
bounds_dict: dict[str, float],
stats: dict[str, Any],
total_points: int,
ground_summary: dict[str, Any],
manifest: dict[str, Any],
sheet_model: dict[str, Any] | None,
total_started: float,
) -> dict[str, Any]:
"""manifest에서 모델 목록을 추려 분석 결과 dict를 조립한다."""
processed = {
"processed_file_path": _relative_to_project(project_root, structured_path),
"converted_file_path": None,
@@ -403,6 +464,8 @@ def run_surface_analysis(
"layers": layers,
}
)
if sheet_model is not None:
models.append(sheet_model)
logger.info(
"B04 WF1 분석 완료: 모델 %d개, 총 %.1fs", len(models), time.monotonic() - total_started
@@ -0,0 +1,299 @@
"""도엽등고선 3D 서피스 — 1:5,000 수치지형도 등고선으로 DTM 격자를 만든다.
LAS 없는 설계(2026-08-30 사용자 확정)의 지형 원천이자, LAS가 있어도 참고용으로
같이 만들어 두는 서피스다. 산출 형식은 LAS 파이프라인의 DTM과 완전히 같게 맞춘다
(`dtm_sheet.npz`: x/y/z/valid_mask) — 종·횡단·배수 세부설계가 쓰는
`build_surface_sampler(models_dir, "sheet", "dtm", smooth=False)`가 무수정으로 돈다.
절취 범위는 노선 XY bbox + `SHEET_SURFACE_MARGIN_M`(300m) 직사각형(사용자 확정),
격자는 `SHEET_SURFACE_GRID_M`(1m). 등고선→정점 구름→Delaunay TIN 보간은 배수유역
엔진(`_Watershed_Grid`)의 검증된 경로를 그대로 쓴다.
"""
import json
import logging
import time
from pathlib import Path
from typing import Any
import numpy as np
from pyproj import Transformer
from B04_PreProcess.B04_PreProcess_Engine_ModelContext import (
atomic_npz,
clip_and_compact_mesh,
grid_faces,
grid_vertices,
write_glb,
)
from B04_PreProcess.B04_PreProcess_Engine_Watershed_Grid import (
build_contour_cloud,
grid_spec_from_bounds,
interpolate_elevation,
)
from config.config_system import (
SHEET_SURFACE_GRID_M,
SHEET_SURFACE_MARGIN_M,
SURFACE_MAX_PREVIEW_VERTICES,
)
logger = logging.getLogger(__name__)
# 도엽 병합 산출물 파일명 (B04_PreProcess_Router_Watershed와 같은 값)
_CONTOUR_FILE = "도엽_등고선.geojson"
# 산출 모델 식별자 — surface_models.generation_params.source_filter 및 파일 stem에 쓴다.
SHEET_SOURCE_FILTER = "sheet"
def _load_contour_features_metric(processed_dir: Path, epsg: int) -> list[dict[str, Any]]:
"""병합 도엽 등고선(WGS84)을 읽어 사업지 CRS(m)로 재투영한다."""
path = processed_dir / _CONTOUR_FILE
if not path.exists():
logger.warning("도엽 서피스: 등고선 파일이 없습니다: %s", path)
return []
try:
with path.open("r", encoding="utf-8") as file:
data = json.load(file)
except (OSError, json.JSONDecodeError):
logger.warning("도엽 서피스: 등고선 GeoJSON을 읽지 못했습니다: %s", path)
return []
features = data.get("features")
if not isinstance(features, list):
return []
transformer = Transformer.from_crs("EPSG:4326", f"EPSG:{epsg}", always_xy=True)
def _map(coords: Any) -> Any:
if not isinstance(coords, list):
return coords
if coords and isinstance(coords[0], (int, float)):
x, y = transformer.transform(coords[0], coords[1])
return [x, y, *coords[2:]]
return [_map(item) for item in coords]
converted: list[dict[str, Any]] = []
for feature in features:
geometry = feature.get("geometry") or {}
coordinates = _map(geometry.get("coordinates"))
if coordinates is None:
continue
converted.append(
{
"type": "Feature",
"properties": feature.get("properties") or {},
"geometry": {"type": geometry.get("type"), "coordinates": coordinates},
}
)
return converted
def _preview_mesh(
x: np.ndarray, y: np.ndarray, z: np.ndarray, valid: np.ndarray
) -> tuple[np.ndarray, np.ndarray]:
"""프리뷰용 정점·면 — 정점 수가 상한을 넘으면 격자를 성기게 딴다."""
stride = 1
while (len(x) // stride + 1) * (len(y) // stride + 1) > SURFACE_MAX_PREVIEW_VERTICES:
stride += 1
px, py = x[::stride], y[::stride]
pz, pv = z[::stride, ::stride], valid[::stride, ::stride]
vertices = grid_vertices(px, py, pz.astype(np.float64))
faces = grid_faces(len(py), len(px))
return clip_and_compact_mesh(vertices, faces, pv.reshape(-1))
def build_sheet_surface_model(
project_root: Path,
processed_dir: Path,
models_dir: Path,
route_xy: np.ndarray,
epsg: int,
) -> dict[str, Any] | None:
"""도엽등고선으로 DTM npz·프리뷰 glb를 만들고 surface_models 등록용 dict를 돌려준다.
`route_xy`: (N, 2) 노선 정점 XY(사업지 CRS, m). 실패하면 None — 호출측은
분석을 계속한다(도엽 미확보 지역 폴백).
"""
started = time.monotonic()
features = _load_contour_features_metric(processed_dir, epsg)
if not features:
return None
x_min = float(np.min(route_xy[:, 0])) - SHEET_SURFACE_MARGIN_M
x_max = float(np.max(route_xy[:, 0])) + SHEET_SURFACE_MARGIN_M
y_min = float(np.min(route_xy[:, 1])) - SHEET_SURFACE_MARGIN_M
y_max = float(np.max(route_xy[:, 1])) + SHEET_SURFACE_MARGIN_M
# 저지대 제거(floor) 없이 전부 쓴다 — 종·횡단은 낮은 지반도 필요하다.
cloud = build_contour_cloud(features, None, (x_min, y_min, x_max, y_max))
if cloud.is_empty:
logger.warning("도엽 서피스: 절취 범위 안에 등고선 정점이 없습니다.")
return None
spec = grid_spec_from_bounds(x_min, y_min, x_max, y_max, SHEET_SURFACE_GRID_M)
surface = interpolate_elevation(spec, cloud) # (R, C), 북→남 행 순서, 외부 NaN
# DtmGridSampler 규약에 맞춰 y 오름차순으로 뒤집어 저장한다.
x_coords = spec.cell_centers_x()
y_coords = spec.cell_centers_y()[::-1]
z_grid = surface[::-1, :].astype(np.float32)
valid_grid = np.isfinite(z_grid)
if not valid_grid.any():
logger.warning("도엽 서피스: 유효 표고 셀이 없습니다.")
return None
stem = f"dtm_{SHEET_SOURCE_FILTER}"
model_path = models_dir / f"{stem}.npz"
preview_path = models_dir / f"{stem}_preview.glb"
atomic_npz(
model_path,
x=x_coords,
y=y_coords,
z=z_grid,
valid_mask=valid_grid,
resolution=np.array([SHEET_SURFACE_GRID_M], np.float32),
)
vertices, faces = _preview_mesh(x_coords, y_coords, z_grid, valid_grid)
finite_z = z_grid[valid_grid]
bounds = np.array(
[
[x_coords[0], x_coords[-1]],
[y_coords[0], y_coords[-1]],
[float(finite_z.min()), float(finite_z.max())],
]
)
write_glb(preview_path, vertices, faces, bounds)
logger.info(
"도엽 서피스 생성 완료: %d×%d 격자, 정점 %d개 (%.1fs)",
spec.n_rows,
spec.n_cols,
cloud.xy.shape[0],
time.monotonic() - started,
)
return {
"model_type": "dtm",
"source_filter": SHEET_SOURCE_FILTER,
"representation": "regular_grid",
"model_file_path": str(model_path.relative_to(project_root)).replace("\\", "/"),
"resolution_m": SHEET_SURFACE_GRID_M,
"generation_params": {
"source_filter": SHEET_SOURCE_FILTER,
"representation": "regular_grid",
"source": "map_sheet_contours",
"margin_m": SHEET_SURFACE_MARGIN_M,
},
"layers": [
{
"layer_name": f"dtm_{SHEET_SOURCE_FILTER}_preview",
"geometry_type": "MESH",
"file_path": str(preview_path.relative_to(project_root)).replace("\\", "/"),
"file_format": "glb",
}
],
}
def build_sheet_surface_from_route(
project_root: Path, processed_dir: Path, models_dir: Path
) -> dict[str, Any] | None:
"""B03 업로드 계획노선 CSV를 찾아 도엽 서피스를 만든다. 없거나 실패하면 None."""
from common_util.common_util_route_geometry import (
find_planned_route_file,
read_planned_route_csv,
)
route_file = find_planned_route_file(project_root / "B03_FileInput" / "input")
if route_file is None:
logger.warning("도엽 서피스: 계획 노선 파일이 없습니다.")
return None
planned = read_planned_route_csv(route_file)
if planned is None or len(planned.vertices) < 2:
logger.warning("도엽 서피스: 계획 노선 파일을 읽지 못했습니다: %s", route_file.name)
return None
route_xy = np.array([(v.x, v.y) for v in planned.vertices], dtype=np.float64)
return build_sheet_surface_model(
project_root, processed_dir, models_dir, route_xy, planned.epsg or 5186
)
def run_sheet_surface_analysis(
project_root: Path,
route_csv_path: Path,
*,
on_progress: Any = None,
) -> dict[str, Any]:
"""LAS 없는 WF1 — 도엽 확보 후 도엽등고선 서피스만으로 분석 결과를 만든다.
반환 형식은 `run_surface_analysis()`와 같다(save_surface_analysis_to_db 호환).
"""
from common_util.common_util_route_geometry import read_planned_route_csv
def _report(percent: int, stage: str, message: str) -> None:
if on_progress is not None:
on_progress(percent, stage, message)
stage_root = project_root / "B04_PreProcess"
processed_dir = stage_root / "processed"
models_dir = stage_root / "models"
processed_dir.mkdir(parents=True, exist_ok=True)
models_dir.mkdir(parents=True, exist_ok=True)
planned = read_planned_route_csv(route_csv_path)
if planned is None or len(planned.vertices) < 2:
raise ValueError(f"계획 노선 파일을 읽지 못했습니다: {route_csv_path.name}")
epsg = planned.epsg or 5186
route_xy = np.array([(v.x, v.y) for v in planned.vertices], dtype=np.float64)
bounds_dict = {
"x": [float(route_xy[:, 0].min()), float(route_xy[:, 0].max())],
"y": [float(route_xy[:, 1].min()), float(route_xy[:, 1].max())],
"z": [
float(min(v.z for v in planned.vertices)),
float(max(v.z for v in planned.vertices)),
],
}
# VWorld 지도·GIS 벡터·도엽 확보 — LAS 경로와 같은 공용 블록 (지연 import로 순환 회피)
_report(30, "download_maps", "VWorld 지도 및 수치지형도 도엽 확보 중")
from B04_PreProcess.B04_PreProcess_Engine import download_geodata
download_geodata(
project_root,
processed_dir,
bounds_dict,
route_csv_path.parent,
rebuild=False,
default_epsg=f"EPSG:{epsg}",
report=_report,
)
_report(70, "surface_model", "도엽등고선 3D 서피스 생성 중")
model = build_sheet_surface_model(project_root, processed_dir, models_dir, route_xy, epsg)
if model is None:
raise ValueError("도엽등고선으로 지표면을 만들지 못했습니다 — 도엽 확보를 확인하세요.")
_report(95, "saving", "결과 저장 중")
return {
"processed": {
"processed_file_path": str(
(processed_dir / _CONTOUR_FILE).relative_to(project_root)
).replace("\\", "/"),
"converted_file_path": None,
"point_count": int(len(route_xy)),
"bounds": {
"x_min": bounds_dict["x"][0],
"x_max": bounds_dict["x"][1],
"y_min": bounds_dict["y"][0],
"y_max": bounds_dict["y"][1],
},
"statistics": {
"min_z": bounds_dict["z"][0],
"max_z": bounds_dict["z"][1],
"mean_z": None,
},
},
"ground_summary": {},
"manifest": {"status": "sheet_only"},
"models": [model],
}
+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", () => {
@@ -793,3 +793,14 @@
grid-template-columns: 1fr;
}
}
/* 도엽등고 3D 서피스 컨테이너 — 2026-08-30 */
.b04-surface__sheet-section {
margin: 0 var(--spacing-24) var(--spacing-16);
padding: var(--spacing-16);
box-sizing: border-box;
}
.b04-surface__sheet-section > .terrain-model-group {
margin-top: var(--spacing-12);
}
+6
View File
@@ -183,6 +183,12 @@ SURFACE_SMOOTHING_TIN_TAUBIN_MU = float(os.getenv("SURFACE_SMOOTHING_TIN_TAUBIN_
SURFACE_CONTOUR_INTERVAL_M = float(os.getenv("SURFACE_CONTOUR_INTERVAL_M", "1.0"))
SURFACE_CONTOUR_GRID_RESOLUTION_M = float(os.getenv("SURFACE_CONTOUR_GRID_RESOLUTION_M", "1.0"))
# 도엽등고선 3D 서피스 (LAS 없는 설계 — 2026-08-30 사용자 확정)
# 노선 XY bbox에 더하는 절취 여유(m). 300m = 횡단·코리도·성토면 여유(사용자 확정값).
SHEET_SURFACE_MARGIN_M = float(os.getenv("SHEET_SURFACE_MARGIN_M", "300.0"))
# 도엽등고선 DTM 격자 한 변(m). LAS DTM·등고선 캐시와 같은 1m(사용자 확정값).
SHEET_SURFACE_GRID_M = float(os.getenv("SHEET_SURFACE_GRID_M", "1.0"))
# 일반 사용자 WF1 자동 확정 기본값
SURFACE_CONFIRM_DEFAULT_FILTER = os.getenv("SURFACE_CONFIRM_DEFAULT_FILTER", "csf")
SURFACE_CONFIRM_DEFAULT_METHOD = os.getenv("SURFACE_CONFIRM_DEFAULT_METHOD", "dtm")
+172 -40
View File
@@ -29,16 +29,31 @@ export const ui_locales_b1 = {
B01_Account_Field_Name: ["이름", "Name"],
B01_Account_Field_Email: ["이메일", "Email"],
B01_Account_Field_Phone: ["연락처", "Phone"],
B01_Account_Field_Phone_Placeholder: ["연락처를 입력하세요", "Enter phone number"],
B01_Account_Field_Phone_Placeholder: [
"연락처를 입력하세요",
"Enter phone number",
],
B01_Account_Field_CurrentPw: ["현재 비밀번호", "Current password"],
B01_Account_Field_NewPw: ["새 비밀번호", "New password"],
B01_Account_Field_ConfirmPw: ["새 비밀번호 확인", "Confirm new password"],
B01_Account_Save_Profile: ["기본 정보 저장", "Save profile"],
B01_Account_Save_Password: ["비밀번호 변경", "Change password"],
B01_Account_Success_Profile: ["기본 정보가 저장되었습니다.", "Profile has been saved."],
B01_Account_Success_Password: ["비밀번호가 변경되었습니다.", "Password has been changed."],
B01_Account_Error_Required: ["필수 항목을 입력하세요.", "Please fill in required fields."],
B01_Account_Error_PwMismatch: ["새 비밀번호가 일치하지 않습니다.", "New passwords do not match."],
B01_Account_Success_Profile: [
"기본 정보가 저장되었습니다.",
"Profile has been saved.",
],
B01_Account_Success_Password: [
"비밀번호가 변경되었습니다.",
"Password has been changed.",
],
B01_Account_Error_Required: [
"필수 항목을 입력하세요.",
"Please fill in required fields.",
],
B01_Account_Error_PwMismatch: [
"새 비밀번호가 일치하지 않습니다.",
"New passwords do not match.",
],
B01_Account_Error_PwLength: [
"비밀번호는 8자 이상이어야 합니다.",
"Password must be at least 8 characters.",
@@ -79,8 +94,14 @@ export const ui_locales_b1 = {
/* --- B01 임시 보관함 (프로젝트 생성 전 업로드, 2026-08-08) --- */
B01_Temp_Section: ["임시 보관함", "Temporary storage"],
B01_Temp_Field_Name: ["보관 이름", "Storage name"],
B01_Temp_Field_Name_Placeholder: ["예: 2026년 3공구 측량자료", "e.g. 2026 Section 3 survey"],
B01_Temp_Field_Files: ["파일 선택 (계획노선·라이다·좌표계·래스터)", "Select files"],
B01_Temp_Field_Name_Placeholder: [
"예: 2026년 3공구 측량자료",
"e.g. 2026 Section 3 survey",
],
B01_Temp_Field_Files: [
"파일 선택 (계획노선·라이다·좌표계·래스터)",
"Select files",
],
B01_Temp_Btn_Pick: ["파일 선택", "Choose files"],
B01_Temp_Btn_Add: ["파일 추가", "Add files"],
B01_Temp_Modal_Create: ["임시 자료 등록", "New stored set"],
@@ -100,7 +121,10 @@ export const ui_locales_b1 = {
"Delete this file from temporary storage?",
],
B01_Temp_File_Delete_Success: ["파일을 삭제했습니다.", "File deleted."],
B01_Temp_File_Delete_Failed: ["파일 삭제에 실패했습니다.", "Failed to delete the file."],
B01_Temp_File_Delete_Failed: [
"파일 삭제에 실패했습니다.",
"Failed to delete the file.",
],
/* 보관 기간은 섹션 제목 옆 태그로만 알린다(안내 문단 폐기, 2026-08-08). */
B01_Temp_Hint_Days: ["일 보관", " days retained"],
B01_Temp_Status_Uploading: ["업로드 중", "Uploading"],
@@ -111,9 +135,18 @@ export const ui_locales_b1 = {
B01_Temp_Meta_Linked: ["프로젝트로 이동 완료", "Moved to project"],
B01_Temp_Error_Name: ["보관 이름을 입력하세요.", "Enter a storage name."],
B01_Temp_Error_Files: ["올릴 파일을 선택하세요.", "Select files to upload."],
B01_Temp_Upload_Success: ["보관함에 저장했습니다.", "Saved to temporary storage."],
B01_Temp_Upload_Failed: ["보관함 업로드에 실패했습니다.", "Failed to upload."],
B01_Temp_Load_Failed: ["보관함을 불러오지 못했습니다.", "Failed to load storage."],
B01_Temp_Upload_Success: [
"보관함에 저장했습니다.",
"Saved to temporary storage.",
],
B01_Temp_Upload_Failed: [
"보관함 업로드에 실패했습니다.",
"Failed to upload.",
],
B01_Temp_Load_Failed: [
"보관함을 불러오지 못했습니다.",
"Failed to load storage.",
],
B01_Temp_Delete_Confirm: [
"이 보관 자료를 삭제할까요? 되돌릴 수 없습니다.",
"Delete this stored set? This cannot be undone.",
@@ -152,7 +185,10 @@ export const ui_locales_b1 = {
B01_Dashboard_Modal_FindCompany: ["회사 검색", "Find company"],
B01_Dashboard_Modal_AddMember: ["팀원 추가", "Add member"],
B01_Dashboard_Saved: ["저장되었습니다.", "Saved."],
B01_Dashboard_LoadFailed: ["대시보드를 불러오지 못했습니다.", "Failed to load dashboard."],
B01_Dashboard_LoadFailed: [
"대시보드를 불러오지 못했습니다.",
"Failed to load dashboard.",
],
B01_Dashboard_RequestFailed: ["요청 처리에 실패했습니다.", "Request failed."],
// 프로젝트 관리
@@ -164,7 +200,10 @@ export const ui_locales_b1 = {
B01_Dashboard_EditUser: ["사용자 수정", "Edit User"],
B01_Dashboard_DeleteUser: ["사용자 삭제", "Delete User"],
B01_Dashboard_ChangeRole: ["역할 변경", "Change Role"],
B01_Dashboard_SelectAvailableUsers: ["사용 가능한 사용자 선택", "Select Available Users"],
B01_Dashboard_SelectAvailableUsers: [
"사용 가능한 사용자 선택",
"Select Available Users",
],
// 확인 메시지
B01_Dashboard_Confirm_DeleteProject: [
@@ -176,7 +215,10 @@ export const ui_locales_b1 = {
"[하드 삭제 모드] 업로드한 라이다 원본과 모든 계산 결과가 서버에서 영구 삭제됩니다. 복구할 수 없습니다. 삭제하시겠습니까?",
"[Hard delete mode] The uploaded LiDAR source and every computed result will be permanently erased from the server. This cannot be recovered. Delete anyway?",
],
B01_Dashboard_Confirm_DeleteUser: ["사용자를 삭제하시겠습니까?", "Delete this user?"],
B01_Dashboard_Confirm_DeleteUser: [
"사용자를 삭제하시겠습니까?",
"Delete this user?",
],
B01_Dashboard_Confirm_LastAdmin: [
"회사의 유일한 관리자는 삭제할 수 없습니다.",
"Cannot delete the last admin of the company.",
@@ -207,15 +249,24 @@ export const ui_locales_b1 = {
B02_Proj_RoadType_Work: ["작업임도", "Work forest road"],
B02_Proj_Field_Year: ["사업 연도", "Project year"],
B02_Proj_Field_Length: ["예상 연장 (m)", "Estimated length (m)"],
B02_Proj_Field_Length_Placeholder: ["예상 노선 길이", "Estimated route length"],
B02_Proj_Field_Length_Placeholder: [
"예상 노선 길이",
"Estimated route length",
],
B02_Proj_Field_Memo: ["비고", "Notes"],
B02_Proj_Field_Memo_Placeholder: ["추가 메모 (선택)", "Additional notes (optional)"],
B02_Proj_Field_Memo_Placeholder: [
"추가 메모 (선택)",
"Additional notes (optional)",
],
B02_Proj_Submit: ["프로젝트 생성", "Create project"],
B02_Proj_Success: [
"프로젝트가 생성되었습니다. 파일 입력 단계로 이동합니다.",
"Project created. Moving to the file input step.",
],
B02_Proj_Error_Required: ["필수 항목을 입력하세요.", "Please fill in required fields."],
B02_Proj_Error_Required: [
"필수 항목을 입력하세요.",
"Please fill in required fields.",
],
/* --- B03_FileInput 파일 입력 --- */
B03_File_Title: ["파일입력", "File Input"],
@@ -236,7 +287,10 @@ export const ui_locales_b1 = {
"현재 프로젝트가 선택되지 않았습니다. 프로젝트를 먼저 생성하거나 선택하세요.",
"No current project is selected. Create or select a project first.",
],
B03_File_Error_Required: ["업로드할 파일을 선택하세요.", "Select files to upload."],
B03_File_Error_Required: [
"업로드할 파일을 선택하세요.",
"Select files to upload.",
],
B03_File_Error_Count: [
"한 번에 업로드할 수 있는 파일 수를 초과했습니다.",
"Too many files were selected for one upload.",
@@ -245,10 +299,22 @@ export const ui_locales_b1 = {
"LAS 또는 LAZ 파일을 정확히 1개 선택하세요.",
"Select exactly one LAS or LAZ file.",
],
B03_File_Error_Extension: ["허용되지 않은 파일 형식입니다.", "Unsupported file type."],
B03_File_Error_Size: ["파일 크기 제한을 초과했습니다.", "File size limit exceeded."],
B03_File_Upload_Success: ["입력 파일 업로드를 완료했습니다.", "Input files uploaded."],
B03_File_Upload_Failed: ["파일 업로드에 실패했습니다.", "File upload failed."],
B03_File_Error_Extension: [
"허용되지 않은 파일 형식입니다.",
"Unsupported file type.",
],
B03_File_Error_Size: [
"파일 크기 제한을 초과했습니다.",
"File size limit exceeded.",
],
B03_File_Upload_Success: [
"입력 파일 업로드를 완료했습니다.",
"Input files uploaded.",
],
B03_File_Upload_Failed: [
"파일 업로드에 실패했습니다.",
"File upload failed.",
],
B03_File_Analysis_InProgress: [
"WF1 분석이 백그라운드에서 진행 중입니다. 완료되면 자동으로 이동합니다.",
"WF1 analysis is running in the background. You will move automatically when it completes.",
@@ -264,12 +330,23 @@ export const ui_locales_b1 = {
B03_File_Group_Inputs: ["입력 자료", "Input files"],
B03_File_Slot_PlannedRoute: ["계획노선 좌표", "Planned Route Coordinates"],
B03_File_Slot_PointCloud: ["포인트클라우드", "Point Cloud"],
B03_File_LasFree_Toggle: [
"LAS 없이 설계 (도엽등고선 기반)",
"Design without LAS (map sheet contours)",
],
B03_File_LasFree_Hint: [
"포인트클라우드 없이 1:5,000 수치지형도 등고선으로 지형을 만듭니다.",
"Terrain is built from 1:5,000 map sheet contours without a point cloud.",
],
B03_File_Slot_Projection: ["좌표계 정의", "Projection"],
B03_File_Slot_RasterCoord: ["래스터 좌표", "Raster Coord 1"],
B03_File_Slot_TerrainDem: ["지형 래스터", "Terrain DEM"],
B03_File_Slot_CadDrawing: ["CAD 도면", "CAD Drawing"],
/* --- B03 임시 보관함 불러오기 (2026-08-08) --- */
B03_Temp_Btn_Open: ["임시 보관함에서 불러오기", "Load from temporary storage"],
B03_Temp_Btn_Open: [
"임시 보관함에서 불러오기",
"Load from temporary storage",
],
B03_Temp_None: ["선택된 보관 자료 없음", "No stored set selected"],
B03_Temp_Selected: ["선택됨:", "Selected:"],
B03_Temp_FileCount: ["개 파일", " files"],
@@ -279,12 +356,18 @@ export const ui_locales_b1 = {
"No usable stored set. Upload all required files in the dashboard temporary storage first.",
],
B03_Temp_Select_Required: ["보관 자료를 선택하세요.", "Select a stored set."],
B03_Temp_Load_Failed: ["보관 자료를 불러오지 못했습니다.", "Failed to load stored sets."],
B03_Temp_Load_Failed: [
"보관 자료를 불러오지 못했습니다.",
"Failed to load stored sets.",
],
B03_Temp_Attach_Success: [
"보관 자료를 프로젝트로 옮겼습니다. 분석을 시작합니다.",
"Stored files moved to the project. Analysis started.",
],
B03_Temp_Attach_Failed: ["보관 자료 연결에 실패했습니다.", "Failed to attach stored files."],
B03_Temp_Attach_Failed: [
"보관 자료 연결에 실패했습니다.",
"Failed to attach stored files.",
],
B03_Temp_Attach_NoAnalysis: [
"파일은 옮겼지만 라이다 파일이 없어 분석을 시작하지 못했습니다.",
"Files moved, but analysis did not start (no point cloud file).",
@@ -313,7 +396,10 @@ export const ui_locales_b1 = {
B03_File_Status_Completed: ["완료", "Completed"],
B03_File_Status_Failed: ["실패", "Failed"],
B03_File_Status_Detected: ["중단된 업로드 감지", "Paused upload detected"],
B03_File_Restore_State: ["저장된 업로드/분석 상태 복구", "Restored upload/analysis state"],
B03_File_Restore_State: [
"저장된 업로드/분석 상태 복구",
"Restored upload/analysis state",
],
B03_File_Resume_Button: ["업로드 재개", "Resume upload"],
B03_File_New_Button: ["새 파일로 시작", "Start new file"],
B03_File_Overview_Complete: [
@@ -350,6 +436,10 @@ export const ui_locales_b1 = {
B04_Surface_Group_Filters: ["지면 필터", "Ground filter"],
B04_Surface_Group_Methods: ["서피스", "Surface"],
B04_Surface_Group_Display: ["모델 표시 옵션", "Model display options"],
B04_Surface_SheetSurface: [
"도엽등고 3D 서피스",
"Map-sheet contour 3D surface",
],
B04_Surface_Group_ViewControls: ["뷰어 시점 제어", "Viewer camera"],
B04_Surface_Field_Smoothing: ["스무딩", "Smoothing"],
B04_Surface_Smoothing_On: ["적용", "On"],
@@ -371,10 +461,16 @@ export const ui_locales_b1 = {
B04_Surface_Input_FileName: ["파일명", "File name"],
B04_Surface_Input_Crs: ["좌표계", "CRS"],
B04_Surface_Input_Size: ["크기(MB)", "Size (MB)"],
B04_Surface_PointCloud_Title: ["포인트클라우드 미리보기", "Point cloud preview"],
B04_Surface_PointCloud_Title: [
"포인트클라우드 미리보기",
"Point cloud preview",
],
B04_Surface_Status_Unknown: ["상태 미확인", "Unknown"],
B04_Surface_GroundStats_Title: ["지면 필터 통계", "Ground filter stats"],
B04_Surface_GroundStats_Empty: ["표시할 지면 통계가 없습니다.", "No ground stats to display."],
B04_Surface_GroundStats_Empty: [
"표시할 지면 통계가 없습니다.",
"No ground stats to display.",
],
B04_Surface_GroundStats_Filter: ["필터", "Filter"],
B04_Surface_GroundStats_SourcePoints: ["지면 포인트", "Ground points"],
B04_Surface_Result_Title: ["생성된 지표면 모델", "Generated Surface Models"],
@@ -392,8 +488,14 @@ export const ui_locales_b1 = {
"모델을 확정했습니다. 필터: {filter}, 기법: {method}, 스무딩/표현: {smoothing}",
"Model confirmed. Filter: {filter}, method: {method}, smoothing/representation: {smoothing}",
],
B04_Surface_Confirm_Failed: ["모델 확정에 실패했습니다.", "Failed to confirm model."],
B04_Surface_Map_Title: ["2D 배경 지도 및 GIS 레이어", "2D Basemap and GIS Layers"],
B04_Surface_Confirm_Failed: [
"모델 확정에 실패했습니다.",
"Failed to confirm model.",
],
B04_Surface_Map_Title: [
"2D 배경 지도 및 GIS 레이어",
"2D Basemap and GIS Layers",
],
B04_Surface_Map_Background: ["배경 지도", "Basemap"],
B04_Surface_Map_GisLayer: ["국가 GIS 레이어", "National GIS Layer"],
B04_Surface_Map_None: ["없음", "None"],
@@ -424,14 +526,20 @@ export const ui_locales_b1 = {
"Failed to load the drainage analysis.",
],
/* {message}=원인 */
B04_Surface_Watershed_Failed: ["유역 분석 실패: {message}", "Basin analysis failed: {message}"],
B04_Surface_Watershed_Failed: [
"유역 분석 실패: {message}",
"Basin analysis failed: {message}",
],
B04_Surface_Watershed_NoSaved: [
"저장된 배수유역 분석이 없습니다. [유역 분석]을 누르세요.",
"No stored drainage analysis. Press [Basin analysis].",
],
B04_Surface_Watershed_Origin_Cached: ["저장분", "Cached"],
/* {seconds}=재산정에 걸린 시간(초) */
B04_Surface_Watershed_Origin_Recomputed: ["재산정 {seconds}초", "Recomputed in {seconds}s"],
B04_Surface_Watershed_Origin_Recomputed: [
"재산정 {seconds}초",
"Recomputed in {seconds}s",
],
/* 도로 유입 흐름 강도 */
B04_Surface_Flow_Strength: ["흐름 강도", "Flow strength"],
B04_Surface_Flow_Strength_Tip: [
@@ -444,7 +552,10 @@ export const ui_locales_b1 = {
"노선 위에서 물이 특히 많이 모이는 자리(유입 집중점)를 마커로 표시합니다. 마커를 누르면 그 지점으로 들어오는 셀들의 외곽선을 보여 줍니다.",
"Marks the spots along the route that collect the most water. Click a marker to outline the cells draining into it.",
],
B04_Surface_Flow_Inflow_Loading: ["유입 셀을 불러오는 중…", "Loading the contributing cells…"],
B04_Surface_Flow_Inflow_Loading: [
"유입 셀을 불러오는 중…",
"Loading the contributing cells…",
],
/* {index}=마커 번호, {chainage}=누가거리, {area}=유입면적, {cells}=셀 수, {path}=최장 유하장 */
B04_Surface_Flow_Inflow_Summary: [
"유입 집중점 {index} · 측점 {chainage}m — 유입면적 {area} · 셀 {cells}개 · 최장 유하장 {path}m",
@@ -516,16 +627,37 @@ export const ui_locales_b1 = {
"배경 지도 또는 GIS 레이어를 선택하세요.",
"Select a basemap or GIS layer.",
],
B04_Surface_Map_Loading: ["지도 레이어를 불러오는 중입니다.", "Loading map layers."],
B04_Surface_Map_Loading: [
"지도 레이어를 불러오는 중입니다.",
"Loading map layers.",
],
B04_Surface_Map_Features: ["{count}개 객체 표시", "Showing {count} features"],
B04_Surface_Map_LoadFailed: ["지도 레이어를 불러오지 못했습니다.", "Failed to load map."],
B04_Surface_Error_Project: ["먼저 프로젝트를 선택하세요.", "Select a project first."],
B04_Surface_Error_InputId: ["유효한 입력 파일 ID를 입력하세요.", "Enter a valid input file ID."],
B04_Surface_Map_LoadFailed: [
"지도 레이어를 불러오지 못했습니다.",
"Failed to load map.",
],
B04_Surface_Error_Project: [
"먼저 프로젝트를 선택하세요.",
"Select a project first.",
],
B04_Surface_Error_InputId: [
"유효한 입력 파일 ID를 입력하세요.",
"Enter a valid input file ID.",
],
B04_Surface_Error_Selection: [
"지면 필터와 지표면 표현을 각각 1개 이상 선택하세요.",
"Select at least one filter and one method.",
],
B04_Surface_Analyze_Success: ["지표면 분석을 완료했습니다.", "Surface analysis complete."],
B04_Surface_Analyze_Failed: ["지표면 분석에 실패했습니다.", "Surface analysis failed."],
B04_Surface_Load_Failed: ["모델 목록을 불러오지 못했습니다.", "Failed to load models."],
B04_Surface_Analyze_Success: [
"지표면 분석을 완료했습니다.",
"Surface analysis complete.",
],
B04_Surface_Analyze_Failed: [
"지표면 분석에 실패했습니다.",
"Surface analysis failed.",
],
B04_Surface_Load_Failed: [
"모델 목록을 불러오지 못했습니다.",
"Failed to load models.",
],
} as const satisfies Record<string, LocaleEntry>;