From b3dcb73784405f6aa95a5c886339a6cd1b2f04e9 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 1 Nov 2026 11:53:48 +0900 Subject: [PATCH] =?UTF-8?q?feat(637-3):=20B05=20=EC=A7=80=ED=98=95=20?= =?UTF-8?q?=EC=B9=A9=20=EC=98=86=20[LAS=20=EC=98=AC=EB=A6=AC=EA=B8=B0]=20?= =?UTF-8?q?=C2=B7=20=EC=A7=84=ED=96=89=20=EC=84=9C=ED=81=B4=20=C2=B7=20?= =?UTF-8?q?=ED=99=95=EC=9D=B8=EC=B0=BD=20=E2=86=92=20=EC=84=A4=EA=B3=84=20?= =?UTF-8?q?=EC=A7=80=ED=98=95=20=EB=B0=94=EA=BF=88=20=C2=B7=20=EA=B3=B5?= =?UTF-8?q?=EC=9A=A9=20=EC=98=AC=EB=A6=AC=EA=B8=B0=20=EB=B6=80=ED=92=88=20?= =?UTF-8?q?map=5Flidar=5Fupload(=EC=98=88=EC=A0=95=EB=85=B8=EC=84=A0=20?= =?UTF-8?q?=EA=B0=99=EC=9D=80=20=EB=8B=A8=EC=B6=94=20=C2=B7=20=ED=9D=90?= =?UTF-8?q?=EB=A6=84)=20=C2=B7=20=EA=B0=88=EC=9D=8C=20=EB=95=8C=20?= =?UTF-8?q?=EC=95=88=20=EC=A7=80=EC=9D=80=20=EB=9D=BC=EC=9D=B4=EB=8B=A4=20?= =?UTF-8?q?=ED=95=84=ED=84=B0=20=ED=8C=8C=EC=9D=BC=20=C2=B7=20=ED=96=89=20?= =?UTF-8?q?=EC=A7=80=EC=9B=80(=EC=9A=94=EA=B5=AC=20546)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 공용 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 --- A00_Common/map/map_lidar_upload.ts | 117 ++++++++++++++++++ B05_Profile/B05_Profile_Router_Lidar.py | 10 +- .../B05_Profile_UI_RouteEdit_Terrain.ts | 114 ++++++++++++++--- .../B05_Profile_UI_Style_RouteEdit.css | 11 ++ D03_PlanAnalysis/D03_PlanAnalysis_Api.ts | 15 --- .../D03_PlanAnalysis_UI_LidarUpload.ts | 75 ++++------- common_util/common_util_lidar_upload.py | 62 +++++++++- resources/tester/test_624_4b_plan_lidar_ui.py | 17 ++- resources/tester/test_637_lidar.py | 92 ++++++++++++-- 9 files changed, 409 insertions(+), 104 deletions(-) create mode 100644 A00_Common/map/map_lidar_upload.ts diff --git a/A00_Common/map/map_lidar_upload.ts b/A00_Common/map/map_lidar_upload.ts new file mode 100644 index 000000000..902ed182f --- /dev/null +++ b/A00_Common/map/map_lidar_upload.ts @@ -0,0 +1,117 @@ +/* ============================================================================= + * map_lidar_upload.ts + * 공용 [LAS 올리기](PLAN 637-3 · 요구 546) — 예정노선(D03) · B05 지형 칩 옆 같은 단추 · 같은 흐름. + * 단추 → 파일 고름(LAS · LAZ + .prj) → `POST `(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; + /** 지켜보는 중. */ + busy: () => boolean; +} + +const errorText = (error: unknown): string => + error instanceof Error ? error.message : String(error); + +/** LAS 묶음 올리기 — 못 받으면 서버 `detail` 글 그대로 오류. */ +async function postFiles(path: string, files: File[]): Promise { + 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(opts: { + /** 올리기 주소(`API_BASE_URL` 뒤). */ + path: string; + /** 진행 한 번 묻기. */ + poll: () => Promise; + /** 진행 보임(0~1 · 글) · null = 끝. */ + show: (state: { ratio: number; text: string } | null) => void; + /** 지켜보기를 감쌈(예: 서클 일 셈). */ + track?: (job: Promise) => Promise; + /** 지켜보기 시작 · 끝. */ + onBusy?: (busy: boolean) => void; + /** 짓기가 끝난 뒤(오류 알림 뒤 · 단추 풀린 뒤 · 마지막 진행). */ + onDone: (job: J) => Promise | 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 { + try { + await postFiles(opts.path, files); + } catch (error) { + return showToast(`LAS 올리지 못함 — ${errorText(error)}`, "error"); + } + await follow(); + } + + async function watch(): Promise { + 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 => (opts.track ? opts.track(watch()) : watch()); + return { button, follow, busy: () => busy }; +} diff --git a/B05_Profile/B05_Profile_Router_Lidar.py b/B05_Profile/B05_Profile_Router_Lidar.py index e24accbed..2d6bed715 100644 --- a/B05_Profile/B05_Profile_Router_Lidar.py +++ b/B05_Profile/B05_Profile_Router_Lidar.py @@ -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() diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Terrain.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Terrain.ts index 716bb48c4..50bc03725 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Terrain.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Terrain.ts @@ -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({ + path: lidarPath, + poll: () => requestJson(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 { - if (busy || next === design) return chip.set(toChip(design)); + /** 올림 끝 — 칩 다시 · 덮임 보고 확인창(설계 지형 바꿈 · 이미 라이다 = 다시 셈). */ + async function afterUpload(job: RouteLidar): Promise { + 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 { + const info = await requestJson(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 { + 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, }; diff --git a/B05_Profile/B05_Profile_UI_Style_RouteEdit.css b/B05_Profile/B05_Profile_UI_Style_RouteEdit.css index 6169619ab..106d40063 100644 --- a/B05_Profile/B05_Profile_UI_Style_RouteEdit.css +++ b/B05_Profile/B05_Profile_UI_Style_RouteEdit.css @@ -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; diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts b/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts index d655ce616..31def2906 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts @@ -172,21 +172,6 @@ export interface PlanLidar { export const fetchPlanLidar = (projectId: string): Promise => request(`${base(projectId)}/lidar`); -/** LAS · LAZ(+ .prj 하나) 올리기 → 뒤에서 지형(진행 = `fetchPlanProgress().lidar`) · 못 받으면 서버 글 그대로 오류. */ -export async function uploadPlanLidar(projectId: string, files: File[]): Promise { - 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 => request(`${base(projectId)}/network`); diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_LidarUpload.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_LidarUpload.ts index 34fc7db85..2fad94931 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_LidarUpload.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_LidarUpload.ts @@ -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('[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 { @@ -109,42 +112,6 @@ export function createPlanTerrain(opts: { opts.onChange(source); } - async function send(files: File[]): Promise { - try { - await uploadPlanLidar(projectId, files); - } catch (error) { - return showToast(`LAS 올리지 못함 — ${errorText(error)}`, "error"); - } - await work.track(follow()); - } - - /** 올린 뒤 지형 짓기를 끝날 때까지 지켜봄 — 진행 = 서클. */ - async function follow(): Promise { - 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 { 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 }), }; } diff --git a/common_util/common_util_lidar_upload.py b/common_util/common_util_lidar_upload.py index a78e57d15..52c958af8 100644 --- a/common_util/common_util_lidar_upload.py +++ b/common_util/common_util_lidar_upload.py @@ -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() diff --git a/resources/tester/test_624_4b_plan_lidar_ui.py b/resources/tester/test_624_4b_plan_lidar_ui.py index ade97ede7..5da81519e 100644 --- a/resources/tester/test_624_4b_plan_lidar_ui.py +++ b/resources/tester/test_624_4b_plan_lidar_ui.py @@ -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 diff --git a/resources/tester/test_637_lidar.py b/resources/tester/test_637_lidar.py index 002c716e7..fa206d109 100644 --- a/resources/tester/test_637_lidar.py +++ b/resources/tester/test_637_lidar.py @@ -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({" 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