feat(637-3): B05 지형 칩 옆 [LAS 올리기] · 진행 서클 · 확인창 → 설계 지형 바꿈 · 공용 올리기 부품 map_lidar_upload(예정노선 같은 단추 · 흐름) · 갈음 때 안 지은 라이다 필터 파일 · 행 지움(요구 546)
- 공용 A00_Common/map/map_lidar_upload — 파일 고름 · POST multipart · 1 초 묻기 · 서클 · 끝난 뒤 일(주소 · 진행 묻기만 인자) - 예정노선 UI_LidarUpload = 부품 부르기(화면 · 동작 그대로) · 안 쓰는 uploadPlanLidar 걷음 - B05 UI_RouteEdit_Terrain — 칩 · 단추 · 서클 한 칸 · 끝나면 확인창 「설계 지형을 라이다로 바꿈?」(이미 라이다 = force 다시 셈) · covered 거짓 = 바꿈 막고 알림 · 들어올 때 도는 일 이어 봄 - common_util_lidar_upload.drop_stale_filters — 갈음 뒤 안 지은 라이다 필터(옛 LAS) 모델 · 등고선 · 마스크 · 지면점 · manifest 줄 지움 · save_models drop = 그 행만(확정 행 남김) - 시험 test_637_lidar(필터 지움 · 행 지움 · B05 화면) · test_624_4b(올리기 = 공용 부품으로 옮김) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
/* =============================================================================
|
||||
* map_lidar_upload.ts
|
||||
* 공용 [LAS 올리기](PLAN 637-3 · 요구 546) — 예정노선(D03) · B05 지형 칩 옆 같은 단추 · 같은 흐름.
|
||||
* 단추 → 파일 고름(LAS · LAZ + .prj) → `POST <path>`(multipart `files` · 못 받으면 서버 글 그대로 알림)
|
||||
* → 1 초마다 `poll` 로 진행(`show` = 페이지 진행 서클) → 끝(오류면 알림) → 서클 · 단추 풀고 `onDone(마지막 진행)`.
|
||||
* 주소 · 진행 묻기 · 서클 · 끝난 뒤 일은 페이지 인자(D = `/plan/lidar` · B = `/route/lidar`).
|
||||
* 지켜보는 동안 단추 막힘 · 페이지를 떠나면(단추가 문서 밖) 그침.
|
||||
* ========================================================================== */
|
||||
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import { createButton } from "@ui/ui_template_button";
|
||||
import { pickFiles } from "@ui/ui_template_drop";
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
|
||||
const POLL_MS = 1000;
|
||||
|
||||
/** 서버 뒤 일 진행(`common_util_lidar_upload.start_job` 꼴). */
|
||||
export interface LidarJob {
|
||||
running: boolean;
|
||||
percent: number;
|
||||
message?: string | null;
|
||||
error?: string | null;
|
||||
interrupted?: boolean;
|
||||
}
|
||||
|
||||
export interface LidarUpload {
|
||||
/** [LAS 올리기] 단추(보임은 페이지 몫). */
|
||||
button: HTMLButtonElement;
|
||||
/** 도는 일을 끝날 때까지 지켜봄(들어올 때 이어 보기). */
|
||||
follow: () => Promise<void>;
|
||||
/** 지켜보는 중. */
|
||||
busy: () => boolean;
|
||||
}
|
||||
|
||||
const errorText = (error: unknown): string =>
|
||||
error instanceof Error ? error.message : String(error);
|
||||
|
||||
/** LAS 묶음 올리기 — 못 받으면 서버 `detail` 글 그대로 오류. */
|
||||
async function postFiles(path: string, files: File[]): Promise<void> {
|
||||
const form = new FormData();
|
||||
for (const file of files) form.append("files", file);
|
||||
const response = await fetch(`${API_BASE_URL}${path}`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
body: form,
|
||||
});
|
||||
if (response.ok) return;
|
||||
const body = (await response.json().catch(() => ({}))) as { detail?: unknown };
|
||||
throw new Error(typeof body.detail === "string" ? body.detail : `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
export function createLidarUpload<J extends LidarJob>(opts: {
|
||||
/** 올리기 주소(`API_BASE_URL` 뒤). */
|
||||
path: string;
|
||||
/** 진행 한 번 묻기. */
|
||||
poll: () => Promise<J>;
|
||||
/** 진행 보임(0~1 · 글) · null = 끝. */
|
||||
show: (state: { ratio: number; text: string } | null) => void;
|
||||
/** 지켜보기를 감쌈(예: 서클 일 셈). */
|
||||
track?: (job: Promise<void>) => Promise<void>;
|
||||
/** 지켜보기 시작 · 끝. */
|
||||
onBusy?: (busy: boolean) => void;
|
||||
/** 짓기가 끝난 뒤(오류 알림 뒤 · 단추 풀린 뒤 · 마지막 진행). */
|
||||
onDone: (job: J) => Promise<void> | void;
|
||||
}): LidarUpload {
|
||||
let busy = false;
|
||||
const button = createButton({
|
||||
label: "LAS 올리기",
|
||||
variant: "ghost",
|
||||
size: 32,
|
||||
iconStart: "upload",
|
||||
onClick: () =>
|
||||
pickFiles({ accept: ".las,.laz,.prj", multiple: true }, (files) => void send(files)),
|
||||
});
|
||||
|
||||
const setBusy = (on: boolean): void => {
|
||||
busy = on;
|
||||
button.disabled = on;
|
||||
opts.onBusy?.(on);
|
||||
};
|
||||
|
||||
async function send(files: File[]): Promise<void> {
|
||||
try {
|
||||
await postFiles(opts.path, files);
|
||||
} catch (error) {
|
||||
return showToast(`LAS 올리지 못함 — ${errorText(error)}`, "error");
|
||||
}
|
||||
await follow();
|
||||
}
|
||||
|
||||
async function watch(): Promise<void> {
|
||||
let last: J | null = null;
|
||||
setBusy(true);
|
||||
opts.show({ ratio: 0, text: "" });
|
||||
try {
|
||||
while (button.isConnected) {
|
||||
const job = await opts.poll();
|
||||
opts.show({ ratio: job.percent / 100, text: job.message ?? "" });
|
||||
if (!job.running) {
|
||||
if (job.error) showToast(`라이다 지형 못 지음 — ${job.error}`, "error");
|
||||
last = job;
|
||||
break;
|
||||
}
|
||||
await new Promise((done) => setTimeout(done, POLL_MS));
|
||||
}
|
||||
} catch (error) {
|
||||
showToast(`라이다 진행을 읽지 못했습니다 — ${errorText(error)}`, "error");
|
||||
} finally {
|
||||
opts.show(null);
|
||||
setBusy(false);
|
||||
}
|
||||
if (last) await opts.onDone(last);
|
||||
}
|
||||
|
||||
const follow = (): Promise<void> => (opts.track ? opts.track(watch()) : watch());
|
||||
return { button, follow, busy: () => busy };
|
||||
}
|
||||
@@ -10,7 +10,8 @@
|
||||
길 = 공용 `common_util_lidar_upload`(예정노선과 같음 · `maps=False` = 지도 · GIS · 도엽 서피스
|
||||
안 받음 → 수치지형도 모델 · 배수유역 그대로).
|
||||
DB = `surface_models` 넣기/고치기만(`keep` — 갈음 때 id · 확정 · 노선 연결 살림 ·
|
||||
`save_surface_analysis_to_db` 안 씀).
|
||||
`save_surface_analysis_to_db` 안 씀) · 안 지은 라이다 필터(옛 LAS 것) = 파일 · 행 지움
|
||||
(`drop_stale_filters` · 뒤에 고르면 새 LAS 로 다시 지음 · PLAN 637-3).
|
||||
라이다 확정값 — 지금 설계가 라이다면 그 필터로 갈음 · 수치지형도면 `las_selection` 에 적음
|
||||
(칩 라이다 열림 · 설계 지형은 그대로).
|
||||
노선 · 노드 · 초기값 · 종단 · 횡단 안 바뀜 — 설계 값은 사용자가 지형 칩 확인창에서
|
||||
@@ -150,7 +151,8 @@ def build_route_lidar(
|
||||
}
|
||||
atomic_write_json(root / META, meta)
|
||||
models = [model for model in result["models"] if model.get("model_file_path")]
|
||||
return {"meta": meta, "models": models}
|
||||
dropped = lidar_upload.drop_stale_filters(root, list(result["source_filters"]))
|
||||
return {"meta": meta, "models": models, "dropped": dropped}
|
||||
|
||||
|
||||
async def _root_and_params(project_id: UUID) -> tuple[Path, dict[str, Any]]:
|
||||
@@ -183,7 +185,9 @@ async def upload_route_lidar(project_id: UUID, files: list[UploadFile] = File(..
|
||||
async def work(report: Any) -> None:
|
||||
async with lock:
|
||||
got = await asyncio.to_thread(build_route_lidar, root, epsg, target, report)
|
||||
await lidar_upload.save_models(project_id, got["models"], epsg, keep=True)
|
||||
await lidar_upload.save_models(
|
||||
project_id, got["models"], epsg, keep=True, drop=got["dropped"]
|
||||
)
|
||||
if design == "sheet": # 칩 라이다 열림 · 설계 지형은 그대로
|
||||
async with get_db_pool().acquire() as connection:
|
||||
await connection.begin()
|
||||
|
||||
@@ -4,10 +4,14 @@
|
||||
* 고를 수 있는 지형 = `/route/terrain-sources`(없는 쪽 막힘) · 바꾸면 확인창 → `POST /route/design-terrain`
|
||||
* (설계 지형 바꿈 · 뒤 단계 모두 다시 셈 · 노선 값 · 초기값 · 추천 저장본 그대로 · 잠금 막) → 초안 비움 → 페이지 다시 세움.
|
||||
* 취소 · 실패 = 칩을 지금 지형으로 되돌림. 옛 길(추천 [적용] 안 지형 고르기 · 509-5) 대신.
|
||||
* 칩 옆 [LAS 올리기](637-3 · 공용 `map_lidar_upload` · 예정노선과 같은 단추) → `POST /route/lidar` → 진행 서클(1 초 묻기)
|
||||
* → 끝나면 칩 다시 읽고 확인창 「설계 지형을 라이다로 바꿈?」(이미 라이다 = 갈음 → `force` 다시 셈) ·
|
||||
* 노선을 다 못 덮음(`covered` 거짓) = 라이다로 바꿈 막고 알림 · 설계 값은 확인창을 눌러야만 바뀜.
|
||||
* 부르는 곳 = `_Chrome`(도구줄 `terrain` 칸) · `_RouteEdit`(지형 읽은 뒤 `load`).
|
||||
* ========================================================================== */
|
||||
|
||||
import { clearDrafts } from "../A00_Common/b_page_state";
|
||||
import { createLidarUpload, type LidarJob } from "../A00_Common/map/map_lidar_upload";
|
||||
import {
|
||||
createTerrainChip,
|
||||
TERRAIN_NAMES,
|
||||
@@ -15,6 +19,7 @@ import {
|
||||
} from "../A00_Common/map/map_terrain_chip";
|
||||
import { askConfirm } from "@ui/ui_template_modal";
|
||||
import { hideLoadingOverlay, showLoadingOverlay, showToast } from "@ui/ui_template_elements";
|
||||
import { createProgressCircle } from "@ui/ui_template_progress";
|
||||
import { requestJson, type AnalysisTerrain } from "./B05_Profile_Api_Replan";
|
||||
import { forgetDesignTerrainGrid } from "./B05_Profile_Api_TerrainGrid";
|
||||
import { rebuildAfterRouteSave } from "./B05_Profile_UI_RouteEdit_Apply";
|
||||
@@ -23,6 +28,15 @@ import { rebuildAfterRouteSave } from "./B05_Profile_UI_RouteEdit_Apply";
|
||||
const toChip = (terrain: AnalysisTerrain): TerrainSource => (terrain === "las" ? "lidar" : "sheet");
|
||||
const toServer = (source: TerrainSource): AnalysisTerrain => (source === "lidar" ? "las" : "sheet");
|
||||
|
||||
/** `GET /route/lidar`(637-2) — 진행 + 올린 라이다 메타(`covered_ratio` 없음 = 올린 적 없음). */
|
||||
interface RouteLidar extends LidarJob {
|
||||
ready: boolean;
|
||||
covered: boolean;
|
||||
covered_ratio: number | null;
|
||||
design: AnalysisTerrain;
|
||||
sources: AnalysisTerrain[];
|
||||
}
|
||||
|
||||
export interface RouteTerrain {
|
||||
root: HTMLElement;
|
||||
/** 지형을 읽음(못 읽음 = null · 칩 숨김). */
|
||||
@@ -34,22 +48,84 @@ export interface RouteTerrain {
|
||||
export function createRouteTerrain(projectId: string): RouteTerrain {
|
||||
let design: AnalysisTerrain = "las";
|
||||
let busy = false;
|
||||
/** 올린 라이다 덮임 — 다 덮음 = null(올린 적 없음 포함 · 안 막음) · 못 덮음 = 덮인 비율. */
|
||||
let uncovered: number | null = null;
|
||||
const chip = createTerrainChip({
|
||||
page: "b05",
|
||||
value: toChip(design),
|
||||
have: [],
|
||||
onChange: (value) => void change(toServer(value)),
|
||||
});
|
||||
chip.root.hidden = true;
|
||||
const circle = createProgressCircle({ size: 16, inline: true });
|
||||
circle.root.hidden = true;
|
||||
const lidarPath = `/projects/${projectId}/route/lidar`;
|
||||
const upload = createLidarUpload<RouteLidar>({
|
||||
path: lidarPath,
|
||||
poll: () => requestJson<RouteLidar>(lidarPath, { method: "GET" }, 60000),
|
||||
show: (state) => {
|
||||
circle.root.hidden = !state;
|
||||
if (!state) return;
|
||||
circle.set(state.ratio, "라이다");
|
||||
circle.root.title = `라이다 짓기 ${Math.round(state.ratio * 100)}% · ${state.text}`;
|
||||
},
|
||||
onDone: afterUpload,
|
||||
});
|
||||
const root = document.createElement("div");
|
||||
root.className = "b05-routeedit__terrain";
|
||||
root.append(chip.root, upload.button, circle.root);
|
||||
root.hidden = true;
|
||||
|
||||
async function change(next: AnalysisTerrain): Promise<void> {
|
||||
if (busy || next === design) return chip.set(toChip(design));
|
||||
/** 올림 끝 — 칩 다시 · 덮임 보고 확인창(설계 지형 바꿈 · 이미 라이다 = 다시 셈). */
|
||||
async function afterUpload(job: RouteLidar): Promise<void> {
|
||||
if (job.interrupted) showToast("라이다 짓기가 끊겼습니다 — LAS 를 다시 올리세요.", "error");
|
||||
if (job.error || job.interrupted || !job.ready) return;
|
||||
note(job);
|
||||
load({ sources: job.sources, design: job.design === "sheet" ? "sheet" : "las" });
|
||||
if (uncovered != null) return warnUncovered();
|
||||
await change("las", design === "las");
|
||||
}
|
||||
|
||||
const note = (info: RouteLidar): void => {
|
||||
uncovered = info.covered_ratio != null && !info.covered ? info.covered_ratio : null;
|
||||
};
|
||||
const warnUncovered = (): void =>
|
||||
showToast(
|
||||
`올린 라이다가 노선을 다 덮지 못합니다(덮임 ${Math.round((uncovered ?? 0) * 100)}%) — ` +
|
||||
"설계 지형을 라이다로 바꾸지 않습니다. 노선을 덮는 LAS 를 다시 올리세요.",
|
||||
"warning",
|
||||
6000,
|
||||
);
|
||||
|
||||
/** 들어올 때 — 덮임 · 도는 올리기 이어 보기. */
|
||||
async function resume(): Promise<void> {
|
||||
const info = await requestJson<RouteLidar>(lidarPath, { method: "GET" }, 60000).catch(
|
||||
() => null,
|
||||
);
|
||||
if (!info) return;
|
||||
note(info);
|
||||
if (info.running && !upload.busy()) void upload.follow();
|
||||
}
|
||||
|
||||
/** 설계 지형 바꿈 — `force` = 같은 지형 다시 셈(라이다 갈음 뒤). */
|
||||
async function change(next: AnalysisTerrain, force = false): Promise<void> {
|
||||
if (busy || upload.busy() || (next === design && !force)) return chip.set(toChip(design));
|
||||
if (next === "las" && uncovered != null) {
|
||||
warnUncovered();
|
||||
return chip.set(toChip(design));
|
||||
}
|
||||
const ok = await askConfirm({
|
||||
title: "설계 지형 바꿈",
|
||||
title: force
|
||||
? "설계 지형 다시 셈"
|
||||
: next === "las"
|
||||
? "설계 지형을 라이다로 바꿈?"
|
||||
: "설계 지형 바꿈",
|
||||
message:
|
||||
`설계 지형을 ${TERRAIN_NAMES[toChip(next)]}(으)로 바꿉니다 — 종단 · 횡단 · 배수 · 토공을 모두 다시 셈합니다. ` +
|
||||
"평면 노선 · 초기값([초기화])은 그대로 · 저장 안 한 고침은 버립니다.",
|
||||
okLabel: "바꿈",
|
||||
(force
|
||||
? "새로 올린 라이다로 설계 지형을 다시 셈합니다 — 종단 · 횡단 · 배수 · 토공을 모두 다시 셈합니다. "
|
||||
: `설계 지형을 ${TERRAIN_NAMES[toChip(next)]}(으)로 바꿉니다 — 종단 · 횡단 · 배수 · 토공을 모두 다시 셈합니다. `) +
|
||||
"평면 노선 · 초기값([초기화])은 그대로 · 저장 안 한 고침은 버립니다." +
|
||||
(force ? " 안 하면 설계 값은 옛 라이다 셈 그대로입니다." : ""),
|
||||
okLabel: force ? "다시 셈" : "바꿈",
|
||||
});
|
||||
if (!ok) return chip.set(toChip(design));
|
||||
busy = true;
|
||||
@@ -57,13 +133,16 @@ export function createRouteTerrain(projectId: string): RouteTerrain {
|
||||
try {
|
||||
await requestJson(
|
||||
`/projects/${projectId}/route/design-terrain`,
|
||||
{ method: "POST", body: JSON.stringify({ terrain: next }) },
|
||||
{ method: "POST", body: JSON.stringify({ terrain: next, force }) },
|
||||
600000,
|
||||
);
|
||||
design = next;
|
||||
clearDrafts(projectId);
|
||||
forgetDesignTerrainGrid(projectId); // 새 지형 격자로(509-2)
|
||||
showToast(`설계 지형을 ${TERRAIN_NAMES[toChip(next)]}(으)로 바꿨습니다.`, "success");
|
||||
showToast(
|
||||
`설계 지형을 ${TERRAIN_NAMES[toChip(next)]}(으)로 ${force ? "다시 셈했" : "바꿨"}습니다.`,
|
||||
"success",
|
||||
);
|
||||
await rebuildAfterRouteSave(projectId);
|
||||
} catch (error) {
|
||||
chip.set(toChip(design));
|
||||
@@ -77,14 +156,19 @@ export function createRouteTerrain(projectId: string): RouteTerrain {
|
||||
}
|
||||
}
|
||||
|
||||
function load(sources: { sources: AnalysisTerrain[]; design: AnalysisTerrain } | null): void {
|
||||
root.hidden = !sources;
|
||||
if (!sources) return;
|
||||
design = sources.design;
|
||||
chip.set(toChip(design));
|
||||
chip.setHave(sources.sources.map(toChip));
|
||||
}
|
||||
|
||||
return {
|
||||
root: chip.root,
|
||||
root,
|
||||
load(sources) {
|
||||
chip.root.hidden = !sources;
|
||||
if (!sources) return;
|
||||
design = sources.design;
|
||||
chip.set(toChip(design));
|
||||
chip.setHave(sources.sources.map(toChip));
|
||||
load(sources);
|
||||
if (sources) void resume();
|
||||
},
|
||||
design: () => design,
|
||||
};
|
||||
|
||||
@@ -324,6 +324,17 @@ html.is-bm-route .b05-routeedit__tools {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* 637-3 도구줄 지형 칸 — 지형 칩 · LAS 올리기 · 진행 서클 한 줄(예정노선 `.d03-plan__terrain` 과 같음) */
|
||||
.b05-routeedit__terrain {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.b05-routeedit__terrain[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ㉔ 잰 값 — 지도 오른쪽 아래 작은 창(뜬 종단 판이 덮으면 그 위로 · 408-3). 닫으면 잰 것이 지워진다. */
|
||||
.b05-routeedit__measure {
|
||||
position: absolute;
|
||||
|
||||
@@ -172,21 +172,6 @@ export interface PlanLidar {
|
||||
export const fetchPlanLidar = (projectId: string): Promise<PlanLidar> =>
|
||||
request(`${base(projectId)}/lidar`);
|
||||
|
||||
/** LAS · LAZ(+ .prj 하나) 올리기 → 뒤에서 지형(진행 = `fetchPlanProgress().lidar`) · 못 받으면 서버 글 그대로 오류. */
|
||||
export async function uploadPlanLidar(projectId: string, files: File[]): Promise<PlanJob> {
|
||||
const form = new FormData();
|
||||
for (const file of files) form.append("files", file);
|
||||
const response = await fetch(`${API_BASE_URL}${base(projectId)}/lidar`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
body: form,
|
||||
});
|
||||
const body = (await response.json().catch(() => ({}))) as PlanJob & { detail?: unknown };
|
||||
if (!response.ok)
|
||||
throw new Error(typeof body.detail === "string" ? body.detail : `HTTP ${response.status}`);
|
||||
return body;
|
||||
}
|
||||
|
||||
export const fetchPlanNetwork = (projectId: string): Promise<PlanNetworkView> =>
|
||||
request(`${base(projectId)}/network`);
|
||||
|
||||
|
||||
@@ -5,24 +5,20 @@
|
||||
* 끝나면 「라이다」를 고를 수 있음(저절로 안 바꿈).
|
||||
* 고른 원천 = 화면 캐시(세션 `d03-terrain`)만 · 기본 = 수치지형도 · 라이다 지형이 없어지면 수치지형도로.
|
||||
* 바꾸면 높이 격자(긋기 기울기)를 그 원천으로 받은 뒤 `onChange` — 겹(급경사 · 음영 · 0차곡)은 페이지가 다시 받음.
|
||||
* 칩 안은 공용 그대로(`map_terrain_chip` · `onChange` · `setHave` 만 씀).
|
||||
* 칩 안은 공용 그대로(`map_terrain_chip` · `onChange` · `setHave` 만 씀) · 올리기 = 공용 `map_lidar_upload`(637-3).
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton } from "@ui/ui_template_button";
|
||||
import { pickFiles } from "@ui/ui_template_drop";
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
import { readState, writeState } from "../A00_Common/b_page_state";
|
||||
import { createLidarUpload } from "../A00_Common/map/map_lidar_upload";
|
||||
import { createTerrainChip, type TerrainSource } from "../A00_Common/map/map_terrain_chip";
|
||||
import {
|
||||
fetchPlanLidar,
|
||||
fetchPlanProgress,
|
||||
fetchPlanSurface,
|
||||
uploadPlanLidar,
|
||||
type PlanSurface,
|
||||
} from "./D03_PlanAnalysis_Api";
|
||||
import { errorText, sleep, type WorkCircle } from "./D03_PlanAnalysis_UI_Terrain";
|
||||
|
||||
const POLL_MS = 1000;
|
||||
import { errorText, type WorkCircle } from "./D03_PlanAnalysis_UI_Terrain";
|
||||
|
||||
export interface PlanTerrain {
|
||||
/** 도구줄 `terrain` 칸 — 칩 + [LAS 올리기]. */
|
||||
@@ -49,7 +45,6 @@ export function createPlanTerrain(opts: {
|
||||
const { projectId, work } = opts;
|
||||
let source: TerrainSource = "sheet";
|
||||
let ready = false;
|
||||
let uploading = false;
|
||||
let editable = false;
|
||||
let modelFile = "";
|
||||
let lidar: PlanSurface | null = null;
|
||||
@@ -61,14 +56,23 @@ export function createPlanTerrain(opts: {
|
||||
onChange: (next) => void choose(next),
|
||||
});
|
||||
const lidarItem = chip.root.querySelector<HTMLElement>('[data-value="lidar"]')!;
|
||||
const upload = createButton({
|
||||
label: "LAS 올리기",
|
||||
variant: "ghost",
|
||||
size: 32,
|
||||
iconStart: "upload",
|
||||
onClick: () =>
|
||||
pickFiles({ accept: ".las,.laz,.prj", multiple: true }, (files) => void send(files)),
|
||||
const lidarUpload = createLidarUpload({
|
||||
path: `/projects/${projectId}/plan/lidar`,
|
||||
poll: async () => (await fetchPlanProgress(projectId)).lidar,
|
||||
show: (state) => work.terrain(state && { ...state, label: "라이다" }),
|
||||
track: (job) => work.track(job),
|
||||
onBusy: () => render(),
|
||||
onDone: async () => {
|
||||
try {
|
||||
await readLidar();
|
||||
if (ready) showToast("라이다 지형 끝 — 지형 칩에서 「라이다」를 고를 수 있음", "success");
|
||||
} catch (error) {
|
||||
showToast(`라이다 진행을 읽지 못했습니다 — ${errorText(error)}`, "error");
|
||||
}
|
||||
render();
|
||||
},
|
||||
});
|
||||
const upload = lidarUpload.button;
|
||||
// 마우스 글 = 지형 칸 표 호버제목(634-2 · 「수치지형도/라이다」 · 단추 글 「LAS 올리기」 그대로)
|
||||
const root = document.createElement("div");
|
||||
root.className = "d03-plan__terrain";
|
||||
@@ -79,7 +83,6 @@ export function createPlanTerrain(opts: {
|
||||
chip.set(source);
|
||||
lidarItem.style.display = !ready && editable ? "none" : ""; // 올릴 수 있으면 막힌 「라이다」 대신 단추
|
||||
upload.hidden = ready || !editable;
|
||||
upload.disabled = uploading;
|
||||
}
|
||||
|
||||
async function readLidar(): Promise<void> {
|
||||
@@ -109,42 +112,6 @@ export function createPlanTerrain(opts: {
|
||||
opts.onChange(source);
|
||||
}
|
||||
|
||||
async function send(files: File[]): Promise<void> {
|
||||
try {
|
||||
await uploadPlanLidar(projectId, files);
|
||||
} catch (error) {
|
||||
return showToast(`LAS 올리지 못함 — ${errorText(error)}`, "error");
|
||||
}
|
||||
await work.track(follow());
|
||||
}
|
||||
|
||||
/** 올린 뒤 지형 짓기를 끝날 때까지 지켜봄 — 진행 = 서클. */
|
||||
async function follow(): Promise<void> {
|
||||
uploading = true;
|
||||
work.terrain({ ratio: 0, text: "", label: "라이다" });
|
||||
render();
|
||||
try {
|
||||
for (;;) {
|
||||
if (!root.isConnected) return; // 페이지 떠남
|
||||
const job = (await fetchPlanProgress(projectId)).lidar;
|
||||
work.terrain({ ratio: job.percent / 100, text: job.message ?? "", label: "라이다" });
|
||||
if (!job.running) {
|
||||
if (job.error) showToast(`라이다 지형 못 지음 — ${job.error}`, "error");
|
||||
break;
|
||||
}
|
||||
await sleep(POLL_MS);
|
||||
}
|
||||
await readLidar();
|
||||
if (ready) showToast("라이다 지형 끝 — 지형 칩에서 「라이다」를 고를 수 있음", "success");
|
||||
} catch (error) {
|
||||
showToast(`라이다 진행을 읽지 못했습니다 — ${errorText(error)}`, "error");
|
||||
} finally {
|
||||
uploading = false;
|
||||
work.terrain(null);
|
||||
render();
|
||||
}
|
||||
}
|
||||
|
||||
async function load(): Promise<void> {
|
||||
try {
|
||||
await readLidar();
|
||||
@@ -157,7 +124,7 @@ export function createPlanTerrain(opts: {
|
||||
}
|
||||
render();
|
||||
const job = await fetchPlanProgress(projectId).catch(() => null);
|
||||
if (job?.lidar.running) void work.track(follow());
|
||||
if (job?.lidar.running) void lidarUpload.follow();
|
||||
}
|
||||
|
||||
render();
|
||||
@@ -170,6 +137,6 @@ export function createPlanTerrain(opts: {
|
||||
render();
|
||||
},
|
||||
load,
|
||||
state: () => ({ source, ready, uploading, heights: !!lidar }),
|
||||
state: () => ({ source, ready, uploading: lidarUpload.busy(), heights: !!lidar }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,9 +4,12 @@
|
||||
(좌표계 다르면 x · y 옮겨 적음)
|
||||
- 받기 고리 `receive_uploads` · 입력 자리 갈음 `stage_las` · 짓기 `build_surface`
|
||||
(B04 엔진 `run_surface_analysis(maps=False)` — 지도 · GIS 받기 · 도엽 서피스 안 함)
|
||||
- 갈음 뒤 안 지은 라이다 필터 지움 `drop_stale_filters`
|
||||
(옛 LAS 산출 · 뒤에 고르면 새 LAS 로 다시 지음)
|
||||
- 뒤 일 `start_job` · 진행 기록 `note_job` · `saved_job`(서버 다시 켜짐 = 「끊김」)
|
||||
- DB `save_models` — `surface_models` 넣기/고치기만
|
||||
(다른 행 안 지움 · `save_surface_analysis_to_db` 안 씀)
|
||||
(다른 행 안 지움 · `drop` = 지운 필터 파일 행만 · 확정 행 남김 ·
|
||||
`save_surface_analysis_to_db` 안 씀)
|
||||
|
||||
부르는 쪽 = `D03_PlanAnalysis_Lidar`(자리 `B04_PreProcess/lidar/`) ·
|
||||
`B05_Profile_Router_Lidar`(프로젝트 뿌리).
|
||||
@@ -143,6 +146,45 @@ def build_surface(
|
||||
)
|
||||
|
||||
|
||||
def drop_stale_filters(base: Path, built: list[str]) -> list[str]:
|
||||
"""갈음 뒤 — 이번에 안 지은 라이다 필터의 옛 LAS 산출(모델 · 미리보기 · 등고선 · 마스크 ·
|
||||
지면점 · 점 미리보기 · manifest 줄)을 지움 → 지운 모델 파일(`base` 기준 상대경로).
|
||||
|
||||
B04 엔진은 LAS 가 바뀌면 고른 필터만 다시 지음 — 남은 다른 필터 파일은 옛 LAS 것.
|
||||
지우면 뒤에 그 필터를 고를 때 캐시가 없어 새 LAS 로 다시 지음.
|
||||
수치지형도(`sheet_*`)는 필터 밖."""
|
||||
from B04_PreProcess.B04_PreProcess_Engine_Ground import available_filters
|
||||
from B04_PreProcess.B04_PreProcess_Engine_ModelBuild import BUILDERS
|
||||
|
||||
models = base / "B04_PreProcess" / "models"
|
||||
processed = base / "B04_PreProcess" / "processed"
|
||||
stale = [key for key in available_filters() if key not in built]
|
||||
gone: list[str] = []
|
||||
for key in stale:
|
||||
paths = [
|
||||
*(path for method in BUILDERS for path in models.glob(f"{method}_{key}.*")),
|
||||
*(path for method in BUILDERS for path in models.glob(f"{method}_{key}_*")),
|
||||
*models.glob(f"contour_{key}_*"),
|
||||
*(processed / name for name in (f"mask_{key}.npy", f"ground_points_{key}.npz")),
|
||||
processed / f"preview_points_{key}.bin",
|
||||
]
|
||||
for path in paths:
|
||||
if path.is_file():
|
||||
path.unlink()
|
||||
if path.parent == models and path.suffix == ".npz":
|
||||
gone.append(path.relative_to(base).as_posix())
|
||||
manifest = models / "manifest.json"
|
||||
try:
|
||||
data = json.loads(manifest.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
data = None
|
||||
filters = data.get("source_filters") if isinstance(data, dict) else None
|
||||
if isinstance(filters, dict) and any(key in filters for key in stale):
|
||||
data["source_filters"] = {k: v for k, v in filters.items() if k not in stale}
|
||||
atomic_write_json(manifest, data)
|
||||
return sorted(gone)
|
||||
|
||||
|
||||
# ── 뒤 일 · 진행 기록 ─────────────────────────────────────────────────────────
|
||||
|
||||
IDLE = {"running": False, "percent": 100, "message": None, "error": None, "interrupted": False}
|
||||
@@ -214,12 +256,19 @@ def start_job(
|
||||
|
||||
|
||||
async def save_models(
|
||||
project_id: UUID, models: list[dict[str, Any]], epsg: int, *, keep: bool = False
|
||||
project_id: UUID,
|
||||
models: list[dict[str, Any]],
|
||||
epsg: int,
|
||||
*,
|
||||
keep: bool = False,
|
||||
drop: list[str] | None = None,
|
||||
) -> None:
|
||||
"""지형면 → `surface_models` 행(같은 파일 · 같은 지문이면 id 살림 · 다른 행 그대로).
|
||||
|
||||
`keep` = 같은 파일을 가리키는 옛 행은 지문이 달라도 id · 상태(확정) · 입력 연결을 살림
|
||||
(LAS 갈음 — 노선 `surface_model_id` · 설계 확정이 끊기지 않게 · B05)."""
|
||||
(LAS 갈음 — 노선 `surface_model_id` · 설계 확정이 끊기지 않게 · B05).
|
||||
`drop` = 지운 모델 파일(`drop_stale_filters`)을 가리키는 행 지움(확정 행은 남김 ·
|
||||
terrain_layers CASCADE · routes SET NULL)."""
|
||||
from B04_PreProcess.B04_PreProcess_Repository import (
|
||||
_existing_models,
|
||||
_model_key,
|
||||
@@ -259,6 +308,13 @@ async def save_models(
|
||||
)
|
||||
if status == "CONFIRMED": # 고쳐 쓰기가 COMPLETE 로 내린 확정을 되살림
|
||||
await confirm_surface_model(connection, project_id, model_id)
|
||||
if drop:
|
||||
async with connection.cursor() as cursor:
|
||||
await cursor.execute(
|
||||
"DELETE FROM surface_models WHERE project_id = %s AND status <> 'CONFIRMED'"
|
||||
" AND model_file_path IN (" + ", ".join(["%s"] * len(drop)) + ")",
|
||||
(str(project_id), *drop),
|
||||
)
|
||||
await connection.commit()
|
||||
except Exception:
|
||||
await connection.rollback()
|
||||
|
||||
@@ -31,15 +31,20 @@ def test_chip_made_by_plan_terrain() -> None:
|
||||
|
||||
|
||||
def test_upload_uses_parts_and_one_circle() -> None:
|
||||
# 올리기 단추 · 보냄 · 1 초 묻기 = 공용 부품(637-3 · B05 같음) · 예정노선 = 주소 · 서클만
|
||||
part = _read(ROOT / "A00_Common" / "map" / "map_lidar_upload.ts")
|
||||
assert 'label: "LAS 올리기"' in part and "createButton({" in part
|
||||
assert 'pickFiles({ accept: ".las,.laz,.prj", multiple: true }' in part
|
||||
assert "const POLL_MS = 1000;" in part and "button.disabled = on;" in part
|
||||
lidar = _read(D03 / "D03_PlanAnalysis_UI_LidarUpload.ts")
|
||||
assert 'label: "LAS 올리기"' in lidar and "createButton({" in lidar
|
||||
assert 'pickFiles({ accept: ".las,.laz,.prj", multiple: true }' in lidar
|
||||
assert 'createElement("button")' not in lidar and 'createElement("input")' not in lidar
|
||||
assert "createProgressCircle" not in lidar # 서클 = 위치 칸 하나(604-3)
|
||||
assert 'label: "라이다"' in lidar and "work.track(follow())" in lidar
|
||||
for text in (lidar, part):
|
||||
assert 'createElement("button")' not in text and 'createElement("input")' not in text
|
||||
assert "createProgressCircle" not in text # 서클 = 위치 칸 하나(604-3)
|
||||
assert "path: `/projects/${projectId}/plan/lidar`" in lidar
|
||||
assert 'label: "라이다"' in lidar and "track: (job) => work.track(job)" in lidar
|
||||
assert "void lidarUpload.follow()" in lidar
|
||||
assert 'lidarItem.style.display = !ready && editable ? "none" : ""' in lidar
|
||||
api = _read(D03 / "D03_PlanAnalysis_Api.ts")
|
||||
assert "`${API_BASE_URL}${base(projectId)}/lidar`" in api
|
||||
assert "Promise<{ terrain: PlanJob; lidar: PlanJob }>" in api
|
||||
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
@@ -63,8 +64,8 @@ def test_진행_기록_끊김(tmp_path: Path) -> None:
|
||||
|
||||
|
||||
class _Cursor:
|
||||
def __init__(self, rows: list[tuple]) -> None:
|
||||
self.rows = rows
|
||||
def __init__(self, rows: list[tuple], log: list | None = None) -> None:
|
||||
self.rows, self.log = rows, log if log is not None else []
|
||||
|
||||
async def __aenter__(self) -> "_Cursor":
|
||||
return self
|
||||
@@ -72,8 +73,8 @@ class _Cursor:
|
||||
async def __aexit__(self, *_: Any) -> None:
|
||||
return None
|
||||
|
||||
async def execute(self, *_: Any) -> None:
|
||||
return None
|
||||
async def execute(self, sql: str, args: tuple = ()) -> None:
|
||||
self.log.append((sql, args))
|
||||
|
||||
async def fetchall(self) -> list[tuple]:
|
||||
return self.rows
|
||||
@@ -81,7 +82,7 @@ class _Cursor:
|
||||
|
||||
class _Connection:
|
||||
def __init__(self, rows: list[tuple]) -> None:
|
||||
self.rows, self.done = rows, []
|
||||
self.rows, self.done, self.sql = rows, [], []
|
||||
|
||||
async def begin(self) -> None:
|
||||
return None
|
||||
@@ -93,7 +94,7 @@ class _Connection:
|
||||
self.done.append("rollback")
|
||||
|
||||
def cursor(self) -> _Cursor:
|
||||
return _Cursor(self.rows)
|
||||
return _Cursor(self.rows, self.sql)
|
||||
|
||||
|
||||
class _Pool:
|
||||
@@ -110,7 +111,9 @@ class _Pool:
|
||||
return None
|
||||
|
||||
|
||||
def _save(monkeypatch, keep: bool) -> tuple[list[dict], list[int], _Connection]: # type: ignore[no-untyped-def]
|
||||
def _save(
|
||||
monkeypatch, keep: bool, drop: list[str] | None = None
|
||||
) -> tuple[list[dict], list[int], _Connection]: # type: ignore[no-untyped-def]
|
||||
"""옛 행 둘(확정 dtm_a · 수치지형도) + 새 모델 dtm_a(지문 다름) → 부른 값."""
|
||||
import asyncio
|
||||
|
||||
@@ -143,7 +146,7 @@ def _save(monkeypatch, keep: bool) -> tuple[list[dict], list[int], _Connection]:
|
||||
"model_file_path": path_a,
|
||||
"generation_params": {"source_filter": "a", "file_stamp": "new"},
|
||||
}
|
||||
asyncio.run(lidar_upload.save_models(PID, [model], EPSG, keep=keep))
|
||||
asyncio.run(lidar_upload.save_models(PID, [model], EPSG, keep=keep, drop=drop))
|
||||
return calls, confirmed, connection
|
||||
|
||||
|
||||
@@ -155,6 +158,54 @@ def test_DB_넣기_고치기만_갈음은_id_확정_살림(monkeypatch) -> None:
|
||||
assert calls[0]["have"] == (11, "new")
|
||||
assert (calls[0]["source_file_id"], calls[0]["processed_cloud_id"]) == (1320, 7)
|
||||
assert confirmed == [11]
|
||||
assert not any("DELETE" in sql for sql, _ in connection.sql) # drop 없음 = 안 지움
|
||||
|
||||
|
||||
def test_DB_지운_필터_행만_지움_확정_남김(monkeypatch) -> None: # type: ignore[no-untyped-def]
|
||||
gone = ["B04_PreProcess/models/dtm_csf.npz", "B04_PreProcess/models/dtm_csf_smooth.npz"]
|
||||
_, _, connection = _save(monkeypatch, keep=True, drop=gone)
|
||||
sql, args = [row for row in connection.sql if "DELETE" in row[0]][0]
|
||||
assert "status <> 'CONFIRMED'" in sql and "project_id = %s" in sql
|
||||
assert args == (str(PID), *gone) and connection.done == ["commit"]
|
||||
|
||||
|
||||
def test_갈음_안_지은_필터_파일_지움(tmp_path: Path) -> None:
|
||||
models = tmp_path / "B04_PreProcess" / "models"
|
||||
processed = tmp_path / "B04_PreProcess" / "processed"
|
||||
models.mkdir(parents=True)
|
||||
processed.mkdir(parents=True)
|
||||
keep = [
|
||||
"dtm_classification.npz",
|
||||
"dtm_classification_smooth.npz",
|
||||
"contour_classification_dtm_1.0m.json",
|
||||
"dtm_sheet_laplace.npz",
|
||||
"dtm_sheet_laplace_smooth_preview.glb",
|
||||
"dtm_sheet_laplace.key.json",
|
||||
]
|
||||
stale = [
|
||||
"dtm_csf.npz",
|
||||
"dtm_csf_preview.glb",
|
||||
"dtm_csf_smooth.npz",
|
||||
"dtm_csf_smooth_grid.bin",
|
||||
"tin_csf.npz",
|
||||
"contour_csf_dtm_smooth_1.0m.json",
|
||||
]
|
||||
for name in keep + stale:
|
||||
(models / name).write_bytes(b"x")
|
||||
for name in ["mask_csf.npy", "ground_points_csf.npz", "preview_points_csf.bin"]:
|
||||
(processed / name).write_bytes(b"x")
|
||||
(processed / "mask_classification.npy").write_bytes(b"x")
|
||||
manifest = {"source_filters": {"classification": {}, "csf": {}}, "x": 1}
|
||||
(models / "manifest.json").write_text(json.dumps(manifest), encoding="utf-8")
|
||||
gone = lidar_upload.drop_stale_filters(tmp_path, ["classification"])
|
||||
assert gone == sorted(
|
||||
f"B04_PreProcess/models/{n}" for n in ["dtm_csf.npz", "dtm_csf_smooth.npz", "tin_csf.npz"]
|
||||
)
|
||||
assert sorted(p.name for p in models.iterdir()) == sorted([*keep, "manifest.json"])
|
||||
assert [p.name for p in processed.iterdir()] == ["mask_classification.npy"]
|
||||
saved = json.loads((models / "manifest.json").read_text(encoding="utf-8"))
|
||||
assert saved == {"source_filters": {"classification": {}}, "x": 1}
|
||||
assert lidar_upload.drop_stale_filters(tmp_path, ["classification"]) == [] # 두 번 = 할 것 없음
|
||||
|
||||
|
||||
# ── 637-2 B05 창구 ──────────────────────────────────────────────────────────
|
||||
@@ -211,6 +262,9 @@ def test_B_뿌리_짓기_수치지형도_그대로_덮임(tmp_path: Path, monkey
|
||||
kept = _route_build(tmp_path, monkeypatch, [[200010, 500010], [200020, 500020]], target)
|
||||
assert kept["meta"]["selection"] == target
|
||||
assert {m["source_filter"] for m in kept["models"]} == {"classification"}
|
||||
# 갈음 = 안 지은 필터(앞서 자동으로 지은 csf)는 옛 LAS 것 → 지움 · 수치지형도 그대로(637-3)
|
||||
assert "B04_PreProcess/models/dtm_csf.npz" in kept["dropped"]
|
||||
assert not list((tmp_path / b05.MODELS).glob("*_csf*")) and sheet.read_bytes() == b"sheet"
|
||||
|
||||
|
||||
class _DictCursor(_Cursor):
|
||||
@@ -259,3 +313,25 @@ def test_지형_바꿈_force_같은_지형도_다시_셈(monkeypatch) -> None:
|
||||
result, calls, line = base._run(monkeypatch, "las", "las")
|
||||
assert result["changed"] is True and calls["args"][4] == "las"
|
||||
assert calls["args"][5] == (line, line) # 노선 그대로
|
||||
|
||||
|
||||
# ── 637-3 B05 화면 ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_B05_칩_옆_올리기_확인창_force_덮임_막음() -> None:
|
||||
root = Path(__file__).resolve().parents[2]
|
||||
ui = (root / "B05_Profile" / "B05_Profile_UI_RouteEdit_Terrain.ts").read_text(encoding="utf-8")
|
||||
assert "createLidarUpload<RouteLidar>({" in ui # 예정노선과 같은 공용 부품
|
||||
assert "const lidarPath = `/projects/${projectId}/route/lidar`;" in ui
|
||||
assert "root.append(chip.root, upload.button, circle.root);" in ui # 칩 옆 · 서클
|
||||
assert "createProgressCircle({ size: 16, inline: true })" in ui
|
||||
# 끝 = 칩 다시 · 못 덮음 = 막고 알림 · 이미 라이다 = force 다시 셈 · 확인창 거쳐서만
|
||||
assert 'await change("las", design === "las");' in ui
|
||||
assert "if (uncovered != null) return warnUncovered();" in ui
|
||||
assert 'if (next === "las" && uncovered != null) {' in ui
|
||||
assert "JSON.stringify({ terrain: next, force })" in ui
|
||||
assert ui.index("await askConfirm({") < ui.index(
|
||||
"`/projects/${projectId}/route/design-terrain`"
|
||||
)
|
||||
assert "if (info.running && !upload.busy()) void upload.follow();" in ui # 들어올 때 이어 봄
|
||||
assert len(ui.splitlines()) < 700
|
||||
|
||||
Reference in New Issue
Block a user