feat(B03·B04): 지형 라이다 여러 장 입력·병합 전처리
- 지형 파일 개수 제한 해제(정확히 1개 → 1장 이상), 한 카드에 여러 장 담기 (화면 표시 「용화_서편.las 외 1장」, 업로드는 한 장씩 차례로 전송) - 구조화 엔진이 여러 파일을 합친 범위로 한 벌 생성 — WF1 자동 전처리·B04 재분석 모두 프로젝트 지형 파일 전부를 대상으로 실행 - 점이 1억 개를 넘으면 씨닝 — 지면 분류점은 전부 남기고 나머지만 0.5m 칸 최저점으로 축소 (용화 실측: 4,900만점 → 249만점, 지면점 1,140,716개 그대로, 1m 지면격자 표고차 0.0000m) - 머리글만 읽어 5km 넘게 떨어진 파일은 업로드 거부 (임도는 길어도 2~3km) - 화면 조립부 700줄 준수를 위해 terrainCoverage 를 판정 모듈로 이동 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -79,11 +79,18 @@ async def trigger_wf1_analysis_and_email(
|
||||
await connection.commit()
|
||||
stored_path = await get_project_storage_relative_path(connection, project_id)
|
||||
project_info = await _get_project_notification_info(connection, project_id)
|
||||
from B04_PreProcess.B04_PreProcess_Repository import get_input_file
|
||||
from B04_PreProcess.B04_PreProcess_Repository import (
|
||||
get_input_file,
|
||||
list_project_point_cloud_paths,
|
||||
)
|
||||
|
||||
input_file = await get_input_file(connection, project_id, input_file_id)
|
||||
project_root = Path(resolve_stored_project_path(stored_path))
|
||||
# 지형 파일이 여러 장이면 합쳐서 한 벌로 전처리한다(2026-09-06 사용자 확정).
|
||||
terrain_paths = await list_project_point_cloud_paths(
|
||||
connection, project_id, project_root
|
||||
)
|
||||
|
||||
project_root = Path(resolve_stored_project_path(stored_path))
|
||||
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"}
|
||||
@@ -118,7 +125,7 @@ async def trigger_wf1_analysis_and_email(
|
||||
analysis_result = await asyncio.to_thread(
|
||||
run_surface_analysis,
|
||||
project_root,
|
||||
source_path,
|
||||
terrain_paths or [source_path],
|
||||
source_filters=None,
|
||||
methods=methods,
|
||||
force=False,
|
||||
|
||||
@@ -24,15 +24,11 @@ import { createUploadFlow } from "./B03_FileInput_UI_Page_Flow";
|
||||
import {
|
||||
isSlotRequired,
|
||||
slotForOverviewFile,
|
||||
terrainCoverage,
|
||||
validateFileForSlot,
|
||||
validateSlots,
|
||||
} from "./B03_FileInput_UI_Page_Rules";
|
||||
import {
|
||||
readCrsLabel,
|
||||
readExtent,
|
||||
renderSlotPreview,
|
||||
type PreviewExtent,
|
||||
} from "./B03_FileInput_UI_Preview";
|
||||
import { readCrsLabel, renderSlotPreview } from "./B03_FileInput_UI_Preview";
|
||||
import { confirmReplaceUpload } from "./B03_FileInput_UI_Upload";
|
||||
import {
|
||||
createFileCardTemplate,
|
||||
@@ -41,9 +37,11 @@ import {
|
||||
initializeSlots,
|
||||
makeSessionKey,
|
||||
planSlotAssignments,
|
||||
pushExtraFile,
|
||||
ROUTE_SLOTS,
|
||||
SHAPEFILE_DEPENDENT_SLOTS,
|
||||
slotConfigs,
|
||||
slotFileLabel,
|
||||
TERRAIN_SLOTS,
|
||||
type FileSlot,
|
||||
type FileSlotState,
|
||||
@@ -209,7 +207,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
|
||||
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 = slotFileLabel(state);
|
||||
if (fileSize) {
|
||||
fileSize.textContent = state.file
|
||||
? formatBytes(state.file.size)
|
||||
@@ -240,7 +238,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
|
||||
const preview = card.querySelector<HTMLDivElement>(".b03-file__preview");
|
||||
if (preview) {
|
||||
const terrain = terrainCoverage();
|
||||
const terrain = terrainCoverage(slots);
|
||||
renderSlotPreview(preview, {
|
||||
metadata: state.serverUploaded?.metadata,
|
||||
// 업로드·분석이 끝난 카드에만 보인다 (2026-09-04 사용자 지시).
|
||||
@@ -258,26 +256,6 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
* 들어오는지 대조하는 기준. 초기 계산 실패의 주된 원인이 범위 불일치라
|
||||
* 카드에서 바로 보이게 한다(2026-09-04).
|
||||
*/
|
||||
function terrainCoverage(): { extent: PreviewExtent | null; crs: string | null } {
|
||||
let extent: PreviewExtent | null = null;
|
||||
let crs: string | null = null;
|
||||
for (const slot of TERRAIN_SLOTS) {
|
||||
const metadata = slots.get(slot)?.serverUploaded?.metadata;
|
||||
const next = readExtent(metadata);
|
||||
if (!next) continue;
|
||||
crs ??= readCrsLabel(metadata);
|
||||
extent = extent
|
||||
? {
|
||||
xMin: Math.min(extent.xMin, next.xMin),
|
||||
xMax: Math.max(extent.xMax, next.xMax),
|
||||
yMin: Math.min(extent.yMin, next.yMin),
|
||||
yMax: Math.max(extent.yMax, next.yMax),
|
||||
}
|
||||
: next;
|
||||
}
|
||||
return { extent, crs };
|
||||
}
|
||||
|
||||
function showErrorMessage(slot: FileSlot, error: string): void {
|
||||
const state = slots.get(slot);
|
||||
if (!state) return;
|
||||
@@ -297,8 +275,13 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
showErrorMessage(state.slot, `${validation} ${file.name}`);
|
||||
return;
|
||||
}
|
||||
if (!targetSlot && state.file && state.file.name !== file.name) {
|
||||
showErrorMessage(state.slot, `${L("B03_File_Error_DuplicateSlot")} ${file.name}`);
|
||||
// 지형 자료는 도엽별로 여러 장이 온다 — 카드에 더 담고 전처리가 합쳐 쓴다.
|
||||
if (state.file && state.file.name !== file.name) {
|
||||
if (!pushExtraFile(state, file)) {
|
||||
showErrorMessage(state.slot, `${L("B03_File_Error_DuplicateSlot")} ${file.name}`);
|
||||
return;
|
||||
}
|
||||
renderSlot(state.slot);
|
||||
return;
|
||||
}
|
||||
// 서버에 이미 완료된 슬롯이면 교체 확인을 받는다(2026-08-04 사용자 지시). 이어올리기로
|
||||
@@ -382,6 +365,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
localStorage.removeItem(makeSessionKey(activeProjectId, state.file));
|
||||
}
|
||||
state.file = undefined;
|
||||
state.extraFiles = undefined;
|
||||
state.uploadSessionId = undefined;
|
||||
state.uploadStatus = "pending";
|
||||
state.progressBytes = 0;
|
||||
|
||||
@@ -201,14 +201,24 @@ export function createUploadFlow(ctx: UploadFlowContext): UploadFlowHandle {
|
||||
ctx.pageError.textContent = "";
|
||||
ctx.setUploading(true);
|
||||
try {
|
||||
for (let index = 0; index < targetStates.length; index += 1) {
|
||||
const state = targetStates[index];
|
||||
// 지형 자료는 한 카드에 여러 장이 담길 수 있다 — 카드 순서대로 한 장씩 올린다.
|
||||
const jobs = targetStates.flatMap((state) =>
|
||||
[state.file!, ...(state.extraFiles ?? [])].map((file) => ({ state, file })),
|
||||
);
|
||||
for (let index = 0; index < jobs.length; index += 1) {
|
||||
const { state, file } = jobs[index];
|
||||
if (file !== state.file) {
|
||||
// 앞 파일이 쓰던 전송 세션·진행률을 물려받지 않게 되돌린다.
|
||||
state.uploadSessionId = undefined;
|
||||
state.progressBytes = 0;
|
||||
}
|
||||
await uploadOneFile(
|
||||
ctx.projectId(),
|
||||
state,
|
||||
index === targetStates.length - 1,
|
||||
index === jobs.length - 1,
|
||||
() => ctx.renderSlot(state.slot),
|
||||
ctx.lasFreeDesign(),
|
||||
file,
|
||||
);
|
||||
}
|
||||
clearDerivedCaches(ctx.projectId());
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import { UPLOAD_MAX_FILES, UPLOAD_MAX_MB } from "@config/config_frontend";
|
||||
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
|
||||
import type { UploadOverviewFile } from "./B03_FileInput_Api_Fetch";
|
||||
import { readCrsLabel, readExtent, type PreviewExtent } from "./B03_FileInput_UI_Preview";
|
||||
import {
|
||||
getExtension,
|
||||
SHAPEFILE_DEPENDENT_SLOTS,
|
||||
@@ -105,3 +106,31 @@ export function slotForOverviewFile(
|
||||
(candidate) => candidate.slot !== "route_prj" && candidate.extensions.includes(extension),
|
||||
)?.slot;
|
||||
}
|
||||
|
||||
/**
|
||||
* 서버에 올라온 지형 자료가 덮는 범위와 좌표계 — 카드 미리보기가 쓴다.
|
||||
* 여러 카드(포인트클라우드·좌표계·래스터)의 범위를 합친다. 화면 조립부가 700줄을
|
||||
* 넘어 옮겨 온 순수 함수다(2026-09-06).
|
||||
*/
|
||||
export function terrainCoverage(slots: SlotMap): {
|
||||
extent: PreviewExtent | null;
|
||||
crs: string | null;
|
||||
} {
|
||||
let extent: PreviewExtent | null = null;
|
||||
let crs: string | null = null;
|
||||
for (const slot of TERRAIN_SLOTS) {
|
||||
const metadata = slots.get(slot)?.serverUploaded?.metadata;
|
||||
const next = readExtent(metadata);
|
||||
if (!next) continue;
|
||||
crs ??= readCrsLabel(metadata);
|
||||
extent = extent
|
||||
? {
|
||||
xMin: Math.min(extent.xMin, next.xMin),
|
||||
xMax: Math.max(extent.xMax, next.xMax),
|
||||
yMin: Math.min(extent.yMin, next.yMin),
|
||||
yMax: Math.max(extent.yMax, next.yMax),
|
||||
}
|
||||
: next;
|
||||
}
|
||||
return { extent, crs };
|
||||
}
|
||||
|
||||
@@ -28,6 +28,12 @@ export interface SlotConfig {
|
||||
|
||||
export interface FileSlotState extends SlotConfig {
|
||||
file?: File;
|
||||
/**
|
||||
* 같은 카드에 더 담은 파일 — 지형 자료(포인트클라우드)만 여러 장을 받는다.
|
||||
* 드론 라이다는 사업지가 넓으면 도엽별로 나뉘어 오고, 전처리가 합쳐서 쓴다
|
||||
* (2026-09-06 사용자 확정). 업로드는 이 목록을 한 장씩 차례로 올린다.
|
||||
*/
|
||||
extraFiles?: File[];
|
||||
uploadSessionId?: string;
|
||||
uploadStatus: UploadStatus;
|
||||
progressBytes: number;
|
||||
@@ -123,6 +129,25 @@ const SLOT_CONFIGS: readonly SlotConfig[] = [
|
||||
},
|
||||
];
|
||||
|
||||
/** 카드에 적을 파일 이름 — 여러 장이면 「첫 장 외 N장」. */
|
||||
export function slotFileLabel(state: FileSlotState): string {
|
||||
const name = state.file?.name ?? state.serverUploaded?.name ?? "";
|
||||
const extras = state.extraFiles?.length ?? 0;
|
||||
return extras > 0 ? `${name} 외 ${extras}장` : name;
|
||||
}
|
||||
|
||||
/**
|
||||
* 같은 카드에 파일을 더 담는다 — 담았으면 true, 이 카드가 한 장짜리면 false.
|
||||
* 지형 자료(포인트클라우드)만 여러 장을 받는다. 같은 이름은 다시 담지 않는다.
|
||||
*/
|
||||
export function pushExtraFile(state: FileSlotState, file: File): boolean {
|
||||
if (state.slot !== "las_laz") return false;
|
||||
const extras = state.extraFiles ?? [];
|
||||
if (!extras.some((item) => item.name === file.name)) state.extraFiles = [...extras, file];
|
||||
state.error = undefined;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function getExtension(fileName: string): string {
|
||||
const index = fileName.lastIndexOf(".");
|
||||
return index >= 0 ? fileName.slice(index).toLowerCase() : "";
|
||||
|
||||
@@ -83,8 +83,10 @@ export async function uploadOneFile(
|
||||
completeUpload: boolean,
|
||||
onProgress: () => void,
|
||||
lasFree = false,
|
||||
// 지형 자료는 한 카드에 여러 장이 담긴다 — 올릴 파일을 지정받는다(2026-09-06).
|
||||
target?: File,
|
||||
): Promise<UploadedFileResult[]> {
|
||||
const file = state.file;
|
||||
const file = target ?? state.file;
|
||||
if (!file) return [];
|
||||
state.error = undefined;
|
||||
state.uploadStatus = "uploading";
|
||||
|
||||
Reference in New Issue
Block a user