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:
2026-11-01 11:53:48 +09:00
co-authored by Claude Opus 5.5
parent d9cc2144f2
commit b3dcb73784
9 changed files with 409 additions and 104 deletions
+117
View File
@@ -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 };
}
+7 -3
View File
@@ -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()
+99 -15
View File
@@ -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;
-15
View File
@@ -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 }),
};
}
+59 -3
View File
@@ -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()
+11 -6
View File
@@ -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
+84 -8
View File
@@ -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