From 3abc2edba6e1a4ee1bac9dd9718e3353237288a5 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 5 Jul 2026 21:27:23 +0900 Subject: [PATCH] 260705_2 --- .agent/agent.md | 160 +++- .agent/backend.md | 32 +- .agent/db_design_proposal.md | 682 --------------- .agent/{db_schema_simple.md => db_schema.md} | 304 +++++-- .agent/migration_plan.md | 574 +++++-------- .agent/structure.md | 15 +- {.antigravity => .agents}/instructions.md | 0 .claude/settings.json | 12 + .claude/settings.local.json | 7 +- .dependencygraph/setting.json | 1 + .env.example | 17 +- B03_FileInput/B03_FileInput_Api_Fetch.ts | 47 ++ B03_FileInput/B03_FileInput_Engine.py | 60 ++ B03_FileInput/B03_FileInput_Engine_Analyze.py | 162 ++++ B03_FileInput/B03_FileInput_Repository.py | 86 ++ B03_FileInput/B03_FileInput_Router.py | 144 ++++ B03_FileInput/B03_FileInput_Schema.py | 52 ++ B03_FileInput/B03_FileInput_UI_Page.ts | 197 ++++- B03_FileInput/B03_FileInput_UI_Style.css | 123 ++- B04_wf1_Surface/B04_wf1_Surface_Api_Fetch.ts | 91 ++ B04_wf1_Surface/B04_wf1_Surface_Engine.py | 125 +++ .../B04_wf1_Surface_Engine_Contour.py | 335 ++++++++ .../B04_wf1_Surface_Engine_Filter_CSF.py | 110 +++ .../B04_wf1_Surface_Engine_Filter_Grid.py | 53 ++ .../B04_wf1_Surface_Engine_Filter_PMF.py | 91 ++ .../B04_wf1_Surface_Engine_Filter_RANSAC.py | 116 +++ .../B04_wf1_Surface_Engine_Ground.py | 64 ++ .../B04_wf1_Surface_Engine_ModelBuild.py | 284 +++++++ .../B04_wf1_Surface_Engine_ModelContext.py | 321 +++++++ .../B04_wf1_Surface_Engine_Pipeline.py | 328 ++++++++ .../B04_wf1_Surface_Engine_Smooth.py | 151 ++++ .../B04_wf1_Surface_Engine_Structurize.py | 112 +++ B04_wf1_Surface/B04_wf1_Surface_Repository.py | 227 +++++ B04_wf1_Surface/B04_wf1_Surface_Router.py | 149 ++++ B04_wf1_Surface/B04_wf1_Surface_Schema.py | 71 ++ B04_wf1_Surface/B04_wf1_Surface_UI_Page.ts | 240 +++++- B05_wf2_Route/B05_wf2_Route_Engine.py | 120 +++ .../B05_wf2_Route_Engine_Geometry.py | 264 ++++++ .../B05_wf2_Route_Engine_RidgeValley.py | 783 ++++++++++++++++++ .../B05_wf2_Route_Engine_Skeleton.py | 315 +++++++ B05_wf2_Route/B05_wf2_Route_Engine_Solver.py | 656 +++++++++++++++ B05_wf2_Route/B05_wf2_Route_Repository.py | 181 ++++ B05_wf2_Route/B05_wf2_Route_Router.py | 133 +++ B05_wf2_Route/B05_wf2_Route_Schema.py | 102 +++ .../B06_wf3_ProfileCross_Engine.py | 117 +++ .../B06_wf3_ProfileCross_Engine_Sampler.py | 196 +++++ .../B06_wf3_ProfileCross_Engine_Section.py | 266 ++++++ .../B06_wf3_ProfileCross_Repository.py | 145 ++++ .../B06_wf3_ProfileCross_Router.py | 164 ++++ .../B06_wf3_ProfileCross_Schema.py | 53 ++ common_util/common_util_atomic.py | 55 ++ common_util/common_util_json.py | 35 + common_util/common_util_storage.py | 36 + common_util/common_util_workflow.py | 42 + config/config_db.py | 35 +- config/config_frontend.ts | 6 + config/config_system.py | 219 ++++- db_management/001_create_schema.sql | 516 ++++++++++++ main.py | 35 +- requirements.txt | 2 +- tests/test_b03_file_input_analyze.py | 110 +++ tests/test_b03_file_input_engine.py | 58 ++ tests/test_b03_file_input_repository.py | 100 +++ tests/test_b03_file_input_router.py | 122 +++ tests/test_b03_file_input_schema.py | 30 + tests/test_b04_surface_engine.py | 64 ++ tests/test_b04_surface_filter_csf.py | 48 ++ tests/test_b04_surface_filter_grid.py | 25 + tests/test_b04_surface_filter_pmf.py | 42 + tests/test_b04_surface_filter_ransac.py | 59 ++ tests/test_b04_surface_pipeline.py | 61 ++ tests/test_b04_surface_repository.py | 143 ++++ tests/test_b04_surface_structurize.py | 40 + tests/test_b05_route_engine.py | 64 ++ tests/test_b05_route_repository.py | 148 ++++ tests/test_b05_route_ridgevalley.py | 44 + tests/test_b05_route_skeleton.py | 51 ++ tests/test_b05_route_solver.py | 103 +++ tests/test_b06_profilecross_section.py | 71 ++ tests/test_common_util_json.py | 18 + tests/test_common_util_workflow.py | 63 ++ tests/test_config_system_storage.py | 59 ++ ui_template/ui_template_locale.ts | 26 + 83 files changed, 10351 insertions(+), 1217 deletions(-) delete mode 100644 .agent/db_design_proposal.md rename .agent/{db_schema_simple.md => db_schema.md} (53%) rename {.antigravity => .agents}/instructions.md (100%) create mode 100644 .claude/settings.json create mode 100644 .dependencygraph/setting.json create mode 100644 B03_FileInput/B03_FileInput_Api_Fetch.ts create mode 100644 B03_FileInput/B03_FileInput_Engine.py create mode 100644 B03_FileInput/B03_FileInput_Engine_Analyze.py create mode 100644 B03_FileInput/B03_FileInput_Repository.py create mode 100644 B03_FileInput/B03_FileInput_Router.py create mode 100644 B03_FileInput/B03_FileInput_Schema.py create mode 100644 B04_wf1_Surface/B04_wf1_Surface_Api_Fetch.ts create mode 100644 B04_wf1_Surface/B04_wf1_Surface_Engine.py create mode 100644 B04_wf1_Surface/B04_wf1_Surface_Engine_Contour.py create mode 100644 B04_wf1_Surface/B04_wf1_Surface_Engine_Filter_CSF.py create mode 100644 B04_wf1_Surface/B04_wf1_Surface_Engine_Filter_Grid.py create mode 100644 B04_wf1_Surface/B04_wf1_Surface_Engine_Filter_PMF.py create mode 100644 B04_wf1_Surface/B04_wf1_Surface_Engine_Filter_RANSAC.py create mode 100644 B04_wf1_Surface/B04_wf1_Surface_Engine_Ground.py create mode 100644 B04_wf1_Surface/B04_wf1_Surface_Engine_ModelBuild.py create mode 100644 B04_wf1_Surface/B04_wf1_Surface_Engine_ModelContext.py create mode 100644 B04_wf1_Surface/B04_wf1_Surface_Engine_Pipeline.py create mode 100644 B04_wf1_Surface/B04_wf1_Surface_Engine_Smooth.py create mode 100644 B04_wf1_Surface/B04_wf1_Surface_Engine_Structurize.py create mode 100644 B04_wf1_Surface/B04_wf1_Surface_Repository.py create mode 100644 B04_wf1_Surface/B04_wf1_Surface_Router.py create mode 100644 B04_wf1_Surface/B04_wf1_Surface_Schema.py create mode 100644 B05_wf2_Route/B05_wf2_Route_Engine.py create mode 100644 B05_wf2_Route/B05_wf2_Route_Engine_Geometry.py create mode 100644 B05_wf2_Route/B05_wf2_Route_Engine_RidgeValley.py create mode 100644 B05_wf2_Route/B05_wf2_Route_Engine_Skeleton.py create mode 100644 B05_wf2_Route/B05_wf2_Route_Engine_Solver.py create mode 100644 B05_wf2_Route/B05_wf2_Route_Repository.py create mode 100644 B05_wf2_Route/B05_wf2_Route_Router.py create mode 100644 B05_wf2_Route/B05_wf2_Route_Schema.py create mode 100644 B06_wf3_ProfileCross/B06_wf3_ProfileCross_Engine.py create mode 100644 B06_wf3_ProfileCross/B06_wf3_ProfileCross_Engine_Sampler.py create mode 100644 B06_wf3_ProfileCross/B06_wf3_ProfileCross_Engine_Section.py create mode 100644 B06_wf3_ProfileCross/B06_wf3_ProfileCross_Repository.py create mode 100644 B06_wf3_ProfileCross/B06_wf3_ProfileCross_Router.py create mode 100644 B06_wf3_ProfileCross/B06_wf3_ProfileCross_Schema.py create mode 100644 common_util/common_util_atomic.py create mode 100644 common_util/common_util_json.py create mode 100644 common_util/common_util_storage.py create mode 100644 common_util/common_util_workflow.py create mode 100644 db_management/001_create_schema.sql create mode 100644 tests/test_b03_file_input_analyze.py create mode 100644 tests/test_b03_file_input_engine.py create mode 100644 tests/test_b03_file_input_repository.py create mode 100644 tests/test_b03_file_input_router.py create mode 100644 tests/test_b03_file_input_schema.py create mode 100644 tests/test_b04_surface_engine.py create mode 100644 tests/test_b04_surface_filter_csf.py create mode 100644 tests/test_b04_surface_filter_grid.py create mode 100644 tests/test_b04_surface_filter_pmf.py create mode 100644 tests/test_b04_surface_filter_ransac.py create mode 100644 tests/test_b04_surface_pipeline.py create mode 100644 tests/test_b04_surface_repository.py create mode 100644 tests/test_b04_surface_structurize.py create mode 100644 tests/test_b05_route_engine.py create mode 100644 tests/test_b05_route_repository.py create mode 100644 tests/test_b05_route_ridgevalley.py create mode 100644 tests/test_b05_route_skeleton.py create mode 100644 tests/test_b05_route_solver.py create mode 100644 tests/test_b06_profilecross_section.py create mode 100644 tests/test_common_util_json.py create mode 100644 tests/test_common_util_workflow.py create mode 100644 tests/test_config_system_storage.py diff --git a/.agent/agent.md b/.agent/agent.md index fe1aa0f..19ad6d1 100644 --- a/.agent/agent.md +++ b/.agent/agent.md @@ -1,44 +1,148 @@ -# ¿¡ÀÌÀüÆ® Çൿ Áöħ ¹× ¶ó¿ìÆÃ °¡À̵å (agent.md) +# 프로젝튞 행동지칚 및 Ʞ술 슀택 명섞 (agent.md) -## 1. °³¹ß ȯ°æ ¹× ±âŒú œºÅà ±âÁØ (Technical Stack Baseline) -¿¡ÀÌÀüÆ®ŽÂ Äڵ垊 ÀÛŒº, ŒöÁ€ ¶ÇŽÂ °ËÁõÇÒ ¶§ ȯ°æ ±âÁذú ȣȯµÇµµ·Ï ±žÇö +## 프로젝튞 & DB 정볎 -### A. ¹é¿£µå ¹× ¿¬»ê ¿£Áø (Python) +### 📌 프로귞랚명 +**Aislo (아읎슬로)** +- **의믞**: AI (읞공지능) + Slotti (핀란드얎: 임도/산늌 도로) +- **윘셉튞**: 산늌의 믞래륌 엎얎가는 읞공지능 겜로 섀계 솔룚션 + +--- + +## 1. Ʞ술 환겜 및 Ʞ볞 슀택 (Technical Stack Baseline) +프로젝튞의 윔드륌 작성, 테슀튞 또는 검슝할 환겜 명확화 및 혾환 Ʞ쀀 제시. + +### A. 백엔드 프레임워크 (Python) * **Runtime:** Python v3.12 or v3.13.7 * **Framework:** FastAPI / Pydantic * **Geometry/GIS Engine:** Trimesh, Whitebox, Geopandas, Shapely, Rasterio, Laspy -### B. µ¥ÀÌÅͺ£ÀÌœº (PostgreSQL) -* **DBMS:** PostgreSQL v17 -* **Spatial Extension:** PostGIS v3.4 (°ø°£ Äõž® ¹× ¿¬»ê Ȱ¿ë) -* **DB Driver:** ºñµ¿±â ¿¬µ¿À» À§ÇÑ `asyncpg` ±âÁØ ±žµ¿ +### B. 데읎터베읎슀 (MariaDB) +* **DBMS:** MariaDB v10.6+ +* **Character Set:** utf8mb4 (한Ꞁ 완벜 지원) +* **Collation:** utf8mb4_unicode_ci +* **DB Driver (비동Ʞ):** `aiomysql` (순수 Python, Windows 혾환. asyncmy는 Cython 빌드 필요로 믞채택) +* **쿌늬 방식:** Raw SQL (ORM 사용 ꞈ지) +* **공간 데읎터:** JSON êž°ë°˜ 저장 (MariaDB는 PostGIS 믞지원) -### C. ÇÁ·ÐÆ®¿£µå ¹× ÀÎÅÍÆäÀÌœº (TypeScript & WebCAD) +### C. 프론튞엔드 & WebCAD (TypeScript & WebGL) * **Language/Runtime:** TypeScript / Node.js -* **ÀÎÅÍÆäÀÌœº:** HTML5 Canvas ¹× WebGL ±â¹ÝÀÇ WebCAD œÃœºÅÛ ¿¬µ¿ Ç¥ÁØ ÁØŒö +* **Rendering:** HTML5 Canvas 및 WebGL êž°ë°˜ WebCAD 시슀템 구현 -### D. µ¥ÀÌÅÍ ¹× ÆÄÀÏ ÀÎÄÚµù Ç¥ÁØ -* **ÀÎÄÚµù:** ÅØœºÆ® ÆÄÀÏÀº UTF-8 Ç¥ÁØÀ» °­ÁŠ Àû¿ë +### D. 읞윔딩 및 닀국얎 표쀀 +* **읞윔딩:** 텍슀튞 파음은 UTF-8 표쀀윌로 통음 --- -## 2. ÄÁÅØœºÆ® ¶ó¿ìÆÃ ±ÔÄ¢ (Context Routing) -ží·É ŒöœÅ œÃ Žäº¯ ÀÛŒº ¹× ÄÚµå ŒöÁ€ Àü ŽÙÀœ ŽÜ°è¿¡ µû¶ó Á€ÀÇŒ­(MD) ÇÊŒö ·Îµå. +## 2. 묞맥 띌우팅 및 묞서 구조 (Context Routing) +윔드 작성 및 Ʞ술 검슝 시 묞서 우선순위에 따륞 계잵적 레퍌런슀 필수 읜Ʞ. -* **1ŽÜ°è (ÇÊŒö):** `.agent/structure.md` ÁïœÃ ·Îµå ¹× ÇöÀç µð·ºÅäž® ±žÁ¶ ºÐŒ®. -* **2ŽÜ°è (Á¶°Ç ºÐ±â):** ÇÁ·ÒÇÁÆ® Œº°Ý¿¡ µû¶ó ÇÊ¿äÇÑ ÆÄÀÏ Œ±º° ·Îµå. - * **Á¶°Ç A (UI, ÁŠŸî, ÄÄÆ÷³ÍÆ®, WebCAD):** `.agent/frontend.md` ÇÊŒö Ãß°¡ ·Îµå. - * **Á¶°Ç B (¿¬»ê ŒöœÄ, Œ³°è ±âÁØ, DB, PostGIS):** `.agent/backend.md` ÇÊŒö Ãß°¡ ·Îµå. +* **1닚계 (필수):** `.agent/structure.md` 읜Ʞ 후 신규 폮더 생성 Ʞ쀀 분석. +* **2닚계 (선택 읜Ʞ):** 프로젝튞 구현에 필요한 섞부 Ʞ술 명섞 읜Ʞ. + * **귞룹 A (UI, 슀타음, 컎포넌튞, WebCAD):** `.agent/frontend.md` 필수 추가 읜Ʞ. + * **귞룹 B (알고늬슘, 저장, DB, MariaDB 값 제얎):** `.agent/backend.md` 필수 추가 읜Ʞ. + * **귞룹 C (DB 구조 변겜):** DB 구조 변겜 시 `.agent/db_schema.md` 선택적 읜Ʞ. --- -## 3. ÄÚµå ÀÛŒº ÁŠŸà Á¶°Ç (Constraints) -* **±žÁ¶ ÁØŒö:** `.agent/structure.md` Æ®ž® ±žÁ¶ °­ÁŠ ÁØŒö. -* **700ÁÙ ÁŠÇÑ:** ŽÜÀÏ ÆÄÀÏ ÄÚµå »ýŒº/ŒöÁ€ ÈÄ 700ÁÙ ÀÌ»ó œÃ ž®ÆÑÅäžµ ÇÊŒö ÁŠŸÈ. À¯Àú œÂÀÎ ÈÄ ºÐÇÒ ¹× `structure.md` µ¿œÃ Ÿ÷µ¥ÀÌÆ®. -* **¿µ¿ª °Ýž®:** ÁöÁ€µÈ ÀÛŸ÷ ¿µ¿ª ¿Ü µð·ºÅäž® ÆÄÀÏ Á¢±Ù¡€ŒöÁ€ œÃ À¯Àú »çÀü œÂÀÎ ÇÊŒö. -* **Á€ÇÕŒº °ËÁõ:** ÀÌÀü žÆ¶ô ŽÜÀýÀ» °í·ÁÇÏ¿© ÇöÀç ŒÒœº ÄÚµå¿Í ºÐÇÒ Á€ÀÇŒ­(`frontend.md`, `backend.md`) °£ Á€ÇÕŒº Ãֿ쌱 °ËÅä. -* **Á€ºž ¿ä±ž ÇÊŒö:** ¿¬°ü ÆÄÀÏÀÇ œÇÁŠ ³»ºÎ Äڵ尡 ÇÁ·ÒÇÁÆ®¿¡ ÁаøµÇÁö ŸÊÀº °æ¿ì, ÀÓÀÇ ÃßÃø ÄÚµùÀ» ÀýŽë ±ÝÁöÇÏžç À¯Àú¿¡°Ô ÆÄÀÏ ³»¿ë ÁŠœÃžŠ žÕÀú ¿ä±žÇÒ °Í. -* **œºÅžÀÏ ÅëÇÕ (ÀÚÀ² Æ÷žÅÆÃ):** ¿¡ÀÌÀüÆ®ŽÂ ÄÚµå ÀÛŒºÀ» ¿Ï·áÇÑ ÈÄ, ¹ÝµåœÃ ÇÁ·ÎÁ§Æ® Å͹̳ο¡Œ­ ŽÙÀœ ží·ÉŸîžŠ Á÷Á¢ œÇÇàÇÏ¿© Œ­œÄÀ» Á€·ÄÇÑ µÚ ÃÖÁŸ ÀúÀåÇØŸß ÇÑŽÙ. (À¯Àú¿¡°Ô Œ­œÄ ±³Á€ ÀÛŸ÷À» Àü°¡ÇÏÁö ž» °Í) - * *Python ÆÄÀÏ ŒöÁ€ œÃ:* `ruff format [ÆÄÀÏží]` ¹× `ruff check --fix [ÆÄÀÏží]` œÇÇà - * *TypeScript/CSS ÆÄÀÏ ŒöÁ€ œÃ:* `npx prettier --write [ÆÄÀÏží]` œÇÇà -* **ÀÇÁžŒº ÁØŒö:** ¿ÜºÎ ¶óÀ̺귯ž® Ȱ¿ë œÃ ÇÁ·ÎÁ§Æ®¿¡ žíœÃµÈ ȣȯ ¹öÀüÀ» ¹ÝµåœÃ ÁØŒöÇÏžç, ÁžÀçÇÏÁö ŸÊ°Å³ª ŽÜÁŸµÈ ÇÔŒöžŠ À¯ÃßÇÏ¿© ÀÛŒºÇÏŽÂ °ÍÀ» ±ÝÁöÇÔ. \ No newline at end of file +## 3. 윔드 작성 Ʞ볞 제앜 (Constraints) +* **구조 쀀수:** `.agent/structure.md` 튞늬 구조 엄격 쀀수. +* **700쀄 제한:** 닚음 파음 윔드 작성/수정 시 700쀄 읎상읎 될 겜우 사전 공지. Ʞ능별 파음 분할 후 `structure.md` 갱신 필수. +* **안전 볎ꎀ:** 백엔드 작업 시 볎안 저장소 겜로 명시 및 임의 삭제 방지 필수. +* **겜로 활용:** 닚계별 페읎지 êž°ë°˜ 폮더 구조(B03~B09)와 DB 겜로 엎의 Ʞ쀀은 `.agent/db_schema_simple.md`의 「파음시슀템 겜로와 DB 링크」륌 따륞닀. +* **음ꎀ성 검슝:** 섀계 닚계에서부터 DB 섀계, 파음 겜로, API 띌우팅읎 몚두 동음한 워크플로우 Ʞ쀀윌로 통음되얎알 한닀. (불음치 발생 시 섀계 닚계에서 재녌의 필수) +* **윔드 포맷팅 (자동화 도구):** 프로젝튞의 윔드 작성읎 완료되는 시, 반드시 프로젝튞 룚튞에서 각 얞얎별 포맷터륌 재싀행하여 슀타음을 통음핎알 한닀. + * *Python 포맷팅 명령얎:* `ruff format [파음명]` 및 `ruff check --fix [파음명]` 싀행 + * *TypeScript/CSS 포맷팅 명령얎:* `npx prettier --write [파음명]` 싀행 +* **왞부 띌읎람러늬 활용:** 왞부 띌읎람러늬 활용 시 프로젝튞에 맞춰 혾환 띌읎람러늬 선정하고, 불필요한 핚수는 작성하지 않도록 최소화. + +--- + +## 4. 데읎터베읎슀 상섞 명섞 (Database Specification) + +### 4.1 슀킀마 Ʞ볞 정볎 +- **DB 명:** `aislo_db` +- **DBMS:** MariaDB v10.6+ +- **읞윔딩:** utf8mb4_unicode_ci +- **테읎랔 수:** 18개 +- **드띌읎버:** aiomysql (비동Ʞ) + +### 4.2 파음 겜로 추적 (Path Tracking) +**몚든 쀑간 산출묌의 파음 겜로륌 DB에 Ʞ록:** +- `input_files.raw_file_path` — 원볞 입력 파음 +- `processed_point_cloud.converted_file_path` — 변환된 포읞튞큎띌우드 +- `surface_models.model_file_path` — 지표멎 몚덞 +- `routes.route_data_path` — 겜로 데읎터 +- `longitudinal_sections.longitudinal_file_path` — 종닚멎 +- `cross_sections.cross_section_file_path` — 횡닚멎 +- `structures.structure_data_path` — 구조묌 배치 +- `quantity_items.quantity_data_path` — 수량 항목 +- `outputs.outputs_directory_path` — 산출묌 폮더 +- `output_files.output_file_path` — 개별 산출 파음 + +### 4.3 저장소 구조 (Workflow-based Folder Structure) +``` +storage/{company_slug}/{user_slug}/{project_id}/ +├── B03_FileInput/input/ (WF0: 원볞 입력) +├── B04_wf1_Surface/processed/ (WF1: 변환된 포읞튞큎띌우드 & 몚덞) +├── B05_wf2_Route/route/ (WF2: 겜로 섀계) +├── B06_wf3_ProfileCross/ (WF3: 종닚멎 & 횡닚멎) +├── B07_wf4_DesignDetail/structures/ (WF4: 구조묌 배치) +├── B08_wf5_Quantity/quantities/ (WF5: 수량 산출) +└── B09_wf6_Estimation/v1,v2,.../ (WF6: 최종 산출묌) +``` + +### 4.4 공간 데읎터 처늬 (MariaDB 특성) +- **지하형 Ʞ하 데읎터:** GEOMETRY 타입 믞지원 → JSON윌로 저장 +- **좌표 저장 예:** `{"type": "Point", "coordinates": [127.5, 37.5]}` +- **겜로 저장 예:** `{"type": "LineString", "coordinates": [[127.5, 37.5], [127.6, 37.6]]}` +- **애플늬쌀읎션 처늬:** Python의 Shapely, Geopandas에서 JSON 파싱 후 Ʞ하 연산 + +--- + +## 5. 워크플로우 6닚계 (6-Stage Workflow) + +``` +WF0: B03_FileInput (파음 입력) + ↓ (LAS, TIF, TFW, PRJ, DXF 업로드) +WF1: B04_wf1_Surface (지표멎 분석) + ↓ (DEM, TIN, 포읞튞큎띌우드 변환) +WF2: B05_wf2_Route (겜로 섀계) + ↓ (최적 겜로 계산) +WF3: B06_wf3_ProfileCross (종횡닚 생성) + ↓ (종닚멎, 횡닚멎 생성) +WF4: B07_wf4_DesignDetail (상섞 섀계) + ↓ (구조묌 배치) +WF5: B08_wf5_Quantity (수량 산출) + ↓ (수량 항목 계산) +WF6: B09_wf6_Estimation (견적·묞서) + ↓ (Excel, PDF, DXF 생성) +``` + +--- + +## 6. 죌요 묞서 ì°žê³  순서 + +1. **구조 섀계:** `.agent/structure.md` 읜Ʞ +2. **프론튞 구현:** `.agent/frontend.md` 읜Ʞ +3. **백엔드 구현:** `.agent/backend.md` 읜Ʞ +4. **마읎귞레읎션:** `.agent/migration_plan.md` 읜Ʞ +5. **DB 슀킀마:** `.agent/db_schema_simple.md` 읜Ʞ +6. **프로젝튞 정볎:** `.agent/project_info.md` 읜Ʞ + +--- + +## 7. 요앜 + +| 항목 | 값 | +|------|-----| +| **프로귞랚명** | Aislo (아읎슬로) | +| **DB 명** | aislo_db | +| **DBMS** | MariaDB v10.6+ | +| **읞윔딩** | utf8mb4_unicode_ci | +| **Backend** | Python 3.12+ / FastAPI | +| **Frontend** | TypeScript / Node.js | +| **드띌읎버** | aiomysql | +| **워크플로우** | 6닚계 (WF0~WF6) | +| **테읎랔 수** | 18개 | +| **폮더 구조** | 워크플로우 êž°ë°˜ (B03~B09) | diff --git a/.agent/backend.md b/.agent/backend.md index d1b9e80..6895e95 100644 --- a/.agent/backend.md +++ b/.agent/backend.md @@ -8,9 +8,12 @@ --- -## 2. DB 및 PostGIS (Database) -* **비동Ʞ 통신:** `async/await` 및 `asyncpg` 드띌읎버 사용 강제. -* **공간 연산:** ORM 사용 ꞈ지. `ST_Volume`, `ST_Distance` 등 PostGIS êž°ë°˜ **Raw SQL** 작성 원칙. +## 2. DB 및 MariaDB (Database) +* **비동Ʞ 통신:** `async/await` 및 `aiomysql` 드띌읎버 사용 강제. (asyncmy는 Cython 빌드 필요로 Windows 환겜 믞채택) +* **쿌늬 방식:** ORM 사용 ꞈ지. **Raw SQL** 작성 원칙. 파띌믞터 바읞딩은 `%s` 플레읎슀홀더 사용. +* **컀넥션/튞랜잭션:** 풀에서 `pool.acquire()`로 컀넥션 확볎 후 `connection.cursor()`로 컀서 생성. 닀걎 쓰Ʞ는 `connection.begin()` → `commit()` / 예왞 시 `rollback()`. +* **자동 슝가 ID:** `INSERT` 후 `cursor.lastrowid`로 조회 (PostgreSQL의 `RETURNING id` 믞지원). +* **공간 데읎터:** GEOMETRY 타입 믞지원. 좌표, 닀각형, 겜로 등은 JSON 형식윌로 저장 후 애플늬쌀읎션에서 처늬. --- @@ -18,6 +21,14 @@ * **하드윔딩 ꞈ지:** 제얎 변수/파띌믞터 하드윔딩 ꞈ지. `config/config_system.py`에서 `import` 필수. * **묌늬 파음 격늬:** 포읞튞 큎띌우드, 분석 쀑간 파음, 메쉬, 임시 컀서는 DB 저장 ꞈ지. * **저장 겜로:** `storage/[고객사명]/[사용자명]/[프로젝튞ID]/`에 묌늬 파음 저장 후 DB에는 겜로만 Ʞ록. +* **닚계별 룚튞 강제:** 프로젝튞 저장소 낎부는 싀제 워크플로우 페읎지명곌 동음한 `B03_FileInput/` ~ `B09_wf6_Estimation/` 폎더로 분늬한닀. `raw/`, `processed/`, `computed/`륌 프로젝튞 룚튞의 공용 폎더로 만듀지 않는닀. +* **겜로 정의 우선순위:** 닚계별 섞부 폎더와 DB 겜로 엎의 Ʞ쀀은 `.agent/db_schema_simple.md`의 「파음시슀템 겜로와 DB 링크」륌 따륞닀. +* **겜로 생성 책임:** 페읎지별 백엔드는 자Ʞ 닚계 폮더만 생성·수정한닀. 닀륞 닚계의 산출묌을 직접 삭제하거나 덮얎쓰지 않고 stale 상태륌 통핎 재계산 필요성을 전파한닀. +* **DB 저장 형식:** DB에는 프로젝튞 룚튞 Ʞ쀀 상대 겜로륌 Ʞ록하고, 싀제 파음 ì ‘ê·Œ 시 섀정의 저장소 룚튞와 안전하게 결합한닀. 사용자 입력 겜로륌 직접 결합하거나 절대 겜로륌 DB에 저장하지 않는닀. +* **MariaDB 특성:** + - 공간 Ʞ하 데읎터(좌표, 폎늬곀, 겜로 등)는 GEOMETRY 타입 믞지원 → JSON 또는 TEXT로 저장 + - 예: `{"type": "Point", "coordinates": [127.5, 37.5]}` (GeoJSON 형식) + - 애플늬쌀읎션(Python)에서 JSON 파싱 후 Ʞ하 연산 처늬 --- @@ -33,11 +44,12 @@ ### 5.1 닀쀑 람띌우저 동시 작업 원칙 * **람띌우저 독늜성:** 같은 계정/프로젝튞륌 여러 람띌우저에서 동시 ì ‘ê·Œ 가능. 각 람띌우저는 서버 데읎터 êž°ë°˜ 독늜 작동. * **큎띌읎얞튞 상태 격늬:** `localStorage`는 람띌우저별 격늬(도메읞 닚위 공유). 공유 데읎터는 항상 서버 영구저장소 우선. -* **영구저장소 섀계:** 닚계별 계산 결곌륌 `storage/[고객사]/[사용자]/[프로젝튞ID]/result_[닚계].json` 형태로 묌늬 저장. - * `result_scan.json` — 포읞튞 큎띌우드 필터링 결곌 - * `result_surface.json` — 지표멎 몚덞 선택 - * `result_route.json` — 겜로 섀계 결곌 - * `result_section.json` — 종횡닚 생성 결곌 +* **영구저장소 섀계:** 계산 결곌는 `.agent/db_schema_simple.md`에 정의된 페읎지별 닚계 폎더에 저장한닀. + * `B03_FileInput/` — 원볞 입력 및 파음 메타데읎터 + * `B04_wf1_Surface/` — 변환 포읞튞큎띌우드, 지표멎 몚덞 및 분석 결곌 + * `B05_wf2_Route/` — 겜로, 겜로점 및 섀계 파띌믞터 + * `B06_wf3_ProfileCross/` — 종닚·횡닚 결곌 및 읞덱슀 +* **워크플로우 상태:** 여러 닚계가 공유하는 `workflow.json`의 싀제 위치는 저장소 겜로 유틞에서 닚음하게 정의하며, 원자적 쓰Ʞ륌 적용한닀. 닚계별 결곌 파음의 위치륌 프로젝튞 룚튞의 `result_*.json` 읎늄윌로 추정하지 않는닀. ### 5.2 Stale 상태 감지 및 전파 * **workflow.json 구조:** @@ -56,7 +68,7 @@ ```python def _patch_workflow_stale(project_id: str, stale_from: str | None) -> None: """workflow.json의 stale_from 필드만 원자적 업데읎튞""" - wf_path = storage_path / project_id / "workflow.json" + wf_path = get_project_workflow_path(project_id) # Ʞ졎 상태 유지하며 stale_from만 변겜 ``` @@ -88,4 +100,4 @@ "changed_by": "other_browser" } ``` -* **마읎귞레읎션 시Ʞ:** 사용자 수 슝가 또는 싀시간성 요구 시 적용 \ No newline at end of file +* **마읎귞레읎션 시Ʞ:** 사용자 수 슝가 또는 싀시간성 요구 시 적용 diff --git a/.agent/db_design_proposal.md b/.agent/db_design_proposal.md deleted file mode 100644 index 6ba52b4..0000000 --- a/.agent/db_design_proposal.md +++ /dev/null @@ -1,682 +0,0 @@ -# DB 구조 섀계 제안서 - -**작성음:** 2026-07-05 -**대상:** 임도 섀계 및 견적 자동화 웹앱 (프로젝튞 êž°ë°˜ 멀티테넌튞) -**Ʞ술 슀택:** PostgreSQL v17 + PostGIS v3.4 / asyncpg / FastAPI - ---- - -## 1. 섀계 원칙 - -### 1.1 멀티테넌튞 아킀텍처 -- **프로젝튞 닚위 데읎터 격늬:** 각 사용자의 프로젝튞는 별도의 녌늬적 넀임슀페읎슀로 ꎀ늬 -- **하읎람늬드 저장소:** - - **DB:** 사용자, 프로젝튞 메타데읎터, 섀계 결곌(겜로, 닚멎, 수량) 저장 - - **파음시슀템:** 원볞 입력파음(LAS/TIF/DXF), 쀑간 산출묌(mesh/타음), 최종 산출묌(DXF/Excel/PDF) 저장 -- **슀토늬지 겜로:** `storage/{회사명}/{사용자명}/{프로젝튞ID}/` 하위로 자동 생성 - -### 1.2 데읎터 계잵 분늬 -``` -[입력] LAS, TFW, PRJ, TIF → [분석] DEM/Mesh/필터 → [섀계] 겜로, 닚멎 → [산출] DXF, Excel - (storage) (DB + storage) (DB + storage) (storage) -``` - -### 1.3 공간 데읎터 전용 섀계 -- PostGIS Ʞ하학적 타입 활용 (geometry, geography) -- 좌표계 음ꎀ성: EPSG 윔드 저장 후 필요 시 변환 -- 벡터 데읎터(겜로, 겜계선) ↔ 래슀터 샘플링 간 추적 가능하도록 섀계 - ---- - -## 2. 핵심 Entity 및 ꎀ계 - -### 2.1 사용자 및 읞슝 (users, user_sessions) -``` -users -├── id (PK) -├── email (UNIQUE) -├── password_hash -├── name -├── company (FK → companies) -├── role (admin, user, guest) -├── created_at, updated_at - -companies -├── id (PK) -├── name (UNIQUE) -├── created_by (FK → users) -├── members [] (users, M:N via user_company_roles) -├── created_at, updated_at - -user_sessions (토큰 저장 — optional, Redis 권장) -├── id (PK) -├── user_id (FK → users) -├── token -├── expires_at -``` - -**섀계 의도:** -- 회사 닚위로 프로젝튞, 사용자륌 귞룹화 -- 회사별 권한 분늬 (협업 확장성) -- 향후 팀 공유 프로젝튞 Ʞ능 추가 가능 - -### 2.2 프로젝튞 메타데읎터 (projects, project_versions) -``` -projects -├── id (PK, UUID) -├── user_id (FK → users) — 소유자 -├── company_id (FK → companies) -├── name -├── region (지역명: 예 "ìšžì§„êµ° ꞈ강송멎") -├── road_type (간선임도, 지선임도, 산불진화임도, 계류볎전) -├── project_year (사업 연도, INT) -├── estimated_length_m (추정 연장) -├── memo -├── status (NEW, ANALYZING, WF1_COMPLETE, WF2_COMPLETE, ... , CONFIRMED, DONE) -├── crs_epsg (좌표계, INT — 예: 5178) -├── bbox (GEOMETRY(Polygon)) — 전첎 프로젝튞 범위 -├── created_at, updated_at -├── deleted_at (soft delete) - -project_versions (버전 ꎀ늬 — 선택) -├── id (PK) -├── project_id (FK → projects) -├── version_num -├── snapshot_at (슀냅샷 시점) -├── status (저장된 상태) -└── data (JSONB — 섀계 데읎터 슀냅샷) -``` - -**섀계 의도:** -- 프로젝튞 생명죌Ʞ 추적 (NEW → WF1 → WF2 → ... → CONFIRMED) -- 좌표계 메타데읎터 저장 (변환 였류 방지) -- 버전 ꎀ늬는 (선택) — 회원가입 쎈Ʞ엔 믞필수, 협업 필요 시 추가 - -### 2.3 입력 파음 ꎀ늬 (input_files) -``` -input_files -├── id (PK) -├── project_id (FK → projects) -├── file_type (las, tif, tfw, prj, dxf, dwg, other) -├── original_filename -├── stored_path (storage/{...}/raw/{file_type}/{filename}) -├── file_size_mb -├── upload_by (FK → users) -├── upload_at -├── crs_epsg (파음읎 가진 좌표계) -├── metadata (JSONB — 핎상도, 데읎터 범위, 포읞튞 수 등) -└── status (UPLOADED, PROCESSED, ARCHIVED) - -point_cloud_metadata -├── id (PK) -├── input_file_id (FK → input_files, LAS만) -├── point_count (INT) -├── min_z, max_z, mean_z (높읎) -├── x_min, x_max, y_min, y_max (공간 범위) -├── densitiy_per_sqm (밀도) -└── classification_summary (JSONB — {ground: N, vegetation: N, building: N, ...}) -``` - -**섀계 의도:** -- 입력 파음의 생명죌Ʞ 추적 -- 포읞튞큎띌우드 메타데읎터로 전처늬 여부 판당 -- storage 폎더와 DB 간 음ꎀ성 확볎 - -### 2.4 지표멎 몚덞 및 분석 결곌 (surface_models, terrain_layers) -``` -surface_models (WF1 출력) -├── id (PK) -├── project_id (FK → projects) -├── model_type (dem_grid, tin, mesh_triangulated, contour_lines) -├── source_file_id (FK → input_files, LAS) -├── status (PROCESSING, COMPLETE, FAILED) -├── crs_epsg -├── resolution_m (래슀터 겜우, NULL if 벡터) -├── stored_path (storage/{...}/processed/surface/) -├── bounds (GEOMETRY(Polygon)) — 몚덞 범위 -├── metadata (JSONB — 필터링 파띌믞터, 생성 시각 등) -├── created_at, completed_at - -terrain_layers -├── id (PK) -├── surface_model_id (FK → surface_models) -├── layer_name (지표, 제1ìžµ, 제2ìžµ 등 15종) -├── geometry_type (POINTCLOUD, GRID, MESH, CONTOUR) -├── stored_path (GeoJSON / GeoTIFF / LAS 등) -└── statistics (JSONB — min_z, max_z, mean_slope, etc.) -``` - -**섀계 의도:** -- WF1 분석 결곌(DEM, TIN, mesh 등)의 출처와 메타데읎터 추적 -- ìžµ(layer)별로 렌더링 최적화 가능 -- 재분석 시 Ʞ졎 결곌와 비교 가능 - -### 2.5 겜로 섀계 (routes, route_points, route_statistics) -``` -routes (WF2 출력) -├── id (PK) -├── project_id (FK → projects) -├── status (DRAFT, CONFIRMED, ARCHIVED) -├── start_point (GEOMETRY(Point)) — 지형 위 싀제 좌표 -├── end_point (GEOMETRY(Point)) -├── start_chainage_m, end_chainage_m (잡점) -├── total_length_m -├── grade_percent[] (JSONB array — 각 구간 종닚 겜사도) -├── constraints (JSONB — max_grade, min_radius, avoidance_zones) -├── algorithm_params (JSONB — 비용핚수 가쀑치, 계산 시간 등) -├── computed_at -└── geometry (GEOMETRY(LineString, Z) — 3D 겜로) - -route_points (겜로 포읞튞 샘플, 웹 렌더링용) -├── id (PK) -├── route_id (FK → routes) -├── chainage_m (잡점) -├── geometry (GEOMETRY(Point, Z)) -├── elevation_m, slope_percent -└── sequence_num - -route_statistics -├── id (PK) -├── route_id (FK → routes) -├── min_slope, max_slope, mean_slope -├── cut_volume_m3, fill_volume_m3 -├── tree_cutting_volume (목재 추정) -└── cost_score (알고늬슘 점수) -``` - -**섀계 의도:** -- 겜로의 Ʞ하학적 정볎(3D LineString) + 잡점 êž°ë°˜ 추적 -- 재계산 시 읎전 결곌와 버전 비교 가능 -- 사용자가 수정한 겜로도 저장 가능 (draft ↔ confirmed) - -### 2.6 종닚멎 및 횡닚멎 (longitudinal_sections, cross_sections) -``` -longitudinal_sections -├── id (PK) -├── project_id (FK → projects) -├── route_id (FK → routes) -├── computed_at -├── geometry (GEOMETRY(LineString, Z)) — 겜로 따띌가며 샘플링한 표고 -├── data (JSONB) -│ ├── chainages [0, 20, 40, ...] -│ ├── elevations [100.5, 102.3, ...] -│ ├── grades [2.5, 1.8, ...] -│ └── design_elevations [100.0, 102.0, ...] (섀계 Ʞ쀀) -└── stored_path (storage/{...}/sections/longitudinal.json) - -cross_sections -├── id (PK) -├── project_id (FK → projects) -├── route_id (FK → routes) -├── chainage_m (잡점) -├── sequence_num (1st, 2nd, ...) -├── geometry (GEOMETRY(LineString, Z)) — 지형 횡닚멎 -├── geometry_design (GEOMETRY(LineString, Z)) — 섀계 횡닚멎 -├── data (JSONB) -│ ├── left_slope, right_slope -│ ├── width_m -│ ├── cut_volume_m3, fill_volume_m3 -│ ├── structures [] (낙석방지책, 돌붙임 등) -│ └── notes -└── stored_path (storage/{...}/sections/cross_{chainage}.json) -``` - -**섀계 의도:** -- 종닚멎: 겜로 따띌 섞로 방향 표고 추적 -- 횡닚멎: 20m 간격 가로 방향 닚멎 + 섀계 Ʞ쀀 -- JSONB로 유연한 추가 메타데읎터 저장 - -### 2.7 섀계 및 구조묌 (design_details, structures, quantity_items) -``` -structures (WF4 구조묌 띌읎람러늬) -├── id (PK) -├── project_id (FK → projects) -├── cross_section_id (FK → cross_sections) -├── structure_type (낙석방지책, 돌붙임, 계간수로, 낙찚공, etc.) -├── chainage_m, location (LEFT, CENTER, RIGHT) -├── length_m, width_m, height_m (Ʞ볞 치수) -├── material (강재, 윘크늬튞, 목재, 돌 등) -├── quantity (개수) -├── unit_price (닚가) -├── geometry (GEOMETRY(Polygon)) — 3D 구조묌 배치 -├── design_notes (JSONB) -└── last_modified_by (FK → users) - -quantity_items (WF5 수량 산출) -├── id (PK) -├── project_id (FK → projects) -├── category (토공, 구조묌, 포장, 배수, 녹화, 안전시섀) -├── item_name (예: "절토 음반", "낙석방지책 섀치" 등) -├── unit (m3, 개, m, m2) -├── quantity_design (섀계 수량) -├── quantity_actual (싀제 수량, 사용자 수정 가능) -├── unit_price (닚가) -├── total_price (수량 × 닚가) -├── standard_reference (섀계 Ʞ쀀 ì°žê³  자료) -├── computed_at -└── data (JSONB — 계산 곌정 메몚) -``` - -**섀계 의도:** -- 구조묌은 횡닚멎별로 배치 -- 수량 항목은 자동 계산 + 사용자 수정 가능 -- 닚가와 Ʞ쀀 추적윌로 감시/감독 Ʞ록 낚김 - -### 2.8 산출묌 ꎀ늬 (outputs, output_files) -``` -outputs -├── id (PK) -├── project_id (FK → projects) -├── output_type (estimation_excel, drawing_dxf, report_pdf, all_bundle) -├── status (GENERATING, COMPLETE, FAILED) -├── generated_by (FK → users) -├── generated_at -├── version (프로젝튞 확정 닚계에서 자동 슝가) -├── metadata (JSONB) -│ ├── template_used -│ ├── company_name, project_name -│ ├── total_cost -│ └── generation_time_sec -└── created_at - -output_files -├── id (PK) -├── output_id (FK → outputs) -├── file_type (xlsx, pdf, dxf, dwg, json, zip) -├── original_filename -├── stored_path (storage/{...}/outputs/{output_type}/{filename}) -├── file_size_mb -├── created_at -└── download_count (통계) -``` - -**섀계 의도:** -- 산출묌 생성 읎력 추적 -- 같은 프로젝튞 닀쀑 버전 ꎀ늬 (재산출 가능) -- 닀욎로드 통계로 사용 현황 파악 - -### 2.9 변겜 읎력 및 감시 (audit_logs, change_logs) -``` -audit_logs (ì ‘ê·Œ 제얎 감시) -├── id (PK) -├── user_id (FK → users) -├── action (CREATE, READ, UPDATE, DELETE, EXPORT) -├── entity_type (projects, routes, structures, etc.) -├── entity_id -├── timestamp -├── ip_address -└── details (JSONB) - -change_logs (섀계 변겜 Ʞ록) -├── id (PK) -├── project_id (FK → projects) -├── changed_by (FK → users) -├── changed_at -├── entity_type (routes, cross_sections, structures) -├── entity_id -├── old_value (JSONB) -├── new_value (JSONB) -└── reason (사용자 입력 선택) -``` - -**섀계 의도:** -- 볎안 감시 (audit_logs) -- 섀계 변겜 추적 및 례백 가능성 (change_logs) - ---- - -## 3. 파음시슀템 디렉토늬 구조 - -``` -storage/ -├── {company_slug}/ -│ ├── {user_slug}/ -│ │ └── {project_uuid}/ -│ │ ├── raw/ # 입력 원볞 -│ │ │ ├── las/ -│ │ │ ├── tif/ -│ │ │ ├── prj/ -│ │ │ ├── tfw/ -│ │ │ └── dwg_dxf/ -│ │ ├── processed/ # 쀑간 산출묌 (DB에 메타데읎터만 저장) -│ │ │ ├── surface/ -│ │ │ │ ├── dem_grid_2m.tif -│ │ │ │ ├── tin_mesh.obj / .gltf -│ │ │ │ └── contours.geojson -│ │ │ ├── points/ -│ │ │ │ ├── ground_filtered.las -│ │ │ │ └── ground_sampled_10m.ply -│ │ │ └── tiles/ -│ │ │ └── (3D Tiles / MVT 렌더링 최적화) -│ │ ├── sections/ # 닚멎 데읎터 (JSON) -│ │ │ ├── longitudinal.json -│ │ │ ├── cross_0000m.json -│ │ │ ├── cross_0020m.json -│ │ │ └── ... -│ │ └── outputs/ # 최종 산출묌 -│ │ ├── estimation/ -│ │ │ ├── v1_2025-07-05.xlsx -│ │ │ ├── v2_2025-07-10.xlsx -│ │ │ └── ... -│ │ ├── drawings/ -│ │ │ ├── v1_base.dxf -│ │ │ ├── v1_detailed.dxf -│ │ │ └── ... -│ │ ├── reports/ -│ │ │ ├── v1_report.pdf -│ │ │ └── ... -│ │ └── bundles/ -│ │ ├── v1_20250705.zip # 완전 묶음 -│ │ └── ... -│ └── {project_uuid2}/ -│ └── ... -├── temp/ # 임시 처늬 파음 (음정 êž°ê°„ 후 삭제) -└── cache/ # 렌더링 캐시 (3D Tiles, 읎믞지 타음) -``` - ---- - -## 4. 죌요 쿌늬 팹턮 및 읞덱슀 전략 - -### 4.1 자죌 사용할 조회 팹턮 -```sql --- 사용자의 전첎 프로젝튞 목록 (상태별 필터) -SELECT * FROM projects -WHERE user_id = :user_id AND deleted_at IS NULL -ORDER BY created_at DESC; - --- 프로젝튞의 최신 겜로 -SELECT * FROM routes -WHERE project_id = :project_id AND status IN ('CONFIRMED', 'DRAFT') -ORDER BY computed_at DESC LIMIT 1; - --- 겜로의 횡닚멎 음ꎄ 조회 (웹 렌더링) -SELECT chainage_m, geometry FROM cross_sections -WHERE route_id = :route_id AND status = 'CONFIRMED' -ORDER BY chainage_m; - --- 수량 항목별 비용 집계 -SELECT category, SUM(total_price) as subtotal -FROM quantity_items -WHERE project_id = :project_id -GROUP BY category; - --- 공간 범위 쿌늬 (지도 렌더링) -SELECT id, geometry FROM routes -WHERE project_id = :project_id -AND ST_Intersects(geometry, ST_GeomFromText(:bbox_wkt)); -``` - -### 4.2 권장 읞덱슀 -```sql --- 사용자별 프로젝튞 조회 -CREATE INDEX idx_projects_user_deleted -ON projects(user_id, deleted_at); - --- 프로젝튞별 겜로 최신순 -CREATE INDEX idx_routes_project_computed -ON routes(project_id, computed_at DESC); - --- 겜로의 횡닚멎 잡점 순 -CREATE INDEX idx_cross_sections_route_chainage -ON cross_sections(route_id, chainage_m); - --- PostGIS 공간 읞덱슀 -CREATE INDEX idx_routes_geometry -ON routes USING GIST(geometry); - -CREATE INDEX idx_cross_sections_geometry -ON cross_sections USING GIST(geometry); - --- 수량 항목 범위 쿌늬 -CREATE INDEX idx_quantity_project_category -ON quantity_items(project_id, category); - --- 감시 로귞 시간순 -CREATE INDEX idx_audit_logs_timestamp -ON audit_logs(user_id, timestamp DESC); -``` - ---- - -## 5. API 띌우터 구조 (FastAPI) - -### 5.1 띌우터 계잵화 -``` -routers/ -├── auth.py # 읞슝 (로귞읞, 회원가입, 토큰 갱신) -├── users.py # 사용자 프로필 -├── companies.py # 회사 ꎀ늬 -├── projects.py # 프로젝튞 CRUD + 상태 ꎀ늬 -├── files.py # 파음 업로드/닀욎로드 -├── workflows/ -│ ├── wf1_surface.py # WF1: 지표멎 분석 (LAS → DEM/mesh) -│ ├── wf2_route.py # WF2: 겜로 섀계 -│ ├── wf3_sections.py # WF3: 종횡닚 생성 -│ ├── wf4_design.py # WF4: 상섞 섀계 (구조묌) -│ ├── wf5_quantity.py # WF5: 수량 산출 -│ └── wf6_outputs.py # WF6: 산출묌 생성 (Excel/PDF/DXF) -├── analysis.py # 분석 결곌 조회 (읜Ʞ 전용) -└── admin.py # ꎀ늬자 Ʞ능 (감시 로귞 등) -``` - -### 5.2 죌요 엔드포읞튞 예시 -``` -POST /api/auth/register 사용자 가입 -POST /api/auth/login 로귞읞 -GET /api/users/me 현재 사용자 정볎 -PATCH /api/users/{user_id} 프로필 수정 - -POST /api/projects 프로젝튞 생성 -GET /api/projects 사용자 프로젝튞 목록 -GET /api/projects/{proj_id} 프로젝튞 상섞 -PATCH /api/projects/{proj_id} 프로젝튞 수정 (읎늄, 메몚 등) - -POST /api/projects/{proj_id}/files/upload 파음 업로드 -GET /api/projects/{proj_id}/files 파음 목록 - -POST /api/projects/{proj_id}/wf1/analyze WF1 시작 -GET /api/projects/{proj_id}/wf1/status WF1 진행률 -GET /api/projects/{proj_id}/wf1/surface-models 생성된 표멎 - -POST /api/projects/{proj_id}/wf2/calculate-route WF2 겜로 계산 -GET /api/projects/{proj_id}/wf2/routes 겜로 목록 -PATCH /api/projects/{proj_id}/wf2/routes/{route_id} 겜로 수정 - -GET /api/projects/{proj_id}/wf3/cross-sections 횡닚멎 목록 -GET /api/projects/{proj_id}/wf3/sections/{ch} 특정 잡점 상섞 - -POST /api/projects/{proj_id}/wf4/structures 구조묌 추가 -PATCH /api/projects/{proj_id}/wf4/structures/{id} 구조묌 수정 - -GET /api/projects/{proj_id}/wf5/quantities 수량 항목 -PATCH /api/projects/{proj_id}/wf5/quantities/{id} 수량 수정 - -POST /api/projects/{proj_id}/wf6/generate-output 산출묌 생성 -GET /api/projects/{proj_id}/outputs 산출묌 목록 -GET /api/projects/{proj_id}/outputs/{id}/download 닀욎로드 - -POST /api/projects/{proj_id}/confirm 프로젝튞 확정 -``` - ---- - -## 6. 비동Ʞ 작업 및 백귞띌욎드 처늬 (Celery / asyncio) - -### 6.1 장시간 작업 큐 -``` -작업 목록: -1. WF1 분석 (LAS 필터링 → 지형 변환) — 수십 분 -2. WF2 겜로 계산 (최적화 알고늬슘) — 수 분 -3. WF6 산출묌 생성 (DXF/Excel/PDF) — 수 분 - -구현 방식: -- Celery + Redis (프로덕션) - 또는 -- FastAPI BackgroundTasks + asyncio (쎈Ʞ 닚계) - -상태 추적: -- jobs 테읎랔에 task_id, status, progress, error_msg 저장 -- WebSocket 또는 polling윌로 진행률 큎띌읎얞튞에 전송 -``` - ---- - -## 7. 마읎귞레읎션 및 테읎랔 생성 전략 - -### 7.1 alembic êž°ë°˜ 버전 ꎀ늬 -``` -alembic/ -├── versions/ -│ ├── 001_initial_schema.py (users, companies, projects) -│ ├── 002_spatial_tables.py (routes, cross_sections, PostGIS) -│ ├── 003_audit_and_logs.py (audit_logs, change_logs) -│ └── ... -└── env.py -``` - -### 7.2 쎈Ʞ 마읎귞레읎션 닚계 -1. 사용자 / 회사 / 프로젝튞 (로귞읞 후 필수) -2. 입력 파음 메타데읎터 (파음 업로드 후 필수) -3. 겜로 / 닚멎 / 구조묌 (워크플로우 싀행 후 필수) -4. 감시 로귞 (선택, 나쀑에 추가 가능) - ---- - -## 8. 데읎터 음ꎀ성 및 검슝 규칙 - -### 8.1 데읎터 묎결성 제앜 -```sql --- 왞래킀 제앜 -ALTER TABLE projects -ADD CONSTRAINT fk_projects_user FOREIGN KEY (user_id) -REFERENCES users(id) ON DELETE RESTRICT; - --- Unique 제앜 -ALTER TABLE users ADD CONSTRAINT uq_users_email UNIQUE(email); -ALTER TABLE companies ADD CONSTRAINT uq_companies_name UNIQUE(name); - --- Check 제앜 (상태 ëšžì‹ ) -ALTER TABLE projects ADD CONSTRAINT check_project_status -CHECK (status IN ('NEW', 'ANALYZING', 'WF1_COMPLETE', ..., 'DONE')); -``` - -### 8.2 좌표계 검슝 -```python -# Python 유횚성 검사 -from pyproj import CRS - -def validate_crs(epsg_code: int): - try: - crs = CRS.from_epsg(epsg_code) - return True - except: - raise ValueError(f"Invalid EPSG code: {epsg_code}") - -# 좌표 변환 시 예왞 처늬 -def transform_coordinates(src_epsg, tgt_epsg, coords): - transformer = Transformer.from_crs(f"EPSG:{src_epsg}", f"EPSG:{tgt_epsg}") - return transformer.transform(coords.x, coords.y) -``` - -### 8.3 입력 파음 검슝 -```python -# 파음 타입 검슝 -ALLOWED_EXTENSIONS = { - 'las': laspy.read, - 'tif': rasterio.open, - 'prj': lambda f: f.read().decode('utf-8'), - 'dxf': ezdxf.readfile, -} - -# 파음 크Ʞ 제한 (config에서 읜Ʞ) -MAX_FILE_SIZE_MB = 500 - -def validate_upload(file, file_type): - if file.size > MAX_FILE_SIZE_MB * 1024 * 1024: - raise ValueError(f"File too large: {file.size / 1024 / 1024:.1f}MB") -``` - ---- - -## 9. 쎈Ʞ vs 장Ʞ 구현 로드맵 - -### Phase 1 (쎈Ʞ, 회원가입 MVP — 2-3죌) -**필수 테읎랔:** users, companies, projects, input_files, audit_logs -**API:** 읞슝, 프로젝튞 CRUD, 파음 업로드 -**워크플로우:** 파음 입력(B03)까지만 DB 연동 - -### Phase 2 (WF1-WF3, 분석 — 4-6죌) -**추가 테읎랔:** surface_models, routes, cross_sections, change_logs -**API:** WF1 분석 시작, 겜로 계산, 닚멎 조회 -**백귞띌욎드:** Celery 작업 큐 도입 - -### Phase 3 (WF4-WF6, 산출묌 — 6-8죌) -**추가 테읎랔:** structures, quantity_items, outputs, output_files -**API:** 구조묌 추가, 수량 산출, 산출묌 생성 -**Ʞ능:** Excel/PDF/DXF 자동 생성 - -### Phase 4 (협업 및 감시, 선택) -**추가 Ʞ능:** 팀 공유 프로젝튞, 권한 섞분화, 비용 청구 -**감시:** audit_logs êž°ë°˜ 컎플띌읎얞슀 늬포튞 - ---- - -## 10. 볎안 및 성능 고렀사항 - -### 10.1 볎안 -- **읞슝:** JWT 토큰 (httponly cookie) -- **권한:** user_id êž°ë°˜ row-level security (RLS) + 애플늬쌀읎션 검슝 -- **파음 ì ‘ê·Œ:** 서명된 URL 또는 토큰 검슝 -- **감시:** audit_logs에 몚든 수정 Ʞ록 -- **SQL injection 방지:** ORM (SQLAlchemy) + 파띌믞터화된 쿌늬 - -### 10.2 성능 최적화 -- **쿌늬:** 읞덱슀 전략 (위 4.2 ì°žê³ ) -- **캐싱:** Redis에 자죌 조회하는 메타데읎터 캐시 (섀정값, 사용자 권한 등) -- **파음 저장소:** 큎띌우드 슀토늬지 옵션 (AWS S3, GCS) ê³ ë € -- **3D 렌더링:** LOD/타음링윌로 대용량 지형 최적화 -- **비동Ʞ:** 장시간 작업은 백귞띌욎드 큐에서 처늬 - -### 10.3 백업 및 복구 -- **DB 백업:** 음 1회 자동 백업 (point-in-time recovery) -- **파음 백업:** 쀑요 산출묌은 별도 저장소에 복제 -- **버전 ꎀ늬:** change_logs로 프로젝튞 상태 례백 가능 - ---- - -## 11. 검토 항목 및 닀음 닚계 - -### 첎크늬슀튞 -- [ ] ERD(Entity-Relationship Diagram) 작성 및 늬뷰 -- [ ] 좌표계 변환 로직 검슝 (EPSG 윔드) -- [ ] 파음 저장소 정책 확정 (로컬 vs 큎띌우드) -- [ ] 권한 몚덞 섞밀화 (팀 공유 필요성 판당) -- [ ] 비용 청구 로직 정의 (필요 시) -- [ ] 성능 테슀튞 (대용량 파음, 대량 사용자) -- [ ] 법적 데읎터 볎ꎀ êž°ê°„ 확읞 - -### 닀음 작업 -1. **ERD 닀읎얎귞랚 생성** — dbdiagram.io 또는 draw.io -2. **alembic 마읎귞레읎션 쎈안 작성** — Phase 1 테읎랔부터 -3. **FastAPI 몚덞(Pydantic) 정의** — 입력/출력 슀킀마 -4. **API 묞서(OpenAPI)** — Swagger 자동 생성 -5. **테슀튞 DB 구성** — 로컬 PostgreSQL + 샘플 데읎터 - ---- - -## 12. ì°žê³ : 구현 ì–žì–Ž & 도구 - -| 영역 | 도구 | 용도 | -|------|------|------| -| **ORM** | SQLAlchemy 2.0 + asyncpg | async DB 쿌늬 | -| **마읎귞레읎션** | Alembic | DB 버전 ꎀ늬 | -| **검슝** | Pydantic v2 | 입력 데읎터 검슝 | -| **공간 쿌늬** | GeoAlchemy2 + PostGIS | Ʞ하학 연산 | -| **비동Ʞ** | Celery + Redis 또는 FastAPI BackgroundTasks | 장시간 작업 | -| **파음 처늬** | laspy, rasterio, geopandas, ezdxf | 공간 데읎터 | -| **묞서 생성** | openpyxl, reportlab, ezdxf | Excel/PDF/DXF 출력 | - ---- - -**읎 제안서는 Ʞ쎈 섀계 닚계입니닀. 닀음 늬뷰에서 변겜, 추가, 삭제 사항을 정늬한 후 ERD 및 마읎귞레읎션 윔드로 구첎화하겠습니닀.** diff --git a/.agent/db_schema_simple.md b/.agent/db_schema.md similarity index 53% rename from .agent/db_schema_simple.md rename to .agent/db_schema.md index e5de3d4..c21e018 100644 --- a/.agent/db_schema_simple.md +++ b/.agent/db_schema.md @@ -9,8 +9,10 @@ users ├── email (TEXT, UNIQUE) — 로귞읞 읎메음 ├── password_hash (TEXT) — 비밀번혞 암혞화 저장 ├── name (TEXT) — 사용자 읎늄 +├── position (TEXT) — 직꞉ (예: 곌장, 대늬, 사원) +├── department (TEXT) — 부서명 (예: 섀계팀, 영업팀) +├── phone (TEXT) — 연띜처 (예: 010-1234-5678) ├── company_id (INT, FK → companies.id) — 소속 회사 -├── role (TEXT) — 역할: admin, user, guest ├── created_at (TIMESTAMP) — 가입음 ├── updated_at (TIMESTAMP) — 수정음 └── deleted_at (TIMESTAMP, NULL) — 삭제음 (soft delete) @@ -21,9 +23,14 @@ users companies ├── id (INT, PK) — 회사 고유 번혞 ├── name (TEXT, UNIQUE) — 회사명 +├── business_registration_number (TEXT, UNIQUE) — 사업자등록번혞 +├── business_address (TEXT) — 사업장 죌소 +├── business_owner (TEXT) — 사업죌 읎늄 +├── business_status (TEXT) — êž°ì—… 상태 (예: 활동쀑, 폐업, 휎업) ├── created_by (INT, FK → users.id) — 회사 생성자 ├── created_at (TIMESTAMP) — 생성음 -└── updated_at (TIMESTAMP) — 수정음 +├── updated_at (TIMESTAMP) — 수정음 +└── deleted_at (TIMESTAMP, NULL) — 삭제음 (soft delete) ``` --- @@ -67,14 +74,14 @@ project_versions ## 3. 파음 & 입력 데읎터 귞룹 -### 3-1. input_files 테읎랔 +### 3-1. input_files 테읎랔 (원볞 입력 파음 — 영구저장소) ``` input_files ├── id (INT, PK) — 파음 고유 번혞 ├── project_id (UUID, FK → projects.id) — 속한 프로젝튞 ├── file_type (TEXT) — 파음 종류: las, tif, tfw, prj, dxf, dwg, other ├── original_filename (TEXT) — 원래 파음명 (예: "cloud_merged.las") -├── stored_path (TEXT) — 저장 겜로 (예: "storage/.../raw/las/cloud_merged.las") +├── raw_file_path (TEXT) — 원볞 저장 겜로 (예: "storage/.../raw/las/cloud_merged.las") ├── file_size_mb (FLOAT) — 파음 크Ʞ (MB) ├── upload_by (INT, FK → users.id) — 업로드한 사용자 ├── upload_at (TIMESTAMP) — 업로드 날짜 @@ -86,11 +93,16 @@ input_files └── status (TEXT) — 상태: UPLOADED, PROCESSED, ARCHIVED ``` -### 3-2. point_cloud_metadata 테읎랔 +### 3-2. processed_point_cloud 테읎랔 (변환된 포읞튞큎띌우드 데읎터 — 영구저장소) ``` -point_cloud_metadata -├── id (INT, PK) -├── input_file_id (INT, FK → input_files.id) — LAS 파음 ì°žì¡° +processed_point_cloud +├── id (INT, PK) — 변환 데읎터 고유 번혞 +├── input_file_id (INT, FK → input_files.id) — 원볞 LAS 파음 ì°žì¡° +├── project_id (UUID, FK → projects.id) +├── process_type (TEXT) — 변환 방식: filtered, sampled, classified 등 +├── processed_file_path (TEXT) — 변환된 파음 겜로 (예: "storage/.../processed/las/ground_filtered.las") +├── converted_format (TEXT) — 변환 포맷: las, ply, laz 등 (컎퓚터가 빠륎게 읜Ʞ 위핎) +├── converted_file_path (TEXT) — 변환 포맷 저장 겜로 (예: "storage/.../processed/ply/cloud_sampled_10m.ply") ├── point_count (INT) — 포읞튞 개수 ├── min_z (FLOAT) — 최저 높읎 ├── max_z (FLOAT) — 최고 높읎 @@ -100,59 +112,70 @@ point_cloud_metadata ├── y_min (FLOAT) — 최소 위도 ├── y_max (FLOAT) — 최대 위도 ├── density_per_sqm (FLOAT) — 닚위 멎적당 포읞튞 밀도 -└── classification_summary (JSONB) — 분류 요앜 - ├── ground (지표멎 포읞튞) - ├── vegetation (식생) - ├── building (걎묌) - └── ... +├── classification_summary (JSONB) — 분류 요앜 +│ ├── ground (지표멎 포읞튞) +│ ├── vegetation (식생) +│ ├── building (걎묌) +│ └── ... +├── processing_params (JSONB) — 변환 파띌믞터 +├── processed_at (TIMESTAMP) — 변환 완료 날짜 +└── status (TEXT) — 상태: PROCESSING, COMPLETE, FAILED ``` --- ## 4. 지표멎 & 분석 결곌 귞룹 -### 4-1. surface_models 테읎랔 (WF1 출력) +### 4-1. surface_models 테읎랔 (WF1 출력 — 지표멎 분석 결곌) ``` surface_models ├── id (INT, PK) — 지표멎 몚덞 고유 번혞 ├── project_id (UUID, FK → projects.id) ├── model_type (TEXT) — 몚덞 종류: dem_grid, tin, mesh_triangulated, contour_lines ├── source_file_id (INT, FK → input_files.id) — 원볞 LAS 파음 +├── processed_cloud_id (INT, FK → processed_point_cloud.id) — 변환된 포읞튞큎띌우드 ì°žì¡° ├── status (TEXT) — 상태: PROCESSING, COMPLETE, FAILED ├── crs_epsg (INT) — 좌표계 ├── resolution_m (FLOAT) — 핎상도 (래슀터읞 겜우, NULL 가능) -├── stored_path (TEXT) — 저장 겜로 (예: "storage/.../processed/surface/dem.tif") +├── model_file_path (TEXT) — 몚덞 저장 겜로 (예: "storage/.../processed/surface/dem.tif") ├── bounds (GEOMETRY) — 몚덞 범위 (닀각형) -├── metadata (JSONB) — 생성 파띌믞터 +├── generation_params (JSONB) — 생성 파띌믞터 │ ├── filter_type (필터링 방식) -│ ├── creation_time (생성 시각) -│ └── ... +│ ├── algorithm (사용 알고늬슘) +│ ├── params (알고늬슘 파띌믞터) +│ └── creation_time (생성 시각) ├── created_at (TIMESTAMP) └── completed_at (TIMESTAMP) ``` -### 4-2. terrain_layers 테읎랔 +### 4-2. terrain_layers 테읎랔 (WF1 산출묌 — 각 지형 레읎얎) ``` terrain_layers -├── id (INT, PK) +├── id (INT, PK) — 레읎얎 고유 번혞 ├── surface_model_id (INT, FK → surface_models.id) ├── layer_name (TEXT) — 레읎얎명 (예: "지표", "제1ìžµ", "제2ìžµ" 등) ├── geometry_type (TEXT) — Ʞ하 종류: POINTCLOUD, GRID, MESH, CONTOUR -├── stored_path (TEXT) — 저장 겜로 (GeoJSON, GeoTIFF, LAS 등) -└── statistics (JSONB) — 통계 - ├── min_z, max_z, mean_slope - └── ... +├── layer_file_path (TEXT) — 레읎얎 파음 저장 겜로 (예: "storage/.../processed/surface/layer_1.geojson") +├── file_format (TEXT) — 파음 형식: geojson, geotiff, ply, obj 등 +├── file_size_mb (FLOAT) — 파음 크Ʞ +├── bounds (GEOMETRY) — 레읎얎 범위 +├── statistics (JSONB) — 통계 +│ ├── min_z, max_z, mean_slope +│ ├── point_count (포읞튞 개수) +│ └── ... +└── created_at (TIMESTAMP) ``` --- ## 5. 겜로 & 섀계 귞룹 -### 5-1. routes 테읎랔 (WF2 출력) +### 5-1. routes 테읎랔 (WF2 출력 — 겜로 섀계 결곌) ``` routes ├── id (INT, PK) — 겜로 고유 번혞 ├── project_id (UUID, FK → projects.id) +├── surface_model_id (INT, FK → surface_models.id) — êž°ë°˜ 지표멎 몚덞 ├── status (TEXT) — 상태: DRAFT, CONFIRMED, ARCHIVED ├── start_point (GEOMETRY(Point)) — 시작점 좌표 (3D) ├── end_point (GEOMETRY(Point)) — 종료점 좌표 (3D) @@ -170,6 +193,7 @@ routes │ ├── cost_weights (비용핚수 가쀑치) │ └── ... ├── geometry (GEOMETRY(LineString, Z)) — 3D 겜로 좌표 ì—Ž +├── route_data_path (TEXT) — 겜로 데읎터 저장 겜로 (예: "storage/.../computed/route/route_main.geojson") ├── computed_at (TIMESTAMP) — 계산 완료 날짜 └── created_at (TIMESTAMP) ``` @@ -204,7 +228,7 @@ route_statistics ## 6. 종닚멎 & 횡닚멎 귞룹 -### 6-1. longitudinal_sections 테읎랔 (WF3 출력) +### 6-1. longitudinal_sections 테읎랔 (WF3 출력 — 종닚멎) ``` longitudinal_sections ├── id (INT, PK) — 종닚멎 고유 번혞 @@ -217,10 +241,11 @@ longitudinal_sections │ ├── elevations ([100.5, 102.3, 101.8, ...]) — 지표 표고 ë°°ì—Ž │ ├── grades ([2.5, 1.8, 1.0, ...]) — 겜사도 ë°°ì—Ž (%) │ └── design_elevations ([100.0, 102.0, 101.5, ...]) — 섀계 표고 -└── stored_path (TEXT) — 저장 겜로 (예: "storage/.../sections/longitudinal.json") +├── longitudinal_file_path (TEXT) — 저장 겜로 (예: "storage/.../computed/sections/longitudinal.json") +└── status (TEXT) — 상태: DRAFT, CONFIRMED ``` -### 6-2. cross_sections 테읎랔 (WF3 출력) +### 6-2. cross_sections 테읎랔 (WF3 출력 — 횡닚멎) ``` cross_sections ├── id (INT, PK) — 횡닚멎 고유 번혞 @@ -238,14 +263,15 @@ cross_sections │ ├── fill_volume_m3 (FLOAT) — 성토량 │ ├── structures ([]) — 읎 닚멎 낮 구조묌 ID ë°°ì—Ž │ └── notes (TEXT) — 메몚 -└── stored_path (TEXT) — 저장 겜로 (예: "storage/.../sections/cross_0020m.json") +├── cross_section_file_path (TEXT) — 저장 겜로 (예: "storage/.../computed/sections/cross_0020m.json") +└── status (TEXT) — 상태: DRAFT, CONFIRMED ``` --- ## 7. 섀계 & 구조묌 귞룹 -### 7-1. structures 테읎랔 (WF4 출력) +### 7-1. structures 테읎랔 (WF4 출력 — 구조묌) ``` structures ├── id (INT, PK) — 구조묌 고유 번혞 @@ -262,11 +288,13 @@ structures ├── unit_price (FLOAT) — 닚가 ├── geometry (GEOMETRY(Polygon)) — 3D 배치 도형 ├── design_notes (JSONB) — 섀계 녾튾 +├── structure_data_path (TEXT) — 구조묌 데읎터 저장 겜로 (예: "storage/.../computed/structures/struct_0020m_001.json") ├── last_modified_by (INT, FK → users.id) — 마지막 수정자 -└── created_at (TIMESTAMP) +├── created_at (TIMESTAMP) +└── updated_at (TIMESTAMP) ``` -### 7-2. quantity_items 테읎랔 (WF5 출력) +### 7-2. quantity_items 테읎랔 (WF5 출력 — 수량 산출) ``` quantity_items ├── id (INT, PK) — 수량 항목 고유 번혞 @@ -280,8 +308,10 @@ quantity_items ├── total_price (FLOAT) — 소계 (quantity_actual × unit_price) ├── standard_reference (TEXT) — Ʞ쀀 ì°žê³  자료 ├── computed_at (TIMESTAMP) — 계산 날짜 +├── quantity_data_path (TEXT) — 수량 데읎터 저장 겜로 (예: "storage/.../computed/quantities/items.json") └── data (JSONB) — 계산 곌정 메몚 ├── formula (계산식) + ├── source (데읎터 출처) └── ... ``` @@ -289,7 +319,7 @@ quantity_items ## 8. 산출묌 & 묞서 귞룹 -### 8-1. outputs 테읎랔 (WF6 출력) +### 8-1. outputs 테읎랔 (WF6 출력 — 최종 산출묌 섞튞) ``` outputs ├── id (INT, PK) — 산출묌 섞튞 고유 번혞 @@ -298,7 +328,8 @@ outputs ├── status (TEXT) — 상태: GENERATING, COMPLETE, FAILED ├── generated_by (INT, FK → users.id) — 생성자 ├── generated_at (TIMESTAMP) — 생성 날짜 -├── version (INT) — 버전 (프로젝튞 확정 시 자동 슝가) +├── version (INT) — 버전 (프로젝튞 확정 닚계에서 자동 슝가) +├── outputs_directory_path (TEXT) — 산출묌 저장 폮더 (예: "storage/.../outputs/v1/") └── metadata (JSONB) — 메타데읎터 ├── template_used (사용한 양식) ├── company_name (회사명) @@ -307,14 +338,14 @@ outputs └── generation_time_sec (생성 소요 시간) ``` -### 8-2. output_files 테읎랔 +### 8-2. output_files 테읎랔 (최종 산출 파음듀) ``` output_files ├── id (INT, PK) — 파음 고유 번혞 ├── output_id (INT, FK → outputs.id) ├── file_type (TEXT) — 파음 형식: xlsx, pdf, dxf, dwg, json, zip ├── original_filename (TEXT) — 파음명 (예: "견적서_v1.xlsx") -├── stored_path (TEXT) — 저장 겜로 (예: "storage/.../outputs/estimation/견적서_v1.xlsx") +├── output_file_path (TEXT) — 저장 겜로 (예: "storage/.../outputs/v1/견적서.xlsx") ├── file_size_mb (FLOAT) — 파음 크Ʞ ├── created_at (TIMESTAMP) — 생성 날짜 └── download_count (INT) — 닀욎로드 횟수 @@ -385,35 +416,100 @@ users (사용자) --- -## 11. 파음시슀템 겜로와 DB 링크 +## 11. 파음시슀템 겜로와 DB 링크 (워크플로우 êž°ë°˜ 구조) + +### 파음시슀템 구조 (6닚계 워크플로우에 맞춀) ``` storage/ ├── {company_slug}/ │ └── {user_slug}/ │ └── {project_id}/ ← projects.storage_path -│ ├── raw/ ← input_files.stored_path -│ │ ├── las/ -│ │ ├── tif/ -│ │ ├── tfw/ -│ │ ├── prj/ -│ │ └── dxf/ -│ ├── processed/ ← surface_models.stored_path, terrain_layers.stored_path -│ │ ├── surface/ -│ │ ├── points/ -│ │ └── tiles/ -│ ├── sections/ ← longitudinal_sections.stored_path, cross_sections.stored_path -│ │ ├── longitudinal.json -│ │ ├── cross_0000m.json -│ │ ├── cross_0020m.json -│ │ └── ... -│ └── outputs/ ← output_files.stored_path -│ ├── estimation/ -│ ├── drawings/ -│ ├── reports/ +│ │ +│ ├── B03_FileInput/ ← WF0: 파음 입력 +│ │ ├── input/ (원볞 입력 파음듀) +│ │ │ ├── las/ +│ │ │ │ └── cloud_merged.las ← input_files.raw_file_path +│ │ │ ├── tif/ +│ │ │ ├── tfw/ +│ │ │ ├── prj/ +│ │ │ └── dxf/ +│ │ └── metadata.json (파음 메타데읎터) +│ │ +│ ├── B04_wf1_Surface/ ← WF1: 지표멎 분석 +│ │ ├── processed/ (변환된 포읞튞큎띌우드) +│ │ │ ├── cloud_filtered.las ← processed_point_cloud.processed_file_path +│ │ │ └── cloud_sampled.ply ← processed_point_cloud.converted_file_path +│ │ ├── models/ (지표멎 몚덞) +│ │ │ ├── dem_2m.tif ← surface_models.model_file_path +│ │ │ ├── tin_mesh.obj +│ │ │ ├── layer_ground.geojson ← terrain_layers.layer_file_path +│ │ │ ├── layer_1.geojson +│ │ │ └── ... +│ │ └── analysis_report.json +│ │ +│ ├── B05_wf2_Route/ ← WF2: 겜로 섀계 +│ │ ├── route/ +│ │ │ ├── route_main.geojson ← routes.route_data_path +│ │ │ ├── route_points.json +│ │ │ └── route_statistics.json ← route_statistics 데읎터 +│ │ └── design_params.json +│ │ +│ ├── B06_wf3_ProfileCross/ ← WF3: 종횡닚 생성 +│ │ ├── longitudinal/ +│ │ │ └── longitudinal.json ← longitudinal_sections.longitudinal_file_path +│ │ ├── cross_sections/ +│ │ │ ├── cross_0000m.json ← cross_sections.cross_section_file_path +│ │ │ ├── cross_0020m.json +│ │ │ ├── cross_0040m.json +│ │ │ └── ... +│ │ └── sections_index.json +│ │ +│ ├── B07_wf4_DesignDetail/ ← WF4: 상섞 섀계 +│ │ ├── structures/ +│ │ │ ├── struct_0020m_001.json ← structures.structure_data_path +│ │ │ ├── struct_0020m_002.json +│ │ │ ├── struct_0040m_001.json +│ │ │ └── ... +│ │ └── design_review.json +│ │ +│ ├── B08_wf5_Quantity/ ← WF5: 수량 산출 +│ │ ├── quantities/ +│ │ │ └── items.json ← quantity_items.quantity_data_path +│ │ ├── breakdown_by_category.json (항목별 집계) +│ │ └── cost_summary.json (비용 요앜) +│ │ +│ └── B09_wf6_Estimation/ ← WF6: 견적·묞서 +│ ├── v1/ ← outputs.outputs_directory_path +│ │ ├── 견적서.xlsx ← output_files.output_file_path +│ │ ├── 섀계도.dxf +│ │ ├── 볎고서.pdf +│ │ ├── 종닚멎도.pdf +│ │ └── 횡닚멎도.pdf +│ ├── v2/ +│ │ ├── 견적서.xlsx +│ │ └── ... │ └── bundles/ +│ ├── v1_complete.zip (버전 1 전첎 팚킀지) +│ └── v2_complete.zip (버전 2 전첎 팚킀지) ``` +**핵심 개념:** +- **B03_FileInput/**: WF0 파음 입력 닚계 (사용자 업로드 원볞) +- **B04_wf1_Surface/**: WF1 지표멎 분석 결곌 (DEM, TIN, 메시, 레읎얎) +- **B05_wf2_Route/**: WF2 겜로 섀계 결곌 (겜로 GeoJSON) +- **B06_wf3_ProfileCross/**: WF3 종횡닚 생성 결곌 (종닚멎, 횡닚멎 JSON) +- **B07_wf4_DesignDetail/**: WF4 상섞 섀계 결곌 (구조묌 배치) +- **B08_wf5_Quantity/**: WF5 수량 산출 결곌 (수량 항목) +- **B09_wf6_Estimation/**: WF6 최종 산출묌 (Excel, PDF, DXF) + +**장점:** +✅ 워크플로우 닚계와 폎더가 1:1 대응 → 직ꎀ적 +✅ 각 닚계의 입력/출력읎 명확히 분늬 +✅ 각 닚계 재싀행 시 폮더만 쎈Ʞ화하멎 됚 +✅ 버전 ꎀ늬 (v1, v2, ...) 닚순화 + + --- ## 12. 핵심 쿌늬 예제 @@ -480,20 +576,86 @@ ORDER BY timestamp DESC; ## 요앜 -**쎝 17개 테읎랔:** -1. users, companies -2. projects, project_versions -3. input_files, point_cloud_metadata -4. surface_models, terrain_layers -5. routes, route_points, route_statistics -6. longitudinal_sections, cross_sections -7. structures, quantity_items -8. outputs, output_files -9. audit_logs, change_logs +### 죌요 변겜사항 +1. ✅ **users 테읎랔**: position(직꞉), department(부서), phone(연띜처) 추가 +2. ✅ **companies 테읎랔**: business_registration_number, business_address, business_owner, business_status 추가 +3. ✅ **겜로 ꎀ늬 추가**: 몚든 데읎터의 저장소 겜로륌 DB에 Ʞ록 +4. ✅ **processed_point_cloud 테읎랔 신규**: LAS → 빠륞 포맷 변환 추적 +5. ✅ **파음 겜로 섞분화**: + - `raw_file_path`: 원볞 파음 위치 + - `processed_file_path / converted_file_path`: 변환된 파음 위치 + - `*_file_path`: 각 워크플로우 닚계별 산출묌 위치 -**닀음 닚계:** -- [ ] PostgreSQL에 위 테읎랔 생성 +### 쎝 18개 테읎랔: +1. **사용자/회사**: users, companies (2개) +2. **프로젝튞**: projects, project_versions (2개) +3. **입력 데읎터**: input_files, processed_point_cloud (2개) +4. **지표멎 분석**: surface_models, terrain_layers (2개) +5. **겜로 섀계**: routes, route_points, route_statistics (3개) +6. **닚멎 섀계**: longitudinal_sections, cross_sections (2개) +7. **구조묌/수량**: structures, quantity_items (2개) +8. **산출묌**: outputs, output_files (2개) +9. **감시/읎력**: audit_logs, change_logs (2개) + +### DB vs 파음시슀템 역할 분늬 +| 저장 위치 | 낎용 | 예시 | +|----------|------|------| +| **DB** | 메타데읎터 + 겜로정볎 | 파음명, 크Ʞ, 좌표계, 상태, 저장겜로 | +| **파음시슀템** | 싀제 파음 데읎터 | LAS, DXF, Excel, PDF, JSON, GeoJSON 파음듀 | + +**워크플로우 6닚계에 따륞 파음 흐멄:** +``` +B03_FileInput/ (WF0: 파음 입력) + └─ input/*.las, *.tif, *.prj, *.tfw + ↓ +B04_wf1_Surface/ (WF1: 지표멎 분석) + ├─ processed/*.ply (변환된 포읞튞큎띌우드) + └─ models/*.tif, *.geojson (DEM, TIN, 레읎얎) + ↓ +B05_wf2_Route/ (WF2: 겜로 섀계) + └─ route/*.geojson (최적 겜로) + ↓ +B06_wf3_ProfileCross/ (WF3: 종횡닚 생성) + ├─ longitudinal/*.json (종닚멎) + └─ cross_sections/*.json (횡닚멎 ë°°ì—Ž) + ↓ +B07_wf4_DesignDetail/ (WF4: 상섞 섀계) + └─ structures/*.json (구조묌 배치) + ↓ +B08_wf5_Quantity/ (WF5: 수량 산출) + └─ quantities/*.json (수량 항목) + ↓ +B09_wf6_Estimation/ (WF6: 견적·묞서) + ├─ v1/*.xlsx, *.pdf, *.dxf (최종 산출묌 v1) + ├─ v2/*.xlsx, *.pdf, *.dxf (최종 산출묌 v2) + └─ bundles/*.zip (완전 팚킀지) +``` + +**각 닚계별 재싀행:** +- 예: B05 겜로 섀계 재싀행 → `B05_wf2_Route/route/` 폮더만 쎈Ʞ화 +- 예: B09 묞서 재생성 → `B09_wf6_Estimation/v3/` 폮더 생성 (v1, v2는 유지) + +### 워크플로우 êž°ë°˜ 폮더 구조의 읎점 + +| 항목 | 읎전 (raw/processed/computed) | 현재 (B04/B05/B06/.../B09) | +|------|-----|------| +| **직ꎀ성** | 계잵적읎나 추상적 | ✅ 워크플로우 닚계와 정확히 음치 | +| **닚계 재싀행** | 얎느 폎더륌 쎈Ʞ화할지 몚혞 | ✅ 핎당 B0X 폮더만 쎈Ʞ화 | +| **버전 ꎀ늬** | outputs/v1, v2, ... 만 가능 | ✅ 몚든 닚계에서 가능 | +| **파음 ì°Ÿêž°** | raw/ 또는 processed/ 에서 검색 | ✅ B0X_Naming윌로 명확핚 | +| **API 띌우터** | 겜로 결정읎 복잡 | ✅ `f"storage/{company}/{user}/{project_id}/B{step}_*/..."` | + +### 닀음 닚계: +- [X] MariaDB에 위 테읎랔 생성 (18개 테읎랔) - [ ] ꎀ계(FK) 섀정 -- [ ] 읞덱슀 추가 -- [ ] Alembic 마읎귞레읎션 윔드 작성 -- [ ] Python ORM(SQLAlchemy) 몚덞 정의 +- [ ] 읞덱슀 추가 (겜로 조회, 프로젝튞별 필터 등) +- [ ] Alembic 마읎귞레읎션 윔드 작성<<<<<< 확읞 필요(mariaDB사용쀑) +- [ ] Python ORM(SQLAlchemy) 몚덞 정의<<<<<< 확읞 필요(mariaDB사용쀑) +- [ ] API 띌우터에서 파음 겜로 저장 로직 추가 + - B03 파음 업로드 → `input_files.raw_file_path` 저장 + - B04 WF1 싀행 → `surface_models.model_file_path`, `terrain_layers.layer_file_path` 저장 + - B05 WF2 싀행 → `routes.route_data_path` 저장 + - B06 WF3 싀행 → `longitudinal_sections.longitudinal_file_path`, `cross_sections.cross_section_file_path` 저장 + - B07 WF4 싀행 → `structures.structure_data_path` 저장 + - B08 WF5 싀행 → `quantity_items.quantity_data_path` 저장 + - B09 WF6 싀행 → `outputs.outputs_directory_path`, `output_files.output_file_path` 저장 diff --git a/.agent/migration_plan.md b/.agent/migration_plan.md index a9333b2..286f451 100644 --- a/.agent/migration_plan.md +++ b/.agent/migration_plan.md @@ -1,388 +1,274 @@ -# 프로젝튞 늬팩토링 및 마읎귞레읎션 계획서 +# B03~B06 Ʞ능 마읎귞레읎션 계획서 -**작성음:** 2026-07-05 -**상태:** 계획 수늜 완료 -**목표:** Ʞ졎 POC(Proof of Concept) 윔드륌 정식 프로덕션 구조로 전환 +**갱신음:** 2026-07-05 +**상태:** 백엔드 마읎귞레읎션 완료 — Stage 0~4 (B03~B06) 백엔드 읎전 완료. 낚은 항목은 프론튞엔드(Vanilla TS) 재작성곌 싀제 DB 연동 검슝. +**범위:** `B03_FileInput` ~ `B06_wf3_ProfileCross` +**DB:** MariaDB v10.6+ (aiomysql 드띌읎버, Raw SQL) + +**진척 요앜:** +- Stage 0 (공통 êž°ë°˜): ✅ 완료 +- Stage 1 B03 (파음 입력): ✅ 백엔드 완료 +- Stage 2 B04 (지표멎 분석): ✅ 백엔드 완료 (필터 4종 + 몚덞 5종 + 슀묎딩 + 등고선 + 파읎프띌읞) +- Stage 3 B05 (겜로 섀계): ✅ 백엔드 완료 (Dijkstra + ridge-valley + skeleton) +- Stage 4 B06 (종횡닚 생성): ✅ 백엔드 완료 (sampler + 종횡닚 + Repository/Router) +- 공통: 전 페읎지 DB 접귌을 aiomysql Raw SQL로 통음, 순수 계산부는 싀데읎터로 검슝 +- 낚은 작업: B04~B06 프론튞엔드(Vanilla TS/WebCAD), 싀제 aislo_db 연동·구형 수치 비교 --- -## 1. 현황 분석 (Baseline Assessment) +## 1. 확정 Ʞ쀀 -### 1.1 Ʞ졎 상태 -- **위치:** `0_old/` 폎더에 백업 완료 -- **제왞 사항:** `venv/`, `node_modules/` 믞포핚 (별도 재섀치 필요) -- **특성:** 프로토타입 개발용 닚순 구조 - -### 1.2 신 구조 목표 -- **거대 몚듈 ꞈ지:** Ʞ능별/페읎지별 분할 자동화 -- **수직 통합 ꎀ늬:** 프론튞엔드(TypeScript) + 백엔드(Python) 페얎링 -- **명확한 책임 분늬:** 시슀템 폮더 vs. 페읎지 폮더 격늬 +- Ʞ능 동작의 Ʞ쀀 원볞은 `0_old/main.py`와 `0_old/utils/`읎닀. +- `0_old/backend/app/`은 읎전 프로토타입 ì°žê³  자료읎며 최종 동작 Ʞ쀀윌로 사용하지 않는닀. +- 신규 배치 구조는 `.agent/structure.md`륌 따륞닀. 구형 파음 구조는 승계하지 않는닀. +- 백엔드는 `.agent/backend.md`, 프론튞엔드는 `.agent/frontend.md`륌 따륞닀. +- 저장 구조 및 DB 겜로 엎은 `.agent/db_schema_simple.md`의 닚계별 파음시슀템 구조륌 따륞닀. +- DB 슀킀마 작성·수정은 별도 닎당 작업읎닀. 읎 마읎귞레읎션은 확정된 DB 계앜만 `aiomysql`와 Raw SQL로 사용한닀. +- 룚튞 `main.py`에는 페읎지 띌우터 등록만 추가한닀. +- 구형 React/TSX UI는 복사하지 않고 현재 Vanilla TypeScript 구조와 공통 UI 템플늿에 맞게 재작성한닀. --- -## 2. 신 디렉토늬 구조 (New Structure) +## 2. 작업 원칙 -``` -프로젝튞_룚튞/ -├── .agent/ # 에읎전튞 명섞서 -│ ├── structure.md # 폮더/파음 명명규칙 -│ ├── agent.md # Ʞ술슀택 및 제앜조걎 -│ ├── backend.md # 백엔드 명섞서 -│ ├── frontend.md # 프론튞엔드 명섞서 -│ └── migration_plan.md # 볞 파음 (마읎귞레읎션 가읎드) -│ -├── common_util/ # 공통 유틞늬티 몚듈 -│ ├── common_util_geometry.py # Ʞ하학 연산 (Trimesh, Shapely) -│ ├── common_util_gis.py # GIS 유틞 (PostGIS, Geopandas) -│ ├── common_util_file.py # 파음 I/O 및 겜로 ꎀ늬 -│ ├── common_util_validation.py # 데읎터 검슝 (Pydantic) -│ ├── common_util_math.py # 수학/통계 핚수 -│ └── common_util_string.ts # 묞자엎 처늬 (TypeScript) -│ -├── db_management/ # DB 슀킀마 및 마읎귞레읎션 -│ ├── schema.sql # PostgreSQL + PostGIS 쎈Ʞ 슀킀마 -│ ├── migration_001_init.py # 마읎귞레읎션 슀크늜튞 v1 -│ └── README.md # DB 욎영 가읎드 -│ -├── resources/ # UI 늬소슀 (로고, 읎믞지, 폰튾) -│ ├── images/ -│ │ ├── logo.svg -│ │ ├── favicon.ico -│ │ └── banners/ -│ └── fonts/ -│ └── noto-sans-kr.woff2 -│ -├── storage/ # 영구 저장소 (묌늬 파음) -│ └── README.md # 저장소 구조 섀명 -│ -├── ui_template/ # 공통 UI 템플늿 및 테마 -│ ├── ui_template_theme.css # Ꞁ로벌 색상/폰튾 변수 -│ ├── ui_template_locale.ts # 닀국얎 ꎀ늬 (i18n) -│ ├── ui_template_elements.ts # 공통 컎포넌튞 정의 -│ └── ui_template_styles.css # Ʞ볞 컎포넌튞 슀타음 -│ -├── templates/ # 볎고서/도멎 템플늿 -│ ├── template_report_standard.xlsx # 표쀀 볎고서 -│ ├── template_drawing_base.dwg # CAD Ʞ볞 도멎 -│ └── template_quantity_sheet.xlsx # 수량 산출서 -│ -├── config/ # 환겜 섀정 -│ ├── config_system.py # 백엔드 파띌믞터 -│ ├── config_db.py # DB 연결 섀정 -│ ├── config_frontend.ts # 프론튞엔드 상수 -│ └── .env.example # 환겜 변수 템플늿 -│ -├── A01_Home/ # 로귞읞 전 01: 홈 -│ ├── A01_Home_UI_Page.ts # 페읎지 UI 컎포넌튞 -│ ├── A01_Home_UI_Style.css # 페읎지 슀타음 -│ ├── A01_Home_Api_Fetch.py # API 띌우터 -│ └── A01_Home_Schema.py # Pydantic 슀킀마 -│ -├── A02_ProgDetail/ ... A08_Support/ # 로귞읞 전 페읎지듀 (동음 구조) -│ -├── B01_AccountDetail/ # 로귞읞 후 01: 계정 -├── B02_ProjRegister/ # 로귞읞 후 02: 프로젝튞 등록 -├── B03_FileInput/ # 로귞읞 후 03: 파음 입력 -│ -├── B04_wf1_Surface/ # Workflow 1: 지표멎 분석 -│ ├── B04_wf1_Surface_UI_View.ts # UI ë·°ì–Ž -│ ├── B04_wf1_Surface_UI_Form.ts # 입력 폌 -│ ├── B04_wf1_Surface_UI_Style.css -│ ├── B04_wf1_Surface_Api_Router.py # API 띌우터 -│ ├── B04_wf1_Surface_Engine.py # 연산 엔진 (Trimesh, PostGIS) -│ └── B04_wf1_Surface_Schema.py # 데읎터 슀킀마 -│ -├── B05_wf2_Route/ ... B09_wf6_Estimation/ # 워크플로우 2-6 (동음 구조) -│ -├── B10_Payment/ & B11_Status/ # 로귞읞 후 최종 페읎지 -│ -├── main.py # FastAPI 애플늬쌀읎션 진입점 -├── pyproject.toml # Python 의졎성 (Poetry) -├── package.json & package-lock.json # Node.js 의졎성 -├── CLAUDE.md # 프로젝튞 Ʞ볞 정볎 -├── README.md # 프로젝튞 개요 -└── .gitignore # Git 제왞 목록 +### 2.1 핚수 하나씩 읎전 + +각 작업 닚위는 닀음 순서륌 지킚닀. + +1. 대상 구형 핚수와 직접 의졎성 확읞 +2. 신규 페읎지 폎더에 책임에 맞는 파음 하나 생성 +3. 핚수 하나만 읎전·수정 +4. 핎당 핚수 닚위 테슀튞 또는 최소 싀행 검슝 +5. 포맷터·늰터 싀행 +6. 검슝 완료 후 닀음 핚수 진행 + +한 번에 구형 몚듈 전첎륌 복사하지 않는닀. 구형 전역 겜로, 동Ʞ식 파음 처늬, 하드윔딩 섀정은 귞대로 가젞였지 않는닀. + +### 2.2 파음 분늬 + +- `*_Router.py`: FastAPI 엔드포읞튞와 응답 변환 +- `*_Schema.py`: Pydantic 요청·응답 검슝 +- `*_Engine_*.py`: 페읎지 고유 연산 +- `*_Repository.py`: `asyncpg` Raw SQL곌 DB 겜로 메타데읎터 처늬 +- `common_util/`: 둘 읎상의 페읎지가 싀제로 공유하는 겜로·원자적 파음 쓰Ʞ·Ʞ하 유틞만 배치 +- 닚음 파음읎 700쀄에 도달하Ʞ 전에 Ʞ능별 파음 분할을 제안하고 승읞 후 `structure.md`륌 갱신한닀. + +### 2.3 저장소 겜계 + +```text +storage/{company_slug}/{user_slug}/{project_id}/ +├── B03_FileInput/ +├── B04_wf1_Surface/ +├── B05_wf2_Route/ +└── B06_wf3_ProfileCross/ ``` ---- - -## 3. 마읎귞레읎션 닚계별 계획 (Phased Migration) - -### Phase 1: Ʞ쎈 읞프띌 (Infrastructure Setup) - 1-2죌 -**닎당:** 전첎 -**목표:** 섀정 및 공통 유틞 정늬 - -#### 1.1 섀정 파음 정늬 -- [ ] `config_system.py` 작성 - - FastAPI 포튞, 로깅 레벚, 알고늬슘 파띌믞터 - - 예: `MESH_GRID_SIZE`, `UPLOAD_MAX_MB`, `LOG_LEVEL` -- [ ] `config_db.py` 작성 - - PostgreSQL 연결 묞자엎 (asyncpg) - - PostGIS 테읎랔 정의 위치 -- [ ] `config_frontend.ts` 작성 - - API Ʞ볞 URL, 파음 업로드 제한 크Ʞ - - WebCAD 렌더링 옵션 - -#### 1.2 공통 유틞 마읎귞레읎션 -- [ ] **0_old/** 에서 Ʞ졎 유틞 핚수 추출 -- [ ] `common_util/` 낮 분류: - - `common_util_geometry.py` ← Trimesh, Shapely ꎀ렚 - - `common_util_gis.py` ← PostGIS 쿌늬, Geopandas 변환 - - `common_util_file.py` ← LAS, DEM, DWG 읜Ʞ/ì“°êž° - - `common_util_validation.py` ← Pydantic 몚덞, 데읎터 검슝 - -#### 1.3 UI 템플늿 Ʞ쎈 섀정 -- [ ] `ui_template_theme.css` 작성 - - CSS 변수 정의 (띌읎튞/닀크 몚드) - - 예: `--color-bg`, `--color-text`, `--color-primary` -- [ ] `ui_template_locale.ts` 작성 - - 한Ꞁ / 영묞 í‚€-값 쌍 - - 예: `A01_Home_Title: ["반갑습니닀", "Welcome"]` -- [ ] `ui_template_elements.ts` 작성 - - 공통 버튌, 입력찜, 칎드 컎포넌튞 정의 - -#### 1.4 DB 슀킀마 및 마읎귞레읎션 쀀비 -- [ ] `db_management/schema.sql` 작성 - - 사용자, 프로젝튞, 파음 정볎 테읎랔 - - PostGIS Ʞ하학 타입 (geometry, raster) -- [ ] `db_management/migration_001_init.py` 작성 - - asyncpg êž°ë°˜ 쎈Ʞ화 슀크늜튞 - -#### 1.5 FastAPI 메읞 애플늬쌀읎션 구조 -- [ ] `main.py` 작성 (띌우터 등록만) - ```python - from fastapi import FastAPI - # A01_Home, B04_wf1_Surface 등에서 띌우터 import - # app.include_router(a01_router, prefix="/api/a01") - ``` +각 페읎지는 자Ʞ 폮더만 직접 생성·수정한닀. 상위 닚계 변겜 시 하위 결곌륌 임의 삭제하지 않고 `stale_from`을 갱신한닀. --- -### Phase 2: 로귞읞 전 페읎지 (Pre-Login Pages) - 2-3죌 -**닎당:** 프론튞엔드 + 백엔드 (페읎지별 페얎) -**목표:** A01~A08 페읎지 완성 +## 3. 구형 Ʞ능 분석 결곌 -#### 2.1 페읎지별 구조 (A01_Home 예시) -각 페읎지는 닀음 파음 구성: +### 3.1 B03_FileInput -**프론튞엔드 (TypeScript)** -- `A01_Home_UI_Page.ts` ← 페읎지 진입점 + 컎포넌튞 조늜 -- `A01_Home_UI_Style.css` ← 페읎지별 컀슀텀 슀타음 +구형 원볞: -**백엔드 (Python)** -- `A01_Home_Api_Fetch.py` ← FastAPI 띌우터 (`/api/a01/...`) -- `A01_Home_Schema.py` ← Pydantic 요청/응답 몚덞 +- `0_old/main.py`: `check_sample`, `upload_files` +- 볎조 ì°žê³ : `0_old/backend/app/analyzer.py`의 파음 분석 핚수 -#### 2.2 A01_Home (홈) - Week 1 -- [ ] `A01_Home_UI_Page.ts` 작성 - - 로고, 최신 소식 팹널, 사읎튞맵 링크 -- [ ] `A01_Home_Api_Fetch.py` 작성 - - `GET /api/a01/news` ← 최신 소식 조회 -- [ ] Ʞ볞 슀타음 적용 +책임: -#### 2.3 A02_ProgDetail ~ A08_Support - Week 2-3 -동음 구조로 순찚 진행: -- [ ] A02_ProgDetail (프로귞랚 상섞) -- [ ] A03_CompDetail (회사 상섞) -- [ ] A04_NewsHistory (뉎슀/읎력) -- [ ] A05_EduDetail (교육) -- [ ] A06_Login (로귞읞) ← **핵심: JWT 토큰 발꞉** -- [ ] A07_Register (회원가입) ← **핵심: 사용자 생성, 읎메음 검슝** -- [ ] A08_Support (Ʞ술 지원) +- LAS/TIF/TFW/PRJ/DXF 업로드 검슝 +- `B03_FileInput/input/{file_type}/` 저장 +- 파음 메타데읎터 추출 및 `input_files` Ʞ록 +- 업로드 완료 상태 반환 + +B04로 넘Ꞟ Ʞ능: + +- `process_pipeline` +- `run_ground_analysis` +- 포읞튞 필터링 및 지표멎 몚덞 생성 + +### 3.2 B04_wf1_Surface + +구형 원볞: + +- `0_old/main.py`: `process_pipeline`, `ensure_terrain_models`, 지표멎·포읞튞 API +- `0_old/utils/structurizer.py` +- `filter_grid_min_z.py`, `filter_csf.py`, `filter_pmf.py`, `filter_ransac.py` +- `terrain_model_converter.py`, `surface_smoother.py`, `contour_extractor.py` + +책임: + +- LAS 구조화와 지멎 필터 생성 +- 변환 포읞튞큎띌우드 저장 +- TIN/DTM/NURBS/implicit/meshfree 몚덞 생성 +- 슀묎딩, 믞늬볎Ʞ, 등고선 생성 +- 지표멎 몚덞 선택 확정 및 하위 닚계 stale 전파 + +### 3.3 B05_wf2_Route + +구형 원볞: + +- `0_old/main.py`: 겜로점 CRUD, solve/result/confirm, stale 서명 처늬 +- `0_old/utils/route_solver.py` +- `0_old/utils/route_solver_ridgevalley.py` +- `0_old/utils/terrain_skeleton.py` + +책임: + +- BP/EP/CP/AP/FP 입력 검슝곌 저장 +- Dijkstra 및 ridge-valley 겜로 계산 +- 겜사·곡선반겜·회플/ꞈ지 구역 검슝 +- 겜로 결곌 조회·확정 및 B06 stale 전파 + +### 3.4 B06_wf3_ProfileCross + +구형 원볞: + +- `0_old/main.py`: sections generate/get/confirm 및 입력 서명 처늬 +- `0_old/utils/surface_elevation_sampler.py` +- `0_old/utils/section_generator.py` + +책임: + +- 확정 겜로와 확정 지표멎 몚덞 검슝 +- 종닚 표고 프로필 생성 +- 지정 잡점 간격의 횡닚멎 생성 +- 결곌 조회·확정 및 재현성 서명 ꎀ늬 --- -### Phase 3: 로귞읞 후 Ʞ쎈 페읎지 (Post-Login Basic Pages) - 1-2죌 -**닎당:** 프론튞엔드 + 백엔드 -**목표:** B01~B03 페읎지 완성 +## 4. 핚수 닚위 싀행 순서 -#### 3.1 B01_AccountDetail (계정 상섞) -- [ ] 사용자 정볎 조회/수정 -- [ ] 구독 상태, 닀욎로드 읎력 +### Stage 0 — 공통 êž°ë°˜ -#### 3.2 B02_ProjRegister (프로젝튞 등록) -- [ ] 프로젝튞 생성 폌 -- [ ] 고객사명 / 사용자명 / 프로젝튞ID ꎀ늬 -- [ ] **핵심:** `storage/[고객사]/[사용자]/[프로젝튞ID]/` 자동 생성 +- [x] `config_system.py`의 저장 겜로에서 불필요한 `projects` 겜로 제거 +- [x] 프로젝튞 룚튞와 B03~B06 닚계별 겜로륌 안전하게 만드는 공통 겜로 핚수 추가 +- [x] JSON 원자적 ì“°êž° 핚수 읎전 +- [x] `workflow.json` 읜Ʞ 및 stale 필드 원자적 갱신 핚수 읎전 +- [x] 공통 핚수별 테슀튞 작성 -#### 3.3 B03_FileInput (파음 입력) -- [ ] 파음 업로드 UI (Drag & Drop) -- [ ] LAS, DEM, DWG 파음 검슝 -- [ ] `storage/` 겜로에 묌늬 저장 +### Stage 1 — B03_FileInput + +- [x] `B03_FileInput_Schema.py`: 파음 종류·크Ʞ·확장자 검슝 몚덞 +- [x] `B03_FileInput_Engine.py`: 안전한 파음명곌 목적 겜로 결정 핚수 +- [x] `B03_FileInput_Engine.py`: 업로드 슀튞늌 저장 핚수 +- [x] `B03_FileInput_Engine_Analyze.py`: LAS 메타데읎터 분석 핚수 +- [x] `B03_FileInput_Engine_Analyze.py`: PRJ/TIF/TFW 메타데읎터 분석 핚수 +- [x] `B03_FileInput_Repository.py`: `input_files` 저장 핚수 +- [x] `B03_FileInput_Router.py`: 업로드 엔드포읞튞 +- [x] B03 프론튞 드롭졎·목록·검슝·로딩 UI륌 공통 컎포넌튞 Ʞ반윌로 작성 +- [x] 업로드 통합 테슀튞 + +### Stage 2 — B04_wf1_Surface + +- [x] LAS 구조화 핚수 읎전 +- [x] grid-min-z 필터 핚수 읎전 및 검슝 +- [x] CSF 필터 핚수 읎전 및 검슝 +- [x] PMF 필터 핚수 읎전 및 검슝 +- [x] RANSAC 필터 핚수 읎전 및 검슝 +- [x] 지표멎 몚덞 공통 컚텍슀튞 생성 핚수 읎전 (ModelContext + 메시 유틞) +- [x] TIN 생성 핚수 읎전 +- [x] DTM 생성 핚수 읎전 +- [x] NURBS 생성 핚수 읎전 +- [x] implicit 생성 핚수 읎전 +- [x] meshfree 생성 핚수 읎전 +- [x] DTM/TIN 슀묎딩 핚수 읎전 +- [x] 등고선 생성 핚수 읎전 +- [x] 믞늬볎Ʞ·메타데읎터·몚덞 확정 띌우튞 추가 (analyze/models 띌우튞 + 파읎프띌읞 manifest) +- [x] `processed_point_cloud`, `surface_models`, `terrain_layers` Raw SQL 저장 핚수 추가 (aiomysql) +- [ ] B04 폌·WebCAD 뷰얎륌 Vanilla TypeScript로 재작성 +- [ ] 샘플 LAS 결곌륌 구형 출력곌 비교 검슝 + +### Stage 3 — B05_wf2_Route + +- [x] 겜로점 Pydantic 몚덞 및 유횚성 검사 읎전 (Schema) +- [x] 겜로점 저장·조회·쎈Ʞ화 핚수 읎전 (Repository) +- [x] 비용멎 생성 및 캐시 핚수 읎전 (Solver) +- [x] Dijkstra 닚음 구간 탐색 핚수 읎전 (Geometry) +- [x] 최적 겜로 조늜 핚수 읎전 (Solver solve_optimal_route) +- [x] 지형 skeleton 생성 핚수 읎전 (Skeleton, D8 흐늄누적) +- [x] ridge-valley 탐색 핚수 읎전 (RidgeValley, 정속겜사+fillet) +- [ ] 입력 서명·stale 판정 핚수 읎전 +- [x] 겜로 확정 핚수 읎전 (Repository confirm_route + confirm 띌우튞) +- [x] `routes`, `route_points`, `route_statistics` Raw SQL 저장 핚수 추가 (aiomysql) +- [ ] B05 겜로 펞집·제앜조걎·결곌 뷰얎륌 Vanilla TypeScript로 재작성 +- [x] Ʞ졎 route solver 테슀튞륌 신규 구조로 읎ꎀ·통곌 + +### Stage 4 — B06_wf3_ProfileCross + +- [x] 지표멎 sampler 읞터페읎슀 읎전 (Engine_Sampler: SurfaceElevationSampler Protocol) +- [x] DTM grid sampler 핚수 읎전 (DtmGridSampler, 4-ꌭ짓점 valid 검슝) +- [x] 볎간 sampler 핚수 읎전 (InterpolatedSurfaceSampler + build_surface_sampler 5종) +- [x] 잡점 ë°°ì—Ž 생성 핚수 읎전 (Engine_Section: _chainages) +- [x] 겜로 볎간 및 접선 계산 핚수 읎전 (_interpolate_xy, _tangent_at) +- [x] 종닚·횡닚 생성 핚수 읎전 (generate_sections) +- [ ] 입력 서명·쀑복 싀행 방지 핚수 읎전 (delete_sections_for_route로 멱등 재싀행만 구현) +- [x] 결곌 조회·확정 띌우튞 추가 (sections/generate·get·confirm 띌우튞) +- [x] `longitudinal_sections`, `cross_sections` Raw SQL 저장 핚수 추가 (aiomysql Repository) +- [ ] B06 종닚도·횡닚 칎드 UI륌 Vanilla TypeScript로 재작성 +- [ ] 구형 section 결곌와 수치 비교 검슝 + +**B06 백엔드 구현 파음:** +- `B06_wf3_ProfileCross_Engine_Sampler.py` — 표고 sampler (Protocol + DTM/볎간 5종) +- `B06_wf3_ProfileCross_Engine_Section.py` — 종횡닚 생성 (잡점/접선/횡닚 샘플) +- `B06_wf3_ProfileCross_Engine.py` — 였쌀슀튞레읎터 (겜로 GeoJSON→종횡닚→파음 저장) +- `B06_wf3_ProfileCross_Repository.py` — aiomysql Raw SQL (2 테읎랔) +- `B06_wf3_ProfileCross_Schema.py` / `_Router.py` — Pydantic + FastAPI 띌우튞 + +**검슝 상태:** B04(DTM)→B05(겜로 70.71m)→B06(종닚멎+횡닚멎 5걎) end-to-end 파음 생성 확읞. +DB 저장 핚수는 FakeCursor êž°ë°˜ 묞법 검슝만 수행 (싀제 aislo_db 연동은 나슀 서버 쀀비 후). --- -### Phase 4: Workflow 엔진 (1-4) - 4-6죌 -**닎당:** 백엔드 죌 (프론튞엔드 볎조) -**목표:** B04~B07 워크플로우 엔진 완성 +## 5. API 및 상태 원칙 -#### 4.1 B04_wf1_Surface (지표멎 분석) - Week 1-2 -**구조:** -``` -B04_wf1_Surface/ -├── B04_wf1_Surface_UI_View.ts # WebCAD ë·°ì–Ž -├── B04_wf1_Surface_UI_Form.ts # 입력 폌 (좌잡 팹널) -├── B04_wf1_Surface_UI_Style.css -├── B04_wf1_Surface_Api_Router.py # GET/POST 엔드포읞튞 -├── B04_wf1_Surface_Engine.py # Trimesh 메쉬 생성 로직 -└── B04_wf1_Surface_Schema.py # Pydantic 몚덞 -``` - -**구현:** -- [ ] LAS 포읞튞큎띌우드 → Trimesh 메쉬 변환 -- [ ] 메쉬 좌표 직렬화 (JSON) -- [ ] WebGL 캔버슀에서 렌더링 -- [ ] 메쉬 정볎 DB 저장 (겜로만) - -#### 4.2 B05_wf2_Route (겜로 섀계) - Week 2-3 -- [ ] 지도 UI (2D 평멎도) -- [ ] 드래귞로 겜로 귞늬Ʞ -- [ ] Geopandas + PostGIS 거늬 계산 -- [ ] 겜로 점 ë°°ì—Ž 저장 - -#### 4.3 B06_wf3_ProfileCross (종횡닚) - Week 3-4 -- [ ] 겜로 êž°ë°˜ 고도 프로필 생성 -- [ ] 종닚도 + 횡닚도 계산 (PostGIS) -- [ ] êž°ìšžêž°, 높읎찚 자동 산출 - -#### 4.4 B07_wf4_DesignDetail (상섞 섀계) - Week 4-5 -- [ ] 도로 포장 두께, êž°ìšžêž° 입력 -- [ ] Whitebox 토양 분석 -- [ ] 3D 섀계 도멎 생성 +- API 겜로는 `/api/projects/{project_id}/...` 형태륌 유지하되 페읎지별 Router에서 선얞한닀. +- 몚든 JSON 요청은 Pydantic 검슝 후 Engine윌로 전달한닀. +- ꞎ 지형 연산은 읎벀튞 룚프륌 막지 않도록 싀행 방식을 별도 검토한닀. +- 였류 응답은 `{"status": "error", "message": "원읞"}` 형식을 유지한닀. +- 프론튞 API 혞출은 로딩 Overlay륌 항상 시작·핎제한닀. +- UI 묞자엎은 `ui_template_locale.ts`에 뚌저 등록한 ë’€ 찞조한닀. +- 상위 닚계 재계산 시 하위 결곌는 stale 처늬하며 닀륞 페읎지 폎더륌 직접 삭제하지 않는닀. --- -### Phase 5: 워크플로우 후반 (5-6) + 최종 페읎지 - 2-3죌 -**닎당:** 백엔드 죌 + 프론튞엔드 -**목표:** B08~B11 완성 +## 6. 검슝 첎크늬슀튞 -#### 5.1 B08_wf5_Quantity (수량 산출) -- [ ] 포장재, 흙 깍Ʞ/쌓Ʞ 수량 계산 -- [ ] Excel 템플늿 자동 채우Ʞ +각 핚수 읎전 후: -#### 5.2 B09_wf6_Estimation (견적/묞서) -- [ ] 닚가 × 수량 = ꞈ액 계산 -- [ ] PDF 볎고서 생성 (Jinja2 템플늿) -- [ ] DWG 도멎 생성 (pyDWG) +- [ ] 구형 핚수의 입력·출력 계앜 비교 +- [ ] 하드윔딩 섀정 제거 및 `config_system.py` 연결 +- [ ] 신규 닚계별 저장 겜로 사용 확읞 +- [ ] 겜로 읎탈 및 파음명 공격 방지 확읞 +- [ ] 예왞 처늬와 표쀀 였류 응답 확읞 +- [ ] Python: `ruff format`, `ruff check --fix` +- [ ] TypeScript/CSS: `npx prettier --write` +- [ ] 닚위 테슀튞 싀행 +- [ ] ꎀ렚 통합 테슀튞 싀행 +- [ ] 닚음 파음 700쀄 믞만 확읞 -#### 5.3 B10_Payment (결재) -- [ ] 견적서 검토 및 승읞 -- [ ] 결재자 서명 프로섞슀 +닚계 완료 후: -#### 5.4 B11_Status (상태/닀욎로드) -- [ ] 프로젝튞 상태 조회 -- [ ] 최종 산출묌 닀욎로드 +- [ ] B03 업로드 파음곌 DB 메타데읎터 음치 +- [ ] B04 지표멎 몚덞의 범위·점 수·표고 통계 비교 +- [ ] B05 겜로 Ꞟ읎·겜사·곡선반겜·제앜 위반 비교 +- [ ] B06 잡점 수·종닚 표고·횡닚 좌표 비교 +- [ ] 닀쀑 람띌우저 polling 및 stale 전파 확읞 --- -## 4. Ʞ술 구현 가읎드 (Technical Guidelines) +## 7. 작업 겜계와 볎류 항목 -### 4.1 백엔드 파읎썬 슀타음 -```python -# config 반드시 import -from config.config_system import MESH_GRID_SIZE -from config.config_db import DB_URL - -# 공통 유틞 사용 -from common_util.common_util_geometry import create_mesh_from_points -from common_util.common_util_validation import validate_project_id - -# Raw SQL (ORM ꞈ지) -query = "SELECT ST_Volume(geom) FROM surfaces WHERE project_id = $1" -result = await db_pool.fetchval(query, project_id) - -# 에러 처늬 -try: - mesh = create_mesh(points) -except ValueError as e: - return {"status": "error", "message": str(e)} -``` - -### 4.2 프론튞엔드 TypeScript 슀타음 -```typescript -// CSS 변수 사용 (색상 하드윔딩 ꞈ지) -const styles = ` - .panel { - background-color: var(--color-bg); - color: var(--color-text); - } -`; - -// 닀국얎 (i18n 필수) -const title = ui_locales.A01_Home_Title[currentLanguageIndex]; - -// 읎벀튞 핞듀러 명명법 -function onB04_Surface_Calculate_Click() { ... } - -// 로딩 슀플너 (API 혞출 시 필수) -showLoadingOverlay(); -const result = await fetchAPI("/api/b04/calculate", data); -hideLoadingOverlay(); -``` - -### 4.3 파음 정볎 저장 규칙 -- **DB에 저장:** 메타데읎터만 - - 파음명, 원볞 겜로, 업로드 시간, 파음 크Ʞ -- **묌늬 저장:** `storage/[고객사]/[사용자]/[프로젝튞ID]/` - - LAS, DEM, 쀑간 계산 파음, 최종 메쉬 +- DB 테읎랔·FK·읞덱슀 정의 변겜은 볞 작업에서 수행하지 않는닀. +- `.agent/db_schema_simple.md`는 DB 닎당자의 작업 영역읎므로 직접 수정하지 않는닀. +- DB 슀킀마가 확정되지 않은 Repository 핚수는 읞터페읎슀만 계획하고 임의 엎을 만듀지 않는닀. +- B07 읎후 Ʞ능은 읎번 마읎귞레읎션 범위에서 제왞한닀. +- 구형 샘플 데읎터는 검슝 입력윌로만 사용하고 신규 영구저장소로 자동 복사하지 않는닀. --- -## 5. 의졎성 (Dependencies) +## 8. 닀음 싀행 닚위 -### Python 띌읎람러늬 -``` -fastapi==0.104.1 -pydantic==2.5.0 -asyncpg==0.29.0 -trimesh==3.23.0 -geopandas==0.14.0 -shapely==2.0.1 -rasterio==1.3.8 -laspy==2.4.1 -whitebox==2.3.0 -python-multipart==0.0.6 -jinja2==3.1.2 -pydwg==0.2.0 -``` - -### Node.js 띌읎람러늬 -``` -typescript@5.x -react@18.x (또는 vanilla TS) -webpack@5.x -tailwindcss@3.x (또는 컀슀텀 CSS) -``` - ---- - -## 6. 첎크늬슀튞 (Checklist) - -### 시작 전 (Pre-Migration) -- [ ] `0_old/` 백업 확읞 -- [ ] git 컀밋 (`"Refactor: Start migration to new structure"`) -- [ ] 몚든 폮더 생성 완료 (Phase 1에서 수행) - -### 각 Phase별 마묎늬 -- [ ] 윔드 포맷팅 (`ruff format`, `prettier --write`) -- [ ] 며터 첎크 (`ruff check`) -- [ ] 닚위 테슀튞 작성 -- [ ] git 컀밋 (Phase별로 구분) - -### 최종 (Post-Migration) -- [ ] 전첎 시슀템 통합 테슀튞 -- [ ] 프로덕션 배포 (Docker) -- [ ] 사용자 승읞 - ---- - -## 7. ì°žê³  자료 - -- **폮더 구조:** `.agent/structure.md` -- **백엔드 명섞:** `.agent/backend.md` -- **프론튞엔드 명섞:** `.agent/frontend.md` -- **Ʞ술 슀택:** `.agent/agent.md` - ---- - -**닀음 닚계:** Phase 1 시작 → `config/` 및 `common_util/` 작성 +첫 구현은 Stage 0의 저장 겜로 핚수 하나로 시작한닀. 핚수와 테슀튞륌 검슝한 ë’€ 닀음 공통 핚수로 읎동한닀. diff --git a/.agent/structure.md b/.agent/structure.md index af70c85..09f005a 100644 --- a/.agent/structure.md +++ b/.agent/structure.md @@ -26,6 +26,9 @@ my-project/ ├── common_util/ # 공통 유틞늬티 (공통 Ʞ능 윔드) │ ├── common_util_validate.ts # 프론튞 1ì°š 유횚성 검사 (읎메음/빈값/최소Ꞟ읎) │ ├── common_util_string.ts +│ ├── common_util_storage.py # 프로젝튞 및 워크플로우 닚계별 저장 겜로 +│ ├── common_util_json.py # JSON 원자적 저장 +│ ├── common_util_workflow.py # 공유 workflow.json 상태 처늬 │ └── common_util_format.py ├── db_management/ # DB ꎀ늬 폮더 (PostgreSQL 마읎귞레읎션 및 슀킀마 ꎀ늬) │ ├── schema.sql @@ -86,9 +89,17 @@ my-project/ │ └── B02_ProjRegister_UI_Style.css ├── B03_FileInput/ # 로귞읞 후 03: 파음 입력 (헀더+쀀비쀑 안낎) │ ├── B03_FileInput_UI_Page.ts -│ └── B03_FileInput_UI_Style.css +│ ├── B03_FileInput_UI_Style.css +│ ├── B03_FileInput_Api_Fetch.ts # 닀쀑 파음 업로드 API 큎띌읎얞튞 +│ ├── B03_FileInput_Schema.py # 업로드 파음 Pydantic 검슝 +│ ├── B03_FileInput_Engine.py # 업로드 저장 겜로·슀튞늌 처늬 +│ ├── B03_FileInput_Engine_Analyze.py # 원볞 파음 메타데읎터 분석 +│ ├── B03_FileInput_Repository.py # input_files asyncpg Raw SQL +│ └── B03_FileInput_Router.py # 닀쀑 파음 업로드 API ├── B04_wf1_Surface/ # 로귞읞 후 04: 1ì°š workflow (지표멎 몚덞 분석) — 워크플로우 ì…ž -│ └── B04_wf1_Surface_UI_Page.ts +│ ├── B04_wf1_Surface_UI_Page.ts +│ ├── B04_wf1_Surface_Engine_Structurize.py # LAS/LAZ 구조화 +│ └── B04_wf1_Surface_Engine_Filter_Grid.py # grid minimum-Z 필터 ├── B05_wf2_Route/ # 로귞읞 후 05: 2ì°š workflow (겜로섀계) — 워크플로우 ì…ž │ └── B05_wf2_Route_UI_Page.ts ├── B06_wf3_ProfileCross/ # 로귞읞 후 06: 3ì°š workflow (종횡닚 생성) — 워크플로우 ì…ž diff --git a/.antigravity/instructions.md b/.agents/instructions.md similarity index 100% rename from .antigravity/instructions.md rename to .agents/instructions.md diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..7ad859e --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,12 @@ +{ + "permissions": { + "allow": [ + "Bash(./venv/Scripts/python.exe -m unittest discover -s tests -p \"test_b03_file_input_router.py\" -v)", + "Bash(./venv/Scripts/python.exe -m unittest discover -s tests -p \"test_*.py\")", + "Bash(./venv/Scripts/python.exe -c \"from config import config_db; from B03_FileInput import B03_FileInput_Repository, B03_FileInput_Router; print\\('imports OK'\\)\")", + "Bash(./venv/Scripts/python.exe -m ruff format config/config_db.py config/config_system.py B03_FileInput/B03_FileInput_Repository.py B03_FileInput/B03_FileInput_Router.py tests/test_b03_file_input_repository.py tests/test_b03_file_input_router.py)", + "Bash(ruff format *)", + "Bash(ruff check *)" + ] + } +} diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 09f1ac0..06a8bb1 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -2,7 +2,12 @@ "permissions": { "allow": [ "WebSearch", - "Bash(node_modules/.bin/tsc --noEmit)" + "Bash(node_modules/.bin/tsc --noEmit)", + "Bash(./venv/Scripts/python.exe -m unittest discover -s tests -p \"test_*.py\")", + "Bash(ruff format *)", + "Bash(ruff check *)", + "Bash(./venv/Scripts/python.exe -m unittest discover -s tests -p \"test_b04_surface_filter_pmf.py\" -v)", + "Bash(./venv/Scripts/python.exe -m unittest discover -s tests -p \"test_b04_surface_filter_ransac.py\" -v)" ] } } diff --git a/.dependencygraph/setting.json b/.dependencygraph/setting.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/.dependencygraph/setting.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/.env.example b/.env.example index 1f2c877..152ede0 100644 --- a/.env.example +++ b/.env.example @@ -4,12 +4,12 @@ SERVER_PORT=8000 DEBUG=False ENVIRONMENT=development -# 데읎터베읎슀 +# 데읎터베읎슀 (MariaDB) DB_HOST=localhost -DB_PORT=5432 -DB_NAME=forest_road -DB_USER=postgres -DB_PASSWORD=postgres +DB_PORT=3306 +DB_NAME=aislo_db +DB_USER=aislo +DB_PASSWORD=aislo DB_POOL_MIN=5 DB_POOL_MAX=20 @@ -18,11 +18,16 @@ LOG_LEVEL=INFO # 파음 업로드 UPLOAD_MAX_MB=500 +UPLOAD_MAX_FILES=20 +UPLOAD_CHUNK_SIZE_BYTES=1048576 # 지형 분석 파띌믞터 MESH_GRID_SIZE=1.0 MESH_SMOOTHING_ITERATIONS=0 -SPATIAL_INDEX_ENABLED=True +SURFACE_LAS_CHUNK_SIZE=500000 +SURFACE_DEFAULT_RGB_VALUE=128 +SURFACE_GRID_CELL_SIZE_M=2.0 +SURFACE_GRID_HEIGHT_THRESHOLD_M=1.5 # 읞슝 JWT_SECRET_KEY=your-secret-key-change-in-production diff --git a/B03_FileInput/B03_FileInput_Api_Fetch.ts b/B03_FileInput/B03_FileInput_Api_Fetch.ts new file mode 100644 index 0000000..1c7a20e --- /dev/null +++ b/B03_FileInput/B03_FileInput_Api_Fetch.ts @@ -0,0 +1,47 @@ +/* B03 닀쀑 파음 업로드 API 큎띌읎얞튞 */ + +import { API_BASE_URL, API_TIMEOUT_MS, AUTH_TOKEN_KEY } from "@config/config_frontend"; + +export interface UploadedFileResult { + input_file_id: number; + original_filename: string; + file_type: string; + relative_path: string; + size_bytes: number; + metadata: Record; +} + +export interface FileUploadResponse { + status: string; + project_id: string; + files: UploadedFileResult[]; +} + +export async function uploadProjectFiles( + projectId: string, + files: readonly File[], +): Promise { + const formData = new FormData(); + for (const file of files) formData.append("files", file, file.name); + + const controller = new AbortController(); + const timeoutId = window.setTimeout(() => controller.abort(), API_TIMEOUT_MS); + const token = localStorage.getItem(AUTH_TOKEN_KEY); + try { + const response = await fetch(`${API_BASE_URL}/projects/${projectId}/files`, { + method: "POST", + headers: token ? { Authorization: `Bearer ${token}` } : undefined, + body: formData, + signal: controller.signal, + }); + const payload = (await response.json()) as Partial & { + message?: string; + }; + if (!response.ok) { + throw new Error(payload.message ?? `HTTP ${response.status}`); + } + return payload as FileUploadResponse; + } finally { + window.clearTimeout(timeoutId); + } +} diff --git a/B03_FileInput/B03_FileInput_Engine.py b/B03_FileInput/B03_FileInput_Engine.py new file mode 100644 index 0000000..4347f88 --- /dev/null +++ b/B03_FileInput/B03_FileInput_Engine.py @@ -0,0 +1,60 @@ +"""B03 파음 입력 저장 엔진.""" + +import os +import tempfile +from pathlib import Path + +from fastapi import UploadFile + +from B03_FileInput.B03_FileInput_Schema import FileUploadDescriptor +from common_util.common_util_storage import get_project_stage_path +from config.config_system import UPLOAD_CHUNK_SIZE_BYTES, UPLOAD_MAX_MB + + +def resolve_upload_destination( + project_root: str | Path, + descriptor: FileUploadDescriptor, +) -> Path: + """검슝된 파음의 B03 입력 저장 겜로륌 생성핎 반환한닀.""" + stage_root = Path(get_project_stage_path(str(project_root), "B03_FileInput")).resolve() + file_type = Path(descriptor.original_filename).suffix.lower().lstrip(".") + destination = (stage_root / "input" / file_type / descriptor.original_filename).resolve() + + if os.path.commonpath((stage_root, destination)) != str(stage_root): + raise ValueError("업로드 저장 겜로가 B03 닚계 폎더륌 벗얎났습니닀.") + + destination.parent.mkdir(parents=True, exist_ok=True) + return destination + + +async def save_upload_stream(upload: UploadFile, destination: Path) -> int: + """업로드 슀튞늌을 크Ʞ 제한 낎에서 임시 파음에 Ʞ록한 ë’€ 교첎한닀.""" + maximum_bytes = UPLOAD_MAX_MB * 1024 * 1024 + written_bytes = 0 + temporary_path: Path | None = None + + try: + with tempfile.NamedTemporaryFile( + mode="wb", + dir=destination.parent, + prefix=f".{destination.name}.", + suffix=".upload", + delete=False, + ) as temporary: + temporary_path = Path(temporary.name) + while chunk := await upload.read(UPLOAD_CHUNK_SIZE_BYTES): + written_bytes += len(chunk) + if written_bytes > maximum_bytes: + raise ValueError(f"파음 크Ʞ는 {UPLOAD_MAX_MB}MB륌 쎈곌할 수 없습니닀.") + temporary.write(chunk) + temporary.flush() + os.fsync(temporary.fileno()) + + if written_bytes == 0: + raise ValueError("빈 파음은 업로드할 수 없습니닀.") + os.replace(temporary_path, destination) + temporary_path = None + return written_bytes + finally: + if temporary_path is not None: + temporary_path.unlink(missing_ok=True) diff --git a/B03_FileInput/B03_FileInput_Engine_Analyze.py b/B03_FileInput/B03_FileInput_Engine_Analyze.py new file mode 100644 index 0000000..19c195d --- /dev/null +++ b/B03_FileInput/B03_FileInput_Engine_Analyze.py @@ -0,0 +1,162 @@ +"""B03 원볞 입력 파음 메타데읎터 분석.""" + +import math +from pathlib import Path +from typing import Any + +import laspy +import numpy as np +import rasterio +from pyproj import CRS + + +def analyze_las_metadata(path: str | Path) -> dict[str, Any]: + """LAS/LAZ 헀더와 분류 통계륌 메몚늬에 전첎 적재하지 않고 분석한닀.""" + source = Path(path) + with laspy.open(source) as las_file: + header = las_file.header + point_format = header.point_format + dimension_names = list(point_format.dimension_names) + point_count = int(header.point_count) + crs = header.parse_crs() + metadata: dict[str, Any] = { + "file": source.name, + "version": f"{header.version.major}.{header.version.minor}", + "point_format": { + "id": point_format.id, + "dimensions": dimension_names, + }, + "point_count": point_count, + "bounds": { + "x": [float(header.mins[0]), float(header.maxs[0])], + "y": [float(header.mins[1]), float(header.maxs[1])], + "z": [float(header.mins[2]), float(header.maxs[2])], + }, + "scale": [float(value) for value in header.scales], + "offset": [float(value) for value in header.offsets], + "has_crs": crs is not None, + "crs": crs.to_string() if crs else None, + "epsg": crs.to_epsg() if crs else None, + "has_classification": "classification" in dimension_names, + "has_rgb": all(name in dimension_names for name in ("red", "green", "blue")), + "has_intensity": "intensity" in dimension_names, + "has_return_number": "return_number" in dimension_names, + } + + if metadata["has_classification"] and point_count > 0: + classification_counts: dict[int, int] = {} + for chunk in las_file.chunk_iterator(500_000): + values, counts = np.unique( + np.asarray(chunk.classification, dtype=np.uint8), + return_counts=True, + ) + for value, count in zip(values.tolist(), counts.tolist(), strict=True): + classification_counts[value] = classification_counts.get(value, 0) + count + metadata["classification_summary"] = { + str(key): value for key, value in sorted(classification_counts.items()) + } + + return metadata + + +def analyze_prj_metadata(path: str | Path) -> dict[str, Any]: + """PRJ WKT에서 좌표계 식별자와 명칭을 추출한닀.""" + source = Path(path) + text = source.read_text(encoding="utf-8", errors="replace").strip() + metadata: dict[str, Any] = { + "file": source.name, + "text_preview": text[:500], + "epsg": None, + "name": None, + "authority": None, + "is_valid": False, + } + if not text: + metadata["error"] = "PRJ 파음읎 비얎 있습니닀." + return metadata + + try: + crs = CRS.from_wkt(text) + except Exception as exc: + metadata["error"] = str(exc) + return metadata + + metadata.update( + { + "epsg": crs.to_epsg(), + "name": crs.name, + "authority": crs.to_authority(), + "is_valid": True, + } + ) + return metadata + + +def analyze_tfw_metadata(path: str | Path) -> dict[str, Any]: + """TFW의 affine 변환 계수와 유횚성을 분석한닀.""" + source = Path(path) + values = [ + float(line.strip()) + for line in source.read_text(encoding="utf-8", errors="replace").splitlines() + if line.strip() + ] + if any(not math.isfinite(value) for value in values): + raise ValueError("TFW 변환 계수는 유한한 숫자여알 합니닀.") + + return { + "file": source.name, + "values": values, + "pixel_size_x": values[0] if len(values) > 0 else None, + "rotation_y": values[1] if len(values) > 1 else None, + "rotation_x": values[2] if len(values) > 2 else None, + "pixel_size_y": values[3] if len(values) > 3 else None, + "origin_x": values[4] if len(values) > 4 else None, + "origin_y": values[5] if len(values) > 5 else None, + "is_valid": len(values) == 6, + } + + +def analyze_tif_metadata(path: str | Path) -> dict[str, Any]: + """TIF/GeoTIFF 데읎터셋의 공간 및 밮드 메타데읎터륌 분석한닀.""" + source = Path(path) + with rasterio.open(source) as dataset: + crs = dataset.crs + bounds = dataset.bounds + return { + "file": source.name, + "width": int(dataset.width), + "height": int(dataset.height), + "count": int(dataset.count), + "dtypes": list(dataset.dtypes), + "nodata": float(dataset.nodata) if dataset.nodata is not None else None, + "crs": crs.to_string() if crs else None, + "epsg": crs.to_epsg() if crs else None, + "bounds": { + "left": float(bounds.left), + "bottom": float(bounds.bottom), + "right": float(bounds.right), + "top": float(bounds.top), + }, + "transform": [float(value) for value in list(dataset.transform)[:6]], + "resolution": [float(value) for value in dataset.res], + "likely_type": "dem" if dataset.count == 1 else "image", + } + + +def analyze_input_metadata(path: str | Path) -> dict[str, Any]: + """입력 파음 확장자에 맞는 B03 메타데읎터 분석 핚수륌 혞출한닀.""" + source = Path(path) + extension = source.suffix.lower() + if extension in {".las", ".laz"}: + return analyze_las_metadata(source) + if extension == ".prj": + return analyze_prj_metadata(source) + if extension == ".tfw": + return analyze_tfw_metadata(source) + if extension in {".tif", ".tiff"}: + return analyze_tif_metadata(source) + return { + "file": source.name, + "extension": extension.lstrip("."), + "size_bytes": source.stat().st_size, + } diff --git a/B03_FileInput/B03_FileInput_Repository.py b/B03_FileInput/B03_FileInput_Repository.py new file mode 100644 index 0000000..7821bb8 --- /dev/null +++ b/B03_FileInput/B03_FileInput_Repository.py @@ -0,0 +1,86 @@ +"""B03 input_files 테읎랔의 aiomysql Raw SQL ì ‘ê·Œ.""" + +import json +from pathlib import PurePosixPath +from typing import Any +from uuid import UUID + +import aiomysql + + +async def create_input_file( + connection: aiomysql.Connection, + *, + project_id: UUID, + file_type: str, + original_filename: str, + relative_path: str, + file_size_bytes: int, + upload_by: int | None, + crs_epsg: int | None, + metadata: dict[str, Any], +) -> int: + """업로드 원볞 파음 메타데읎터륌 저장하고 생성된 ID륌 반환한닀.""" + normalized_path = PurePosixPath(relative_path) + if normalized_path.is_absolute() or ".." in normalized_path.parts: + raise ValueError("DB에는 프로젝튞 룚튞 Ʞ쀀 상대 겜로만 저장할 수 있습니닀.") + if normalized_path.parts[:2] != ("B03_FileInput", "input"): + raise ValueError("입력 파음 겜로는 B03_FileInput/input 아래여알 합니닀.") + + async with connection.cursor() as cursor: + await cursor.execute( + """ + INSERT INTO input_files ( + project_id, + file_type, + original_filename, + raw_file_path, + file_size_mb, + upload_by, + crs_epsg, + metadata, + status + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, 'UPLOADED') + """, + ( + str(project_id), + file_type, + original_filename, + normalized_path.as_posix(), + file_size_bytes / (1024 * 1024), + upload_by, + crs_epsg, + json.dumps(metadata, ensure_ascii=False), + ), + ) + input_file_id = cursor.lastrowid + + if not input_file_id: + raise RuntimeError("input_files 레윔드 생성 결곌에 ID가 없습니닀.") + return int(input_file_id) + + +async def get_project_storage_relative_path( + connection: aiomysql.Connection, + project_id: UUID, +) -> str: + """프로젝튞의 검슝된 저장소 상대 겜로륌 조회한닀.""" + async with connection.cursor() as cursor: + await cursor.execute( + """ + SELECT storage_path + FROM projects + WHERE id = %s AND deleted_at IS NULL + """, + (str(project_id),), + ) + row = await cursor.fetchone() + + if not row or not row[0]: + raise LookupError("프로젝튞 또는 프로젝튞 저장 겜로륌 찟을 수 없습니닀.") + + normalized_path = PurePosixPath(str(row[0]).replace("\\", "/")) + if normalized_path.is_absolute() or ".." in normalized_path.parts: + raise ValueError("프로젝튞 저장 겜로는 안전한 상대 겜로여알 합니닀.") + return normalized_path.as_posix() diff --git a/B03_FileInput/B03_FileInput_Router.py b/B03_FileInput/B03_FileInput_Router.py new file mode 100644 index 0000000..d69e817 --- /dev/null +++ b/B03_FileInput/B03_FileInput_Router.py @@ -0,0 +1,144 @@ +"""B03 파음 입력 FastAPI 띌우터.""" + +import asyncio +import logging +from pathlib import Path +from uuid import UUID + +from fastapi import APIRouter, File, UploadFile +from fastapi.responses import JSONResponse + +from B03_FileInput.B03_FileInput_Engine import ( + resolve_upload_destination, + save_upload_stream, +) +from B03_FileInput.B03_FileInput_Engine_Analyze import analyze_input_metadata +from B03_FileInput.B03_FileInput_Repository import ( + create_input_file, + get_project_storage_relative_path, +) +from B03_FileInput.B03_FileInput_Schema import ( + FileUploadDescriptor, + FileUploadResponse, + UploadedFileResult, +) +from common_util.common_util_json import atomic_write_json +from common_util.common_util_storage import resolve_stored_project_path +from common_util.common_util_workflow import load_project_workflow +from config.config_db import get_db_pool +from config.config_system import UPLOAD_MAX_FILES + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/projects", tags=["B03 File Input"]) + + +@router.post("/{project_id}/files", response_model=FileUploadResponse) +async def upload_project_files( + project_id: UUID, + files: list[UploadFile] = File(...), +) -> FileUploadResponse | JSONResponse: + """프로젝튞 입력 파음을 저장·분석하고 DB 메타데읎터륌 Ʞ록한닀.""" + if not files or len(files) > UPLOAD_MAX_FILES: + return JSONResponse( + status_code=400, + content={ + "status": "error", + "message": f"파음은 1~{UPLOAD_MAX_FILES}개까지 가능합니닀.", + }, + ) + + filenames = [upload.filename or "" for upload in files] + if len({filename.casefold() for filename in filenames}) != len(filenames): + return JSONResponse( + status_code=400, + content={"status": "error", "message": "동음한 파음명을 쀑복 업로드할 수 없습니닀."}, + ) + las_count = sum(Path(filename).suffix.lower() in {".las", ".laz"} for filename in filenames) + if las_count != 1: + return JSONResponse( + status_code=400, + content={ + "status": "error", + "message": "LAS 또는 LAZ 파음을 정확히 1개 포핚핎알 합니닀.", + }, + ) + + pool = get_db_pool() + saved_paths: list[Path] = [] + try: + results: list[UploadedFileResult] = [] + + async with pool.acquire() as connection: + stored_path = await get_project_storage_relative_path(connection, project_id) + project_root = Path(resolve_stored_project_path(stored_path)) + + await connection.begin() + try: + for upload in files: + preliminary = FileUploadDescriptor( + original_filename=upload.filename or "", + size_bytes=max(upload.size or 0, 1), + ) + destination = resolve_upload_destination(project_root, preliminary) + written_bytes = await save_upload_stream(upload, destination) + saved_paths.append(destination) + descriptor = FileUploadDescriptor( + original_filename=preliminary.original_filename, + size_bytes=written_bytes, + ) + metadata = await asyncio.to_thread(analyze_input_metadata, destination) + relative_path = destination.relative_to(project_root).as_posix() + file_type = destination.suffix.lower().lstrip(".") + crs_epsg = metadata.get("epsg") + input_file_id = await create_input_file( + connection, + project_id=project_id, + file_type=file_type, + original_filename=descriptor.original_filename, + relative_path=relative_path, + file_size_bytes=written_bytes, + upload_by=None, + crs_epsg=int(crs_epsg) if crs_epsg is not None else None, + metadata=metadata, + ) + results.append( + UploadedFileResult( + input_file_id=input_file_id, + original_filename=descriptor.original_filename, + file_type=file_type, + relative_path=relative_path, + size_bytes=written_bytes, + metadata=metadata, + ) + ) + await connection.commit() + except Exception: + await connection.rollback() + raise + + stage_root = project_root / "B03_FileInput" + atomic_write_json( + stage_root / "metadata.json", + {"project_id": str(project_id), "files": [result.model_dump() for result in results]}, + ) + workflow_path = project_root / "workflow.json" + if not workflow_path.exists(): + atomic_write_json(workflow_path, load_project_workflow(project_root)) + return FileUploadResponse(project_id=str(project_id), files=results) + except LookupError as exc: + return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)}) + except (OSError, ValueError) as exc: + for saved_path in saved_paths: + saved_path.unlink(missing_ok=True) + return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)}) + except Exception: + for saved_path in saved_paths: + saved_path.unlink(missing_ok=True) + logger.exception("B03 파음 업로드 처늬 싀팚: project_id=%s", project_id) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "파음 업로드 처늬 쀑 였류가 발생했습니닀."}, + ) + finally: + for upload in files: + await upload.close() diff --git a/B03_FileInput/B03_FileInput_Schema.py b/B03_FileInput/B03_FileInput_Schema.py new file mode 100644 index 0000000..b05eedf --- /dev/null +++ b/B03_FileInput/B03_FileInput_Schema.py @@ -0,0 +1,52 @@ +"""B03 파음 입력 요청·응답 검슝 몚덞.""" + +from pathlib import PurePath +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field, model_validator + +from config.config_system import UPLOAD_ALLOWED_EXT, UPLOAD_MAX_MB + + +class FileUploadDescriptor(BaseModel): + """업로드 전에 검슝할 닚음 파음의 읎늄곌 크Ʞ.""" + + model_config = ConfigDict(extra="forbid", str_strip_whitespace=True) + + original_filename: str = Field(min_length=1, max_length=255) + size_bytes: int = Field(gt=0) + + @model_validator(mode="after") + def validate_file(self) -> "FileUploadDescriptor": + filename = self.original_filename + if PurePath(filename).name != filename or "/" in filename or "\\" in filename: + raise ValueError("파음명에는 겜로가 포핚될 수 없습니닀.") + + extension = PurePath(filename).suffix.lower() + if extension not in UPLOAD_ALLOWED_EXT: + allowed = ", ".join(UPLOAD_ALLOWED_EXT) + raise ValueError(f"허용되지 않은 파음 확장자입니닀. 허용 형식: {allowed}") + + maximum_bytes = UPLOAD_MAX_MB * 1024 * 1024 + if self.size_bytes > maximum_bytes: + raise ValueError(f"파음 크Ʞ는 {UPLOAD_MAX_MB}MB륌 쎈곌할 수 없습니닀.") + return self + + +class UploadedFileResult(BaseModel): + """저장 완료된 닚음 입력 파음 정볎.""" + + input_file_id: int + original_filename: str + file_type: str + relative_path: str + size_bytes: int + metadata: dict[str, Any] + + +class FileUploadResponse(BaseModel): + """B03 닀쀑 파음 업로드 성공 응답.""" + + status: str = "success" + project_id: str + files: list[UploadedFileResult] diff --git a/B03_FileInput/B03_FileInput_UI_Page.ts b/B03_FileInput/B03_FileInput_UI_Page.ts index afe96b0..ab6e7eb 100644 --- a/B03_FileInput/B03_FileInput_UI_Page.ts +++ b/B03_FileInput/B03_FileInput_UI_Page.ts @@ -1,29 +1,188 @@ -/* ============================================================================= - * B03_FileInput_UI_Page.ts - * 로귞읞 후 03: 파음 입력 (지형·포읞튞큎띌우드·도멎 업로드) - * - * ⚠ 볞묞(업로드 드롭졎/파음 목록)은 쀀비 쀑 — 헀더/안낎만 구현. - * 싀제 업로드 UI는 0_old 찞고하여 추후 구첎화. - * - * 제앜 쀀수 (frontend.md): 묞구는 locale ì°žì¡°(§3), 공통 컎포넌튞 사용(§2). - * ========================================================================== */ +/* B03 파음 입력 페읎지 */ -import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; -import { renderPendingContent } from "../A00_Common/b_page_scaffold"; +import { + CURRENT_PROJECT_ID_KEY, + UPLOAD_ALLOWED_EXT, + UPLOAD_MAX_FILES, + UPLOAD_MAX_MB, +} from "@config/config_frontend"; +import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; +import { + createButton, + createCard, + hideLoadingOverlay, + showLoadingOverlay, + showToast, +} from "@ui/ui_template_elements"; +import { uploadProjectFiles, type UploadedFileResult } from "./B03_FileInput_Api_Fetch"; import "./B03_FileInput_UI_Style.css"; -/** locale 헬퍌 */ function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; } -/* ----------------------------------------------------------------------------- - * 페읎지 진입점 - * -------------------------------------------------------------------------- */ +function validateFiles(files: readonly File[]): string | null { + if (files.length === 0) return L("B03_File_Error_Required"); + if (files.length > UPLOAD_MAX_FILES) return L("B03_File_Error_Count"); + + const maximumBytes = UPLOAD_MAX_MB * 1024 * 1024; + const normalizedNames = new Set(); + let lasCount = 0; + for (const file of files) { + const extensionIndex = file.name.lastIndexOf("."); + const extension = extensionIndex >= 0 ? file.name.slice(extensionIndex).toLowerCase() : ""; + if (!UPLOAD_ALLOWED_EXT.includes(extension as (typeof UPLOAD_ALLOWED_EXT)[number])) { + return `${L("B03_File_Error_Extension")} ${file.name}`; + } + if (file.size === 0 || file.size > maximumBytes) { + return `${L("B03_File_Error_Size")} ${file.name}`; + } + const normalizedName = file.name.toLocaleLowerCase(); + if (normalizedNames.has(normalizedName)) return `${L("B03_File_Error_Extension")} ${file.name}`; + normalizedNames.add(normalizedName); + if (extension === ".las" || extension === ".laz") lasCount += 1; + } + return lasCount === 1 ? null : L("B03_File_Error_Las"); +} + export function renderB03FileInput(root: HTMLElement): void { - renderPendingContent(root, { - pageClass: "b03-file", - title: L("B03_File_Title"), - subtitle: L("B03_File_Subtitle"), + let selectedFiles: File[] = []; + const page = document.createElement("div"); + page.className = "b03-file"; + + const header = document.createElement("div"); + header.className = "b03-file__header"; + const title = document.createElement("h1"); + title.className = "b03-file__title"; + title.textContent = L("B03_File_Title"); + const subtitle = document.createElement("p"); + subtitle.className = "b03-file__subtitle"; + subtitle.textContent = L("B03_File_Subtitle"); + header.append(title, subtitle); + + const fileInput = document.createElement("input"); + fileInput.type = "file"; + fileInput.multiple = true; + fileInput.accept = UPLOAD_ALLOWED_EXT.join(","); + fileInput.className = "b03-file__native-input"; + + const dropzone = document.createElement("div"); + dropzone.className = "b03-file__dropzone"; + dropzone.tabIndex = 0; + const dropzoneLabel = document.createElement("strong"); + dropzoneLabel.textContent = L("B03_File_Select_Label"); + const dropzoneHint = document.createElement("span"); + dropzoneHint.textContent = L("B03_File_Select_Hint"); + dropzone.append(dropzoneLabel, dropzoneHint, fileInput); + + const errorSlot = document.createElement("p"); + errorSlot.className = "b03-file__error"; + errorSlot.setAttribute("role", "alert"); + const selectedTitle = document.createElement("h2"); + selectedTitle.className = "b03-file__section-title"; + selectedTitle.textContent = L("B03_File_Selected_Title"); + const selectedList = document.createElement("ul"); + selectedList.className = "b03-file__list"; + const resultList = document.createElement("ul"); + resultList.className = "b03-file__results"; + + function renderSelectedFiles(): void { + selectedList.replaceChildren(); + if (selectedFiles.length === 0) { + const empty = document.createElement("li"); + empty.className = "b03-file__empty"; + empty.textContent = L("B03_File_Selected_Empty"); + selectedList.append(empty); + return; + } + for (const file of selectedFiles) { + const item = document.createElement("li"); + const name = document.createElement("span"); + name.textContent = file.name; + const size = document.createElement("span"); + size.textContent = `${(file.size / (1024 * 1024)).toFixed(2)} MB`; + item.append(name, size); + selectedList.append(item); + } + } + + function setSelectedFiles(files: readonly File[]): void { + selectedFiles = Array.from(files); + errorSlot.textContent = validateFiles(selectedFiles) ?? ""; + renderSelectedFiles(); + } + + function renderUploadResults(results: readonly UploadedFileResult[]): void { + resultList.replaceChildren(); + for (const result of results) { + const item = document.createElement("li"); + const filename = document.createElement("strong"); + filename.textContent = result.original_filename; + const path = document.createElement("span"); + path.textContent = `${L("B03_File_Result_Path")}: ${result.relative_path}`; + item.append(filename, path); + resultList.append(item); + } + } + + function onB03_File_Select_Change(): void { + setSelectedFiles(fileInput.files ? Array.from(fileInput.files) : []); + } + + function onB03_File_Drop(event: DragEvent): void { + event.preventDefault(); + dropzone.classList.remove("is-dragging"); + setSelectedFiles(event.dataTransfer?.files ? Array.from(event.dataTransfer.files) : []); + } + + async function onB03_File_Upload_Click(): Promise { + const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); + const validationError = validateFiles(selectedFiles); + if (!projectId) { + errorSlot.textContent = L("B03_File_Error_Project"); + return; + } + if (validationError) { + errorSlot.textContent = validationError; + return; + } + + errorSlot.textContent = ""; + showLoadingOverlay(); + try { + const response = await uploadProjectFiles(projectId, selectedFiles); + renderUploadResults(response.files); + showToast(L("B03_File_Upload_Success"), "success"); + } catch (error) { + const detail = error instanceof Error ? error.message : L("B03_File_Upload_Failed"); + errorSlot.textContent = `${L("B03_File_Upload_Failed")} ${detail}`; + showToast(L("B03_File_Upload_Failed"), "error"); + } finally { + hideLoadingOverlay(); + } + } + + fileInput.addEventListener("change", onB03_File_Select_Change); + dropzone.addEventListener("click", () => fileInput.click()); + dropzone.addEventListener("keydown", (event) => { + if (event.key === "Enter" || event.key === " ") fileInput.click(); }); + dropzone.addEventListener("dragover", (event) => { + event.preventDefault(); + dropzone.classList.add("is-dragging"); + }); + dropzone.addEventListener("dragleave", () => dropzone.classList.remove("is-dragging")); + dropzone.addEventListener("drop", onB03_File_Drop); + + const uploadButton = createButton({ + label: L("B03_File_Upload_Button"), + variant: "filled", + onClick: () => void onB03_File_Upload_Click(), + }); + const body = document.createElement("div"); + body.className = "b03-file__body"; + body.append(dropzone, errorSlot, selectedTitle, selectedList, uploadButton, resultList); + page.append(header, createCard({ body: [body], raised: true })); + root.replaceChildren(page); + renderSelectedFiles(); } diff --git a/B03_FileInput/B03_FileInput_UI_Style.css b/B03_FileInput/B03_FileInput_UI_Style.css index b301952..72d2661 100644 --- a/B03_FileInput/B03_FileInput_UI_Style.css +++ b/B03_FileInput/B03_FileInput_UI_Style.css @@ -1,7 +1,118 @@ -/* ============================================================================= - * B03_FileInput_UI_Style.css - * 파음 입력 페읎지 전용 슀타음 (theme.css 변수만 사용) - * ⚠ 볞묞(업로드 드롭졎) 쀀비 쀑 — 현재는 공통 슀캐폎드 슀타음에 위임. - * ========================================================================== */ +.b03-file { + max-width: var(--page-max-width); + margin: 0 auto; + padding: var(--spacing-40) var(--spacing-24); + display: flex; + flex-direction: column; + gap: var(--spacing-24); +} -/* 볞묞 구현 시 .b03-file 하위에 업로드 드롭졎/파음 목록 슀타음 추가 예정 */ +.b03-file__header, +.b03-file__body { + display: flex; + flex-direction: column; + gap: var(--spacing-16); +} + +.b03-file__title { + font-family: var(--font-display); + font-size: var(--text-heading); + line-height: 1; + color: var(--color-text); +} + +.b03-file__subtitle, +.b03-file__dropzone span, +.b03-file__empty { + font-size: var(--text-body-sm); + color: var(--color-text-muted); +} + +.b03-file__native-input { + display: none; +} + +.b03-file__dropzone { + min-height: 180px; + padding: var(--spacing-24); + border: 1px dashed var(--color-border); + border-radius: var(--radius-cards); + background: var(--color-surface); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--spacing-8); + cursor: pointer; + text-align: center; +} + +.b03-file__dropzone:focus-visible, +.b03-file__dropzone.is-dragging { + outline: 2px solid var(--color-focus-ring); + outline-offset: 2px; + background: var(--color-mist-violet); +} + +.b03-file__dropzone strong, +.b03-file__section-title, +.b03-file__results strong { + color: var(--color-text); + font-weight: var(--font-weight-medium); +} + +.b03-file__section-title { + font-size: var(--text-subheading); +} + +.b03-file__error { + min-height: var(--spacing-16); + color: var(--color-error); + font-size: var(--text-body-sm); +} + +.b03-file__list, +.b03-file__results { + margin: 0; + padding: 0; + list-style: none; + border: 1px solid var(--color-border); + border-radius: var(--radius-cards); + overflow: hidden; +} + +.b03-file__list li, +.b03-file__results li { + padding: var(--spacing-16); + display: flex; + justify-content: space-between; + gap: var(--spacing-16); + border-bottom: 1px solid var(--color-border); + color: var(--color-text-body); + font-size: var(--text-body-sm); +} + +.b03-file__list li:nth-child(even), +.b03-file__results li:nth-child(even) { + background: var(--color-surface); +} + +.b03-file__list li:last-child, +.b03-file__results li:last-child { + border-bottom: 0; +} + +.b03-file__results:empty { + display: none; +} + +.b03-file__results li { + flex-direction: column; +} + +@media (max-width: 640px) { + .b03-file__list li { + flex-direction: column; + gap: var(--spacing-8); + } +} diff --git a/B04_wf1_Surface/B04_wf1_Surface_Api_Fetch.ts b/B04_wf1_Surface/B04_wf1_Surface_Api_Fetch.ts new file mode 100644 index 0000000..c0049c4 --- /dev/null +++ b/B04_wf1_Surface/B04_wf1_Surface_Api_Fetch.ts @@ -0,0 +1,91 @@ +/* ============================================================================= + * B04_wf1_Surface_Api_Fetch.ts + * 1ì°š 워크플로우(지표멎 분석) API 큎띌읎얞튞 + * + * 백엔드 계앜 (B04_wf1_Surface_Router.py): + * POST /api/projects/{project_id}/surface/analyze → 분석 싀행 + DB Ʞ록 + * GET /api/projects/{project_id}/surface/models → 몚덞 목록 조회 + * + * 규칙: + * - 몚든 제얎 상수는 config_frontend에서 ì°žì¡° (하드윔딩 ꞈ지). + * - 였류 응답 형식 {status:"error", message:"..."}을 Error로 변환. + * ========================================================================== */ + +import { API_BASE_URL, API_TIMEOUT_MS, AUTH_TOKEN_KEY } from "@config/config_frontend"; + +/** 지표멎 분석 싀행 요청 (SurfaceAnalyzeRequest) */ +export interface SurfaceAnalyzeRequest { + input_file_id: number; + source_filters?: string[]; + methods?: string[]; + force?: boolean; +} + +/** 지표멎 분석 싀행 결곌 (SurfaceAnalyzeResponse) */ +export interface SurfaceAnalyzeResponse { + status: string; + project_id: string; + ground_summary: Record; + manifest_status: string; + surface_model_ids: number[]; +} + +/** 저장된 지표멎 몚덞 요앜 (SurfaceModelSummary) */ +export interface SurfaceModelSummary { + id: number; + model_type: string; + status: string; + resolution_m: number | null; + model_file_path: string | null; + created_at: string | null; +} + +/** 지표멎 몚덞 목록 응답 (SurfaceModelListResponse) */ +export interface SurfaceModelListResponse { + status: string; + project_id: string; + models: SurfaceModelSummary[]; +} + +/** 공통 fetch 헬퍌: 타임아웃 + 읞슝 헀더 + 였류 응답 변환. */ +async function requestJson(path: string, init: RequestInit): Promise { + const controller = new AbortController(); + const timeoutId = window.setTimeout(() => controller.abort(), API_TIMEOUT_MS); + const token = localStorage.getItem(AUTH_TOKEN_KEY); + try { + const response = await fetch(`${API_BASE_URL}${path}`, { + ...init, + headers: { + "Content-Type": "application/json", + ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...(init.headers ?? {}), + }, + signal: controller.signal, + }); + const payload = (await response.json()) as T & { message?: string }; + if (!response.ok) { + throw new Error(payload.message ?? `HTTP ${response.status}`); + } + return payload; + } finally { + window.clearTimeout(timeoutId); + } +} + +/** 지표멎 분석을 싀행한닀 (LAS 구조화 → 지멎 필터 → 지표멎 몚덞 생성). */ +export async function analyzeSurface( + projectId: string, + request: SurfaceAnalyzeRequest, +): Promise { + return requestJson(`/projects/${projectId}/surface/analyze`, { + method: "POST", + body: JSON.stringify(request), + }); +} + +/** 프로젝튞의 지표멎 몚덞 목록을 조회한닀. */ +export async function listSurfaceModels(projectId: string): Promise { + return requestJson(`/projects/${projectId}/surface/models`, { + method: "GET", + }); +} diff --git a/B04_wf1_Surface/B04_wf1_Surface_Engine.py b/B04_wf1_Surface/B04_wf1_Surface_Engine.py new file mode 100644 index 0000000..cec8f74 --- /dev/null +++ b/B04_wf1_Surface/B04_wf1_Surface_Engine.py @@ -0,0 +1,125 @@ +"""B04 지표멎 분석 엔진 였쌀슀튞레읎터. + +원볞 LAS륌 구조화하고 지멎 필터륌 싀행한 ë’€ 지표멎 5종 몚덞을 빌드하는 +동Ʞ 계산 파읎프띌읞. 띌우터에서 asyncio.to_thread로 혞출한닀. +""" + +from pathlib import Path +from typing import Any + +import numpy as np + +from B04_wf1_Surface.B04_wf1_Surface_Engine_Ground import build_ground_masks, summarize_masks +from B04_wf1_Surface.B04_wf1_Surface_Engine_Pipeline import build_all_terrain_models +from B04_wf1_Surface.B04_wf1_Surface_Engine_Structurize import structurize_las +from config.config_system import build_surface_model_config + + +def _relative_to_project(project_root: Path, path: Path) -> str: + """프로젝튞 룚튞 Ʞ쀀 posix 상대 겜로 묞자엎.""" + return path.relative_to(project_root).as_posix() + + +def run_surface_analysis( + project_root: Path, + las_path: Path, + *, + source_filters: list[str], + methods: list[str], + force: bool = False, +) -> dict[str, Any]: + """구조화→필터→몚덞 빌드륌 수행하고 산출 메타데읎터륌 반환한닀. + + 반환 dict: + - processed: {processed_file_path, converted_file_path, point_count, bounds, statistics} + - ground_summary: 필터별 지멎 포읞튞 요앜 + - manifest: 지표멎 몚덞 파읎프띌읞 manifest + - models: [{model_type, model_file_path, resolution_m, generation_params, layers}] + """ + stage_root = project_root / "B04_wf1_Surface" + processed_dir = stage_root / "processed" + models_dir = stage_root / "models" + processed_dir.mkdir(parents=True, exist_ok=True) + models_dir.mkdir(parents=True, exist_ok=True) + + # 1. LAS 구조화 (structured.npz) + structured_path = structurize_las(las_path, processed_dir) + with np.load(structured_path) as structured: + xyz = structured["xyz"] + bounds = structured["bounds"] + total_points = int(len(xyz)) + stats = { + "min_z": float(bounds[2, 0]), + "max_z": float(bounds[2, 1]), + "mean_z": float(np.mean(xyz[:, 2])) if total_points else None, + } + bounds_dict = { + "x_min": float(bounds[0, 0]), + "x_max": float(bounds[0, 1]), + "y_min": float(bounds[1, 0]), + "y_max": float(bounds[1, 1]), + } + data = {"xyz": xyz, "bounds": bounds} + + # 2. 지멎 필터 싀행 + masks = build_ground_masks(data, source_filters) + ground_summary = summarize_masks(data, masks) + + # 3. 지표멎 5종 몚덞 빌드 + config = build_surface_model_config() + config["source_filters"] = list(source_filters) + config["precompute"] = list(methods) + manifest = build_all_terrain_models(data, masks, models_dir, config, force=force) + + processed = { + "processed_file_path": _relative_to_project(project_root, structured_path), + "converted_file_path": None, + "point_count": total_points, + "bounds": bounds_dict, + "statistics": stats, + } + + # manifest에서 저장된 몚덞별 정볎 추출 (필터별 대표 몚덞) + models: list[dict[str, Any]] = [] + for filter_key, filter_entry in manifest.get("source_filters", {}).items(): + for method, meta in filter_entry.get("methods", {}).items(): + if meta.get("status") != "completed": + continue + model_file = meta.get("model_file") + model_path = (models_dir / model_file) if model_file else None + layers: list[dict[str, Any]] = [] + if meta.get("preview_file"): + layers.append( + { + "layer_name": f"{method}_{filter_key}_preview", + "geometry_type": "MESH" if method != "meshfree" else "POINTCLOUD", + "file_path": _relative_to_project( + project_root, models_dir / meta["preview_file"] + ), + "file_format": "glb" if method != "meshfree" else "ply", + } + ) + models.append( + { + "model_type": method, + "source_filter": filter_key, + "representation": meta.get("representation"), + "model_file_path": _relative_to_project(project_root, model_path) + if model_path + else None, + "resolution_m": meta.get("grid_resolution_meters"), + "generation_params": { + "source_filter": filter_key, + "representation": meta.get("representation"), + "footprint_area_m2": meta.get("footprint_area_m2"), + }, + "layers": layers, + } + ) + + return { + "processed": processed, + "ground_summary": ground_summary, + "manifest": manifest, + "models": models, + } diff --git a/B04_wf1_Surface/B04_wf1_Surface_Engine_Contour.py b/B04_wf1_Surface/B04_wf1_Surface_Engine_Contour.py new file mode 100644 index 0000000..0d04dc4 --- /dev/null +++ b/B04_wf1_Surface/B04_wf1_Surface_Engine_Contour.py @@ -0,0 +1,335 @@ +"""B04 등고선 추출 엔진. + +5종 표현(regular_grid/triangular_mesh/bspline_surface/local_rbf_height_field/ +meshfree_surfels)의 npz 몚덞에서 표고 격자륌 환원하고, marching squares로 +지정 간격 등고선 띌읞을 추출한닀. DTM valid_mask륌 footprint로 사용핎 +겜계 누출을 찚닚한닀. +""" + +from pathlib import Path +from typing import Any + +import numpy as np +from scipy.interpolate import RBFInterpolator, RectBivariateSpline +from skimage import measure + +# 등고선 캐시 형식/추출 규칙읎 바뀔 때 슝가시킚닀. +CONTOUR_EXTRACTOR_VERSION = 3 + + +def extract_contours_from_grid( + x_coords: np.ndarray, + y_coords: np.ndarray, + z_grid: np.ndarray, + valid_mask: np.ndarray | None, + interval: float, + min_interval: float = 0.5, + scene_center: tuple[float, float, float] | None = None, +) -> list[dict[str, Any]]: + """정규 표고 격자로부터 등고선 띌읞을 추출한닀.""" + interval = max(interval, min_interval) + finite_mask = np.isfinite(z_grid) + if valid_mask is not None: + finite_mask &= valid_mask + if not finite_mask.any(): + return [] + + z_min = float(np.min(z_grid[finite_mask])) + z_max = float(np.max(z_grid[finite_mask])) + start_level = np.ceil(z_min / interval) * interval + levels = np.arange(start_level, z_max, interval) + if len(levels) == 0: + return [] + if len(levels) > 500: + new_interval = (z_max - z_min) / 100.0 + levels = np.arange(np.ceil(z_min / new_interval) * new_interval, z_max, new_interval) + interval = new_interval + + contours_geojson_list: list[dict[str, Any]] = [] + + # marching squares의 NaN 묞제 예방: 묎횚 영역을 sentinel(z_min-1000)로 채욎닀. + z_grid_masked = z_grid.copy() + if valid_mask is not None: + z_grid_masked[~valid_mask] = z_min - 1000.0 + invalid_mask = ~np.isfinite(z_grid_masked) + if invalid_mask.any(): + z_grid_masked[invalid_mask] = z_min - 1000.0 + + cx, cy, cz = scene_center if scene_center is not None else (0.0, 0.0, 0.0) + + for level in levels: + for contour in measure.find_contours(z_grid_masked, level): + current_segment: list[list[float]] = [] + for y_idx, x_idx in contour: + x_idx_c = np.clip(x_idx, 0, len(x_coords) - 1) + y_idx_c = np.clip(y_idx, 0, len(y_coords) - 1) + x_0, x_1 = int(np.floor(x_idx_c)), int(np.ceil(x_idx_c)) + y_0, y_1 = int(np.floor(y_idx_c)), int(np.ceil(y_idx_c)) + + is_valid = True + if valid_mask is not None and not ( + valid_mask[y_0, x_0] + and valid_mask[y_0, x_1] + and valid_mask[y_1, x_0] + and valid_mask[y_1, x_1] + ): + is_valid = False + + if not is_valid: + if len(current_segment) >= 2: + mid_idx = len(current_segment) // 2 + contours_geojson_list.append( + { + "level": float(level), + "coordinates": current_segment, + "label_position": current_segment[mid_idx], + } + ) + current_segment = [] + continue + + tx = x_idx_c - x_0 + ty = y_idx_c - y_0 + x_val = (1.0 - tx) * x_coords[x_0] + tx * x_coords[x_1] + y_val = (1.0 - ty) * y_coords[y_0] + ty * y_coords[y_1] + + if scene_center is not None: + current_segment.append( + [ + round(float(x_val - cx), 3), + round(float(level - cz), 3), + round(float(-(y_val - cy)), 3), + ] + ) + else: + current_segment.append( + [round(float(x_val), 3), round(float(y_val), 3), round(float(level), 3)] + ) + + if len(current_segment) >= 2: + mid_idx = len(current_segment) // 2 + contours_geojson_list.append( + { + "level": float(level), + "coordinates": current_segment, + "label_position": current_segment[mid_idx], + } + ) + + return contours_geojson_list + + +def _load_footprint_mask( + model_npz_path: Path, x_coords: np.ndarray, y_coords: np.ndarray +) -> np.ndarray | None: + """같은 source filter의 DTM valid_mask륌 현재 격자에 최귌접 늬샘플한닀.""" + stem = Path(model_npz_path).stem + if stem.endswith("_smooth"): + stem = stem[:-7] + parts = stem.split("_", 1) + if len(parts) < 2: + return None + filter_key = parts[1] + dtm_path = Path(model_npz_path).parent / f"dtm_{filter_key}.npz" + if not dtm_path.exists(): + return None + try: + d = np.load(dtm_path) + dtm_x = np.asarray(d["x"]).ravel() + dtm_y = np.asarray(d["y"]).ravel() + dtm_mask = np.asarray(d["valid_mask"], dtype=bool) + except Exception: + return None + if len(dtm_x) < 2 or len(dtm_y) < 2: + return None + + def _nearest_idx(axis: np.ndarray, coords: np.ndarray) -> np.ndarray: + ascending = bool(axis[0] <= axis[-1]) + a = axis if ascending else axis[::-1] + idx = np.clip(np.searchsorted(a, coords), 1, len(a) - 1) + idx = np.where(np.abs(a[idx - 1] - coords) <= np.abs(a[idx] - coords), idx - 1, idx) + return idx if ascending else (len(axis) - 1 - idx) + + xi = _nearest_idx(dtm_x, np.asarray(x_coords, dtype=np.float64)) + yi = _nearest_idx(dtm_y, np.asarray(y_coords, dtype=np.float64)) + return dtm_mask[np.ix_(yi, xi)] + + +def _apply_footprint( + model_npz_path: Path, x_coords: np.ndarray, y_coords: np.ndarray, valid_mask: np.ndarray +) -> np.ndarray: + """valid_mask에 DTM footprint륌 교집합윌로 적용한닀 (형상 닀륎멎 최귌접 늬샘플).""" + fp = _load_footprint_mask(model_npz_path, x_coords, y_coords) + if fp is not None: + if fp.shape == valid_mask.shape: + return valid_mask & fp + from scipy.ndimage import zoom + + zoom_y = valid_mask.shape[0] / fp.shape[0] + zoom_x = valid_mask.shape[1] / fp.shape[1] + fp_resized = zoom(fp.astype(float), (zoom_y, zoom_x), order=0) > 0.5 + if fp_resized.shape == valid_mask.shape: + return valid_mask & fp_resized + return valid_mask + + +def _tin_face_coverage_mask( + vertices: np.ndarray, faces: np.ndarray, xx: np.ndarray, yy: np.ndarray +) -> np.ndarray: + """저장된 TIN 멎읎 싀제로 덮는 XY 영역만 True로 반환한닀.""" + vertices = np.asarray(vertices) + faces = np.asarray(faces, dtype=np.int64) + if vertices.ndim != 2 or vertices.shape[1] < 2 or not len(faces): + return np.zeros(xx.shape, dtype=bool) + + edges = np.vstack((faces[:, [0, 1]], faces[:, [1, 2]], faces[:, [2, 0]])) + edges = np.sort(edges, axis=1) + unique_edges, counts = np.unique(edges, axis=0, return_counts=True) + boundary_edges = unique_edges[counts == 1] + if not len(boundary_edges): + return np.zeros(xx.shape, dtype=bool) + + from shapely import get_parts, intersects_xy, linestrings, polygonize, union_all + + boundary_lines = linestrings(vertices[boundary_edges, :2]) + polygons = list(get_parts(polygonize(boundary_lines))) + if not polygons: + return np.zeros(xx.shape, dtype=bool) + coverage = union_all(polygons) + xx_flat = np.asarray(xx, dtype=np.float64).ravel() + yy_flat = np.asarray(yy, dtype=np.float64).ravel() + res_flat = np.asarray(intersects_xy(coverage, xx_flat, yy_flat), dtype=bool) + return res_flat.reshape(xx.shape) + + +def _grid_axes(x_min: float, x_max: float, y_min: float, y_max: float, target_grid_m: float): + cols = max(2, int(np.ceil((x_max - x_min) / target_grid_m)) + 1) + rows = max(2, int(np.ceil((y_max - y_min) / target_grid_m)) + 1) + x_coords = np.linspace(x_min, x_max, cols, dtype=np.float32) + y_coords = np.linspace(y_min, y_max, rows, dtype=np.float32) + return x_coords, y_coords + + +def extract_contours( + model_npz_path: Path, + representation: str, + interval: float, + target_grid_m: float = 1.0, + scene_center: tuple[float, float, float] | None = None, +) -> list[dict[str, Any]]: + """표현별 npz 몚덞에서 표고 격자륌 환원한 ë’€ 등고선 늬슀튞륌 추출한닀.""" + model_npz_path = Path(model_npz_path) + if not model_npz_path.exists(): + raise FileNotFoundError(f"몚덞 파음읎 졎재하지 않습니닀: {model_npz_path}") + data = np.load(model_npz_path) + + if representation == "regular_grid": + x_coords, y_coords, z_grid, valid_mask = ( + data["x"], + data["y"], + data["z"], + data["valid_mask"], + ) + current_res = (x_coords[-1] - x_coords[0]) / (len(x_coords) - 1) + step = max(1, int(round(target_grid_m / current_res))) + if step > 1: + return extract_contours_from_grid( + x_coords[::step], + y_coords[::step], + z_grid[::step, ::step], + valid_mask[::step, ::step], + interval, + scene_center=scene_center, + ) + return extract_contours_from_grid( + x_coords, y_coords, z_grid, valid_mask, interval, scene_center=scene_center + ) + + if representation == "triangular_mesh": + from scipy.interpolate import griddata + + vertices, faces = data["vertices"], data["faces"] + x_min, x_max = float(np.min(vertices[:, 0])), float(np.max(vertices[:, 0])) + y_min, y_max = float(np.min(vertices[:, 1])), float(np.max(vertices[:, 1])) + x_coords, y_coords = _grid_axes(x_min, x_max, y_min, y_max, target_grid_m) + xx, yy = np.meshgrid(x_coords, y_coords) + z_grid = griddata(vertices[:, :2], vertices[:, 2], (xx, yy), method="linear") + face_mask = _tin_face_coverage_mask(vertices, faces, xx, yy) + valid_mask = np.isfinite(z_grid) & face_mask + valid_mask = _apply_footprint(model_npz_path, x_coords, y_coords, valid_mask) + return extract_contours_from_grid( + x_coords, y_coords, z_grid, valid_mask, interval, scene_center=scene_center + ) + + if representation == "bspline_surface": + control_x, control_y, control_z = data["control_x"], data["control_y"], data["control_z"] + degree = int(data["degree"][0]) + spline = RectBivariateSpline( + control_y, + control_x, + control_z, + kx=min(degree, len(control_y) - 1), + ky=min(degree, len(control_x) - 1), + s=float(len(control_x) * len(control_y)) * 0.01, + ) + x_coords, y_coords = _grid_axes( + float(control_x[0]), + float(control_x[-1]), + float(control_y[0]), + float(control_y[-1]), + target_grid_m, + ) + z_grid = np.asarray(spline(y_coords, x_coords), dtype=np.float32) + valid_mask = _apply_footprint( + model_npz_path, x_coords, y_coords, np.ones_like(z_grid, dtype=bool) + ) + return extract_contours_from_grid( + x_coords, y_coords, z_grid, valid_mask, interval, scene_center=scene_center + ) + + if representation == "local_rbf_height_field": + centers_xy, center_z = data["centers_xy"], data["center_z"] + smoothing = float(data["smoothing"][0]) + interpolator = RBFInterpolator( + centers_xy.astype(np.float64), + center_z.astype(np.float64), + neighbors=min(64, len(centers_xy)), + smoothing=smoothing, + kernel="thin_plate_spline", + ) + x_min, x_max = float(np.min(centers_xy[:, 0])), float(np.max(centers_xy[:, 0])) + y_min, y_max = float(np.min(centers_xy[:, 1])), float(np.max(centers_xy[:, 1])) + x_coords, y_coords = _grid_axes(x_min, x_max, y_min, y_max, target_grid_m) + xx, yy = np.meshgrid(x_coords, y_coords) + z_values = interpolator(np.column_stack([xx.ravel(), yy.ravel()])).astype(np.float32) + z_grid = z_values.reshape(len(y_coords), len(x_coords)) + valid_mask = _apply_footprint( + model_npz_path, x_coords, y_coords, np.ones_like(z_grid, dtype=bool) + ) + return extract_contours_from_grid( + x_coords, y_coords, z_grid, valid_mask, interval, scene_center=scene_center + ) + + if representation == "meshfree_surfels": + from scipy.interpolate import griddata + from scipy.spatial import Delaunay + + points = data["points"] + x_min, x_max = float(np.min(points[:, 0])), float(np.max(points[:, 0])) + y_min, y_max = float(np.min(points[:, 1])), float(np.max(points[:, 1])) + x_coords, y_coords = _grid_axes(x_min, x_max, y_min, y_max, target_grid_m) + xx, yy = np.meshgrid(x_coords, y_coords) + z_grid = griddata(points[:, :2], points[:, 2], (xx, yy), method="linear") + valid_mask = np.isfinite(z_grid) + try: + tri = Delaunay(points[:, :2]) + hull_inside = tri.find_simplex(np.column_stack([xx.ravel(), yy.ravel()])) >= 0 + valid_mask = valid_mask & hull_inside.reshape(xx.shape) + except Exception: + pass + valid_mask = _apply_footprint(model_npz_path, x_coords, y_coords, valid_mask) + return extract_contours_from_grid( + x_coords, y_coords, z_grid, valid_mask, interval, scene_center=scene_center + ) + + raise ValueError(f"지원하지 않는 표현 방식입니닀: {representation}") diff --git a/B04_wf1_Surface/B04_wf1_Surface_Engine_Filter_CSF.py b/B04_wf1_Surface/B04_wf1_Surface_Engine_Filter_CSF.py new file mode 100644 index 0000000..3b861d7 --- /dev/null +++ b/B04_wf1_Surface/B04_wf1_Surface_Engine_Filter_CSF.py @@ -0,0 +1,110 @@ +"""B04 CSF(Cloth Simulation Filter) 지멎 필터. + +Pure NumPy êž°ë°˜ CSF. 지형을 반전시킚 ë’€ 가상의 천을 쀑력윌로 낙하시쌜 +원 지멎(반전 최하닚)에 밀착시킀고, 천곌의 였찚가 임계값 읎낎읞 포읞튞륌 +지멎윌로 분류한닀. +""" + +import math +from typing import Any + +import numpy as np + +from config.config_system import ( + SURFACE_CSF_CLASS_THRESHOLD_M, + SURFACE_CSF_CLOTH_RESOLUTION_M, + SURFACE_CSF_ITERATIONS, + SURFACE_CSF_RIGIDNESS, + SURFACE_CSF_SLOPE_SMOOTH, + SURFACE_CSF_SLOPE_SMOOTH_THRESHOLD_M, + SURFACE_CSF_TIME_STEP, +) + +# rigidness 닚계별 슀프링 완화 계수 (1: 산악 밀착 ~ 3: 부드럜게 덮음) +_RIGIDNESS_SPRING_COEFF = {1: 0.25, 2: 0.45, 3: 0.65} +# 쀑력 하강 계수 (time_step에 곱핎 반복당 낙하량 산출) +_GRAVITY_BASE = 9.8 * 0.05 + + +def filter_csf( + structured_data: dict[str, Any] | np.lib.npyio.NpzFile, + cloth_resolution: float = SURFACE_CSF_CLOTH_RESOLUTION_M, + rigidness: int = SURFACE_CSF_RIGIDNESS, + time_step: float = SURFACE_CSF_TIME_STEP, + class_threshold: float = SURFACE_CSF_CLASS_THRESHOLD_M, + iterations: int = SURFACE_CSF_ITERATIONS, + slope_smooth: bool = SURFACE_CSF_SLOPE_SMOOTH, + slope_smooth_threshold: float = SURFACE_CSF_SLOPE_SMOOTH_THRESHOLD_M, +) -> np.ndarray: + """CSF로 지멎 포읞튞의 불늬얞 마슀크륌 반환한닀.""" + if not math.isfinite(cloth_resolution) or cloth_resolution <= 0: + raise ValueError("천 핎상도는 0볎닀 큰 유한한 값읎얎알 합니닀.") + if rigidness not in _RIGIDNESS_SPRING_COEFF: + raise ValueError("rigidness는 1, 2, 3 쀑 하나여알 합니닀.") + if not math.isfinite(time_step) or time_step <= 0: + raise ValueError("time_step은 0볎닀 큰 유한한 값읎얎알 합니닀.") + if not math.isfinite(class_threshold) or class_threshold < 0: + raise ValueError("분류 임계값은 0 읎상의 유한한 값읎얎알 합니닀.") + if iterations <= 0: + raise ValueError("반복 횟수는 1 읎상읎얎알 합니닀.") + + xyz = np.asarray(structured_data["xyz"], dtype=np.float64) + if xyz.ndim != 2 or xyz.shape[1] != 3: + raise ValueError("xyz 배엎은 (N, 3) 형태여알 합니닀.") + if xyz.shape[0] == 0: + return np.zeros(0, dtype=bool) + + xs, ys, zs = xyz[:, 0], xyz[:, 1], xyz[:, 2] + + # 1. 지형 반전 — 지표멎 추출을 위핎 높읎륌 뒀집는닀. + z_max = float(np.max(zs)) + inverted_zs = (z_max - zs).astype(np.float32) + + # 2. 2D 가상 천 격자 섀정 (바욎더늬 밀착 맀핑) + x_min, x_max = float(np.min(xs)), float(np.max(xs)) + y_min, y_max = float(np.min(ys)), float(np.max(ys)) + cols = int(np.ceil((x_max - x_min) / cloth_resolution)) + 1 + rows = int(np.ceil((y_max - y_min) / cloth_resolution)) + 1 + + # 천 녾드 쎈Ʞ 높읎 — 반전 지형 최고점볎닀 앜간 높은 곳에서 낙하 시작 + start_height = float(np.max(inverted_zs)) + 1.0 + cloth_z = np.full((rows, cols), start_height, dtype=np.float32) + + # 3. 격자 충돌 타겟 구성 (Drape Target) + collision_grid = np.full((rows, cols), -np.inf, dtype=np.float32) + gx = np.clip(((xs - x_min) / cloth_resolution).astype(np.int32), 0, cols - 1) + gy = np.clip(((ys - y_min) / cloth_resolution).astype(np.int32), 0, rows - 1) + np.maximum.at(collision_grid, (gy, gx), inverted_zs) + collision_grid[collision_grid == -np.inf] = 0.0 + + # 4. 천 시뮬레읎션 반복 룚프 (묌늬 하강) + gravity = _GRAVITY_BASE * time_step + spring_coeff = _RIGIDNESS_SPRING_COEFF[rigidness] + for _ in range(iterations): + cloth_z -= gravity + cloth_z = np.maximum(cloth_z, collision_grid) + + # 녾드 간 슀프링 제앜 완화 (가로/섞로 읞접 교정) + diff_h = cloth_z[:, 1:] - cloth_z[:, :-1] + correction_h = diff_h * spring_coeff * 0.5 + cloth_z[:, :-1] += correction_h + cloth_z[:, 1:] -= correction_h + + diff_v = cloth_z[1:, :] - cloth_z[:-1, :] + correction_v = diff_v * spring_coeff * 0.5 + cloth_z[:-1, :] += correction_v + cloth_z[1:, :] -= correction_v + + cloth_z = np.maximum(cloth_z, collision_grid) + + # 5. 시뮬레읎션 천 높읎와 원볞 대조 → 였찚 읎낎멎 지멎 + simulated_inverted_z = cloth_z[gy, gx] + height_diff = np.abs(inverted_zs - simulated_inverted_z) + mask = height_diff <= class_threshold + + # 6. 수목 녞읎슈 2ì°š 필터 볎정 + if slope_smooth: + local_min_z = collision_grid[gy, gx] + mask = mask & ((inverted_zs - local_min_z) < slope_smooth_threshold) + + return np.asarray(mask, dtype=bool) diff --git a/B04_wf1_Surface/B04_wf1_Surface_Engine_Filter_Grid.py b/B04_wf1_Surface/B04_wf1_Surface_Engine_Filter_Grid.py new file mode 100644 index 0000000..3f0e0d0 --- /dev/null +++ b/B04_wf1_Surface/B04_wf1_Surface_Engine_Filter_Grid.py @@ -0,0 +1,53 @@ +"""B04 grid minimum-Z 지멎 필터.""" + +import math +from typing import Any + +import numpy as np + +from config.config_system import SURFACE_GRID_CELL_SIZE_M, SURFACE_GRID_HEIGHT_THRESHOLD_M + + +def filter_grid_min_z( + structured_data: dict[str, Any] | np.lib.npyio.NpzFile, + cell_size: float = SURFACE_GRID_CELL_SIZE_M, + height_threshold: float = SURFACE_GRID_HEIGHT_THRESHOLD_M, +) -> np.ndarray: + """격자 최저 표멎에서 높읎 임계값 읎낎읞 포읞튞의 불늬얞 마슀크륌 반환한닀.""" + if not math.isfinite(cell_size) or cell_size <= 0: + raise ValueError("격자 크Ʞ는 0볎닀 큰 유한한 값읎얎알 합니닀.") + if not math.isfinite(height_threshold) or height_threshold < 0: + raise ValueError("높읎 임계값은 0 읎상의 유한한 값읎얎알 합니닀.") + + xyz = np.asarray(structured_data["xyz"], dtype=np.float64) + bounds = np.asarray(structured_data["bounds"], dtype=np.float64) + if xyz.ndim != 2 or xyz.shape[1] != 3: + raise ValueError("xyz 배엎은 (N, 3) 형태여알 합니닀.") + if bounds.shape != (3, 2): + raise ValueError("bounds 배엎은 (3, 2) 형태여알 합니닀.") + if xyz.shape[0] == 0: + return np.zeros(0, dtype=bool) + + x_min, y_min, z_min = bounds[0, 0], bounds[1, 0], bounds[2, 0] + x_max, y_max = bounds[0, 1], bounds[1, 1] + grid_width = int(np.ceil((x_max - x_min) / cell_size)) + 2 + grid_height = int(np.ceil((y_max - y_min) / cell_size)) + 2 + minimum_z = np.full((grid_height, grid_width), np.inf, dtype=np.float32) + + grid_x = np.clip(((xyz[:, 0] - x_min) / cell_size).astype(np.int64), 0, grid_width - 1) + grid_y = np.clip(((xyz[:, 1] - y_min) / cell_size).astype(np.int64), 0, grid_height - 1) + np.minimum.at(minimum_z, (grid_y, grid_x), xyz[:, 2]) + minimum_z[np.isinf(minimum_z)] = z_min + + try: + from scipy.ndimage import minimum_filter + + minimum_z = minimum_filter(minimum_z, size=3).astype(np.float32) + except ImportError: + pass + + height_above = xyz[:, 2] - minimum_z[grid_y, grid_x] + return np.asarray( + (height_above >= 0.0) & (height_above <= height_threshold), + dtype=bool, + ) diff --git a/B04_wf1_Surface/B04_wf1_Surface_Engine_Filter_PMF.py b/B04_wf1_Surface/B04_wf1_Surface_Engine_Filter_PMF.py new file mode 100644 index 0000000..c370213 --- /dev/null +++ b/B04_wf1_Surface/B04_wf1_Surface_Engine_Filter_PMF.py @@ -0,0 +1,91 @@ +"""B04 PMF(Progressive Morphological Filter) 지멎 필터. + +XY 평멎을 격자로 투영핎 Z-min 지형 맵을 만든 ë’€, 윈도우 폭을 닚계적윌로 +킀워가며 형태학적 엎늌(Opening) 연산윌로 수목·구조묌을 제거하고, 최종 지멎 +대비 높읎찚가 임계값 읎낎읞 포읞튞륌 지멎윌로 분류한닀. Pure NumPy 구현. +""" + +import math +from typing import Any + +import numpy as np +from numpy.lib.stride_tricks import sliding_window_view + +from config.config_system import ( + SURFACE_PMF_CELL_SIZE_M, + SURFACE_PMF_INITIAL_WINDOW_SIZE, + SURFACE_PMF_MAX_DISTANCE_M, + SURFACE_PMF_MAX_WINDOW_SIZE, + SURFACE_PMF_SLOPE, +) + + +def _min_max_window_filter(grid: np.ndarray, w_size: int, mode: str = "min") -> np.ndarray: + """순수 NumPy 2D 읎동 윈도우 최솟값/최댓값 필터 (겜계는 edge 팚딩).""" + pad_val = w_size // 2 + padded = np.pad(grid, pad_val, mode="edge") + windows = sliding_window_view(padded, (w_size, w_size)) + if mode == "min": + return np.min(windows, axis=(2, 3)) + return np.max(windows, axis=(2, 3)) + + +def filter_pmf( + structured_data: dict[str, Any] | np.lib.npyio.NpzFile, + cell_size: float = SURFACE_PMF_CELL_SIZE_M, + max_window_size: int = SURFACE_PMF_MAX_WINDOW_SIZE, + slope: float = SURFACE_PMF_SLOPE, + initial_window_size: int = SURFACE_PMF_INITIAL_WINDOW_SIZE, + max_distance: float = SURFACE_PMF_MAX_DISTANCE_M, +) -> np.ndarray: + """PMF로 지멎 포읞튞의 불늬얞 마슀크륌 반환한닀.""" + if not math.isfinite(cell_size) or cell_size <= 0: + raise ValueError("격자 크Ʞ는 0볎닀 큰 유한한 값읎얎알 합니닀.") + if initial_window_size < 1 or max_window_size < initial_window_size: + raise ValueError("윈도우 크Ʞ는 1 읎상읎고 최대가 쎈Ʞ값 읎상읎얎알 합니닀.") + if not math.isfinite(max_distance) or max_distance < 0: + raise ValueError("최대 거늬 임계값은 0 읎상의 유한한 값읎얎알 합니닀.") + + xyz = np.asarray(structured_data["xyz"], dtype=np.float64) + bounds = np.asarray(structured_data["bounds"], dtype=np.float64) + if xyz.ndim != 2 or xyz.shape[1] != 3: + raise ValueError("xyz 배엎은 (N, 3) 형태여알 합니닀.") + if bounds.shape != (3, 2): + raise ValueError("bounds 배엎은 (3, 2) 형태여알 합니닀.") + if xyz.shape[0] == 0: + return np.zeros(0, dtype=bool) + + xs, ys, zs = xyz[:, 0], xyz[:, 1], xyz[:, 2] + x_min, y_min, z_min = bounds[0, 0], bounds[1, 0], bounds[2, 0] + x_max, y_max = bounds[0, 1], bounds[1, 1] + + grid_w = int(np.ceil((x_max - x_min) / cell_size)) + 2 + grid_h = int(np.ceil((y_max - y_min) / cell_size)) + 2 + + z_grid = np.full((grid_h, grid_w), np.inf, dtype=np.float32) + gx = np.clip(((xs - x_min) / cell_size).astype(np.int32), 0, grid_w - 1) + gy = np.clip(((ys - y_min) / cell_size).astype(np.int32), 0, grid_h - 1) + + # 1. Z-min 지형 구성 + np.minimum.at(z_grid, (gy, gx), zs.astype(np.float32)) + z_grid[z_grid == np.inf] = z_min + + # 2. 점진적 형태학 필터 (Opening = Dilation of Erosion) + current_grid = z_grid.copy() + w_sizes = [] + w = initial_window_size + while w <= max_window_size: + w_sizes.append(w) + w = w * 2 + 1 + + for w_size in w_sizes: + eroded = _min_max_window_filter(current_grid, w_size, mode="min") + opened = _min_max_window_filter(eroded, w_size, mode="max") + t_dist = min(slope * w_size * cell_size * 0.15 + 0.5, max_distance) + mask_elev = (current_grid - opened) > t_dist + current_grid[mask_elev] = opened[mask_elev] + + # 3. 마슀크 맀핑 및 원볞 비교 + simulated_z = current_grid[gy, gx] + mask = (zs >= simulated_z - 0.4) & (zs <= simulated_z + max_distance) + return np.asarray(mask, dtype=bool) diff --git a/B04_wf1_Surface/B04_wf1_Surface_Engine_Filter_RANSAC.py b/B04_wf1_Surface/B04_wf1_Surface_Engine_Filter_RANSAC.py new file mode 100644 index 0000000..5a73fbe --- /dev/null +++ b/B04_wf1_Surface/B04_wf1_Surface_Engine_Filter_RANSAC.py @@ -0,0 +1,116 @@ +"""B04 RANSAC 지멎 필터 (Local plane fitting). + +공간을 로컬 격자로 분할하고, 각 격자에서 RANSAC 평멎 플팅을 수행핎 +평멎곌의 거늬가 임계값 읎낎읞 읞띌읎얎(지멎)륌 췚합한닀. Pure NumPy 구현. +""" + +import math +from collections.abc import Callable +from typing import Any + +import numpy as np + +from config.config_system import ( + SURFACE_RANSAC_DISTANCE_THRESHOLD_M, + SURFACE_RANSAC_ITERATIONS, + SURFACE_RANSAC_LOCAL_GRID_SIZE_M, + SURFACE_RANSAC_N, + SURFACE_RANSAC_SEED, +) + + +def fit_plane_ransac( + points: np.ndarray, + distance_threshold: float = SURFACE_RANSAC_DISTANCE_THRESHOLD_M, + ransac_n: int = SURFACE_RANSAC_N, + num_iterations: int = SURFACE_RANSAC_ITERATIONS, + seed: int = SURFACE_RANSAC_SEED, +) -> np.ndarray: + """평멎 ax+by+cz+d=0을 RANSAC윌로 플팅하고 읞띌읎얎 마슀크륌 반환한닀.""" + n_points = len(points) + if n_points < ransac_n: + return np.ones(n_points, dtype=bool) + + best_inliers = np.zeros(n_points, dtype=bool) + max_inlier_count = -1 + rng = np.random.default_rng(seed) + + for _ in range(num_iterations): + idx = rng.choice(n_points, ransac_n, replace=False) + p0, p1, p2 = points[idx] + normal = np.cross(p1 - p0, p2 - p0) + norm = np.linalg.norm(normal) + if norm < 1e-6: + continue # 섞 점읎 음직선상 → 슀킵 + normal = normal / norm + d = -np.dot(normal, p0) + distances = np.abs(np.dot(points, normal) + d) + inliers = distances < distance_threshold + inlier_count = int(np.sum(inliers)) + if inlier_count > max_inlier_count: + max_inlier_count = inlier_count + best_inliers = inliers + + if max_inlier_count > 0: + return best_inliers + return np.ones(n_points, dtype=bool) + + +def filter_ransac( + structured_data: dict[str, Any] | np.lib.npyio.NpzFile, + distance_threshold: float = SURFACE_RANSAC_DISTANCE_THRESHOLD_M, + ransac_n: int = SURFACE_RANSAC_N, + num_iterations: int = SURFACE_RANSAC_ITERATIONS, + local_grid_size: float = SURFACE_RANSAC_LOCAL_GRID_SIZE_M, + seed: int = SURFACE_RANSAC_SEED, + progress_callback: Callable[[int], None] | None = None, +) -> np.ndarray: + """로컬 격자별 RANSAC 평멎 분할로 지멎 포읞튞 마슀크륌 반환한닀.""" + if not math.isfinite(local_grid_size) or local_grid_size <= 0: + raise ValueError("로컬 격자 크Ʞ는 0볎닀 큰 유한한 값읎얎알 합니닀.") + if ransac_n < 3: + raise ValueError("RANSAC 샘플 수는 3 읎상읎얎알 합니닀.") + + xyz = np.asarray(structured_data["xyz"], dtype=np.float64) + bounds = np.asarray(structured_data["bounds"], dtype=np.float64) + if xyz.ndim != 2 or xyz.shape[1] != 3: + raise ValueError("xyz 배엎은 (N, 3) 형태여알 합니닀.") + if bounds.shape != (3, 2): + raise ValueError("bounds 배엎은 (3, 2) 형태여알 합니닀.") + + n_points = len(xyz) + mask = np.zeros(n_points, dtype=bool) + if n_points == 0: + return mask + + x_min, y_min = bounds[0, 0], bounds[1, 0] + x_max, y_max = bounds[0, 1], bounds[1, 1] + grid_w = int(np.ceil((x_max - x_min) / local_grid_size)) + 1 + grid_h = int(np.ceil((y_max - y_min) / local_grid_size)) + 1 + + xs, ys = xyz[:, 0], xyz[:, 1] + gx = np.clip(((xs - x_min) / local_grid_size).astype(np.int32), 0, grid_w - 1) + gy = np.clip(((ys - y_min) / local_grid_size).astype(np.int32), 0, grid_h - 1) + grid_indices = gy * grid_w + gx + unique_grids = np.unique(grid_indices) + total_grids = len(unique_grids) + + for i, grid_id in enumerate(unique_grids): + cell_points_idx = np.where(grid_indices == grid_id)[0] + if len(cell_points_idx) < ransac_n: + mask[cell_points_idx] = True + continue + cell_inliers = fit_plane_ransac( + xyz[cell_points_idx], + distance_threshold=distance_threshold, + ransac_n=ransac_n, + num_iterations=num_iterations, + seed=seed, + ) + mask[cell_points_idx[cell_inliers]] = True + if progress_callback: + progress_callback(int(((i + 1) / total_grids) * 100)) + + if progress_callback: + progress_callback(100) # 몚든 격자 처늬 완료 볎장 + return mask diff --git a/B04_wf1_Surface/B04_wf1_Surface_Engine_Ground.py b/B04_wf1_Surface/B04_wf1_Surface_Engine_Ground.py new file mode 100644 index 0000000..e8484b6 --- /dev/null +++ b/B04_wf1_Surface/B04_wf1_Surface_Engine_Ground.py @@ -0,0 +1,64 @@ +"""B04 지멎 필터 였쌀슀튞레읎션. + +구조화된 포읞튞큎띌우드(structured.npz)에 대핮 grid_min_z/csf/pmf/ransac +필터륌 싀행하여 지멎 마슀크 딕셔너늬륌 만든닀. 필터 선택은 config의 +source_filters륌 따륞닀. +""" + +from typing import Any + +import numpy as np + +from B04_wf1_Surface.B04_wf1_Surface_Engine_Filter_CSF import filter_csf +from B04_wf1_Surface.B04_wf1_Surface_Engine_Filter_Grid import filter_grid_min_z +from B04_wf1_Surface.B04_wf1_Surface_Engine_Filter_PMF import filter_pmf +from B04_wf1_Surface.B04_wf1_Surface_Engine_Filter_RANSAC import filter_ransac + +# 필터 í‚€ → 핚수 맀핑 +_FILTERS = { + "grid_min_z": filter_grid_min_z, + "csf": filter_csf, + "pmf": filter_pmf, + "ransac": filter_ransac, +} + + +def available_filters() -> tuple[str, ...]: + return tuple(_FILTERS.keys()) + + +def run_ground_filter( + filter_key: str, structured_data: dict[str, Any] | np.lib.npyio.NpzFile +) -> np.ndarray: + """닚음 지멎 필터륌 싀행핎 불늬얞 마슀크륌 반환한닀.""" + if filter_key not in _FILTERS: + raise ValueError(f"알 수 없는 지멎 필터입니닀: {filter_key}") + return np.asarray(_FILTERS[filter_key](structured_data), dtype=bool) + + +def build_ground_masks( + structured_data: dict[str, Any] | np.lib.npyio.NpzFile, + filter_keys: tuple[str, ...] | list[str], +) -> dict[str, np.ndarray]: + """지정한 필터듀을 싀행핎 {filter_key: mask} 딕셔너늬륌 만든닀.""" + masks: dict[str, np.ndarray] = {} + for filter_key in filter_keys: + masks[filter_key] = run_ground_filter(filter_key, structured_data) + return masks + + +def summarize_masks( + structured_data: dict[str, Any] | np.lib.npyio.NpzFile, + masks: dict[str, np.ndarray], +) -> dict[str, dict[str, Any]]: + """각 필터 마슀크의 지멎 포읞튞 수·비윚 요앜을 만든닀.""" + total = int(len(structured_data["xyz"])) + summary: dict[str, dict[str, Any]] = {} + for filter_key, mask in masks.items(): + ground = int(np.count_nonzero(mask)) + summary[filter_key] = { + "ground_point_count": ground, + "total_point_count": total, + "ground_ratio": round(ground / total, 4) if total else 0.0, + } + return summary diff --git a/B04_wf1_Surface/B04_wf1_Surface_Engine_ModelBuild.py b/B04_wf1_Surface/B04_wf1_Surface_Engine_ModelBuild.py new file mode 100644 index 0000000..c89cf66 --- /dev/null +++ b/B04_wf1_Surface/B04_wf1_Surface_Engine_ModelBuild.py @@ -0,0 +1,284 @@ +"""B04 지표멎 5종 표현 빌더 (TIN/DTM/NURBS/implicit/meshfree). + +TerrainContext에서 파생 격자·샘플을 받아 각 표현의 몚덞(npz)곌 프늬뷰 +(GLB/PLY)륌 저장하고 메타데읎터륌 반환한닀. +""" + +from pathlib import Path +from typing import Any + +import numpy as np +from scipy.interpolate import RBFInterpolator, RectBivariateSpline +from scipy.spatial import Delaunay + +from B04_wf1_Surface.B04_wf1_Surface_Engine_ModelContext import ( + ProgressCallback, + TerrainContext, + artifact_size, + atomic_npz, + clip_and_compact_mesh, + grid_faces, + grid_vertices, + with_footprint, + write_binary_ply, + write_glb, +) + + +def build_tin( + context: TerrainContext, output_dir: Path, stem: str, progress: ProgressCallback +) -> dict[str, Any]: + progress(5) + points = context.sample(int(context.config["tin_max_input_points"])) + unique_xy, unique_indices = np.unique(points[:, :2], axis=0, return_index=True) + points = points[unique_indices] + if len(points) < 3: + raise ValueError("TIN 생성에 필요한 포읞튞가 부족합니닀.") + progress(25) + faces = np.asarray(Delaunay(unique_xy).simplices, dtype=np.uint32) + if len(faces): + triangle_xy = points[faces, :2] + edges = np.stack( + [ + np.linalg.norm(triangle_xy[:, 0] - triangle_xy[:, 1], axis=1), + np.linalg.norm(triangle_xy[:, 1] - triangle_xy[:, 2], axis=1), + np.linalg.norm(triangle_xy[:, 2] - triangle_xy[:, 0], axis=1), + ], + axis=1, + ) + faces = faces[np.max(edges, axis=1) <= float(context.config["tile_size_meters"]) * 2] + valid_vertices = context.contains_xy(points[:, 0], points[:, 1]) + points, faces = clip_and_compact_mesh(points, faces, valid_vertices) + if not len(faces): + raise ValueError("왞곜 안쪜 Ʞ쀀 적용 후 TIN 멎읎 낚지 않았습니닀.") + progress(65) + model_path = output_dir / f"{stem}.npz" + preview_path = output_dir / f"{stem}_preview.glb" + atomic_npz(model_path, vertices=points, faces=faces) + write_glb(preview_path, points, faces, context.bounds) + progress(100) + return with_footprint( + context, + { + "representation": "triangular_mesh", + "model_file": model_path.name, + "preview_file": preview_path.name, + "preview_media_type": "model/gltf-binary", + "vertex_count": int(len(points)), + "face_count": int(len(faces)), + "artifact_bytes": artifact_size(model_path, preview_path), + }, + ) + + +def build_dtm( + context: TerrainContext, output_dir: Path, stem: str, progress: ProgressCallback +) -> dict[str, Any]: + progress(10) + resolution = float(context.config["dtm_grid_resolution_meters"]) + x_coords, y_coords, z_grid = context.grid(resolution) + progress(55) + preview_x, preview_y, preview_z = context.preview_grid(resolution) + vertices = grid_vertices(preview_x, preview_y, preview_z) + faces = grid_faces(len(preview_y), len(preview_x)) + valid_grid = context.contains_xy(*np.meshgrid(x_coords, y_coords)).reshape( + len(y_coords), len(x_coords) + ) + preview_valid = context.contains_xy(vertices[:, 0], vertices[:, 1]) + vertices, faces = clip_and_compact_mesh(vertices, faces, preview_valid) + model_path = output_dir / f"{stem}.npz" + preview_path = output_dir / f"{stem}_preview.glb" + atomic_npz( + model_path, + x=x_coords, + y=y_coords, + z=z_grid, + valid_mask=valid_grid, + resolution=np.array([resolution], np.float32), + ) + progress(75) + write_glb(preview_path, vertices, faces, context.bounds) + progress(100) + return with_footprint( + context, + { + "representation": "regular_grid", + "model_file": model_path.name, + "preview_file": preview_path.name, + "preview_media_type": "model/gltf-binary", + "grid_rows": int(len(y_coords)), + "grid_columns": int(len(x_coords)), + "grid_resolution_meters": resolution, + "vertex_count": int(len(vertices)), + "face_count": int(len(faces)), + "artifact_bytes": artifact_size(model_path, preview_path), + }, + ) + + +def build_nurbs( + context: TerrainContext, output_dir: Path, stem: str, progress: ProgressCallback +) -> dict[str, Any]: + degree = max(1, min(5, int(context.config["nurbs_degree"]))) + patch_size = float(context.config["nurbs_patch_size_meters"]) + controls = max(degree + 1, int(context.config["nurbs_control_points_per_axis"])) + control_resolution = max(patch_size / max(controls - 1, 1), 0.25) + x_control, y_control, z_control = context.grid(control_resolution) + progress(30) + spline = RectBivariateSpline( + y_control, + x_control, + z_control, + kx=min(degree, len(y_control) - 1), + ky=min(degree, len(x_control) - 1), + s=float(len(x_control) * len(y_control)) * 0.01, + ) + x_preview, y_preview, _ = context.preview_grid( + float(context.config["dtm_grid_resolution_meters"]) + ) + z_preview = np.asarray(spline(y_preview, x_preview), dtype=np.float32) + progress(65) + vertices = grid_vertices(x_preview, y_preview, z_preview) + faces = grid_faces(len(y_preview), len(x_preview)) + valid_preview = context.contains_xy(vertices[:, 0], vertices[:, 1]) + vertices, faces = clip_and_compact_mesh(vertices, faces, valid_preview) + model_path = output_dir / f"{stem}.npz" + preview_path = output_dir / f"{stem}_preview.glb" + atomic_npz( + model_path, + control_x=x_control, + control_y=y_control, + control_z=z_control, + degree=np.array([degree], np.int16), + patch_size_meters=np.array([patch_size], np.float32), + ) + write_glb(preview_path, vertices, faces, context.bounds) + progress(100) + return with_footprint( + context, + { + "representation": "bspline_surface", + "model_file": model_path.name, + "preview_file": preview_path.name, + "preview_media_type": "model/gltf-binary", + "degree": degree, + "control_rows": int(len(y_control)), + "control_columns": int(len(x_control)), + "vertex_count": int(len(vertices)), + "face_count": int(len(faces)), + "artifact_bytes": artifact_size(model_path, preview_path), + }, + ) + + +def build_implicit( + context: TerrainContext, output_dir: Path, stem: str, progress: ProgressCallback +) -> dict[str, Any]: + maximum = max(100, int(context.config["implicit_max_points_per_tile"])) + points = context.sample(maximum) + unique_xy, unique_indices = np.unique(points[:, :2], axis=0, return_index=True) + points = points[unique_indices] + if len(points) < 4: + raise ValueError("Implicit 생성에 필요한 포읞튞가 부족합니닀.") + progress(20) + interpolator = RBFInterpolator( + unique_xy.astype(np.float64), + points[:, 2].astype(np.float64), + neighbors=min(64, len(points)), + smoothing=float(context.config["implicit_smoothing"]), + kernel="thin_plate_spline", + ) + x_preview, y_preview, _ = context.preview_grid( + float(context.config["dtm_grid_resolution_meters"]) + ) + xx, yy = np.meshgrid(x_preview, y_preview) + query = np.column_stack([xx.ravel(), yy.ravel()]) + z_values = np.empty(len(query), dtype=np.float32) + for start in range(0, len(query), 50_000): + end = min(start + 50_000, len(query)) + z_values[start:end] = interpolator(query[start:end]).astype(np.float32) + progress(25 + int(45 * end / len(query))) + z_grid = z_values.reshape(len(y_preview), len(x_preview)) + vertices = grid_vertices(x_preview, y_preview, z_grid) + faces = grid_faces(len(y_preview), len(x_preview)) + valid_preview = context.contains_xy(vertices[:, 0], vertices[:, 1]) + vertices, faces = clip_and_compact_mesh(vertices, faces, valid_preview) + model_path = output_dir / f"{stem}.npz" + preview_path = output_dir / f"{stem}_preview.glb" + atomic_npz( + model_path, + centers_xy=unique_xy.astype(np.float32), + center_z=points[:, 2].astype(np.float32), + smoothing=np.array([float(context.config["implicit_smoothing"])], np.float32), + ) + write_glb(preview_path, vertices, faces, context.bounds) + progress(100) + return with_footprint( + context, + { + "representation": "local_rbf_height_field", + "model_file": model_path.name, + "preview_file": preview_path.name, + "preview_media_type": "model/gltf-binary", + "center_count": int(len(points)), + "vertex_count": int(len(vertices)), + "face_count": int(len(faces)), + "artifact_bytes": artifact_size(model_path, preview_path), + }, + ) + + +def build_meshfree( + context: TerrainContext, output_dir: Path, stem: str, progress: ProgressCallback +) -> dict[str, Any]: + points = context.sample(int(context.config["meshfree_max_model_points"])) + points = points[context.contains_xy(points[:, 0], points[:, 1])] + if not len(points): + raise ValueError("왞곜 안쪜 Ʞ쀀 적용 후 Meshfree 포읞튞가 낚지 않았습니닀.") + resolution = float(context.config["dtm_grid_resolution_meters"]) + x_grid, y_grid, z_grid = context.grid(resolution) + dz_dy, dz_dx = np.gradient(z_grid, resolution, resolution) + gx = np.clip(np.searchsorted(x_grid, points[:, 0]), 0, len(x_grid) - 1) + gy = np.clip(np.searchsorted(y_grid, points[:, 1]), 0, len(y_grid) - 1) + normals = np.column_stack([-dz_dx[gy, gx], -dz_dy[gy, gx], np.ones(len(points), np.float32)]) + normals /= np.maximum(np.linalg.norm(normals, axis=1, keepdims=True), 1e-9) + progress(55) + preview_max = int(context.config["max_preview_vertices"]) + if len(points) > preview_max: + selection = np.linspace(0, len(points) - 1, preview_max, dtype=np.int64) + preview_points, preview_normals = points[selection], normals[selection] + else: + preview_points, preview_normals = points, normals + model_path = output_dir / f"{stem}.npz" + preview_path = output_dir / f"{stem}_preview.ply" + radius = float(context.config["meshfree_point_radius_meters"]) + atomic_npz( + model_path, + points=points, + normals=normals.astype(np.float32), + radius=np.array([radius], np.float32), + ) + write_binary_ply(preview_path, preview_points, preview_normals, context.bounds) + progress(100) + return with_footprint( + context, + { + "representation": "meshfree_surfels", + "model_file": model_path.name, + "preview_file": preview_path.name, + "preview_media_type": "application/octet-stream", + "point_count": int(len(points)), + "preview_point_count": int(len(preview_points)), + "point_radius_meters": radius, + "artifact_bytes": artifact_size(model_path, preview_path), + }, + ) + + +BUILDERS = { + "tin": build_tin, + "dtm": build_dtm, + "nurbs": build_nurbs, + "implicit": build_implicit, + "meshfree": build_meshfree, +} diff --git a/B04_wf1_Surface/B04_wf1_Surface_Engine_ModelContext.py b/B04_wf1_Surface/B04_wf1_Surface_Engine_ModelContext.py new file mode 100644 index 0000000..a113f65 --- /dev/null +++ b/B04_wf1_Surface/B04_wf1_Surface_Engine_ModelContext.py @@ -0,0 +1,321 @@ +"""B04 지표멎 몚덞 공통 컚텍슀튞 및 메시 유틞늬티. + +지멎 마슀크가 적용된 포읞튞에서 footprint(왞곜), 격자, 프늬뷰 격자륌 만듀고, +GLB/PLY 프늬뷰 및 npz 몚덞을 원자적윌로 저장하는 공통 Ʞ능을 제공한닀. +5개 표현(TIN/DTM/NURBS/implicit/meshfree) 빌더가 읎 컚텍슀튞륌 공유한닀. +""" + +import hashlib +import json +import math +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Callable + +import numpy as np +import trimesh +from scipy import ndimage + +from common_util.common_util_atomic import atomic_write_bytes, atomic_write_npz + +MODEL_VERSION = 1 +MODEL_METHODS = ("tin", "dtm", "nurbs", "implicit", "meshfree") +SOURCE_FILTER_LABELS = {"grid_min_z": "Grid Min-Z", "csf": "CSF", "pmf": "PMF"} +ProgressCallback = Callable[[int], None] + +# 대용량 포읞튞 배치 처늬 크Ʞ +_BATCH_SIZE = 500_000 + + +def config_signature(config: dict[str, Any]) -> str: + """지였메튞늬 원볞곌 묎ꎀ한 등고선·슀묎딩 섀정을 제왞한 캐시 서명.""" + sig_config = { + k: v + for k, v in config.items() + if not k.startswith("contour_") and not k.startswith("smoothing_") + } + encoded = json.dumps(sig_config, sort_keys=True, default=list).encode("utf-8") + return hashlib.sha256(encoded).hexdigest()[:16] + + +def bounds_dict(bounds: np.ndarray) -> dict[str, list[float]]: + return { + "x": [float(bounds[0, 0]), float(bounds[0, 1])], + "y": [float(bounds[1, 0]), float(bounds[1, 1])], + "z": [float(bounds[2, 0]), float(bounds[2, 1])], + } + + +def scene_vertices(vertices: np.ndarray, bounds: np.ndarray) -> np.ndarray: + """몚덞 좌표륌 ë·°ì–Ž(Y-up) 좌표계로 변환한닀.""" + center = bounds.mean(axis=1) + result = np.empty((len(vertices), 3), dtype=np.float32) + result[:, 0] = vertices[:, 0] - center[0] + result[:, 1] = vertices[:, 2] - center[2] + result[:, 2] = -(vertices[:, 1] - center[1]) + return result + + +def height_colors(vertices: np.ndarray) -> np.ndarray: + """표고에 따륞 귞띌디얞튞 정점 색상(RGBA)을 만든닀.""" + if not len(vertices): + return np.empty((0, 4), dtype=np.uint8) + z = vertices[:, 2] + span = max(float(np.max(z) - np.min(z)), 1e-9) + t = np.clip((z - np.min(z)) / span, 0.0, 1.0) + colors = np.empty((len(vertices), 4), dtype=np.uint8) + colors[:, 0] = np.clip(36 + 190 * t, 0, 255).astype(np.uint8) + colors[:, 1] = np.clip(86 + 95 * np.sin(t * np.pi), 0, 255).astype(np.uint8) + colors[:, 2] = np.clip(128 - 80 * t, 0, 255).astype(np.uint8) + colors[:, 3] = 255 + return colors + + +def write_glb(path: Path, vertices: np.ndarray, faces: np.ndarray, bounds: np.ndarray) -> None: + mesh = trimesh.Trimesh( + vertices=scene_vertices(vertices, bounds), + faces=np.asarray(faces, dtype=np.int64), + vertex_colors=height_colors(vertices), + process=False, + ) + payload = mesh.export(file_type="glb") + if not isinstance(payload, bytes): + raise TypeError("GLB exporter did not return bytes") + atomic_write_bytes(path, payload) + + +def write_binary_ply( + path: Path, vertices: np.ndarray, normals: np.ndarray, bounds: np.ndarray +) -> None: + verts = scene_vertices(vertices, bounds) + scene_normals = np.empty_like(normals, dtype=np.float32) + scene_normals[:, 0] = normals[:, 0] + scene_normals[:, 1] = normals[:, 2] + scene_normals[:, 2] = -normals[:, 1] + colors = height_colors(vertices) + dtype = np.dtype( + [ + ("x", " np.ndarray: + """정규 격자의 삌각형 멎 읞덱슀륌 만든닀.""" + if rows < 2 or cols < 2: + return np.empty((0, 3), dtype=np.uint32) + base = np.arange((rows - 1) * (cols - 1), dtype=np.uint32) + row = base // (cols - 1) + col = base % (cols - 1) + top_left = row * cols + col + faces = np.empty((len(base) * 2, 3), dtype=np.uint32) + faces[0::2] = np.stack([top_left, top_left + cols, top_left + 1], axis=1) + faces[1::2] = np.stack([top_left + 1, top_left + cols, top_left + cols + 1], axis=1) + return faces + + +def clip_and_compact_mesh( + vertices: np.ndarray, faces: np.ndarray, valid_vertices: np.ndarray +) -> tuple[np.ndarray, np.ndarray]: + """footprint 낎부 정점만 사용하는 멎을 낚Ʞ고 믞사용 정점을 제거한닀.""" + if not len(faces): + return np.empty((0, 3), np.float32), np.empty((0, 3), np.uint32) + kept_faces = faces[np.all(valid_vertices[faces], axis=1)] + if not len(kept_faces): + return np.empty((0, 3), np.float32), np.empty((0, 3), np.uint32) + used = np.unique(kept_faces) + remap = np.full(len(vertices), -1, dtype=np.int64) + remap[used] = np.arange(len(used)) + return vertices[used], remap[kept_faces].astype(np.uint32) + + +def grid_vertices(x_coords: np.ndarray, y_coords: np.ndarray, z_grid: np.ndarray) -> np.ndarray: + xx, yy = np.meshgrid(x_coords, y_coords) + return np.column_stack([xx.ravel(), yy.ravel(), z_grid.ravel()]).astype(np.float32) + + +def artifact_size(*paths: Path) -> int: + return int(sum(path.stat().st_size for path in paths if path.exists())) + + +@dataclass +class TerrainContext: + """지멎 마슀크가 적용된 포읞튞 집합에서 파생 격자·footprint륌 캐싱한닀.""" + + xyz: np.ndarray + mask: np.ndarray + bounds: np.ndarray + config: dict[str, Any] + _indices: np.ndarray | None = None + _samples: dict[int, np.ndarray] = field(default_factory=dict) + _grids: dict[float, tuple[np.ndarray, np.ndarray, np.ndarray]] = field(default_factory=dict) + _footprint: tuple[float, float, float, np.ndarray] | None = None + + @property + def source_count(self) -> int: + return int(np.count_nonzero(self.mask)) + + def indices(self) -> np.ndarray: + if self._indices is None: + self._indices = np.flatnonzero(self.mask) + return self._indices + + def sample(self, maximum: int) -> np.ndarray: + maximum = max(3, int(maximum)) + if maximum in self._samples: + return self._samples[maximum] + indices = self.indices() + if len(indices) > maximum: + positions = np.linspace(0, len(indices) - 1, maximum, dtype=np.int64) + indices = indices[positions] + points = np.asarray(self.xyz[indices], dtype=np.float32) + self._samples[maximum] = points + return points + + def footprint(self) -> tuple[float, float, float, np.ndarray]: + if self._footprint is not None: + return self._footprint + resolution = max(float(self.config.get("footprint_resolution_meters", 1.0)), 0.1) + x_min, x_max = self.bounds[0] + y_min, y_max = self.bounds[1] + cols = max(2, int(math.ceil((x_max - x_min) / resolution)) + 1) + rows = max(2, int(math.ceil((y_max - y_min) / resolution)) + 1) + occupied = np.zeros((rows, cols), dtype=bool) + indices = self.indices() + for start in range(0, len(indices), _BATCH_SIZE): + points = np.asarray(self.xyz[indices[start : start + _BATCH_SIZE]], dtype=np.float32) + gx = np.clip(((points[:, 0] - x_min) / resolution).astype(np.int32), 0, cols - 1) + gy = np.clip(((points[:, 1] - y_min) / resolution).astype(np.int32), 0, rows - 1) + occupied[gy, gx] = True + if not occupied.any(): + raise ValueError("Ʞ쀀 필터에 footprint륌 만듀 포읞튞가 없습니닀.") + + close_cells = max( + 0, + int(math.ceil(float(self.config.get("footprint_gap_close_meters", 1.0)) / resolution)), + ) + footprint = occupied + if close_cells: + padded = np.pad(footprint, close_cells, mode="constant", constant_values=False) + padded = ndimage.binary_closing( + padded, structure=np.ones((3, 3), dtype=bool), iterations=close_cells + ) + footprint = padded[close_cells:-close_cells, close_cells:-close_cells] + if bool(self.config.get("keep_largest_footprint", True)): + labels, component_count = ndimage.label( + footprint, structure=np.ones((3, 3), dtype=bool) + ) + if component_count: + sizes = np.bincount(labels.ravel()) + sizes[0] = 0 + footprint = labels == int(np.argmax(sizes)) + footprint = ndimage.binary_fill_holes(footprint) + + inset_cells = max( + 0, int(math.ceil(float(self.config.get("boundary_inset_meters", 1.0)) / resolution)) + ) + if inset_cells: + footprint = ndimage.binary_erosion( + footprint, + structure=np.ones((3, 3), dtype=bool), + iterations=inset_cells, + border_value=0, + ) + if not footprint.any(): + raise ValueError("왞곜 안쪜 Ʞ쀀 적용 후 유횚한 footprint가 없습니닀.") + self._footprint = (float(x_min), float(y_min), resolution, footprint) + return self._footprint + + def contains_xy(self, x: np.ndarray, y: np.ndarray) -> np.ndarray: + x_min, y_min, resolution, footprint = self.footprint() + gx = np.floor((np.asarray(x) - x_min) / resolution).astype(np.int64) + gy = np.floor((np.asarray(y) - y_min) / resolution).astype(np.int64) + valid = (gx >= 0) & (gx < footprint.shape[1]) & (gy >= 0) & (gy < footprint.shape[0]) + result = np.zeros(np.broadcast(x, y).shape, dtype=bool) + result[valid] = footprint[gy[valid], gx[valid]] + return result + + def footprint_metadata(self) -> dict[str, Any]: + _, _, resolution, footprint = self.footprint() + return { + "footprint_area_m2": round(float(footprint.sum()) * resolution * resolution, 3), + "footprint_resolution_meters": resolution, + "boundary_inset_meters": float(self.config.get("boundary_inset_meters", 1.0)), + } + + def grid(self, resolution: float) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + resolution = max(float(resolution), 0.05) + cached = self._grids.get(resolution) + if cached is not None: + return cached + x_min, x_max = self.bounds[0] + y_min, y_max = self.bounds[1] + cols = max(2, int(math.ceil((x_max - x_min) / resolution)) + 1) + rows = max(2, int(math.ceil((y_max - y_min) / resolution)) + 1) + grid = np.full((rows, cols), np.inf, dtype=np.float32) + indices = self.indices() + for start in range(0, len(indices), _BATCH_SIZE): + points = np.asarray(self.xyz[indices[start : start + _BATCH_SIZE]], dtype=np.float32) + gx = np.clip(((points[:, 0] - x_min) / resolution).astype(np.int32), 0, cols - 1) + gy = np.clip(((points[:, 1] - y_min) / resolution).astype(np.int32), 0, rows - 1) + np.minimum.at(grid, (gy, gx), points[:, 2]) + missing = ~np.isfinite(grid) + if missing.all(): + raise ValueError("Ʞ쀀 필터에 지멎 포읞튞가 없습니닀.") + if missing.any(): + nearest = ndimage.distance_transform_edt( + missing, return_distances=False, return_indices=True + ) + grid = grid[tuple(nearest)] + x_coords = np.linspace(x_min, x_max, cols, dtype=np.float32) + y_coords = np.linspace(y_min, y_max, rows, dtype=np.float32) + result = (x_coords, y_coords, grid) + self._grids[resolution] = result + return result + + def preview_grid( + self, preferred_resolution: float + ) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + x_span = max(float(self.bounds[0, 1] - self.bounds[0, 0]), preferred_resolution) + y_span = max(float(self.bounds[1, 1] - self.bounds[1, 0]), preferred_resolution) + maximum = max(4, int(self.config["max_preview_vertices"])) + predicted = (x_span / preferred_resolution + 1) * (y_span / preferred_resolution + 1) + if predicted > maximum: + preferred_resolution *= math.sqrt(predicted / maximum) + return self.grid(preferred_resolution) + + def clear_caches(self) -> None: + self._samples.clear() + self._grids.clear() + self._indices = None + + +def with_footprint(context: TerrainContext, metadata: dict[str, Any]) -> dict[str, Any]: + metadata.update(context.footprint_metadata()) + return metadata + + +# 몚덞 빌더가 사용하는 원자적 저장 래퍌 (공통 유틞 재녞출) +atomic_npz = atomic_write_npz diff --git a/B04_wf1_Surface/B04_wf1_Surface_Engine_Pipeline.py b/B04_wf1_Surface/B04_wf1_Surface_Engine_Pipeline.py new file mode 100644 index 0000000..6c57e8d --- /dev/null +++ b/B04_wf1_Surface/B04_wf1_Surface_Engine_Pipeline.py @@ -0,0 +1,328 @@ +"""B04 지표멎 몚덞 파읎프띌읞 였쌀슀튞레읎터. + +섞 지멎 필터(grid_min_z/csf/pmf)와 닀섯 표현(TIN/DTM/NURBS/implicit/meshfree)의 +캐시륌 만듀고 manifest.json을 ꎀ늬한닀. 캐시 유횚성 검슝, 슀묎딩/등고선 연동, +동음 출력 폎더의 쀑복 싀행 췚소륌 포핚한닀. +""" + +import json +import threading +import time +from pathlib import Path +from typing import Any, Callable + +import numpy as np + +from B04_wf1_Surface.B04_wf1_Surface_Engine_Contour import ( + CONTOUR_EXTRACTOR_VERSION, + extract_contours, +) +from B04_wf1_Surface.B04_wf1_Surface_Engine_ModelBuild import BUILDERS +from B04_wf1_Surface.B04_wf1_Surface_Engine_ModelContext import ( + MODEL_VERSION, + TerrainContext, + bounds_dict, + config_signature, +) +from B04_wf1_Surface.B04_wf1_Surface_Engine_Smooth import ( + compute_smoothing_signature, + run_smoothing, +) +from common_util.common_util_atomic import atomic_write_bytes +from common_util.common_util_json import atomic_write_json + +# 진행률 윜백: (overall_percent, detail_message) +ProgressReporter = Callable[[int, str], None] + +# 같은 프로섞슀에서 동음 프로젝튞 계산 요청읎 겹치멎 두 번짞 요청을 슉시 췚소. +_ACTIVE_TERRAIN_BUILDS: set[str] = set() +_ACTIVE_TERRAIN_BUILDS_GUARD = threading.Lock() + + +def _write_json_file(path: Path, value: dict[str, Any]) -> None: + atomic_write_json(path, value) + + +def _cache_contours( + output_dir: Path, + stem: str, + filter_key: str, + method: str, + representation: str, + config: dict[str, Any], + bounds_info: dict[str, Any], + metadata: dict[str, Any], +) -> None: + """빌드 완료 직후 Ʞ볞 간격 등고선을 사전 추출·캐싱한닀 (원볞 + 슀묎딩).""" + interval = float(config.get("contour_interval_meters", 5.0)) + target_grid_m = float(config.get("contour_grid_resolution_meters", 1.0)) + model_path = output_dir / f"{stem}.npz" + + if model_path.exists(): + contours = extract_contours( + model_path, + representation=representation, + interval=interval, + target_grid_m=target_grid_m, + scene_center=None, + ) + payload = { + "extractor_version": CONTOUR_EXTRACTOR_VERSION, + "project_id": output_dir.parent.name, + "source_filter": filter_key, + "method": method, + "interval": interval, + "bounds": bounds_info, + "contours": contours, + } + atomic_write_bytes( + output_dir / f"contour_{filter_key}_{method}_{interval}m.json", + json.dumps(payload, ensure_ascii=False).encode("utf-8"), + ) + + smooth_model_path = output_dir / f"{stem}_smooth.npz" + smooth_meta = metadata.get("smooth", {}) + if smooth_model_path.exists() and smooth_meta.get("status") == "completed": + smooth_rep = "regular_grid" if method == "dtm" else "triangular_mesh" + smooth_contours = extract_contours( + smooth_model_path, + representation=smooth_rep, + interval=interval, + target_grid_m=target_grid_m, + scene_center=None, + ) + payload = { + "extractor_version": CONTOUR_EXTRACTOR_VERSION, + "project_id": output_dir.parent.name, + "source_filter": filter_key, + "method": method, + "interval": interval, + "bounds": bounds_info, + "contours": smooth_contours, + } + atomic_write_bytes( + output_dir / f"contour_{filter_key}_{method}_smooth_{interval}m.json", + json.dumps(payload, ensure_ascii=False).encode("utf-8"), + ) + + +_REPRESENTATIONS = { + "meshfree": "meshfree_surfels", + "dtm": "regular_grid", + "tin": "triangular_mesh", + "nurbs": "bspline_surface", + "implicit": "local_rbf_height_field", +} + + +def _cache_is_valid( + output_dir: Path, stem: str, method: str, entry: dict[str, Any], config: dict[str, Any] +) -> bool: + """디슀크의 결곌 파음곌 슀묎딩 메타데읎터가 유횚한지 검사한닀.""" + ext = "ply" if method == "meshfree" else "glb" + files_exist = (output_dir / f"{stem}_preview.{ext}").exists() and ( + output_dir / f"{stem}.npz" + ).exists() + if not files_exist: + return False + if method in config.get("smoothing_methods", ("dtm", "tin")): + smooth_entry = entry.get("smooth", {}) + smooth_exist = (output_dir / f"{stem}_smooth.npz").exists() and ( + output_dir / f"{stem}_smooth_preview.glb" + ).exists() + if ( + not smooth_exist + or smooth_entry.get("status") != "completed" + or smooth_entry.get("smoothing_signature") != compute_smoothing_signature(config) + ): + return False + return True + + +def _build_all_terrain_models( + structured_data: dict[str, np.ndarray] | np.lib.npyio.NpzFile, + ground_masks: dict[str, np.ndarray], + output_dir: Path, + config: dict[str, Any], + *, + force: bool = False, + progress: ProgressReporter | None = None, +) -> dict[str, Any]: + """섞 지멎 필터와 닀섯 표현의 캐시륌 만듀고 manifest륌 반환한닀.""" + output_dir.mkdir(parents=True, exist_ok=True) + manifest_path = output_dir / "manifest.json" + filters = tuple(key for key in config["source_filters"] if key in ground_masks) + methods = tuple(key for key in config["precompute"] if key in BUILDERS) + signature = config_signature(config) + bounds = np.asarray(structured_data["bounds"], dtype=np.float64) + xyz = structured_data["xyz"] + + existing: dict[str, Any] = {} + if manifest_path.exists() and not force: + try: + existing = json.loads(manifest_path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError): + existing = {} + if existing.get("config_signature") != signature: + existing = {} + manifest: dict[str, Any] = existing or { + "version": MODEL_VERSION, + "config_signature": signature, + "bounds": bounds_dict(bounds), + "source_filters": {}, + "started_at_unix": time.time(), + } + started_at = time.monotonic() + timeout = max(0, int(config.get("sync_timeout_seconds", 0))) + total_units = max(1, len(filters) * len(methods)) + done_units = 0 + failures = 0 + + def _report(detail: str) -> None: + if progress: + progress(int(100 * done_units / total_units), detail) + + for filter_index, filter_key in enumerate(filters): + mask = np.asarray(ground_masks[filter_key], dtype=bool) + if len(mask) != len(xyz): + raise ValueError(f"{filter_key} 마슀크 Ꞟ읎가 XYZ 데읎터와 닀늅니닀.") + context = TerrainContext(xyz=xyz, mask=mask, bounds=bounds, config=config) + filter_entry = manifest["source_filters"].setdefault( + filter_key, {"source_point_count": context.source_count, "methods": {}} + ) + filter_entry["source_point_count"] = context.source_count + + for method in methods: + stem = f"{method}_{filter_key}" + entry = filter_entry["methods"].get(method, {}) + + if not force and _cache_is_valid(output_dir, stem, method, entry, config): + if entry.get("status") != "completed": + entry.update( + { + "status": "completed", + "representation": _REPRESENTATIONS[method], + "model_file": f"{stem}.npz", + "preview_file": f"{stem}_preview." + + ("ply" if method == "meshfree" else "glb"), + "preview_media_type": "application/octet-stream" + if method == "meshfree" + else "model/gltf-binary", + "error": None, + } + ) + filter_entry["methods"][method] = entry + _write_json_file(manifest_path, manifest) + done_units += 1 + _report(f"{filter_key}-{method} 캐시 재사용") + continue + + if timeout and time.monotonic() - started_at >= timeout: + failures += 1 + filter_entry["methods"][method] = { + "status": "failed", + "error": f"동Ʞ 계산 제한시간 {timeout}쎈륌 쎈곌했습니닀.", + } + _write_json_file(manifest_path, manifest) + done_units += 1 + _report(f"{filter_key}-{method} 시간 쎈곌") + continue + + method_started = time.monotonic() + filter_entry["methods"][method] = {"status": "running", "error": None} + _write_json_file(manifest_path, manifest) + try: + metadata = BUILDERS[method]( + context, + output_dir, + stem, + lambda value: _report(f"{filter_key}-{method} {value}%"), + ) + metadata.update( + { + "status": "completed", + "duration_seconds": round(time.monotonic() - method_started, 3), + "error": None, + } + ) + if method in config.get("smoothing_methods", ("dtm", "tin")): + original_model_path = output_dir / f"{stem}.npz" + if original_model_path.exists(): + try: + smooth_meta = run_smoothing( + method, context, output_dir, stem, original_model_path + ) + smooth_meta["status"] = "completed" + metadata["smooth"] = smooth_meta + except Exception as smooth_exc: + metadata["smooth"] = {"status": "failed", "error": str(smooth_exc)} + + filter_entry["methods"][method] = metadata + try: + _cache_contours( + output_dir, + stem, + filter_key, + method, + metadata.get("representation", "regular_grid"), + config, + manifest.get("bounds", {}), + metadata, + ) + except Exception: + pass # 등고선 사전 캐시는 싀팚핎도 몚덞 빌드륌 묎횚화하지 않는닀. + except Exception as exc: + failures += 1 + filter_entry["methods"][method] = { + "status": "failed", + "duration_seconds": round(time.monotonic() - method_started, 3), + "error": str(exc), + } + done_units += 1 + _report(f"{filter_key}-{method} 완료") + _write_json_file(manifest_path, manifest) + + context.clear_caches() + + manifest["status"] = "completed" if failures == 0 else "completed_with_errors" + manifest["completed_at_unix"] = time.time() + manifest["duration_seconds"] = round(time.monotonic() - started_at, 3) + manifest["failure_count"] = failures + _write_json_file(manifest_path, manifest) + return manifest + + +def build_all_terrain_models( + structured_data: dict[str, np.ndarray] | np.lib.npyio.NpzFile, + ground_masks: dict[str, np.ndarray], + output_dir: Path, + config: dict[str, Any], + *, + force: bool = False, + progress: ProgressReporter | None = None, +) -> dict[str, Any]: + """동음 출력 폎더의 쀑복 싀행을 슉시 췚소하고 싀제 빌드륌 한 번만 수행한닀.""" + output_dir = Path(output_dir) + build_key = str(output_dir.resolve()) + with _ACTIVE_TERRAIN_BUILDS_GUARD: + if build_key in _ACTIVE_TERRAIN_BUILDS: + manifest_path = output_dir / "manifest.json" + try: + current = json.loads(manifest_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + current = {"status": "running", "source_filters": {}} + response = dict(current) + response["request_status"] = "cancelled_already_running" + response["message"] = ( + "동음 프로젝튞의 지표멎 몚덞 계산읎 읎믞 진행 쀑읎얎서 요청을 췚소했습니닀." + ) + return response + _ACTIVE_TERRAIN_BUILDS.add(build_key) + + try: + return _build_all_terrain_models( + structured_data, ground_masks, output_dir, config, force=force, progress=progress + ) + finally: + with _ACTIVE_TERRAIN_BUILDS_GUARD: + _ACTIVE_TERRAIN_BUILDS.discard(build_key) diff --git a/B04_wf1_Surface/B04_wf1_Surface_Engine_Smooth.py b/B04_wf1_Surface/B04_wf1_Surface_Engine_Smooth.py new file mode 100644 index 0000000..50af0b9 --- /dev/null +++ b/B04_wf1_Surface/B04_wf1_Surface_Engine_Smooth.py @@ -0,0 +1,151 @@ +"""B04 지표멎 슀묎딩 엔진 (DTM 가우시안+B-Spline, TIN Taubin). + +원볞 몚덞(npz)을 로드핎 표고 Z만 평활화한 슀묎딩 몚덞곌 프늬뷰(GLB)륌 만든닀. +XY 위치와 삌각형 연결은 원볞을 유지한닀. +""" + +import hashlib +import json +from pathlib import Path +from typing import Any + +import numpy as np +import trimesh +from scipy import ndimage +from scipy.interpolate import RectBivariateSpline + +from B04_wf1_Surface.B04_wf1_Surface_Engine_ModelContext import ( + TerrainContext, + artifact_size, + atomic_npz, + clip_and_compact_mesh, + grid_faces, + grid_vertices, + write_glb, +) + +SMOOTHING_ALGORITHM_VERSION = 2 + + +def compute_smoothing_signature(config: dict[str, Any]) -> str: + """슀묎딩 ꎀ렚 파띌믞터의 핎시 서명을 계산한닀.""" + smooth_params = {k: v for k, v in config.items() if k.startswith("smoothing_")} + smooth_params["algorithm_version"] = SMOOTHING_ALGORITHM_VERSION + encoded = json.dumps(smooth_params, sort_keys=True, default=list).encode("utf-8") + return hashlib.sha256(encoded).hexdigest()[:16] + + +def _masked_gaussian_filter(grid: np.ndarray, mask: np.ndarray, sigma: float) -> np.ndarray: + """묎횚 영역(mask==False) 였엌을 방지하는 정규화 가우시안 필터.""" + if sigma <= 0: + return grid.copy() + values = grid.copy() + values[~mask] = 0.0 + weights = np.zeros_like(grid, dtype=float) + weights[mask] = 1.0 + value_blur = ndimage.gaussian_filter(values, sigma=sigma, mode="constant", cval=0.0) + weight_blur = ndimage.gaussian_filter(weights, sigma=sigma, mode="constant", cval=0.0) + valid_denom = weight_blur > 1e-10 + result = grid.copy() + result[valid_denom] = value_blur[valid_denom] / weight_blur[valid_denom] + return result + + +def smooth_dtm( + context: TerrainContext, output_dir: Path, stem: str, original_model_path: Path +) -> dict[str, Any]: + """DTM 격자에 가우시안+C2 바읎큐빅 볎간을 적용핎 슀묎딩 몚덞을 만든닀.""" + data = np.load(original_model_path) + x_orig, y_orig, z_orig, valid_orig = data["x"], data["y"], data["z"], data["valid_mask"] + + resolution = float(context.config["dtm_grid_resolution_meters"]) + sigma_meters = float(context.config.get("smoothing_dtm_sigma_meters", 0.5)) + sigma_pixels = sigma_meters / resolution if resolution > 0 else 0.0 + z_pre = _masked_gaussian_filter(z_orig, valid_orig, sigma_pixels) + + spline = RectBivariateSpline( + y_orig, + x_orig, + z_pre, + kx=3, + ky=3, + s=float(context.config.get("smoothing_dtm_spline_smooth", 0.0)), + ) + preview_res = float(context.config.get("smoothing_dtm_preview_resolution_meters", 0.5)) + x_coords, y_coords, _ = context.preview_grid(preview_res) + z_smooth = np.asarray(spline(y_coords, x_coords), dtype=np.float32) + valid_grid = context.contains_xy(*np.meshgrid(x_coords, y_coords)).reshape( + len(y_coords), len(x_coords) + ) + vertices = grid_vertices(x_coords, y_coords, z_smooth) + faces = grid_faces(len(y_coords), len(x_coords)) + preview_valid = context.contains_xy(vertices[:, 0], vertices[:, 1]) + vertices_compact, faces_compact = clip_and_compact_mesh(vertices, faces, preview_valid) + + model_path = output_dir / f"{stem}_smooth.npz" + preview_path = output_dir / f"{stem}_smooth_preview.glb" + atomic_npz( + model_path, + x=x_coords, + y=y_coords, + z=z_smooth, + valid_mask=valid_grid, + resolution=np.array([preview_res], np.float32), + ) + write_glb(preview_path, vertices_compact, faces_compact, context.bounds) + return { + "model_file": model_path.name, + "preview_file": preview_path.name, + "preview_media_type": "model/gltf-binary", + "vertex_count": int(len(vertices_compact)), + "face_count": int(len(faces_compact)), + "artifact_bytes": artifact_size(model_path, preview_path), + "smoothing_signature": compute_smoothing_signature(context.config), + } + + +def smooth_tin( + context: TerrainContext, output_dir: Path, stem: str, original_model_path: Path +) -> dict[str, Any]: + """TIN 삌각망에 Taubin 저수축 슀묎딩을 적용한닀 (Z만 평활화).""" + data = np.load(original_model_path) + vertices, faces = data["vertices"], data["faces"] + if len(vertices) < 3 or not len(faces): + raise ValueError("TIN 슀묎딩을 수행할 삌각망 메쉬 데읎터가 올바륎지 않습니닀.") + + iterations = int(context.config.get("smoothing_tin_taubin_iterations", 10)) + lamb = float(context.config.get("smoothing_tin_taubin_lambda", 0.5)) + mu = float(context.config.get("smoothing_tin_taubin_mu", -0.53)) + mesh = trimesh.Trimesh(vertices=vertices, faces=faces, process=False) + if iterations > 0: + trimesh.smoothing.filter_taubin(mesh, lamb=lamb, nu=mu, iterations=iterations) + + vertices_smooth = np.asarray(mesh.vertices, dtype=np.float32) + # 지표멎 슀묎딩은 늬토폎로지가 아니닀: XY·연결은 원볞, Z만 평활화. + vertices_smooth[:, :2] = np.asarray(vertices[:, :2], dtype=np.float32) + faces_smooth = np.asarray(mesh.faces, dtype=np.uint32) + + model_path = output_dir / f"{stem}_smooth.npz" + preview_path = output_dir / f"{stem}_smooth_preview.glb" + atomic_npz(model_path, vertices=vertices_smooth, faces=faces_smooth) + write_glb(preview_path, vertices_smooth, faces_smooth, context.bounds) + return { + "model_file": model_path.name, + "preview_file": preview_path.name, + "preview_media_type": "model/gltf-binary", + "vertex_count": int(len(vertices_smooth)), + "face_count": int(len(faces_smooth)), + "artifact_bytes": artifact_size(model_path, preview_path), + "smoothing_signature": compute_smoothing_signature(context.config), + } + + +def run_smoothing( + method: str, context: TerrainContext, output_dir: Path, stem: str, original_model_path: Path +) -> dict[str, Any]: + """방식에 따띌 적절한 슀묎딩 엔진을 수행한닀.""" + if method == "dtm": + return smooth_dtm(context, output_dir, stem, original_model_path) + if method == "tin": + return smooth_tin(context, output_dir, stem, original_model_path) + raise ValueError(f"슀묎딩읎 불가능한 지형 표현 방식입니닀: {method}") diff --git a/B04_wf1_Surface/B04_wf1_Surface_Engine_Structurize.py b/B04_wf1_Surface/B04_wf1_Surface_Engine_Structurize.py new file mode 100644 index 0000000..d4927f7 --- /dev/null +++ b/B04_wf1_Surface/B04_wf1_Surface_Engine_Structurize.py @@ -0,0 +1,112 @@ +"""B04 LAS/LAZ 고속 구조화 엔진.""" + +import os +import tempfile +from collections.abc import Callable +from pathlib import Path + +import laspy +import numpy as np + +from config.config_system import SURFACE_DEFAULT_RGB_VALUE, SURFACE_LAS_CHUNK_SIZE + + +def structurize_las( + las_path: str | Path, + output_dir: str | Path, + progress_callback: Callable[[int], None] | None = None, +) -> Path: + """LAS/LAZ 속성을 청크로 읜얎 B04 structured.npz로 원자적 저장한닀.""" + source = Path(las_path) + target_dir = Path(output_dir) + target_dir.mkdir(parents=True, exist_ok=True) + target = target_dir / "structured.npz" + + with laspy.open(source) as las_file: + header = las_file.header + total_points = int(header.point_count) + point_format = header.point_format + dimensions = set(point_format.dimension_names) + has_rgb = {"red", "green", "blue"}.issubset(dimensions) + has_intensity = "intensity" in dimensions + has_returns = {"return_number", "number_of_returns"}.issubset(dimensions) + has_classification = "classification" in dimensions + bounds = np.array( + [ + [float(header.mins[0]), float(header.maxs[0])], + [float(header.mins[1]), float(header.maxs[1])], + [float(header.mins[2]), float(header.maxs[2])], + ], + dtype=np.float64, + ) + + xyz = np.empty((total_points, 3), dtype=np.float64) + intensity = np.zeros(total_points, dtype=np.uint16) + rgb = np.full((total_points, 3), SURFACE_DEFAULT_RGB_VALUE, dtype=np.uint8) + return_number = np.ones(total_points, dtype=np.uint8) + number_of_returns = np.ones(total_points, dtype=np.uint8) + classification = np.zeros(total_points, dtype=np.uint8) + + offset = 0 + for chunk in las_file.chunk_iterator(SURFACE_LAS_CHUNK_SIZE): + chunk_size = len(chunk) + section = slice(offset, offset + chunk_size) + xyz[section, 0] = np.asarray(chunk.x, dtype=np.float64) + xyz[section, 1] = np.asarray(chunk.y, dtype=np.float64) + xyz[section, 2] = np.asarray(chunk.z, dtype=np.float64) + if has_intensity: + intensity[section] = np.asarray(chunk.intensity, dtype=np.uint16) + if has_rgb: + colors = np.stack( + [ + np.asarray(chunk.red, dtype=np.float64), + np.asarray(chunk.green, dtype=np.float64), + np.asarray(chunk.blue, dtype=np.float64), + ], + axis=1, + ) + if colors.size and float(colors.max()) > 255.0: + colors /= 256.0 + rgb[section] = colors.clip(0, 255).astype(np.uint8) + if has_returns: + return_number[section] = np.asarray(chunk.return_number, dtype=np.uint8) + number_of_returns[section] = np.asarray(chunk.number_of_returns, dtype=np.uint8) + if has_classification: + classification[section] = np.asarray(chunk.classification, dtype=np.uint8) + offset += chunk_size + if progress_callback: + progress_callback(int(offset / total_points * 100) if total_points else 100) + + temporary_path: Path | None = None + try: + with tempfile.NamedTemporaryFile( + mode="wb", + dir=target_dir, + prefix=".structured.", + suffix=".npz.tmp", + delete=False, + ) as temporary: + temporary_path = Path(temporary.name) + np.savez_compressed( + temporary, + xyz=xyz, + intensity=intensity, + rgb=rgb, + return_number=return_number, + number_of_returns=number_of_returns, + classification=classification, + bounds=bounds, + total_points=np.array([total_points], dtype=np.int64), + has_rgb=np.array([int(has_rgb)], dtype=np.int8), + ) + temporary.flush() + os.fsync(temporary.fileno()) + os.replace(temporary_path, target) + temporary_path = None + finally: + if temporary_path is not None: + temporary_path.unlink(missing_ok=True) + + if progress_callback and total_points == 0: + progress_callback(100) + return target diff --git a/B04_wf1_Surface/B04_wf1_Surface_Repository.py b/B04_wf1_Surface/B04_wf1_Surface_Repository.py new file mode 100644 index 0000000..0f6f16a --- /dev/null +++ b/B04_wf1_Surface/B04_wf1_Surface_Repository.py @@ -0,0 +1,227 @@ +"""B04 지표멎 분석 결곌의 aiomysql Raw SQL ì ‘ê·Œ. + +processed_point_cloud(변환 포읞튞큎띌우드), surface_models(지표멎 몚덞), +terrain_layers(지형 레읎얎) 테읎랔에 메타데읎터와 상대 겜로륌 Ʞ록한닀. +공간 데읎터는 MariaDB JSON 컬럌에 GeoJSON 묞자엎로 저장한닀. +""" + +import json +from pathlib import PurePosixPath +from typing import Any +from uuid import UUID + +import aiomysql + +_STAGE_ROOT = "B04_wf1_Surface" + + +def _validate_stage_path(relative_path: str) -> str: + """B04_wf1_Surface 아래의 안전한 상대 겜로읞지 검슝하고 posix 묞자엎로 반환한닀.""" + normalized = PurePosixPath(relative_path.replace("\\", "/")) + if normalized.is_absolute() or ".." in normalized.parts: + raise ValueError("DB에는 프로젝튞 룚튞 Ʞ쀀 상대 겜로만 저장할 수 있습니닀.") + if not normalized.parts or normalized.parts[0] != _STAGE_ROOT: + raise ValueError(f"B04 산출묌 겜로는 {_STAGE_ROOT} 아래여알 합니닀.") + return normalized.as_posix() + + +async def create_processed_point_cloud( + connection: aiomysql.Connection, + *, + input_file_id: int, + project_id: UUID, + process_type: str, + processed_file_path: str | None, + converted_format: str | None, + converted_file_path: str | None, + point_count: int | None, + bounds: dict[str, Any] | None, + statistics: dict[str, Any] | None, + classification_summary: dict[str, Any] | None, + processing_params: dict[str, Any] | None, + status: str = "COMPLETE", +) -> int: + """변환 포읞튞큎띌우드 메타데읎터륌 저장하고 생성된 ID륌 반환한닀.""" + processed_rel = _validate_stage_path(processed_file_path) if processed_file_path else None + converted_rel = _validate_stage_path(converted_file_path) if converted_file_path else None + stats = statistics or {} + + async with connection.cursor() as cursor: + await cursor.execute( + """ + INSERT INTO processed_point_cloud ( + input_file_id, project_id, process_type, + processed_file_path, converted_format, converted_file_path, + point_count, min_z, max_z, mean_z, + x_min, x_max, y_min, y_max, density_per_sqm, + classification_summary, processing_params, status + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + """, + ( + input_file_id, + str(project_id), + process_type, + processed_rel, + converted_format, + converted_rel, + point_count, + stats.get("min_z"), + stats.get("max_z"), + stats.get("mean_z"), + (bounds or {}).get("x_min"), + (bounds or {}).get("x_max"), + (bounds or {}).get("y_min"), + (bounds or {}).get("y_max"), + stats.get("density_per_sqm"), + json.dumps(classification_summary, ensure_ascii=False) + if classification_summary is not None + else None, + json.dumps(processing_params, ensure_ascii=False) + if processing_params is not None + else None, + status, + ), + ) + new_id = cursor.lastrowid + if not new_id: + raise RuntimeError("processed_point_cloud 레윔드 생성 결곌에 ID가 없습니닀.") + return int(new_id) + + +async def create_surface_model( + connection: aiomysql.Connection, + *, + project_id: UUID, + model_type: str, + source_file_id: int | None, + processed_cloud_id: int | None, + crs_epsg: int | None, + resolution_m: float | None, + model_file_path: str | None, + generation_params: dict[str, Any] | None, + status: str = "COMPLETE", +) -> int: + """지표멎 몚덞 메타데읎터륌 저장하고 생성된 ID륌 반환한닀.""" + model_rel = _validate_stage_path(model_file_path) if model_file_path else None + + async with connection.cursor() as cursor: + await cursor.execute( + """ + INSERT INTO surface_models ( + project_id, model_type, source_file_id, processed_cloud_id, + status, crs_epsg, resolution_m, model_file_path, + generation_params, completed_at + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, CURRENT_TIMESTAMP) + """, + ( + str(project_id), + model_type, + source_file_id, + processed_cloud_id, + status, + crs_epsg, + resolution_m, + model_rel, + json.dumps(generation_params, ensure_ascii=False) + if generation_params is not None + else None, + ), + ) + new_id = cursor.lastrowid + if not new_id: + raise RuntimeError("surface_models 레윔드 생성 결곌에 ID가 없습니닀.") + return int(new_id) + + +async def create_terrain_layer( + connection: aiomysql.Connection, + *, + surface_model_id: int, + layer_name: str, + geometry_type: str, + layer_file_path: str | None, + file_format: str | None, + file_size_mb: float | None, + statistics: dict[str, Any] | None, +) -> int: + """지형 레읎얎 메타데읎터륌 저장하고 생성된 ID륌 반환한닀.""" + layer_rel = _validate_stage_path(layer_file_path) if layer_file_path else None + + async with connection.cursor() as cursor: + await cursor.execute( + """ + INSERT INTO terrain_layers ( + surface_model_id, layer_name, geometry_type, + layer_file_path, file_format, file_size_mb, statistics + ) + VALUES (%s, %s, %s, %s, %s, %s, %s) + """, + ( + surface_model_id, + layer_name, + geometry_type, + layer_rel, + file_format, + file_size_mb, + json.dumps(statistics, ensure_ascii=False) if statistics is not None else None, + ), + ) + new_id = cursor.lastrowid + if not new_id: + raise RuntimeError("terrain_layers 레윔드 생성 결곌에 ID가 없습니닀.") + return int(new_id) + + +async def get_input_file( + connection: aiomysql.Connection, project_id: UUID, input_file_id: int +) -> dict[str, Any]: + """프로젝튞의 특정 입력 파음 겜로·좌표계륌 조회한닀.""" + async with connection.cursor() as cursor: + await cursor.execute( + """ + SELECT id, file_type, raw_file_path, crs_epsg + FROM input_files + WHERE id = %s AND project_id = %s + """, + (input_file_id, str(project_id)), + ) + row = await cursor.fetchone() + if not row: + raise LookupError("입력 파음을 찟을 수 없습니닀.") + return { + "id": int(row[0]), + "file_type": row[1], + "raw_file_path": row[2], + "crs_epsg": row[3], + } + + +async def list_surface_models( + connection: aiomysql.Connection, project_id: UUID +) -> list[dict[str, Any]]: + """프로젝튞의 지표멎 몚덞 목록을 최신순윌로 조회한닀.""" + async with connection.cursor() as cursor: + await cursor.execute( + """ + SELECT id, model_type, status, resolution_m, model_file_path, created_at + FROM surface_models + WHERE project_id = %s + ORDER BY created_at DESC + """, + (str(project_id),), + ) + rows = await cursor.fetchall() + + return [ + { + "id": int(row[0]), + "model_type": row[1], + "status": row[2], + "resolution_m": row[3], + "model_file_path": row[4], + "created_at": row[5].isoformat() if row[5] else None, + } + for row in rows + ] diff --git a/B04_wf1_Surface/B04_wf1_Surface_Router.py b/B04_wf1_Surface/B04_wf1_Surface_Router.py new file mode 100644 index 0000000..34dbf55 --- /dev/null +++ b/B04_wf1_Surface/B04_wf1_Surface_Router.py @@ -0,0 +1,149 @@ +"""B04 지표멎 분석 FastAPI 띌우터.""" + +import asyncio +import logging +from pathlib import Path +from uuid import UUID + +from fastapi import APIRouter +from fastapi.responses import JSONResponse + +from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path +from B04_wf1_Surface.B04_wf1_Surface_Engine import run_surface_analysis +from B04_wf1_Surface.B04_wf1_Surface_Repository import ( + create_processed_point_cloud, + create_surface_model, + create_terrain_layer, + get_input_file, + list_surface_models, +) +from B04_wf1_Surface.B04_wf1_Surface_Schema import ( + SurfaceAnalyzeRequest, + SurfaceAnalyzeResponse, + SurfaceModelListResponse, + SurfaceModelSummary, +) +from common_util.common_util_storage import resolve_stored_project_path +from config.config_db import get_db_pool + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/projects", tags=["B04 Surface Analysis"]) + + +@router.post("/{project_id}/surface/analyze", response_model=SurfaceAnalyzeResponse) +async def analyze_surface( + project_id: UUID, request: SurfaceAnalyzeRequest +) -> SurfaceAnalyzeResponse | JSONResponse: + """LAS 구조화·지멎 필터·지표멎 몚덞 생성을 싀행하고 DB에 Ʞ록한닀.""" + try: + source_filters = request.resolved_filters() + methods = request.resolved_methods() + except ValueError as exc: + return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)}) + + pool = get_db_pool() + try: + async with pool.acquire() as connection: + stored_path = await get_project_storage_relative_path(connection, project_id) + project_root = Path(resolve_stored_project_path(stored_path)) + input_file = await get_input_file(connection, project_id, request.input_file_id) + las_path = project_root / Path(input_file["raw_file_path"]) + if not las_path.is_file(): + return JSONResponse( + status_code=404, + content={"status": "error", "message": "원볞 LAS 파음을 찟을 수 없습니닀."}, + ) + + # 묎거욎 지형 연산은 읎벀튞 룚프륌 막지 않도록 별도 슀레드에서 싀행. + result = await asyncio.to_thread( + run_surface_analysis, + project_root, + las_path, + source_filters=source_filters, + methods=methods, + force=request.force, + ) + + # DB Ʞ록 (튞랜잭션) + await connection.begin() + try: + processed = result["processed"] + processed_cloud_id = await create_processed_point_cloud( + connection, + input_file_id=request.input_file_id, + project_id=project_id, + process_type="structured", + processed_file_path=processed["processed_file_path"], + converted_format=None, + converted_file_path=processed["converted_file_path"], + point_count=processed["point_count"], + bounds=processed["bounds"], + statistics=processed["statistics"], + classification_summary=None, + processing_params={"filters": source_filters}, + ) + surface_model_ids: list[int] = [] + for model in result["models"]: + model_id = await create_surface_model( + connection, + project_id=project_id, + model_type=model["model_type"], + source_file_id=request.input_file_id, + processed_cloud_id=processed_cloud_id, + crs_epsg=input_file["crs_epsg"], + resolution_m=model["resolution_m"], + model_file_path=model["model_file_path"], + generation_params=model["generation_params"], + ) + surface_model_ids.append(model_id) + for layer in model["layers"]: + await create_terrain_layer( + connection, + surface_model_id=model_id, + layer_name=layer["layer_name"], + geometry_type=layer["geometry_type"], + layer_file_path=layer["file_path"], + file_format=layer["file_format"], + file_size_mb=None, + statistics=None, + ) + await connection.commit() + except Exception: + await connection.rollback() + raise + + return SurfaceAnalyzeResponse( + project_id=str(project_id), + ground_summary=result["ground_summary"], + manifest_status=result["manifest"].get("status", "unknown"), + surface_model_ids=surface_model_ids, + ) + except LookupError as exc: + return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)}) + except (OSError, ValueError) as exc: + return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)}) + except Exception: + logger.exception("B04 지표멎 분석 싀팚: project_id=%s", project_id) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "지표멎 분석 처늬 쀑 였류가 발생했습니닀."}, + ) + + +@router.get("/{project_id}/surface/models", response_model=SurfaceModelListResponse) +async def get_surface_models(project_id: UUID) -> SurfaceModelListResponse | JSONResponse: + """프로젝튞의 지표멎 몚덞 목록을 조회한닀.""" + pool = get_db_pool() + try: + async with pool.acquire() as connection: + models = await list_surface_models(connection, project_id) + return SurfaceModelListResponse( + project_id=str(project_id), + models=[SurfaceModelSummary(**model) for model in models], + ) + except Exception: + logger.exception("B04 지표멎 몚덞 목록 조회 싀팚: project_id=%s", project_id) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "몚덞 목록 조회 쀑 였류가 발생했습니닀."}, + ) diff --git a/B04_wf1_Surface/B04_wf1_Surface_Schema.py b/B04_wf1_Surface/B04_wf1_Surface_Schema.py new file mode 100644 index 0000000..28ffbaa --- /dev/null +++ b/B04_wf1_Surface/B04_wf1_Surface_Schema.py @@ -0,0 +1,71 @@ +"""B04 지표멎 분석 요청·응답 검슝 몚덞.""" + +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field + +from config.config_system import ( + SURFACE_MODEL_PRECOMPUTE, + SURFACE_MODEL_SOURCE_FILTERS, +) + +_ALLOWED_FILTERS = set(SURFACE_MODEL_SOURCE_FILTERS) | {"ransac"} +_ALLOWED_METHODS = set(SURFACE_MODEL_PRECOMPUTE) + + +class SurfaceAnalyzeRequest(BaseModel): + """지표멎 분석 싀행 요청.""" + + model_config = ConfigDict(extra="forbid") + + input_file_id: int = Field(gt=0, description="구조화 대상 원볞 LAS input_files.id") + source_filters: list[str] | None = Field( + default=None, description="싀행할 지멎 필터 (믞지정 시 config Ʞ볞값)" + ) + methods: list[str] | None = Field( + default=None, description="생성할 지표멎 표현 (믞지정 시 config Ʞ볞값)" + ) + force: bool = Field(default=False, description="캐시 묎시 후 강제 재계산") + + def resolved_filters(self) -> list[str]: + filters = self.source_filters or list(SURFACE_MODEL_SOURCE_FILTERS) + invalid = [f for f in filters if f not in _ALLOWED_FILTERS] + if invalid: + raise ValueError(f"허용되지 않은 지멎 필터입니닀: {invalid}") + return filters + + def resolved_methods(self) -> list[str]: + methods = self.methods or list(SURFACE_MODEL_PRECOMPUTE) + invalid = [m for m in methods if m not in _ALLOWED_METHODS] + if invalid: + raise ValueError(f"허용되지 않은 지표멎 표현입니닀: {invalid}") + return methods + + +class SurfaceModelSummary(BaseModel): + """저장된 지표멎 몚덞 요앜.""" + + id: int + model_type: str + status: str + resolution_m: float | None = None + model_file_path: str | None = None + created_at: str | None = None + + +class SurfaceAnalyzeResponse(BaseModel): + """지표멎 분석 싀행 결곌.""" + + status: str = "success" + project_id: str + ground_summary: dict[str, Any] + manifest_status: str + surface_model_ids: list[int] + + +class SurfaceModelListResponse(BaseModel): + """프로젝튞 지표멎 몚덞 목록 응답.""" + + status: str = "success" + project_id: str + models: list[SurfaceModelSummary] diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_Page.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_Page.ts index 09a7e7d..583e70b 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_Page.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_Page.ts @@ -2,27 +2,249 @@ * B04_wf1_Surface_UI_Page.ts * 로귞읞 후 04: 1ì°š 워크플로우 (지표멎 몚덞 분석) * - * ⚠ 좌잡 입력 팹널 / ìš°ìž¡ WebCAD ë·°ì–Ž 볞묞은 쀀비 쀑 — 워크플로우 ì…ž(헀더+ - * 슀텝바 = 3당 레읎아웃)만 구성. 싀제 볞묞은 0_old 찞고하여 추후 구첎화. + * 3당 레읎아웃 (frontend.md §2): + * 상닚: 페읎지 타읎틀 + 진행 닚계 슀텝바 (createWorkflowShell) + * 좌잡: 입력 파음 ID + 지멎 필터/지표멎 표현 선택 + 싀행 옵션 폌 + * ìš°ìž¡: 생성된 지표멎 몚덞 목록 귞늬드 * - * 제앜 쀀수 (frontend.md §2 3당 레읎아웃): createWorkflowShell 재사용. + * 읎벀튞 핞듀러 명명 (frontend.md §4): onB04_Surface_[Ʞ능]_[액션] + * 텍슀튞는 ui_template_locale에 선(先) 등록 후 ì°žì¡° (frontend.md §3). * ========================================================================== */ -import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; -import { renderPendingWorkflow, workflowSteps } from "../A00_Common/b_page_scaffold"; +import { CURRENT_PROJECT_ID_KEY } from "@config/config_frontend"; +import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; +import { + createButton, + createInputField, + createTag, + createWorkflowShell, + hideLoadingOverlay, + showLoadingOverlay, + showToast, +} from "@ui/ui_template_elements"; +import { workflowSteps } from "../A00_Common/b_page_scaffold"; +import { + analyzeSurface, + listSurfaceModels, + type SurfaceModelSummary, +} from "./B04_wf1_Surface_Api_Fetch"; +import "./B04_wf1_Surface_UI_Style.css"; /** locale 헬퍌 */ function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; } -/* ----------------------------------------------------------------------------- - * 페읎지 진입점 - * -------------------------------------------------------------------------- */ +/** 선택 가능한 지멎 필터 (config_system.SURFACE_MODEL_SOURCE_FILTERS + ransac) */ +const SOURCE_FILTERS = ["grid_min_z", "csf", "pmf", "ransac"] as const; +/** 선택 가능한 지표멎 표현 (config_system.SURFACE_MODEL_PRECOMPUTE) */ +const MODEL_METHODS = ["tin", "dtm", "nurbs", "implicit", "meshfree"] as const; + +/** 첎크박슀 귞룹 하나 생성 (띌벚 + 항목듀). 선택 값 Set을 반환. */ +function buildCheckboxGroup( + legend: string, + values: readonly string[], + defaults: readonly string[], +): { root: HTMLElement; selected: Set } { + const selected = new Set(defaults); + const root = document.createElement("fieldset"); + root.className = "b04-surface__group"; + const legendEl = document.createElement("legend"); + legendEl.className = "b04-surface__group-legend"; + legendEl.textContent = legend; + root.append(legendEl); + + for (const value of values) { + const item = document.createElement("label"); + item.className = "b04-surface__check"; + const box = document.createElement("input"); + box.type = "checkbox"; + box.value = value; + box.checked = selected.has(value); + box.addEventListener("change", () => { + if (box.checked) selected.add(value); + else selected.delete(value); + }); + const text = document.createElement("span"); + text.textContent = value; + item.append(box, text); + root.append(item); + } + return { root, selected }; +} + export function renderB04Surface(root: HTMLElement): void { - renderPendingWorkflow(root, { + const shell = createWorkflowShell({ title: L("B04_Surface_Title"), steps: workflowSteps(), activeStep: 0, }); + + /* ---- 좌잡 입력 팹널 ---- */ + const inputFileField = createInputField({ + label: L("B04_Surface_Field_InputId"), + type: "number", + min: 1, + placeholder: L("B04_Surface_Field_InputId_Placeholder"), + }); + + const filterGroup = buildCheckboxGroup(L("B04_Surface_Group_Filters"), SOURCE_FILTERS, [ + "grid_min_z", + "csf", + "pmf", + ]); + const methodGroup = buildCheckboxGroup(L("B04_Surface_Group_Methods"), MODEL_METHODS, [ + "dtm", + "tin", + ]); + + const forceLabel = document.createElement("label"); + forceLabel.className = "b04-surface__check"; + const forceBox = document.createElement("input"); + forceBox.type = "checkbox"; + const forceText = document.createElement("span"); + forceText.textContent = L("B04_Surface_Field_Force"); + forceLabel.append(forceBox, forceText); + + const analyzeButton = createButton({ + label: L("B04_Surface_Btn_Analyze"), + variant: "filled", + onClick: () => void onB04_Surface_Analyze_Click(), + }); + + const leftForm = document.createElement("div"); + leftForm.className = "b04-surface__form"; + leftForm.append( + inputFileField.root, + filterGroup.root, + methodGroup.root, + forceLabel, + analyzeButton, + ); + shell.leftPanel.append(leftForm); + + /* ---- ìš°ìž¡ 결곌 영역 ---- */ + const resultHeader = document.createElement("div"); + resultHeader.className = "b04-surface__result-head"; + const resultTitle = document.createElement("h3"); + resultTitle.textContent = L("B04_Surface_Result_Title"); + const refreshButton = createButton({ + label: L("B04_Surface_Btn_Refresh"), + variant: "ghost", + onClick: () => void onB04_Surface_Refresh_Click(), + }); + resultHeader.append(resultTitle, refreshButton); + + const modelList = document.createElement("div"); + modelList.className = "b04-surface__models"; + + const resultArea = document.createElement("div"); + resultArea.className = "b04-surface__result"; + resultArea.append(resultHeader, modelList); + shell.rightArea.append(resultArea); + + function renderModels(models: readonly SurfaceModelSummary[]): void { + modelList.replaceChildren(); + if (models.length === 0) { + const empty = document.createElement("p"); + empty.className = "b04-surface__empty"; + empty.textContent = L("B04_Surface_Result_Empty"); + modelList.append(empty); + return; + } + for (const model of models) { + const card = document.createElement("div"); + card.className = "b04-surface__model-card"; + const head = document.createElement("div"); + head.className = "b04-surface__model-head"; + const type = document.createElement("strong"); + type.textContent = model.model_type; + const variant = + model.status === "CONFIRMED" ? "success" : model.status === "FAILED" ? "danger" : "neutral"; + head.append(type, createTag(model.status, variant)); + + const meta = document.createElement("div"); + meta.className = "b04-surface__model-meta"; + const resolution = document.createElement("span"); + resolution.textContent = `${L("B04_Surface_Model_Resolution")}: ${ + model.resolution_m ?? "-" + }`; + const path = document.createElement("span"); + path.textContent = `${L("B04_Surface_Model_Path")}: ${model.model_file_path ?? "-"}`; + meta.append(resolution, path); + + card.append(head, meta); + modelList.append(card); + } + } + + function getProjectId(): string | null { + const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); + if (!projectId) { + inputFileField.setError(L("B04_Surface_Error_Project")); + showToast(L("B04_Surface_Error_Project"), "error"); + } + return projectId; + } + + async function onB04_Surface_Analyze_Click(): Promise { + const projectId = getProjectId(); + if (!projectId) return; + + const rawId = inputFileField.input.value.trim(); + const inputFileId = Number(rawId); + if (!rawId || !Number.isInteger(inputFileId) || inputFileId <= 0) { + inputFileField.setError(L("B04_Surface_Error_InputId")); + return; + } + if (filterGroup.selected.size === 0 || methodGroup.selected.size === 0) { + inputFileField.setError(L("B04_Surface_Error_Selection")); + return; + } + inputFileField.setError(); + + showLoadingOverlay(); + try { + const response = await analyzeSurface(projectId, { + input_file_id: inputFileId, + source_filters: [...filterGroup.selected], + methods: [...methodGroup.selected], + force: forceBox.checked, + }); + showToast( + `${L("B04_Surface_Analyze_Success")} (${response.surface_model_ids.length})`, + "success", + ); + await loadModels(projectId); + } catch (error) { + const detail = error instanceof Error ? error.message : L("B04_Surface_Analyze_Failed"); + inputFileField.setError(`${L("B04_Surface_Analyze_Failed")} ${detail}`); + showToast(L("B04_Surface_Analyze_Failed"), "error"); + } finally { + hideLoadingOverlay(); + } + } + + async function loadModels(projectId: string): Promise { + showLoadingOverlay(); + try { + const response = await listSurfaceModels(projectId); + renderModels(response.models); + } catch { + showToast(L("B04_Surface_Load_Failed"), "error"); + } finally { + hideLoadingOverlay(); + } + } + + async function onB04_Surface_Refresh_Click(): Promise { + const projectId = getProjectId(); + if (projectId) await loadModels(projectId); + } + + renderModels([]); + root.replaceChildren(shell.root); + + const initialProjectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); + if (initialProjectId) void loadModels(initialProjectId); } diff --git a/B05_wf2_Route/B05_wf2_Route_Engine.py b/B05_wf2_Route/B05_wf2_Route_Engine.py new file mode 100644 index 0000000..11d3ce0 --- /dev/null +++ b/B05_wf2_Route/B05_wf2_Route_Engine.py @@ -0,0 +1,120 @@ +"""B05 겜로 섀계 엔진 였쌀슀튞레읎터. + +겜로점(BP/CP/EP/AP/FP)곌 옵션을 받아 최적 겜로륌 계산하고, 폎늬띌읞을 +GeoJSON윌로 저장하며 DB Ʞ록용 데읎터(메타·렌더링 샘플·통계)륌 쀀비한닀. +띌우터에서 asyncio.to_thread로 혞출한닀. +""" + +from pathlib import Path +from typing import Any + +from B05_wf2_Route.B05_wf2_Route_Engine_RidgeValley import solve_ridge_valley_route +from B05_wf2_Route.B05_wf2_Route_Engine_Solver import solve_optimal_route +from common_util.common_util_json import atomic_write_json + +_ROUTE_SUBDIR = Path("B05_wf2_Route") / "route" +# route_points 테읎랔에 저장할 렌더링 샘플 최대 개수 +_MAX_RENDER_POINTS = 500 + + +def _route_geojson(polyline: list[list[float]]) -> dict[str, Any]: + """폎늬띌읞을 3D LineString GeoJSON Feature로 변환한닀.""" + return { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [[round(x, 3), round(y, 3), round(z, 3)] for x, y, z in polyline], + }, + "properties": {}, + } + + +def _sample_render_points( + polyline: list[list[float]], chainage_m: list[float], maximum: int +) -> list[dict[str, Any]]: + """폎늬띌읞을 최대 maximum개로 균등 샘플링핎 렌더링용 포읞튞륌 만든닀.""" + n = len(polyline) + if n == 0: + return [] + if n <= maximum: + indices = range(n) + else: + step = n / maximum + indices = (int(i * step) for i in range(maximum)) + + points: list[dict[str, Any]] = [] + for seq, idx in enumerate(indices): + idx = min(idx, n - 1) + _, _, z = polyline[idx] + # 국소 겜사(%) — 직전 정점곌의 찚읎 + slope_pct = 0.0 + if idx > 0: + x0, y0, z0 = polyline[idx - 1] + x1, y1, z1 = polyline[idx] + h = ((x1 - x0) ** 2 + (y1 - y0) ** 2) ** 0.5 + if h > 1e-6: + slope_pct = abs(z1 - z0) / h * 100.0 + points.append( + { + "chainage_m": round(chainage_m[idx], 3) if idx < len(chainage_m) else None, + "elevation_m": round(z, 3), + "slope_percent": round(slope_pct, 3), + "sequence_num": seq, + } + ) + return points + + +def run_route_design( + project_root: Path, + filter_key: str, + method: str, + smooth: bool, + points_data: dict[str, Any], + options: dict[str, Any], + algorithm: str = "dijkstra", +) -> dict[str, Any]: + """겜로 탐색을 싀행하고 GeoJSON 저장 + DB Ʞ록용 데읎터륌 반환한닀. + + algorithm: "dijkstra"(격자 Dijkstra) 또는 "ridge_valley"(능선-계곡 정속겜사). + + 반환 dict: + - route_data_path: 저장한 GeoJSON의 프로젝튞 상대 겜로 + - solver_result: solver 원볞 결곌 (polyline, metrics, segments 등) + - render_points: route_points 테읎랔 저장용 샘플 + - statistics: route_statistics 저장용 요앜 + """ + if algorithm == "ridge_valley": + result = solve_ridge_valley_route( + project_root, filter_key, smooth, points_data, options, method=method + ) + else: + result = solve_optimal_route( + project_root, filter_key, smooth, points_data, options, method=method + ) + polyline = result["polyline"] + chainage_m = result["chainage_m"] + + route_dir = project_root / _ROUTE_SUBDIR + route_dir.mkdir(parents=True, exist_ok=True) + geojson_path = route_dir / "route_main.geojson" + atomic_write_json(geojson_path, _route_geojson(polyline)) + + # 통계 요앜 (solver 메튞늭에서 파생) + metrics = result["metrics"] + statistics = { + "min_slope": 0.0, + "max_slope": metrics.get("max_grade_pct"), + "mean_slope": metrics.get("avg_grade_pct"), + "cost_score": None, + } + + return { + "route_data_path": geojson_path.relative_to(project_root).as_posix(), + "solver_result": result, + "render_points": _sample_render_points(polyline, chainage_m, _MAX_RENDER_POINTS), + "statistics": statistics, + "grade_percent": [seg.get("max_grade_pct") for seg in result.get("segments", [])], + "constraints": result.get("conditions_snapshot", {}), + "algorithm_params": options.get("weights") or {}, + } diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Geometry.py b/B05_wf2_Route/B05_wf2_Route_Engine_Geometry.py new file mode 100644 index 0000000..f304c58 --- /dev/null +++ b/B05_wf2_Route/B05_wf2_Route_Engine_Geometry.py @@ -0,0 +1,264 @@ +"""B05 겜로 섀계 Ʞ하 유틞늬티 및 닚음 구간 Dijkstra. + +8-연결 격자에서 종닚겜사·잡사멎·곡률을 비용윌로 반영하는 방향 읞식 +상태공간 Dijkstra와, 곡률/거늬/교찚 계산 등 순수 Ʞ하 헬퍌륌 제공한닀. +config에 독늜적읎며 몚든 파띌믞터는 혞출잡에서 죌입한닀. +""" + +import heapq +import math +from typing import Any + +import numpy as np + + +def get_neighbors(r: int, c: int, rows: int, cols: int): + """8-연결 읎웃을 (nr, nc, dir_idx)로 순회한닀. (방향 읞덱슀 0..7)""" + neighbors = [ + (-1, 0, 4), # S + (-1, 1, 3), # SE + (0, 1, 2), # E + (1, 1, 1), # NE + (1, 0, 0), # N + (1, -1, 7), # NW + (0, -1, 6), # W + (-1, -1, 5), # SW + ] + for dr, dc, d_idx in neighbors: + nr, nc = r + dr, c + dc + if 0 <= nr < rows and 0 <= nc < cols: + yield nr, nc, d_idx + + +def compute_gradients( + z_grid: np.ndarray, res_y: float, res_x: float | None = None +) -> tuple[np.ndarray, np.ndarray]: + """싀제 격자 간격윌로 지형 Ʞ욞Ʞ륌 계산핎 (dz_dx, dz_dy)륌 반환한닀.""" + if res_x is None: + res_x = res_y + dz_dy, dz_dx = np.gradient(z_grid, res_y, res_x) + return dz_dx, dz_dy + + +def side_slope_magnitude(dr: int, dc: int, gx: float, gy: float) -> float: + """진행 방향에 수직읞 지형 êž°ìšžêž°(잡사멎=절토/성토 프록시)의 크Ʞ.""" + norm = math.hypot(dc, dr) + if norm == 0: + return 0.0 + px, py = -dr / norm, dc / norm + return abs(gx * px + gy * py) + + +def circumradius_2d(p0, p1, p2) -> float: + """연속 섞 점을 지나는 수평멎 왞접원 반지늄(쀑간 정점의 읎산 곡률 반겜).""" + ax, ay = p0[0], p0[1] + bx, by = p1[0], p1[1] + cx, cy = p2[0], p2[1] + a = math.hypot(bx - cx, by - cy) + b = math.hypot(ax - cx, ay - cy) + c = math.hypot(ax - bx, ay - by) + if a < 0.01 or b < 0.01 or c < 0.01: + return float("inf") + area2 = abs((bx - ax) * (cy - ay) - (cx - ax) * (by - ay)) + if area2 < 1e-9: + return float("inf") + return (a * b * c) / (2.0 * area2) + + +def point_to_polyline_dist_2d(px: float, py: float, polyline) -> float: + """점에서 폎늬띌읞까지 최소 수평거늬(점-선분 거늬).""" + if not polyline: + return float("inf") + best = float("inf") + for i in range(len(polyline) - 1): + ax, ay = polyline[i][0], polyline[i][1] + bx, by = polyline[i + 1][0], polyline[i + 1][1] + dx, dy = bx - ax, by - ay + seg2 = dx * dx + dy * dy + if seg2 <= 1e-12: + d = math.hypot(px - ax, py - ay) + else: + t = max(0.0, min(1.0, ((px - ax) * dx + (py - ay) * dy) / seg2)) + d = math.hypot(px - (ax + t * dx), py - (ay + t * dy)) + if d < best: + best = d + if len(polyline) == 1: + best = math.hypot(px - polyline[0][0], py - polyline[0][1]) + return best + + +def circle_intrusions(polyline, circles, labeler) -> list[dict[str, Any]]: + """각 원(AP/FP)에 대핮 겜로의 최소 읎격거늬와 원 낎부 폎늬띌읞 Ꞟ읎륌 볎고한닀.""" + out = [] + for i, circ in enumerate(circles): + cx, cy, radius = circ["x"], circ["y"], circ["radius_m"] + clearance = point_to_polyline_dist_2d(cx, cy, polyline) + intruded_len = 0.0 + for j in range(len(polyline) - 1): + ax, ay = polyline[j][0], polyline[j][1] + bx, by = polyline[j + 1][0], polyline[j + 1][1] + mx, my = 0.5 * (ax + bx), 0.5 * (ay + by) + if math.hypot(mx - cx, my - cy) < radius: + intruded_len += math.hypot(bx - ax, by - ay) + out.append( + { + "index": i, + "label": labeler(i), + "x": cx, + "y": cy, + "radius_m": radius, + "min_clearance_m": round(clearance, 3), + "intrusion_length_m": round(intruded_len, 3), + "intrudes": bool(clearance < radius or intruded_len > 0.0), + } + ) + return out + + +def resample_polyline_2d(polyline, chainage_m, step_m: float): + """폎늬띌읞을 고정 혞장 간격윌로 재샘플한닀(도로 슀쌀음 곡률 잡정용).""" + if len(polyline) < 2 or step_m <= 0: + return [[p[0], p[1]] for p in polyline] + total = chainage_m[-1] + if total <= 0: + return [[polyline[0][0], polyline[0][1]]] + targets = np.arange(0.0, total + step_m * 0.5, step_m) + out = [] + j = 0 + for t in targets: + while j < len(chainage_m) - 2 and chainage_m[j + 1] < t: + j += 1 + seg_len = chainage_m[j + 1] - chainage_m[j] + frac = 0.0 if seg_len <= 1e-9 else (t - chainage_m[j]) / seg_len + frac = min(max(frac, 0.0), 1.0) + x = polyline[j][0] + frac * (polyline[j + 1][0] - polyline[j][0]) + y = polyline[j][1] + frac * (polyline[j + 1][1] - polyline[j][1]) + out.append([x, y]) + return out + + +def turn_radius_from_grid(diff: int, grid_res: float) -> float: + """8-연결 격자의 닚음 방향전환읎 핚의하는 곡선 반겜(m) 귌사.""" + if diff <= 0: + return float("inf") + angle_rad = math.radians(45.0 * diff) + step_len = grid_res * (math.sqrt(2) if diff == 1 else 1.0) + return step_len / angle_rad + + +def single_segment_dijkstra( + r_start: int, + c_start: int, + r_end: int, + c_end: int, + x_coords: np.ndarray, + y_coords: np.ndarray, + z_grid: np.ndarray, + valid_mask: np.ndarray, + dz_dx: np.ndarray, + dz_dy: np.ndarray, + ap_list: list[dict[str, Any]], + weights: dict[str, float], + max_grade: float, + grid_res: float, + min_curve_radius_m: float, + max_uphill_grade: float | None = None, + max_downhill_grade: float | None = None, +) -> list[tuple[int, int]]: + """시작→끝 셀 방향 읞식 상태공간 Dijkstra 탐색. + + 하드 제앜: 종닚겜사 쎈곌 링크·135도 읎상 ꞉전환은 통행불가. + min_curve_radius_m은 방향전환 소프튞 팚널티로 반영된닀. + """ + rows, cols = z_grid.shape + w_dist = weights.get("dist", 1.0) + w_grade = weights.get("grade", 2.0) + w_side = weights.get("side", 1.5) + w_curve = weights.get("curve", 0.5) + w_avoid = weights.get("avoid", 10.0) + + up_limit = max_uphill_grade if max_uphill_grade else max_grade + down_limit = max_downhill_grade if max_downhill_grade else max_grade + + dist: dict[tuple[int, int, int], float] = {} + parent: dict[tuple[int, int, int], tuple[int, int, int]] = {} + pq: list[tuple[float, int, int, int]] = [] + + for d in range(8): + dist[(r_start, c_start, d)] = 0.0 + heapq.heappush(pq, (0.0, r_start, c_start, d)) + + found_dest = False + best_dest_state = None + + while pq: + d_cost, r, c, d = heapq.heappop(pq) + if d_cost > dist.get((r, c, d), float("inf")): + continue + if r == r_end and c == c_end: + found_dest = True + best_dest_state = (r, c, d) + break + + for nr, nc, nd in get_neighbors(r, c, rows, cols): + if not valid_mask[nr, nc]: + continue + + diff = abs(d - nd) + diff = min(diff, 8 - diff) + if diff >= 3: + continue # U턮/꞉전환은 통행불가 + + curve_penalty = 0.0 + if diff > 0: + turn_radius = turn_radius_from_grid(diff, grid_res) + tightness = min(min_curve_radius_m / turn_radius, 20.0) + curve_penalty = w_curve * tightness * grid_res + + is_diagonal = nd % 2 != 0 + h_dist = grid_res * math.sqrt(2) if is_diagonal else grid_res + + z_curr = z_grid[r, c] + z_next = z_grid[nr, nc] + dz = z_next - z_curr + grade = abs(dz) / h_dist + + applicable_limit = up_limit if dz > 0 else down_limit + if grade > applicable_limit: + continue + f_grade = (grade / applicable_limit) ** 2 * 10.0 + + side_slope = side_slope_magnitude(nr - r, nc - c, dz_dx[nr, nc], dz_dy[nr, nc]) + f_side = side_slope * 5.0 + + nx_model = x_coords[nc] + ny_model = y_coords[nr] + avoid_penalty = 0.0 + for ap in ap_list: + dist_to_ap = math.hypot(nx_model - ap["x"], ny_model - ap["y"]) + if dist_to_ap < ap["radius_m"]: + avoid_penalty += w_avoid * 1000.0 * h_dist + + link_cost = ( + w_dist * h_dist + + w_grade * f_grade * h_dist + + w_side * f_side * h_dist + + curve_penalty + + avoid_penalty + ) + next_cost = d_cost + link_cost + state_next = (nr, nc, nd) + if next_cost < dist.get(state_next, float("inf")): + dist[state_next] = next_cost + parent[state_next] = (r, c, d) + heapq.heappush(pq, (next_cost, nr, nc, nd)) + + if not found_dest: + return [] + + path = [] + curr = best_dest_state + while curr: + path.append((curr[0], curr[1])) + curr = parent.get(curr) + return path[::-1] diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_RidgeValley.py b/B05_wf2_Route/B05_wf2_Route_Engine_RidgeValley.py new file mode 100644 index 0000000..3e06352 --- /dev/null +++ b/B05_wf2_Route/B05_wf2_Route_Engine_RidgeValley.py @@ -0,0 +1,783 @@ +"""B05 능선-계곡 정속겜사 임도 êžžì°Ÿêž° (대안 알고늬슘). + +격자 Dijkstra와 분늬된 방식: 지형 슀쌈레톀에서 죌/지 능선·계곡 polyline을 +얻고, 능선↔계곡 녾드 쌍을 잇는 정속겜사 직선 섞귞뚌튞로 귞래프륌 만든 ë’€ +교각 페널티 Dijkstra로 녾드 시퀀슀륌 탐색한닀. 최종 선형은 직선+최소회전반겜 +원혞(fillet)로 구성한닀. 반환 슀킀마는 solve_optimal_route()와 동음하닀. +""" + +import heapq +import math +from pathlib import Path +from typing import Any + +import numpy as np + +from B05_wf2_Route.B05_wf2_Route_Engine_Geometry import ( + circle_intrusions, + circumradius_2d, + point_to_polyline_dist_2d, + resample_polyline_2d, +) +from B05_wf2_Route.B05_wf2_Route_Engine_Skeleton import load_or_build_skeleton +from B05_wf2_Route.B05_wf2_Route_Engine_Solver import ( + _MODELS_SUBDIR, + _load_or_build_cost_surface, +) +from config.config_system import ( + FOREST_ROAD_MIN_CURVE_R_M, + ROUTE_ALT_GRADE_TOLERANCE, + ROUTE_ALT_MAX_GRADE, + ROUTE_ALT_MIN_GRADE, + ROUTE_DEFAULT_GRADE_CLASS, + ROUTE_REQUIRED_POINT_TOLERANCE_M, + SKELETON_NODE_SPACING_M, +) + +# 엣지 후볎 탐색 파띌믞터 (알고늬슘 낎부 상수) +MAX_EDGE_LEN_M = 400.0 +MIN_EDGE_LEN_M = 20.0 +MAX_NEIGHBORS_PER_NODE = 16 +TURN_PENALTY_W = 60.0 +MAX_TURN_DEG = 120.0 + + +class _Grid: + """비용멎 격자에 대한 표고/유횚성 조회 헬퍌.""" + + def __init__(self, x, y, z, valid, grid_res): + self.x = np.asarray(x, dtype=np.float64) + self.y = np.asarray(y, dtype=np.float64) + self.z = np.asarray(z, dtype=np.float64) + self.valid = np.asarray(valid, dtype=bool) + self.res = float(grid_res) + + def _idx(self, coords: np.ndarray, v: float) -> int: + i = int(np.clip(np.searchsorted(coords, v), 0, len(coords) - 1)) + j = max(i - 1, 0) + return j if abs(v - coords[j]) <= abs(coords[i] - v) else i + + def rc(self, px: float, py: float) -> tuple[int, int]: + return self._idx(self.y, py), self._idx(self.x, px) + + def z_at(self, px: float, py: float) -> float: + r, c = self.rc(px, py) + return float(self.z[r, c]) + + def valid_at(self, px: float, py: float) -> bool: + in_bounds = (self.x[0] <= px <= self.x[-1]) and (self.y[0] <= py <= self.y[-1]) + if not in_bounds: + return False + r, c = self.rc(px, py) + return bool(self.valid[r, c]) + + +def _collect_nodes( + skeleton: dict[str, Any], spacing_m: float +) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """능선/계곡 polyline 정점을 spacing 간격윌로 닀욎샘플핎 녾드 배엎을 만든닀.""" + pos, kind, on_main = [], [], [] + + def _add(polys, k, is_main): + for item in polys: + pl = item["polyline"] + acc = spacing_m + prev = None + for p in pl: + step = spacing_m if prev is None else math.hypot(p[0] - prev[0], p[1] - prev[1]) + acc += step + prev = p + if acc >= spacing_m: + acc = 0.0 + pos.append([p[0], p[1], p[2]]) + kind.append(k) + on_main.append(is_main) + + _add(skeleton.get("minor_ridge", []), 0, False) + _add(skeleton.get("minor_valley", []), 1, False) + _add(skeleton.get("main_ridge", []), 0, True) + _add(skeleton.get("main_valley", []), 1, True) + + if not pos: + return (np.zeros((0, 3)), np.zeros(0, dtype=np.int8), np.zeros(0, dtype=bool)) + return ( + np.asarray(pos, dtype=np.float64), + np.asarray(kind, dtype=np.int8), + np.asarray(on_main, dtype=bool), + ) + + +def _build_barrier_mask(grid: _Grid, skeleton: dict[str, Any]) -> np.ndarray: + """죌능선/죌계곡 셀을 True로 표시한 구획 겜계 마슀크.""" + barrier = np.zeros(grid.z.shape, dtype=bool) + for key in ("main_ridge", "main_valley"): + for item in skeleton.get(key, []): + for p in item["polyline"]: + r, c = grid.rc(p[0], p[1]) + barrier[r, c] = True + return barrier + + +def _segment_feasible( + a: np.ndarray, + b: np.ndarray, + grid: _Grid, + barrier: np.ndarray, + blocked_circles: list[dict[str, float]], + min_grade: float, + max_grade: float, + tol: float, + endpoint_free_m: float, + enforce_grade_window: bool = True, +) -> bool: + """a→b 직선읎 정속겜사 섞귞뚌튞로 성늜하는지 검사한닀.""" + dx, dy = b[0] - a[0], b[1] - a[1] + length = math.hypot(dx, dy) + if length < 1e-6: + return False + design_grade = (b[2] - a[2]) / length + g = abs(design_grade) + if enforce_grade_window: + if not (min_grade <= g <= max_grade): + return False + elif g > max_grade: + return False + + step = max(grid.res, 1.0) + n_steps = max(int(length / step), 1) + for i in range(n_steps + 1): + t = i / n_steps + px, py = a[0] + t * dx, a[1] + t * dy + if not grid.valid_at(px, py): + return False + s = t * length + z_design = a[2] + design_grade * s + z_terrain = grid.z_at(px, py) + allowed = tol * max(s, length - s) + grid.res + if abs(z_terrain - z_design) > allowed: + return False + if min(s, length - s) > endpoint_free_m: + r, c = grid.rc(px, py) + if barrier[r, c]: + return False + for circ in blocked_circles: + if math.hypot(px - circ["x"], py - circ["y"]) < circ["radius_m"]: + return False + return True + + +def _build_edges( + pos: np.ndarray, + kind: np.ndarray, + grid: _Grid, + barrier: np.ndarray, + blocked_circles: list[dict[str, float]], + min_grade: float, + max_grade: float, + tol: float, + endpoint_free_m: float, +) -> dict[int, list[tuple[int, float]]]: + """능선↔계곡 녾드 쌍의 정속겜사 직선 엣지륌 만든닀 (묎방향, Ꞟ읎 저장).""" + from scipy.spatial import cKDTree + + adj: dict[int, list[tuple[int, float]]] = {i: [] for i in range(len(pos))} + if len(pos) == 0: + return adj + + ridge_idx = np.nonzero(kind == 0)[0] + valley_idx = np.nonzero(kind == 1)[0] + if len(ridge_idx) == 0 or len(valley_idx) == 0: + return adj + + valley_tree = cKDTree(pos[valley_idx, :2]) + for ri in ridge_idx: + cand = valley_tree.query_ball_point(pos[ri, :2], MAX_EDGE_LEN_M) + cand = sorted( + cand, + key=lambda j: ( + (pos[ri, 0] - pos[valley_idx[j], 0]) ** 2 + + (pos[ri, 1] - pos[valley_idx[j], 1]) ** 2 + ), + ) + added = 0 + for j in cand: + vi = int(valley_idx[j]) + length = math.hypot(pos[ri, 0] - pos[vi, 0], pos[ri, 1] - pos[vi, 1]) + if length < MIN_EDGE_LEN_M: + continue + if pos[ri, 2] <= pos[vi, 2]: + continue + if not _segment_feasible( + pos[ri], + pos[vi], + grid, + barrier, + blocked_circles, + min_grade, + max_grade, + tol, + endpoint_free_m, + ): + continue + adj[int(ri)].append((vi, length)) + adj[vi].append((int(ri), length)) + added += 1 + if added >= MAX_NEIGHBORS_PER_NODE: + break + return adj + + +def _endpoint_connectors( + pt: dict[str, float], + pos: np.ndarray, + grid: _Grid, + barrier: np.ndarray, + blocked_circles: list[dict[str, float]], + max_uphill_grade: float, + max_downhill_grade: float, + tol: float, + endpoint_free_m: float, +) -> list[tuple[int, float]]: + """BP/CP/EP륌 귞래프 녞드에 잇는 연결 섞귞뚌튞 후볎.""" + from scipy.spatial import cKDTree + + if len(pos) == 0: + return [] + p = np.array([pt["x"], pt["y"], grid.z_at(pt["x"], pt["y"])]) + tree = cKDTree(pos[:, :2]) + cand = tree.query_ball_point(p[:2], MAX_EDGE_LEN_M) + cand = sorted(cand, key=lambda j: (p[0] - pos[j, 0]) ** 2 + (p[1] - pos[j, 1]) ** 2) + out = [] + for j in cand: + length = math.hypot(p[0] - pos[j, 0], p[1] - pos[j, 1]) + if length < 1e-6: + out.append((int(j), max(length, 0.01))) + continue + applicable = max_uphill_grade if pos[j, 2] > p[2] else max_downhill_grade + if _segment_feasible( + p, + pos[j], + grid, + barrier, + blocked_circles, + 0.0, + applicable, + tol, + endpoint_free_m, + enforce_grade_window=False, + ): + out.append((int(j), length)) + if len(out) >= MAX_NEIGHBORS_PER_NODE: + break + return out + + +def _turn_angle(p_prev, p_curr, p_next) -> float: + """진행방향 변화(교각) [rad]. 0 = 직진.""" + v1 = (p_curr[0] - p_prev[0], p_curr[1] - p_prev[1]) + v2 = (p_next[0] - p_curr[0], p_next[1] - p_curr[1]) + n1, n2 = math.hypot(*v1), math.hypot(*v2) + if n1 < 1e-9 or n2 < 1e-9: + return 0.0 + cosang = max(-1.0, min(1.0, (v1[0] * v2[0] + v1[1] * v2[1]) / (n1 * n2))) + return math.acos(cosang) + + +def _search_segment( + start_pt: dict[str, float], + end_pt: dict[str, float], + pos: np.ndarray, + adj: dict[int, list[tuple[int, float]]], + grid: _Grid, + barrier: np.ndarray, + blocked_circles: list[dict[str, float]], + max_uphill_grade: float, + max_downhill_grade: float, + tol: float, + endpoint_free_m: float, + min_radius: float, +) -> list[list[float]] | None: + """start→end륌 귞래프 위에서 탐색핎 녾드 좌표 시퀀슀륌 반환 (싀팚 시 None).""" + start_xyz = [start_pt["x"], start_pt["y"], grid.z_at(start_pt["x"], start_pt["y"])] + end_xyz = [end_pt["x"], end_pt["y"], grid.z_at(end_pt["x"], end_pt["y"])] + + direct_limit = max_uphill_grade if end_xyz[2] > start_xyz[2] else max_downhill_grade + if _segment_feasible( + np.asarray(start_xyz), + np.asarray(end_xyz), + grid, + barrier, + blocked_circles, + 0.0, + direct_limit, + tol, + endpoint_free_m, + enforce_grade_window=False, + ): + return [start_xyz, end_xyz] + + start_conn = _endpoint_connectors( + start_pt, + pos, + grid, + barrier, + blocked_circles, + max_uphill_grade, + max_downhill_grade, + tol, + endpoint_free_m, + ) + end_conn = _endpoint_connectors( + end_pt, + pos, + grid, + barrier, + blocked_circles, + max_uphill_grade, + max_downhill_grade, + tol, + endpoint_free_m, + ) + if not start_conn or not end_conn: + return None + end_conn_map = {j: length for j, length in end_conn} + + START, MAX_TURN = -1, math.radians(MAX_TURN_DEG) + + def _xy(i): + return start_xyz if i == START else pos[i] + + def _seg_len(i, j): + a, b = _xy(i), _xy(j) + return math.hypot(a[0] - b[0], a[1] - b[1]) + + def _fillet_ok(theta, len_in, len_out): + if theta < 1e-6: + return True + t_len = min_radius * math.tan(theta / 2.0) + return t_len <= len_in / 2.0 and t_len <= len_out / 2.0 + + dist: dict[tuple[int, int], float] = {} + parent: dict[tuple[int, int], tuple[int, int]] = {} + pq: list[tuple[float, int, int]] = [] + for j, length in start_conn: + dist[(START, j)] = length + heapq.heappush(pq, (length, START, j)) + + best_state, best_cost = None, float("inf") + while pq: + d, u, v = heapq.heappop(pq) + if d > dist.get((u, v), float("inf")): + continue + if v in end_conn_map: + theta = _turn_angle(_xy(u), _xy(v), end_xyz) + if theta <= MAX_TURN and _fillet_ok(theta, _seg_len(u, v), end_conn_map[v]): + total = d + end_conn_map[v] + TURN_PENALTY_W * theta + if total < best_cost: + best_cost, best_state = total, (u, v) + for w, length in adj.get(v, []): + if w == u: + continue + theta = _turn_angle(_xy(u), _xy(v), pos[w]) + if theta > MAX_TURN: + continue + if not _fillet_ok(theta, _seg_len(u, v), length): + continue + nd = d + length + TURN_PENALTY_W * theta + if nd < dist.get((v, w), float("inf")): + dist[(v, w)] = nd + parent[(v, w)] = (u, v) + heapq.heappush(pq, (nd, v, w)) + + if best_state is None: + return None + seq = [end_xyz] + state = best_state + while state is not None: + u, v = state + seq.append([float(pos[v][0]), float(pos[v][1]), float(pos[v][2])]) + state = parent.get(state) + if state is None and u == START: + break + seq.append(start_xyz) + return seq[::-1] + + +def _fillet_alignment( + nodes: list[list[float]], radius: float, step_m: float +) -> tuple[list[list[float]], list[float]]: + """녾드 시퀀슀륌 직선 + 최소회전반겜 원혞(fillet) 선형윌로 변환한닀.""" + n = len(nodes) + if n < 2: + return [list(p) for p in nodes], [float("inf")] * n + + seg_len, seg_grade = [], [] + for i in range(n - 1): + length = math.hypot(nodes[i + 1][0] - nodes[i][0], nodes[i + 1][1] - nodes[i][1]) + seg_len.append(max(length, 1e-9)) + seg_grade.append((nodes[i + 1][2] - nodes[i][2]) / max(length, 1e-9)) + + t_len = [0.0] * n + theta = [0.0] * n + for i in range(1, n - 1): + th = _turn_angle(nodes[i - 1], nodes[i], nodes[i + 1]) + theta[i] = th + if th < 1e-6: + continue + t = radius * math.tan(th / 2.0) + t_len[i] = min(t, seg_len[i - 1] / 2.0, seg_len[i] / 2.0) + + poly: list[list[float]] = [] + radii: list[float] = [] + + def _append(pt, rad): + poly.append([float(pt[0]), float(pt[1]), float(pt[2])]) + radii.append(rad) + + _append(nodes[0], float("inf")) + for i in range(n - 1): + ax, ay, az = nodes[i] + bx, by, bz = nodes[i + 1] + length, g = seg_len[i], seg_grade[i] + ux, uy = (bx - ax) / length, (by - ay) / length + s0, s1 = t_len[i], length - t_len[i + 1] + n_pts = max(int((s1 - s0) / step_m), 1) + for k in range(n_pts + 1): + s = s0 + (s1 - s0) * (k / n_pts) + _append((ax + ux * s, ay + uy * s, az + g * s), float("inf")) + if i < n - 2 and t_len[i + 1] > 1e-9 and theta[i + 1] > 1e-6: + th = theta[i + 1] + t = t_len[i + 1] + eff_r = t / math.tan(th / 2.0) + cx0, cy0 = bx - ux * t, by - uy * t + nx_, ny_, _nz = nodes[i + 2] + length2 = seg_len[i + 1] + vx, vy = (nx_ - bx) / length2, (ny_ - by) / length2 + cx1, cy1 = bx + vx * t, by + vy * t + z_in = az + g * (length - t) + z_out = bz + seg_grade[i + 1] * t + arc_len = eff_r * th + n_arc = max(int(arc_len / step_m), 2) + cross = ux * vy - uy * vx + sign = 1.0 if cross >= 0 else -1.0 + ox, oy = cx0 + (-uy * sign) * eff_r, cy0 + (ux * sign) * eff_r + ang0 = math.atan2(cy0 - oy, cx0 - ox) + for k in range(1, n_arc): + a = ang0 + sign * th * (k / n_arc) + frac = k / n_arc + _append( + ( + ox + eff_r * math.cos(a), + oy + eff_r * math.sin(a), + z_in + (z_out - z_in) * frac, + ), + eff_r, + ) + _ = (cx1, cy1) + _append(nodes[-1], float("inf")) + + cleaned, cleaned_r = [poly[0]], [radii[0]] + for p, r in zip(poly[1:], radii[1:]): + if math.hypot(p[0] - cleaned[-1][0], p[1] - cleaned[-1][1]) > 0.05: + cleaned.append(p) + cleaned_r.append(r) + if len(cleaned) >= 2: + cleaned[-1] = poly[-1] + return cleaned, cleaned_r + + +def resolve_grade_bounds(options: dict[str, Any]) -> dict[str, float]: + """options에서 방향별 겜사 상/하한을 핎석한닀.""" + + def _opt(key: str, default: float) -> float: + v = options.get(key) + return float(v) if v is not None else default + + min_uphill_grade = _opt("min_uphill_grade", ROUTE_ALT_MIN_GRADE) + min_downhill_grade = _opt("min_downhill_grade", ROUTE_ALT_MIN_GRADE) + max_uphill_grade = _opt("max_uphill_grade", ROUTE_ALT_MAX_GRADE) + max_downhill_grade = _opt("max_downhill_grade", ROUTE_ALT_MAX_GRADE) + return { + "min_uphill_grade": min_uphill_grade, + "min_downhill_grade": min_downhill_grade, + "max_uphill_grade": max_uphill_grade, + "max_downhill_grade": max_downhill_grade, + "min_grade_for_edges": max(min_uphill_grade, min_downhill_grade), + "max_grade_for_edges": min(max_uphill_grade, max_downhill_grade), + } + + +def solve_ridge_valley_route( + project_root: Path, + filter_key: str, + smooth: bool, + points_data: dict[str, Any], + options: dict[str, Any], + method: str = "dtm", +) -> dict[str, Any]: + """능선-계곡 정속겜사 방식윌로 BP→(CP
)→EP 겜로륌 계산한닀.""" + project_root = Path(project_root) + models_dir = project_root / _MODELS_SUBDIR + + (x_coords, y_coords, z_grid, valid_mask, _dz_dx, _dz_dy, grid_res) = ( + _load_or_build_cost_surface(project_root, models_dir, filter_key, method, smooth) + ) + grid = _Grid(x_coords, y_coords, z_grid, valid_mask, grid_res) + + bp = points_data.get("bp") + ep = points_data.get("ep") + cp_list = sorted(points_data.get("cp", []), key=lambda x: x.get("order", 0)) + ap_list = points_data.get("ap", []) + fp_list = points_data.get("fp", []) + if not bp or not ep: + raise ValueError("BP/EP 가 배치되얎 있지 않습니닀.") + + skeleton = load_or_build_skeleton(project_root, filter_key, method, smooth) + barrier = _build_barrier_mask(grid, skeleton) + + gb = resolve_grade_bounds(options) + min_uphill_grade = gb["min_uphill_grade"] + min_downhill_grade = gb["min_downhill_grade"] + max_uphill_grade = gb["max_uphill_grade"] + max_downhill_grade = gb["max_downhill_grade"] + max_grade = gb["max_grade_for_edges"] + min_grade = gb["min_grade_for_edges"] + tol = float(options.get("alt_grade_tolerance") or ROUTE_ALT_GRADE_TOLERANCE) + min_curve_radius_m = options.get("min_curve_radius_m") + if not min_curve_radius_m or min_curve_radius_m <= 0: + grade_class = options.get("grade_class", ROUTE_DEFAULT_GRADE_CLASS) + min_curve_radius_m = FOREST_ROAD_MIN_CURVE_R_M.get(grade_class, 12.0) + min_curve_radius_m = float(min_curve_radius_m) + endpoint_free_m = float(SKELETON_NODE_SPACING_M) * 1.5 + + blocked = list(fp_list) + if not options.get("allow_avoid_pass_through", False): + blocked = blocked + list(ap_list) + + pos, kind, _on_main = _collect_nodes(skeleton, float(SKELETON_NODE_SPACING_M)) + adj = _build_edges( + pos, kind, grid, barrier, blocked, min_grade, max_grade, tol, endpoint_free_m + ) + + sequence = [bp] + cp_list + [ep] + + def _label(idx): + if idx == 0: + return "BP" + if idx == len(sequence) - 1: + return "EP" + return f"CP{sequence[idx].get('order', idx)}" + + all_nodes: list[list[float]] = [] + node_seg_marks: list[int] = [] + for i in range(len(sequence) - 1): + seg_nodes = _search_segment( + sequence[i], + sequence[i + 1], + pos, + adj, + grid, + barrier, + blocked, + max_uphill_grade, + max_downhill_grade, + tol, + endpoint_free_m, + min_curve_radius_m, + ) + if not seg_nodes: + raise ValueError( + f"섞귞뚌튞 {i + 1} ({_label(i)} -> {_label(i + 1)}) 능선-계곡 정속겜사 겜로 " + f"탐색 싀팚: 였륎막 {min_uphill_grade * 100:.0f}~{max_uphill_grade * 100:.0f}%·" + f"낎늬막 {min_downhill_grade * 100:.0f}~{max_downhill_grade * 100:.0f}%·" + f"허용였찚 ±{tol * 100:.1f}%·최소곡선반지늄 {min_curve_radius_m:.0f}m 제앜윌로 " + f"성늜하는 지능선-지계곡 연결읎 없습니닀." + ) + if i == 0: + all_nodes.extend(seg_nodes) + else: + all_nodes.extend(seg_nodes[1:]) + node_seg_marks.append(len(all_nodes) - 1) + + polyline, _vertex_radii = _fillet_alignment( + all_nodes, min_curve_radius_m, step_m=max(grid.res, 2.0) + ) + + n = len(polyline) + chainage_m = [0.0] * n + length_m = 0.0 + max_grade_pct = 0.0 + max_uphill_pct = 0.0 + max_downhill_pct = 0.0 + grade_sums = 0.0 + slope_violations = 0 + for i in range(n - 1): + x1, y1, z1 = polyline[i] + x2, y2, z2 = polyline[i + 1] + hd = math.hypot(x2 - x1, y2 - y1) + chainage_m[i + 1] = chainage_m[i] + hd + if hd > 0.01: + dz = z2 - z1 + s = abs(dz) / hd + length_m += hd + grade_sums += s * hd + max_grade_pct = max(max_grade_pct, s) + if dz > 0: + max_uphill_pct = max(max_uphill_pct, s) + applicable = max_uphill_grade + else: + max_downhill_pct = max(max_downhill_pct, s) + applicable = max_downhill_grade + if s > applicable + tol: + slope_violations += 1 + avg_grade_pct = (grade_sums / length_m) if length_m > 0 else 0.0 + + curve_check_step = max(2.0 * grid.res, 4.0) + resampled = resample_polyline_2d(polyline, chainage_m, curve_check_step) + total_chainage = chainage_m[-1] if chainage_m else 0.0 + curve_violations = 0 + min_curve_radius_actual = float("inf") + radii = [float("inf")] * len(resampled) + for i in range(1, len(resampled) - 1): + radius = circumradius_2d(resampled[i - 1], resampled[i], resampled[i + 1]) + radii[i] = radius + min_curve_radius_actual = min(min_curve_radius_actual, radius) + if radius < min_curve_radius_m * 0.99: + curve_violations += 1 + + curve_warning_segments = [] + run_start = None + for i in range(1, len(resampled)): + violating = i < len(resampled) - 1 and radii[i] < min_curve_radius_m * 0.99 + if violating and run_start is None: + run_start = i + if (not violating) and run_start is not None: + run_end = i - 1 + ch_s = min(run_start * curve_check_step, total_chainage) + ch_e = min(run_end * curve_check_step, total_chainage) + curve_warning_segments.append( + { + "chainage_start_m": round(ch_s, 2), + "chainage_end_m": round(ch_e, 2), + "min_radius_m": round(min(radii[run_start : run_end + 1]), 2), + "required_radius_m": round(min_curve_radius_m, 2), + "polyline_start_index": int(np.searchsorted(chainage_m, ch_s)), + "polyline_end_index": int(np.searchsorted(chainage_m, ch_e)), + } + ) + run_start = None + + def _nearest_vertex(pt) -> int: + best, best_d = 0, float("inf") + for i, p in enumerate(polyline): + d = (p[0] - pt[0]) ** 2 + (p[1] - pt[1]) ** 2 + if d < best_d: + best, best_d = i, d + return best + + segments = [] + prev_idx = 0 + for si, mark in enumerate(node_seg_marks): + end_idx = _nearest_vertex(all_nodes[mark]) + s, e = prev_idx, max(end_idx, prev_idx) + seg_max_grade = 0.0 + for i in range(s, e): + x1, y1, z1 = polyline[i] + x2, y2, z2 = polyline[i + 1] + hd = math.hypot(x2 - x1, y2 - y1) + if hd > 0.01: + seg_max_grade = max(seg_max_grade, abs(z2 - z1) / hd) + segments.append( + { + "index": si, + "from": _label(si), + "to": _label(si + 1), + "point_start": s, + "point_end": e, + "chainage_start_m": round(chainage_m[s], 2), + "chainage_end_m": round(chainage_m[e], 2), + "length_m": round(chainage_m[e] - chainage_m[s], 2), + "max_grade_pct": round(seg_max_grade * 100, 2), + } + ) + prev_idx = e + + tol_req = ROUTE_REQUIRED_POINT_TOLERANCE_M + required_point_checks = [] + for idx, pt in enumerate(sequence): + d = point_to_polyline_dist_2d(pt["x"], pt["y"], polyline) + required_point_checks.append( + { + "point": _label(idx), + "x": pt["x"], + "y": pt["y"], + "distance_m": round(d, 3), + "snap_distance_m": 0.0, + "point_on_valid_terrain": grid.valid_at(pt["x"], pt["y"]), + "tolerance_m": tol_req, + "within_tolerance": bool(d <= tol_req), + } + ) + required_points_ok = all(c["within_tolerance"] for c in required_point_checks) + + avoid_intrusions = circle_intrusions(polyline, ap_list, lambda i: f"AP{i + 1}") + forbidden_intrusions = circle_intrusions(polyline, fp_list, lambda i: f"FP{i + 1}") + + constant_grade_segments = [] + for i in range(len(all_nodes) - 1): + length = math.hypot( + all_nodes[i + 1][0] - all_nodes[i][0], all_nodes[i + 1][1] - all_nodes[i][1] + ) + if length > 0.01: + constant_grade_segments.append( + { + "index": i, + "length_m": round(length, 2), + "grade_pct": round((all_nodes[i + 1][2] - all_nodes[i][2]) / length * 100, 2), + } + ) + + conditions_snapshot = { + "filter": filter_key, + "method": method, + "smooth": smooth, + "algorithm": "ridge_valley", + "grade_class": options.get("grade_class", ROUTE_DEFAULT_GRADE_CLASS), + "paved": bool(options.get("paved", False)), + "max_uphill_grade_pct": round(max_uphill_grade * 100, 2), + "max_downhill_grade_pct": round(max_downhill_grade * 100, 2), + "min_uphill_grade_pct": round(min_uphill_grade * 100, 2), + "min_downhill_grade_pct": round(min_downhill_grade * 100, 2), + "grade_tolerance_pct": round(tol * 100, 2), + "min_curve_radius_m": round(min_curve_radius_m, 2), + "weights": options.get("weights") or {}, + "avoid_count": len(ap_list), + "forbidden_count": len(fp_list), + } + + return { + "polyline": polyline, + "chainage_m": [round(v, 3) for v in chainage_m], + "segments": segments, + "required_point_checks": required_point_checks, + "required_points_ok": required_points_ok, + "avoid_intrusions": avoid_intrusions, + "forbidden_intrusions": forbidden_intrusions, + "curve_warning_segments": curve_warning_segments, + "avoid_retry_performed": False, + "conditions_snapshot": conditions_snapshot, + "constant_grade_segments": constant_grade_segments, + "metrics": { + "length_m": round(length_m, 2), + "avg_grade_pct": round(avg_grade_pct * 100, 2), + "max_grade_pct": round(max_grade_pct * 100, 2), + "max_uphill_pct": round(max_uphill_pct * 100, 2), + "max_downhill_pct": round(max_downhill_pct * 100, 2), + "slope_violations": slope_violations, + "curve_violations": curve_violations, + "min_curve_radius_m": round(min_curve_radius_actual, 2) + if math.isfinite(min_curve_radius_actual) + else None, + "min_curve_radius_limit_m": round(min_curve_radius_m, 2), + }, + } diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Skeleton.py b/B05_wf2_Route/B05_wf2_Route_Engine_Skeleton.py new file mode 100644 index 0000000..bd002f6 --- /dev/null +++ b/B05_wf2_Route/B05_wf2_Route_Engine_Skeleton.py @@ -0,0 +1,315 @@ +"""B05 지형 슀쌈레톀(죌/지 능선·계곡) 추출. + +수묞학적 정의: D8 흐늄누적읎 임계값 읎상읞 셀=계곡, DEM 반전 시 능선. +누적값 크Ʞ의 2ì°š 임계값윌로 죌/지륌 나눈닀. whitebox 우선, 싀팚 시 numpy +D8 폎백. 산출 polyline은 비용멎곌 동음 좌표계읎며 B05_wf2_Route/route에 캐시된닀. +""" + +import json +import math +import tempfile +import time +from pathlib import Path +from typing import Any + +import numpy as np + +from B05_wf2_Route.B05_wf2_Route_Engine_Solver import ( + _MODELS_SUBDIR, + _ROUTE_CACHE_SUBDIR, + _cost_surface_signature, + _load_or_build_cost_surface, +) +from config.config_system import ( + SKELETON_MAIN_RIDGE_ACC_THRESHOLD_CELLS, + SKELETON_MAIN_VALLEY_ACC_THRESHOLD_CELLS, + SKELETON_RIDGE_ACC_THRESHOLD_CELLS, + SKELETON_VALLEY_ACC_THRESHOLD_CELLS, +) + +SKELETON_CLASSES = ("main_ridge", "minor_ridge", "main_valley", "minor_valley") + +_D8_OFFSETS = [ + (-1, -1), + (-1, 0), + (-1, 1), + (0, -1), + (0, 1), + (1, -1), + (1, 0), + (1, 1), +] + + +def _default_thresholds() -> dict[str, float]: + return { + "valley_acc": float(SKELETON_VALLEY_ACC_THRESHOLD_CELLS), + "main_valley_acc": float(SKELETON_MAIN_VALLEY_ACC_THRESHOLD_CELLS), + "ridge_acc": float(SKELETON_RIDGE_ACC_THRESHOLD_CELLS), + "main_ridge_acc": float(SKELETON_MAIN_RIDGE_ACC_THRESHOLD_CELLS), + } + + +def d8_flow_accumulation_numpy(z_grid: np.ndarray, valid_mask: np.ndarray) -> np.ndarray: + """numpy êž°ë°˜ D8 흐늄누적 (whitebox 폎백).""" + rows, cols = z_grid.shape + n = rows * cols + z_flat = z_grid.ravel() + valid_flat = valid_mask.ravel() + + receiver = np.full(n, -1, dtype=np.int64) + for idx in range(n): + if not valid_flat[idx]: + continue + r, c = divmod(idx, cols) + zc = z_flat[idx] + best_slope = 0.0 + best = -1 + for dr, dc in _D8_OFFSETS: + nr, nc = r + dr, c + dc + if not (0 <= nr < rows and 0 <= nc < cols): + continue + nidx = nr * cols + nc + if not valid_flat[nidx]: + continue + drop = zc - z_flat[nidx] + if drop <= 0: + continue + dist = math.sqrt(2.0) if (dr != 0 and dc != 0) else 1.0 + slope = drop / dist + if slope > best_slope: + best_slope = slope + best = nidx + receiver[idx] = best + + acc = np.where(valid_flat, 1.0, 0.0) + order = np.argsort(-z_flat, kind="stable") + for idx in order: + recv = receiver[idx] + if recv >= 0 and valid_flat[idx]: + acc[recv] += acc[idx] + return acc.reshape(rows, cols) + + +def _d8_flow_accumulation_whitebox( + z_grid: np.ndarray, + x_coords: np.ndarray, + y_coords: np.ndarray, + valid_mask: np.ndarray, + grid_res: float, +) -> np.ndarray | None: + """WhiteboxTools로 D8 흐늄누적을 계산한닀 (싀팚 시 None → numpy 폎백).""" + try: + import rasterio + from rasterio.transform import from_origin + from whitebox import WhiteboxTools + except Exception: + return None + + nodata = -9999.0 + rows, cols = z_grid.shape + z_out = np.where(valid_mask, z_grid, nodata).astype(np.float32)[::-1, :] + transform = from_origin( + float(x_coords[0]) - grid_res / 2.0, + float(y_coords[-1]) + grid_res / 2.0, + grid_res, + grid_res, + ) + try: + with tempfile.TemporaryDirectory(prefix="wbt_skel_") as tmp: + tmp_path = Path(tmp) + dem_tif = tmp_path / "dem.tif" + acc_tif = tmp_path / "acc.tif" + with rasterio.open( + dem_tif, + "w", + driver="GTiff", + height=rows, + width=cols, + count=1, + dtype="float32", + nodata=nodata, + transform=transform, + ) as dst: + dst.write(z_out, 1) + + wbt = WhiteboxTools() + wbt.set_verbose_mode(False) + wbt.set_working_dir(str(tmp_path)) + if wbt.fill_depressions("dem.tif", "filled.tif") != 0: + raise RuntimeError("fill_depressions 싀팚") + if wbt.d8_flow_accumulation("filled.tif", "acc.tif", out_type="cells") != 0: + raise RuntimeError("d8_flow_accumulation 싀팚") + + with rasterio.open(acc_tif) as src: + acc = src.read(1).astype(np.float64)[::-1, :] + return np.where(np.isfinite(acc) & (acc > 0) & valid_mask, acc, 0.0) + except Exception: + return None + + +def _flow_accumulation( + z_grid: np.ndarray, + x_coords: np.ndarray, + y_coords: np.ndarray, + valid_mask: np.ndarray, + grid_res: float, +) -> np.ndarray: + acc = _d8_flow_accumulation_whitebox(z_grid, x_coords, y_coords, valid_mask, grid_res) + if acc is None: + acc = d8_flow_accumulation_numpy(z_grid, valid_mask) + return acc + + +def _trace_polylines(mask: np.ndarray) -> list[list[tuple[int, int]]]: + """1픜셀 폭 슀쌈레톀 마슀크륌 (r, c) polyline 목록윌로 변환한닀.""" + pixels = set(zip(*np.nonzero(mask))) + if not pixels: + return [] + + def neighbors(p): + r, c = p + return [(r + dr, c + dc) for dr, dc in _D8_OFFSETS if (r + dr, c + dc) in pixels] + + degree = {p: len(neighbors(p)) for p in pixels} + seeds = [p for p in pixels if degree[p] != 2] + visited_edges: set = set() + polylines: list[list[tuple[int, int]]] = [] + + def edge_key(a, b): + return (a, b) if a <= b else (b, a) + + def walk(start, nxt): + path = [start, nxt] + visited_edges.add(edge_key(start, nxt)) + prev, curr = start, nxt + while degree[curr] == 2: + candidates = [q for q in neighbors(curr) if q != prev] + if not candidates: + break + q = candidates[0] + if edge_key(curr, q) in visited_edges: + break + visited_edges.add(edge_key(curr, q)) + path.append(q) + prev, curr = curr, q + return path + + for seed in seeds: + for nb in neighbors(seed): + if edge_key(seed, nb) not in visited_edges: + polylines.append(walk(seed, nb)) + + for p in pixels: + if degree[p] == 2: + for nb in neighbors(p): + if edge_key(p, nb) not in visited_edges: + polylines.append(walk(p, nb)) + + return [pl for pl in polylines if len(pl) >= 2] + + +def _mask_to_polylines( + mask: np.ndarray, x_coords: np.ndarray, y_coords: np.ndarray, z_grid: np.ndarray +) -> list[dict[str, Any]]: + """셀 마슀크륌 섞선화한 ë’€ 몚덞좌표 polyline 목록윌로 변환한닀.""" + if not mask.any(): + return [] + try: + from skimage.morphology import skeletonize + + skel = skeletonize(mask) + except Exception: + skel = mask + out = [] + for pixel_path in _trace_polylines(skel): + poly = [ + [float(x_coords[c]), float(y_coords[r]), float(z_grid[r, c])] for r, c in pixel_path + ] + out.append({"polyline": poly}) + return out + + +def extract_skeleton_from_grid( + x_coords: np.ndarray, + y_coords: np.ndarray, + z_grid: np.ndarray, + valid_mask: np.ndarray, + grid_res: float, + thresholds: dict[str, float] | None = None, + use_whitebox: bool = True, +) -> dict[str, list[dict[str, Any]]]: + """격자에서 죌/지 능선·계곡 polyline을 추출한닀 (캐시 없음, 테슀튞용 공개 API).""" + th = thresholds or _default_thresholds() + + if use_whitebox: + acc_valley = _flow_accumulation(z_grid, x_coords, y_coords, valid_mask, grid_res) + acc_ridge = _flow_accumulation(-z_grid, x_coords, y_coords, valid_mask, grid_res) + else: + acc_valley = d8_flow_accumulation_numpy(z_grid, valid_mask) + acc_ridge = d8_flow_accumulation_numpy(-z_grid, valid_mask) + + valley_mask = valid_mask & (acc_valley >= th["valley_acc"]) + main_valley_mask = valley_mask & (acc_valley >= th["main_valley_acc"]) + minor_valley_mask = valley_mask & ~main_valley_mask + + ridge_mask = valid_mask & ~valley_mask & (acc_ridge >= th["ridge_acc"]) + main_ridge_mask = ridge_mask & (acc_ridge >= th["main_ridge_acc"]) + minor_ridge_mask = ridge_mask & ~main_ridge_mask + + return { + "main_ridge": _mask_to_polylines(main_ridge_mask, x_coords, y_coords, z_grid), + "minor_ridge": _mask_to_polylines(minor_ridge_mask, x_coords, y_coords, z_grid), + "main_valley": _mask_to_polylines(main_valley_mask, x_coords, y_coords, z_grid), + "minor_valley": _mask_to_polylines(minor_valley_mask, x_coords, y_coords, z_grid), + } + + +def _skeleton_signature(models_dir: Path, filter_key: str, method: str, smooth: bool) -> str: + """소슀 몚덞 + 격자 핎상도 + 분류 임계값 서명.""" + th = _default_thresholds() + th_part = "|".join(f"{k}={v}" for k, v in sorted(th.items())) + return _cost_surface_signature(models_dir, filter_key, method, smooth) + "|" + th_part + + +def load_or_build_skeleton( + project_root: Path, filter_key: str, method: str, smooth: bool +) -> dict[str, Any]: + """슀쌈레톀을 캐시에서 로드하거나 새로 계산핎 저장한닀.""" + project_root = Path(project_root) + models_dir = project_root / _MODELS_SUBDIR + cache_dir = project_root / _ROUTE_CACHE_SUBDIR + suffix = "_smooth" if smooth else "" + cache_path = cache_dir / f"terrain_skeleton_{filter_key}_{method}{suffix}.json" + signature = _skeleton_signature(models_dir, filter_key, method, smooth) + + if cache_path.exists(): + try: + with open(cache_path, encoding="utf-8") as f: + cached = json.load(f) + if cached.get("signature") == signature: + return cached + except Exception: + pass + + _t0 = time.time() + (x_coords, y_coords, z_grid, valid_mask, _dz_dx, _dz_dy, grid_res) = ( + _load_or_build_cost_surface(project_root, models_dir, filter_key, method, smooth) + ) + z_grid = np.asarray(z_grid, dtype=np.float64) + valid_mask = np.asarray(valid_mask, dtype=bool) + + skeleton = extract_skeleton_from_grid( + np.asarray(x_coords), np.asarray(y_coords), z_grid, valid_mask, float(grid_res) + ) + result: dict[str, Any] = dict(skeleton) + result["grid_res"] = float(grid_res) + result["signature"] = signature + + try: + cache_dir.mkdir(parents=True, exist_ok=True) + with open(cache_path, "w", encoding="utf-8") as f: + json.dump(result, f, ensure_ascii=False) + except Exception: + pass + return result diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Solver.py b/B05_wf2_Route/B05_wf2_Route_Engine_Solver.py new file mode 100644 index 0000000..3f0ca36 --- /dev/null +++ b/B05_wf2_Route/B05_wf2_Route_Engine_Solver.py @@ -0,0 +1,656 @@ +"""B05 최적 겜로 탐색 였쌀슀튞레읎터. + +확정된 지표멎 몚덞(B04_wf1_Surface/models)의 표고륌 DTM 격자에 샘플링핎 +비용멎을 만듀고(캐시 재사용), BP→CP
→EP 닀구간 Dijkstra로 최적 겜로륌 +계산한 ë’€ 평활·잡점·곡률·제앜검슝 메튞늭을 반환한닀. +""" + +import math +from pathlib import Path +from typing import Any + +import numpy as np + +from B05_wf2_Route.B05_wf2_Route_Engine_Geometry import ( + circle_intrusions, + circumradius_2d, + compute_gradients, + point_to_polyline_dist_2d, + resample_polyline_2d, + single_segment_dijkstra, +) +from config.config_system import ( + FOREST_ROAD_MAX_GRADE, + FOREST_ROAD_MIN_CURVE_R_M, + ROUTE_DEFAULT_GRADE_CLASS, + ROUTE_GRID_RES_M, + ROUTE_MAX_COST_CELLS, + ROUTE_MAX_GRADE, + ROUTE_MAX_GRADE_PAVED, + ROUTE_REQUIRED_POINT_TOLERANCE_M, + ROUTE_W_AVOID, + ROUTE_W_CURVE, + ROUTE_W_DIST, + ROUTE_W_GRADE, + ROUTE_W_SIDE, + ROUTE_WEIGHT_MAX, +) + +# B04 지표멎 몚덞 폎더명 (비용멎의 표고 원볞) +_MODELS_SUBDIR = Path("B04_wf1_Surface") / "models" +# B05 비용멎 캐시 폎더명 +_ROUTE_CACHE_SUBDIR = Path("B05_wf2_Route") / "route" + + +def _load_dtm_grid(models_dir: Path, filter_key: str, smooth: bool): + """필터의 정규 DTM 격자(x, y, z, valid_mask)륌 로드한닀.""" + suffix = "_smooth" if smooth else "" + dtm_path = models_dir / f"dtm_{filter_key}{suffix}.npz" + if not dtm_path.exists(): + dtm_path = models_dir / f"dtm_{filter_key}.npz" + if not dtm_path.exists(): + raise FileNotFoundError(f"DTM 파음을 찟을 수 없습니닀: dtm_{filter_key}{suffix}.npz") + d = np.load(dtm_path) + return ( + np.asarray(d["x"]), + np.asarray(d["y"]), + np.asarray(d["z"], dtype=np.float64), + np.asarray(d["valid_mask"]), + ) + + +def _sample_surface_on_grid( + models_dir: Path, + filter_key: str, + method: str, + smooth: bool, + x_coords: np.ndarray, + y_coords: np.ndarray, + dtm_z: np.ndarray, +) -> np.ndarray: + """확정 지표멎 몚덞의 표고륌 DTM 격자에 샘플링한닀(싀팚 시 DTM윌로 폎백).""" + if method == "dtm": + return dtm_z + + models_dir = Path(models_dir) + xx, yy = np.meshgrid(x_coords, y_coords) + query = np.column_stack([xx.ravel(), yy.ravel()]) + + def _finalize(z_flat: np.ndarray) -> np.ndarray: + z = np.asarray(z_flat, dtype=np.float64).reshape(len(y_coords), len(x_coords)) + bad = ~np.isfinite(z) + if bad.any(): + z[bad] = dtm_z[bad] + return z + + try: + suffix = "_smooth" if smooth else "" + if method == "tin": + from scipy.interpolate import LinearNDInterpolator + + path = models_dir / f"tin_{filter_key}{suffix}.npz" + if not path.exists(): + path = models_dir / f"tin_{filter_key}.npz" + d = np.load(path) + verts = np.asarray(d["vertices"], dtype=np.float64) + interp = LinearNDInterpolator(verts[:, :2], verts[:, 2]) + return _finalize(interp(query)) + + if method == "nurbs": + from scipy.interpolate import RectBivariateSpline + + d = np.load(models_dir / f"nurbs_{filter_key}.npz") + cx = np.asarray(d["control_x"], dtype=np.float64) + cy = np.asarray(d["control_y"], dtype=np.float64) + cz = np.asarray(d["control_z"], dtype=np.float64) + degree = int(d["degree"][0]) if "degree" in d else 3 + spline = RectBivariateSpline( + cy, cx, cz, kx=min(degree, len(cy) - 1), ky=min(degree, len(cx) - 1) + ) + return _finalize(spline(y_coords, x_coords).ravel()) + + if method == "implicit": + from scipy.interpolate import RBFInterpolator + + d = np.load(models_dir / f"implicit_{filter_key}.npz") + centers = np.asarray(d["centers_xy"], dtype=np.float64) + cz = np.asarray(d["center_z"], dtype=np.float64) + smoothing = float(d["smoothing"][0]) if "smoothing" in d else 0.0 + interp = RBFInterpolator( + centers, + cz, + neighbors=min(64, len(centers)), + smoothing=smoothing, + kernel="thin_plate_spline", + ) + out = np.empty(len(query), dtype=np.float64) + for s in range(0, len(query), 50_000): + e = min(s + 50_000, len(query)) + out[s:e] = interp(query[s:e]) + return _finalize(out) + + if method == "meshfree": + from scipy.interpolate import griddata + + d = np.load(models_dir / f"meshfree_{filter_key}.npz") + pts = np.asarray(d["points"], dtype=np.float64) + z = griddata(pts[:, :2], pts[:, 2], query, method="linear") + return _finalize(z) + except Exception: + return dtm_z + + return dtm_z + + +def _source_npz_paths(models_dir: Path, filter_key: str, method: str, smooth: bool) -> list[Path]: + """비용멎읎 의졎하는 소슀 몚덞 파음(졎재하는 것만, 안정 순서).""" + suffix = "_smooth" if smooth else "" + candidates = [ + models_dir / f"dtm_{filter_key}{suffix}.npz", + models_dir / f"dtm_{filter_key}.npz", + ] + if method != "dtm": + candidates.append(models_dir / f"{method}_{filter_key}{suffix}.npz") + candidates.append(models_dir / f"{method}_{filter_key}.npz") + seen: set[Path] = set() + out: list[Path] = [] + for p in candidates: + if p.exists() and p not in seen: + seen.add(p) + out.append(p) + return out + + +def _cost_surface_signature(models_dir: Path, filter_key: str, method: str, smooth: bool) -> str: + """비용멎 재빌드 필요 시 바뀌는 서명(격자핎상도 + 소슀파음 mtime/size).""" + parts = [f"res={ROUTE_GRID_RES_M}", f"method={method}", f"smooth={smooth}"] + for p in _source_npz_paths(models_dir, filter_key, method, smooth): + st = p.stat() + parts.append(f"{p.name}:{int(st.st_mtime)}:{st.st_size}") + return "|".join(parts) + + +def _build_cost_surface(models_dir: Path, filter_key: str, method: str, smooth: bool): + """닀욎샘플된 비용멎(좌표·표고·footprint·Ʞ욞Ʞ)을 만든닀.""" + x_full, y_full, dtm_z_full, valid_full = _load_dtm_grid(models_dir, filter_key, smooth) + + target_res = ROUTE_GRID_RES_M + src_res = (x_full[-1] - x_full[0]) / (len(x_full) - 1) + step = max(1, int(round(target_res / src_res))) + x_sub = np.ascontiguousarray(x_full[::step]) + y_sub = np.ascontiguousarray(y_full[::step]) + + n_cells = len(x_sub) * len(y_sub) + if n_cells > ROUTE_MAX_COST_CELLS: + raise ValueError( + f"겜로 비용멎 격자 셀 수({n_cells:,})가 한도({ROUTE_MAX_COST_CELLS:,})륌 " + f"쎈곌합니닀. ROUTE_GRID_RES_M({target_res} m)륌 킀우거나 영역을 쀄읎섞요." + ) + + dtm_z_sub = np.array(dtm_z_full[::step, ::step], dtype=np.float64) + valid_sub = np.array(valid_full[::step, ::step]) + del dtm_z_full, valid_full + + z_sub = _sample_surface_on_grid(models_dir, filter_key, method, smooth, x_sub, y_sub, dtm_z_sub) + z_sub = np.asarray(z_sub, dtype=np.float64) + if not np.all(np.isfinite(z_sub)): + finite = z_sub[np.isfinite(z_sub)] + fill = float(finite.mean()) if finite.size else 0.0 + z_sub = np.where(np.isfinite(z_sub), z_sub, fill) + + res_y = (y_sub[-1] - y_sub[0]) / (len(y_sub) - 1) if len(y_sub) > 1 else target_res + res_x = (x_sub[-1] - x_sub[0]) / (len(x_sub) - 1) if len(x_sub) > 1 else target_res + dz_dx, dz_dy = compute_gradients(z_sub, res_y, res_x) + + grid_res = float(0.5 * (res_x + res_y)) + return x_sub, y_sub, z_sub, valid_sub, dz_dx, dz_dy, grid_res + + +def _load_or_build_cost_surface( + project_root: Path, models_dir: Path, filter_key: str, method: str, smooth: bool +): + """비용멎을 반환한닀(서명 음치 시 캐시 재사용, 아니멎 재빌드·재캐시).""" + cache_dir = project_root / _ROUTE_CACHE_SUBDIR + suffix = "_smooth" if smooth else "" + cache_path = cache_dir / f"cost_surface_{filter_key}_{method}{suffix}.npz" + signature = _cost_surface_signature(models_dir, filter_key, method, smooth) + + if cache_path.exists(): + try: + cached = np.load(cache_path, allow_pickle=False) + if str(cached["signature"]) == signature: + return ( + cached["x"], + cached["y"], + cached["z"], + cached["valid_mask"], + cached["dz_dx"], + cached["dz_dy"], + float(cached["target_res"][0]), + ) + except Exception: + pass + + surface = _build_cost_surface(models_dir, filter_key, method, smooth) + x_sub, y_sub, z_sub, valid_sub, dz_dx, dz_dy, target_res = surface + try: + cache_dir.mkdir(parents=True, exist_ok=True) + np.savez_compressed( + cache_path, + x=x_sub, + y=y_sub, + z=z_sub, + valid_mask=valid_sub, + dz_dx=dz_dx, + dz_dy=dz_dy, + target_res=np.array([target_res], np.float64), + signature=np.array(signature), + ) + except Exception: + pass + return surface + + +def _empty_route_result() -> dict[str, Any]: + return { + "polyline": [], + "chainage_m": [], + "segments": [], + "required_point_checks": [], + "required_points_ok": False, + "avoid_intrusions": [], + "forbidden_intrusions": [], + "curve_warning_segments": [], + "avoid_retry_performed": False, + "conditions_snapshot": {}, + "metrics": { + "length_m": 0.0, + "avg_grade_pct": 0.0, + "max_grade_pct": 0.0, + "slope_violations": 0, + "curve_violations": 0, + "min_curve_radius_m": None, + "min_curve_radius_limit_m": 0.0, + }, + } + + +def solve_optimal_route( + project_root: Path, + filter_key: str, + smooth: bool, + points_data: dict[str, Any], + options: dict[str, Any], + method: str = "dtm", + _avoid_retry: bool = False, +) -> dict[str, Any]: + """닀구간 비용멎 겜로 탐색을 였쌀슀튞레읎션핎 최종 좌표·메튞늭을 반환한닀.""" + project_root = Path(project_root) + models_dir = project_root / _MODELS_SUBDIR + + ( + x_coords_sub, + y_coords_sub, + z_grid_sub, + valid_mask_sub, + dz_dx, + dz_dy, + target_res, + ) = _load_or_build_cost_surface(project_root, models_dir, filter_key, method, smooth) + + bp = points_data.get("bp") + ep = points_data.get("ep") + cp_list = sorted(points_data.get("cp", []), key=lambda x: x.get("order", 0)) + ap_list = points_data.get("ap", []) + fp_list = points_data.get("fp", []) + + if fp_list: + valid_mask_sub = np.array(valid_mask_sub, copy=True) + xx, yy = np.meshgrid(x_coords_sub, y_coords_sub) + for fp in fp_list: + inside = (xx - fp["x"]) ** 2 + (yy - fp["y"]) ** 2 < float(fp["radius_m"]) ** 2 + valid_mask_sub[inside] = False + + if not bp or not ep: + return _empty_route_result() + + sequence = [bp] + cp_list + [ep] + + def _nearest_coord_index(coords: np.ndarray, value: float) -> int: + upper = int(np.clip(np.searchsorted(coords, value), 0, len(coords) - 1)) + lower = max(upper - 1, 0) + return lower if abs(value - coords[lower]) <= abs(coords[upper] - value) else upper + + def get_grid_indices(pt: dict[str, float]) -> tuple[int, int, bool]: + c = _nearest_coord_index(x_coords_sub, pt["x"]) + r = _nearest_coord_index(y_coords_sub, pt["y"]) + in_bounds = float(x_coords_sub[0]) <= pt["x"] <= float(x_coords_sub[-1]) and float( + y_coords_sub[0] + ) <= pt["y"] <= float(y_coords_sub[-1]) + point_on_valid_terrain = in_bounds and bool(valid_mask_sub[r, c]) + if not point_on_valid_terrain: + valid_ys, valid_xs = np.where(valid_mask_sub) + if len(valid_ys) > 0: + dists = (valid_ys - r) ** 2 + (valid_xs - c) ** 2 + best_idx = np.argmin(dists) + r, c = int(valid_ys[best_idx]), int(valid_xs[best_idx]) + return r, c, point_on_valid_terrain + + tol_req = ROUTE_REQUIRED_POINT_TOLERANCE_M + required_snap = [] + for pt in sequence: + r, c, point_on_valid_terrain = get_grid_indices(pt) + sx, sy = float(x_coords_sub[c]), float(y_coords_sub[r]) + snap_dist = math.hypot(pt["x"] - sx, pt["y"] - sy) + required_snap.append( + { + "r": r, + "c": c, + "snap_x": sx, + "snap_y": sy, + "snap_dist": snap_dist, + "point_on_valid_terrain": point_on_valid_terrain, + } + ) + + weights = options.get("weights") or { + "dist": ROUTE_W_DIST, + "grade": ROUTE_W_GRADE, + "side": ROUTE_W_SIDE, + "curve": ROUTE_W_CURVE, + "avoid": ROUTE_W_AVOID, + } + paved = options.get("paved", False) + + grade_class = options.get("grade_class", ROUTE_DEFAULT_GRADE_CLASS) + base_max_grade = FOREST_ROAD_MAX_GRADE.get(grade_class, ROUTE_MAX_GRADE) + max_grade = max(base_max_grade, ROUTE_MAX_GRADE_PAVED) if paved else base_max_grade + + min_curve_radius_m = options.get("min_curve_radius_m") + if not min_curve_radius_m or min_curve_radius_m <= 0: + min_curve_radius_m = FOREST_ROAD_MIN_CURVE_R_M.get(grade_class, 12.0) + + def _grade_opt(key: str) -> float: + v = options.get(key) + return float(v) if (v is not None and float(v) > 0) else max_grade + + max_uphill_grade = _grade_opt("max_uphill_grade") + max_downhill_grade = _grade_opt("max_downhill_grade") + + def _point_label(idx: int, pt: dict[str, Any]) -> str: + if idx == 0: + return "BP" + if idx == len(sequence) - 1: + return "EP" + return f"CP{pt.get('order', idx)}" + + full_path_grid: list[tuple[int, int]] = [] + segment_bounds: list[dict[str, Any]] = [] + + for i in range(len(sequence) - 1): + pt_start = sequence[i] + pt_end = sequence[i + 1] + r_s, c_s, _ = get_grid_indices(pt_start) + r_e, c_e, _ = get_grid_indices(pt_end) + + segment = single_segment_dijkstra( + r_s, + c_s, + r_e, + c_e, + x_coords_sub, + y_coords_sub, + z_grid_sub, + valid_mask_sub, + dz_dx, + dz_dy, + ap_list, + weights, + max_grade, + target_res, + min_curve_radius_m, + max_uphill_grade, + max_downhill_grade, + ) + if not segment: + fp_note = "·ꞈ지구역(FP)" if fp_list else "" + raise ValueError( + f"섞귞뚌튞 {i + 1} ({_point_label(i, pt_start)} -> {_point_label(i + 1, pt_end)}) " + f"겜로 탐색 싀팚: 종닚겜사 한계({max_grade * 100:.0f}%)·최소곡선반지늄" + f"({min_curve_radius_m:.0f}m)·회플지역{fp_note} 제앜윌로 통곌 겜로가 없습니닀." + ) + + start_idx = max(len(full_path_grid) - 1, 0) + if i > 0 and len(segment) > 0: + full_path_grid.extend(segment[1:]) + else: + full_path_grid.extend(segment) + segment_bounds.append( + { + "index": i, + "from": _point_label(i, pt_start), + "to": _point_label(i + 1, pt_end), + "point_start": start_idx, + "point_end": len(full_path_grid) - 1, + } + ) + + polyline = [] + for r, c in full_path_grid: + polyline.append([float(x_coords_sub[c]), float(y_coords_sub[r]), float(z_grid_sub[r, c])]) + + def _grid_z(px: float, py: float) -> float: + c_idx = _nearest_coord_index(x_coords_sub, px) + r_idx = _nearest_coord_index(y_coords_sub, py) + return float(z_grid_sub[r_idx, c_idx]) + + def _pin_coord_for(seq_idx: int) -> list[float]: + snap = required_snap[seq_idx] + pt = sequence[seq_idx] + if snap["point_on_valid_terrain"]: + return [pt["x"], pt["y"], _grid_z(pt["x"], pt["y"])] + return [snap["snap_x"], snap["snap_y"], _grid_z(snap["snap_x"], snap["snap_y"])] + + pin_coords: dict[int, list[float]] = {} + for sb in segment_bounds: + pin_coords[sb["point_start"]] = _pin_coord_for(sb["index"]) + pin_coords[sb["point_end"]] = _pin_coord_for(sb["index"] + 1) + + if len(polyline) > 4: + original = polyline + smoothed_polyline = [] + window_size = 3 + padded = [original[0]] * (window_size // 2) + original + [original[-1]] * (window_size // 2) + for i in range(len(original)): + if i in pin_coords: + smoothed_polyline.append(list(pin_coords[i])) + continue + window = padded[i : i + window_size] + sx = sum(p[0] for p in window) / window_size + sy = sum(p[1] for p in window) / window_size + sz = sum(p[2] for p in window) / window_size + smoothed_polyline.append([sx, sy, sz]) + polyline = smoothed_polyline + else: + for i, coord in pin_coords.items(): + if 0 <= i < len(polyline): + polyline[i] = list(coord) + + n = len(polyline) + chainage_m = [0.0] * n + length_m = 0.0 + slope_violations = 0 + max_grade_pct = 0.0 + grade_sums = 0.0 + max_uphill_pct = 0.0 + max_downhill_pct = 0.0 + for i in range(n - 1): + x1, y1, z1 = polyline[i] + x2, y2, z2 = polyline[i + 1] + h_dist = math.hypot(x2 - x1, y2 - y1) + chainage_m[i + 1] = chainage_m[i] + h_dist + if h_dist > 0.01: + dz = z2 - z1 + segment_slope = abs(dz) / h_dist + length_m += h_dist + grade_sums += segment_slope * h_dist + max_grade_pct = max(max_grade_pct, segment_slope) + applicable = max_uphill_grade if dz > 0 else max_downhill_grade + if dz > 0: + max_uphill_pct = max(max_uphill_pct, segment_slope) + else: + max_downhill_pct = max(max_downhill_pct, segment_slope) + if segment_slope > applicable: + slope_violations += 1 + + avg_grade_pct = (grade_sums / length_m) if length_m > 0 else 0.0 + + curve_check_step = max(2.0 * target_res, 4.0) + resampled = resample_polyline_2d(polyline, chainage_m, curve_check_step) + total_chainage = chainage_m[-1] if chainage_m else 0.0 + + def _poly_index_at_chainage(ch: float) -> int: + idx = int(np.searchsorted(chainage_m, ch)) if chainage_m else 0 + return int(min(max(idx, 0), max(len(polyline) - 1, 0))) + + curve_violations = 0 + min_curve_radius_actual = float("inf") + radii = [float("inf")] * len(resampled) + for i in range(1, len(resampled) - 1): + radius = circumradius_2d(resampled[i - 1], resampled[i], resampled[i + 1]) + radii[i] = radius + min_curve_radius_actual = min(min_curve_radius_actual, radius) + if radius < min_curve_radius_m: + curve_violations += 1 + + curve_warning_segments = [] + run_start = None + for i in range(1, len(resampled)): + violating = i < len(resampled) - 1 and radii[i] < min_curve_radius_m + if violating and run_start is None: + run_start = i + if (not violating) and run_start is not None: + run_end = i - 1 + ch_s = min(run_start * curve_check_step, total_chainage) + ch_e = min(run_end * curve_check_step, total_chainage) + curve_warning_segments.append( + { + "chainage_start_m": round(ch_s, 2), + "chainage_end_m": round(ch_e, 2), + "min_radius_m": round(min(radii[run_start : run_end + 1]), 2), + "required_radius_m": round(min_curve_radius_m, 2), + "polyline_start_index": _poly_index_at_chainage(ch_s), + "polyline_end_index": _poly_index_at_chainage(ch_e), + } + ) + run_start = None + + segments = [] + for sb in segment_bounds: + s, e = sb["point_start"], min(sb["point_end"], n - 1) + seg_max_grade = 0.0 + for i in range(s, e): + x1, y1, z1 = polyline[i] + x2, y2, z2 = polyline[i + 1] + hd = math.hypot(x2 - x1, y2 - y1) + if hd > 0.01: + seg_max_grade = max(seg_max_grade, abs(z2 - z1) / hd) + segments.append( + { + "index": sb["index"], + "from": sb["from"], + "to": sb["to"], + "point_start": s, + "point_end": e, + "chainage_start_m": round(chainage_m[s], 2), + "chainage_end_m": round(chainage_m[e], 2), + "length_m": round(chainage_m[e] - chainage_m[s], 2), + "max_grade_pct": round(seg_max_grade * 100, 2), + } + ) + + required_point_checks = [] + for idx, pt in enumerate(sequence): + label = _point_label(idx, pt) + poly_dist = point_to_polyline_dist_2d(pt["x"], pt["y"], polyline) + snap = required_snap[idx] + snap_dist = snap["snap_dist"] + dist_val = poly_dist if snap["point_on_valid_terrain"] else max(poly_dist, snap_dist) + required_point_checks.append( + { + "point": label, + "x": pt["x"], + "y": pt["y"], + "distance_m": round(dist_val, 3), + "snap_distance_m": round(snap_dist, 3), + "point_on_valid_terrain": snap["point_on_valid_terrain"], + "tolerance_m": tol_req, + "within_tolerance": bool(dist_val <= tol_req), + } + ) + required_points_ok = all(c["within_tolerance"] for c in required_point_checks) + + avoid_intrusions = circle_intrusions(polyline, ap_list, lambda i: f"AP{i + 1}") + forbidden_intrusions = circle_intrusions(polyline, fp_list, lambda i: f"FP{i + 1}") + allow_pass = bool(options.get("allow_avoid_pass_through", False)) + any_intrusion = any(a["intrudes"] for a in avoid_intrusions) + if any_intrusion and not allow_pass and not _avoid_retry: + boosted = dict(options) + bw = dict(weights) + bw["avoid"] = min(bw.get("avoid", ROUTE_W_AVOID) * 10.0, ROUTE_WEIGHT_MAX) + boosted["weights"] = bw + try: + retried = solve_optimal_route( + project_root, + filter_key, + smooth, + points_data, + boosted, + method=method, + _avoid_retry=True, + ) + retried["avoid_retry_performed"] = True + return retried + except Exception: + pass + + conditions_snapshot = { + "filter": filter_key, + "method": method, + "smooth": smooth, + "grade_class": grade_class, + "paved": paved, + "max_grade_pct": round(max_grade * 100, 2), + "max_uphill_grade_pct": round(max_uphill_grade * 100, 2), + "max_downhill_grade_pct": round(max_downhill_grade * 100, 2), + "min_curve_radius_m": round(min_curve_radius_m, 2), + "weights": weights, + "avoid_count": len(ap_list), + "forbidden_count": len(fp_list), + } + + return { + "polyline": polyline, + "chainage_m": [round(v, 3) for v in chainage_m], + "segments": segments, + "required_point_checks": required_point_checks, + "required_points_ok": required_points_ok, + "avoid_intrusions": avoid_intrusions, + "forbidden_intrusions": forbidden_intrusions, + "curve_warning_segments": curve_warning_segments, + "avoid_retry_performed": _avoid_retry, + "conditions_snapshot": conditions_snapshot, + "metrics": { + "length_m": round(length_m, 2), + "avg_grade_pct": round(avg_grade_pct * 100, 2), + "max_grade_pct": round(max_grade_pct * 100, 2), + "max_uphill_pct": round(max_uphill_pct * 100, 2), + "max_downhill_pct": round(max_downhill_pct * 100, 2), + "slope_violations": slope_violations, + "curve_violations": curve_violations, + "min_curve_radius_m": round(min_curve_radius_actual, 2) + if math.isfinite(min_curve_radius_actual) + else None, + "min_curve_radius_limit_m": round(min_curve_radius_m, 2), + }, + } diff --git a/B05_wf2_Route/B05_wf2_Route_Repository.py b/B05_wf2_Route/B05_wf2_Route_Repository.py new file mode 100644 index 0000000..7438325 --- /dev/null +++ b/B05_wf2_Route/B05_wf2_Route_Repository.py @@ -0,0 +1,181 @@ +"""B05 겜로 섀계 결곌의 aiomysql Raw SQL ì ‘ê·Œ. + +routes(겜로), route_points(렌더링 샘플), route_statistics(통계) 테읎랔에 +메타데읎터와 상대 겜로륌 Ʞ록한닀. 전첎 폎늬띌읞은 route_data_path의 +GeoJSON 파음에 저장하고 DB에는 겜로만 Ʞ록한닀. +""" + +import json +from pathlib import PurePosixPath +from typing import Any +from uuid import UUID + +import aiomysql + +_STAGE_ROOT = "B05_wf2_Route" + + +def _validate_stage_path(relative_path: str) -> str: + normalized = PurePosixPath(relative_path.replace("\\", "/")) + if normalized.is_absolute() or ".." in normalized.parts: + raise ValueError("DB에는 프로젝튞 룚튞 Ʞ쀀 상대 겜로만 저장할 수 있습니닀.") + if not normalized.parts or normalized.parts[0] != _STAGE_ROOT: + raise ValueError(f"B05 산출묌 겜로는 {_STAGE_ROOT} 아래여알 합니닀.") + return normalized.as_posix() + + +async def create_route( + connection: aiomysql.Connection, + *, + project_id: UUID, + surface_model_id: int | None, + total_length_m: float | None, + start_chainage_m: float | None, + end_chainage_m: float | None, + grade_percent: list[float] | None, + constraints: dict[str, Any] | None, + algorithm_params: dict[str, Any] | None, + route_data_path: str, + status: str = "DRAFT", +) -> int: + """겜로 메타데읎터륌 저장하고 생성된 ID륌 반환한닀.""" + route_rel = _validate_stage_path(route_data_path) + async with connection.cursor() as cursor: + await cursor.execute( + """ + INSERT INTO routes ( + project_id, surface_model_id, status, + start_chainage_m, end_chainage_m, total_length_m, + grade_percent, constraints, algorithm_params, + route_data_path, computed_at + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, CURRENT_TIMESTAMP) + """, + ( + str(project_id), + surface_model_id, + status, + start_chainage_m, + end_chainage_m, + total_length_m, + json.dumps(grade_percent) if grade_percent is not None else None, + json.dumps(constraints, ensure_ascii=False) if constraints is not None else None, + json.dumps(algorithm_params, ensure_ascii=False) + if algorithm_params is not None + else None, + route_rel, + ), + ) + route_id = cursor.lastrowid + if not route_id: + raise RuntimeError("routes 레윔드 생성 결곌에 ID가 없습니닀.") + return int(route_id) + + +async def insert_route_points( + connection: aiomysql.Connection, + route_id: int, + points: list[dict[str, Any]], +) -> int: + """겜로 렌더링 샘플 포읞튞륌 음ꎄ 저장하고 저장 걎수륌 반환한닀. + + 각 point dict: {chainage_m, elevation_m, slope_percent, sequence_num} + """ + if not points: + return 0 + rows = [ + ( + route_id, + point.get("chainage_m"), + point.get("elevation_m"), + point.get("slope_percent"), + point.get("sequence_num"), + ) + for point in points + ] + async with connection.cursor() as cursor: + await cursor.executemany( + """ + INSERT INTO route_points ( + route_id, chainage_m, elevation_m, slope_percent, sequence_num + ) + VALUES (%s, %s, %s, %s, %s) + """, + rows, + ) + return len(rows) + + +async def create_route_statistics( + connection: aiomysql.Connection, + *, + route_id: int, + min_slope: float | None, + max_slope: float | None, + mean_slope: float | None, + cut_volume_m3: float | None = None, + fill_volume_m3: float | None = None, + tree_cutting_volume: float | None = None, + cost_score: float | None = None, +) -> int: + """겜로 통계륌 저장하고 생성된 ID륌 반환한닀.""" + async with connection.cursor() as cursor: + await cursor.execute( + """ + INSERT INTO route_statistics ( + route_id, min_slope, max_slope, mean_slope, + cut_volume_m3, fill_volume_m3, tree_cutting_volume, cost_score + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s) + """, + ( + route_id, + min_slope, + max_slope, + mean_slope, + cut_volume_m3, + fill_volume_m3, + tree_cutting_volume, + cost_score, + ), + ) + stat_id = cursor.lastrowid + if not stat_id: + raise RuntimeError("route_statistics 레윔드 생성 결곌에 ID가 없습니닀.") + return int(stat_id) + + +async def get_latest_route( + connection: aiomysql.Connection, project_id: UUID +) -> dict[str, Any] | None: + """프로젝튞의 최신 겜로륌 조회한닀 (없윌멎 None).""" + async with connection.cursor() as cursor: + await cursor.execute( + """ + SELECT id, status, total_length_m, route_data_path, computed_at + FROM routes + WHERE project_id = %s + ORDER BY computed_at DESC, id DESC + LIMIT 1 + """, + (str(project_id),), + ) + row = await cursor.fetchone() + if not row: + return None + return { + "id": int(row[0]), + "status": row[1], + "total_length_m": row[2], + "route_data_path": row[3], + "computed_at": row[4].isoformat() if row[4] else None, + } + + +async def confirm_route(connection: aiomysql.Connection, route_id: int) -> None: + """겜로 상태륌 CONFIRMED로 변겜한닀.""" + async with connection.cursor() as cursor: + await cursor.execute( + "UPDATE routes SET status = 'CONFIRMED' WHERE id = %s", + (route_id,), + ) diff --git a/B05_wf2_Route/B05_wf2_Route_Router.py b/B05_wf2_Route/B05_wf2_Route_Router.py new file mode 100644 index 0000000..ec343b8 --- /dev/null +++ b/B05_wf2_Route/B05_wf2_Route_Router.py @@ -0,0 +1,133 @@ +"""B05 겜로 섀계 FastAPI 띌우터.""" + +import asyncio +import logging +from pathlib import Path +from uuid import UUID + +from fastapi import APIRouter +from fastapi.responses import JSONResponse + +from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path +from B05_wf2_Route.B05_wf2_Route_Engine import run_route_design +from B05_wf2_Route.B05_wf2_Route_Repository import ( + confirm_route, + create_route, + create_route_statistics, + get_latest_route, + insert_route_points, +) +from B05_wf2_Route.B05_wf2_Route_Schema import ( + RouteConfirmResponse, + RouteSolveRequest, + RouteSolveResponse, +) +from common_util.common_util_storage import resolve_stored_project_path +from config.config_db import get_db_pool + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/projects", tags=["B05 Route Design"]) + + +@router.post("/{project_id}/route/solve", response_model=RouteSolveResponse) +async def solve_route( + project_id: UUID, request: RouteSolveRequest +) -> RouteSolveResponse | JSONResponse: + """겜로 탐색을 싀행하고 결곌륌 GeoJSON 저장 + DB Ʞ록한닀.""" + pool = get_db_pool() + try: + async with pool.acquire() as connection: + stored_path = await get_project_storage_relative_path(connection, project_id) + project_root = Path(resolve_stored_project_path(stored_path)) + + # 묎거욎 겜로 탐색은 읎벀튞 룚프륌 막지 않도록 별도 슀레드에서 싀행. + design = await asyncio.to_thread( + run_route_design, + project_root, + request.filter_key, + request.method, + request.smooth, + request.points_data(), + request.options(), + request.algorithm, + ) + solver = design["solver_result"] + metrics = solver["metrics"] + + await connection.begin() + try: + route_id = await create_route( + connection, + project_id=project_id, + surface_model_id=request.surface_model_id, + total_length_m=metrics.get("length_m"), + start_chainage_m=0.0, + end_chainage_m=metrics.get("length_m"), + grade_percent=design["grade_percent"], + constraints=design["constraints"], + algorithm_params=design["algorithm_params"], + route_data_path=design["route_data_path"], + ) + await insert_route_points(connection, route_id, design["render_points"]) + stats = design["statistics"] + await create_route_statistics( + connection, + route_id=route_id, + min_slope=stats["min_slope"], + max_slope=stats["max_slope"], + mean_slope=stats["mean_slope"], + cost_score=stats["cost_score"], + ) + await connection.commit() + except Exception: + await connection.rollback() + raise + + return RouteSolveResponse( + project_id=str(project_id), + route_id=route_id, + total_length_m=metrics.get("length_m", 0.0), + metrics=metrics, + required_points_ok=solver["required_points_ok"], + route_data_path=design["route_data_path"], + ) + except LookupError as exc: + return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)}) + except FileNotFoundError as exc: + return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)}) + except (OSError, ValueError) as exc: + return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)}) + except Exception: + logger.exception("B05 겜로 탐색 싀팚: project_id=%s", project_id) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "겜로 탐색 처늬 쀑 였류가 발생했습니닀."}, + ) + + +@router.post("/{project_id}/route/confirm", response_model=RouteConfirmResponse) +async def confirm_latest_route(project_id: UUID) -> RouteConfirmResponse | JSONResponse: + """프로젝튞의 최신 겜로륌 확정(CONFIRMED)한닀.""" + pool = get_db_pool() + try: + async with pool.acquire() as connection: + latest = await get_latest_route(connection, project_id) + if not latest: + return JSONResponse( + status_code=404, + content={"status": "error", "message": "확정할 겜로가 없습니닀."}, + ) + await connection.begin() + try: + await confirm_route(connection, latest["id"]) + await connection.commit() + except Exception: + await connection.rollback() + raise + return RouteConfirmResponse(project_id=str(project_id), route_id=latest["id"]) + except Exception: + logger.exception("B05 겜로 확정 싀팚: project_id=%s", project_id) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "겜로 확정 처늬 쀑 였류가 발생했습니닀."}, + ) diff --git a/B05_wf2_Route/B05_wf2_Route_Schema.py b/B05_wf2_Route/B05_wf2_Route_Schema.py new file mode 100644 index 0000000..6c4abb9 --- /dev/null +++ b/B05_wf2_Route/B05_wf2_Route_Schema.py @@ -0,0 +1,102 @@ +"""B05 겜로 섀계 요청·응답 검슝 몚덞.""" + +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field, model_validator + +from config.config_system import ROUTE_GRADE_CLASSES + + +class RoutePoint(BaseModel): + """겜로 제얎점 (BP/CP/EP).""" + + model_config = ConfigDict(extra="forbid") + + x: float + y: float + order: int | None = None + + +class CirclePoint(BaseModel): + """회플/ꞈ지 원 (AP/FP).""" + + model_config = ConfigDict(extra="forbid") + + x: float + y: float + radius_m: float = Field(gt=0) + + +class RouteSolveRequest(BaseModel): + """겜로 탐색 싀행 요청.""" + + model_config = ConfigDict(extra="forbid") + + filter_key: str = Field(description="지멎 필터 í‚€ (grid_min_z/csf/pmf)") + method: str = Field(default="dtm", description="지표멎 표현 (dtm/tin/nurbs/implicit/meshfree)") + smooth: bool = Field(default=False) + surface_model_id: int | None = Field(default=None, description="êž°ë°˜ 지표멎 몚덞 id") + algorithm: str = Field(default="dijkstra", description="겜로 알고늬슘 (dijkstra/ridge_valley)") + + bp: RoutePoint + ep: RoutePoint + cp: list[RoutePoint] = Field(default_factory=list) + ap: list[CirclePoint] = Field(default_factory=list) + fp: list[CirclePoint] = Field(default_factory=list) + + grade_class: str = Field(default="trunk") + paved: bool = Field(default=False) + min_curve_radius_m: float | None = None + max_uphill_grade: float | None = None + max_downhill_grade: float | None = None + weights: dict[str, float] | None = None + allow_avoid_pass_through: bool = Field(default=False) + + @model_validator(mode="after") + def validate_choices(self) -> "RouteSolveRequest": + if self.grade_class not in ROUTE_GRADE_CLASSES: + raise ValueError(f"임도 등꞉은 {ROUTE_GRADE_CLASSES} 쀑 하나여알 합니닀.") + if self.algorithm not in ("dijkstra", "ridge_valley"): + raise ValueError("겜로 알고늬슘은 dijkstra 또는 ridge_valley여알 합니닀.") + return self + + def points_data(self) -> dict[str, Any]: + return { + "bp": self.bp.model_dump(), + "ep": self.ep.model_dump(), + "cp": [p.model_dump() for p in self.cp], + "ap": [p.model_dump() for p in self.ap], + "fp": [p.model_dump() for p in self.fp], + } + + def options(self) -> dict[str, Any]: + return { + "grade_class": self.grade_class, + "paved": self.paved, + "min_curve_radius_m": self.min_curve_radius_m, + "max_uphill_grade": self.max_uphill_grade, + "max_downhill_grade": self.max_downhill_grade, + "weights": self.weights, + "allow_avoid_pass_through": self.allow_avoid_pass_through, + } + + +class RouteSolveResponse(BaseModel): + """겜로 탐색 싀행 결곌.""" + + status: str = "success" + project_id: str + route_id: int + total_length_m: float + metrics: dict[str, Any] + required_points_ok: bool + route_data_path: str + + +class RouteConfirmResponse(BaseModel): + """겜로 확정 결곌.""" + + status: str = "success" + project_id: str + route_id: int + confirmed: bool = True diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Engine.py b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Engine.py new file mode 100644 index 0000000..904ee71 --- /dev/null +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Engine.py @@ -0,0 +1,117 @@ +"""B06 종횡닚 생성 엔진 였쌀슀튞레읎터. + +확정 겜로 GeoJSON곌 확정 지표멎 몚덞 sampler로 종닚·횡닚을 생성하고, 종닚은 +longitudinal/, 각 횡닚은 cross_sections/ 아래 파음로 저장한닀. DB Ʞ록용 +데읎터(겜로·요앜·잡점별 파음)륌 쀀비핎 반환한닀. 띌우터에서 asyncio.to_thread로 +혞출한닀. +""" + +import json +from pathlib import Path +from typing import Any + +from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Engine_Sampler import build_surface_sampler +from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Engine_Section import ( + SectionGenerationOptions, + generate_sections, +) +from common_util.common_util_json import atomic_write_json + +_STAGE_SUBDIR = Path("B06_wf3_ProfileCross") +_MODELS_SUBDIR = Path("B04_wf1_Surface") / "models" + + +def _load_route_polyline(project_root: Path, route_data_path: str) -> list[list[float]]: + """B05가 저장한 겜로 GeoJSON에서 3D 폎늬띌읞 좌표엎을 로드한닀.""" + geojson_path = project_root / Path(route_data_path) + if not geojson_path.is_file(): + raise FileNotFoundError(f"겜로 GeoJSON을 찟을 수 없습니닀: {route_data_path}") + geo = json.loads(geojson_path.read_text(encoding="utf-8")) + coords = geo.get("geometry", {}).get("coordinates") + if not coords or len(coords) < 2: + raise ValueError("겜로 GeoJSON에 유횚한 LineString 좌표가 없습니닀.") + return [[float(c[0]), float(c[1]), float(c[2]) if len(c) > 2 else 0.0] for c in coords] + + +def _cross_summary(cross_section: dict[str, Any]) -> dict[str, Any]: + """횡닚멎 상섞에서 DB data 컬럌에 저장할 요앜을 만든닀.""" + samples = cross_section.get("samples", []) + valid_z = [s["elevation_m"] for s in samples if s.get("valid") and s.get("elevation_m") is not None] + return { + "chainage_m": cross_section.get("chainage_m"), + "center_z": cross_section.get("center_z"), + "azimuth_deg": cross_section.get("azimuth_deg"), + "sample_count": len(samples), + "min_elevation_m": min(valid_z) if valid_z else None, + "max_elevation_m": max(valid_z) if valid_z else None, + } + + +def run_section_generation( + project_root: Path, + route_data_path: str, + filter_key: str, + method: str, + smooth: bool, + *, + options: SectionGenerationOptions | None = None, + crs: str | None = None, +) -> dict[str, Any]: + """종횡닚을 생성·저장하고 DB Ʞ록용 데읎터륌 반환한닀. + + 반환 dict: + - longitudinal: {file_path, data(요앜)} + - cross_sections: [{chainage_m, sequence_num, data(요앜), cross_section_file_path}] + - result: generate_sections 원볞 결곌 + """ + polyline = _load_route_polyline(project_root, route_data_path) + models_dir = project_root / _MODELS_SUBDIR + sampler = build_surface_sampler(models_dir, filter_key, method, smooth) + + result = generate_sections( + polyline, + sampler, + options, + source_snapshot={"filter": filter_key, "method": method, "smooth": smooth}, + crs=crs, + ) + + stage_root = project_root / _STAGE_SUBDIR + long_dir = stage_root / "longitudinal" + cross_dir = stage_root / "cross_sections" + long_dir.mkdir(parents=True, exist_ok=True) + cross_dir.mkdir(parents=True, exist_ok=True) + + # 종닚멎 저장 + long_file = long_dir / "longitudinal.json" + atomic_write_json(long_file, result["longitudinal"]) + long_summary = { + "length_m": result["longitudinal"]["length_m"], + "station_count": result["summary"]["station_count"], + "invalid_samples": result["summary"]["invalid_longitudinal_samples"], + } + + # 잡점별 횡닚멎 저장 + cross_records: list[dict[str, Any]] = [] + for seq, cross_section in enumerate(result["cross_sections"]): + chainage = float(cross_section["chainage_m"]) + filename = f"cross_{int(round(chainage)):05d}m.json" + cross_file = cross_dir / filename + atomic_write_json(cross_file, cross_section) + cross_records.append( + { + "chainage_m": chainage, + "sequence_num": seq, + "data": _cross_summary(cross_section), + "cross_section_file_path": cross_file.relative_to(project_root).as_posix(), + } + ) + + return { + "longitudinal": { + "file_path": long_file.relative_to(project_root).as_posix(), + "data": long_summary, + }, + "cross_sections": cross_records, + "result": result, + } diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Engine_Sampler.py b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Engine_Sampler.py new file mode 100644 index 0000000..b6d8ac9 --- /dev/null +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Engine_Sampler.py @@ -0,0 +1,196 @@ +"""B06 지표멎 표고 sampler. + +종·횡닚 생성Ʞ가 의졎하는 최소 표고 조회 읞터페읎슀와, 확정된 지표멎 몚덞 +(B04_wf1_Surface/models)을 음ꎄ XY 표고 sampler로 여는 팩토늬륌 제공한닀. +DTM valid_mask륌 footprint로 결합핎 데읎터가 없는 영역을 임의 표고로 메우지 +않는닀. +""" + +from collections.abc import Callable +from dataclasses import dataclass +from pathlib import Path +from typing import Protocol + +import numpy as np +from scipy.interpolate import RegularGridInterpolator + + +class SurfaceElevationSampler(Protocol): + """종·횡닚 생성Ʞ가 의졎하는 최소 표고 조회 읞터페읎슀.""" + + def sample_xy(self, xy: np.ndarray) -> tuple[np.ndarray, np.ndarray]: + """(N, 2) 몚덞좌표 XY 배엎에 대핮 (z, valid)륌 반환한닀.""" + + +@dataclass +class DtmGridSampler: + """정규 DTM의 표고와 valid_mask륌 볎수적윌로 조회한닀. + + 볎간점 죌변 ë„€ 격자 ꌭ짓점읎 몚두 유횚할 때만 valid=True로 반환한닀. + """ + + x: np.ndarray + y: np.ndarray + z: np.ndarray + valid_mask: np.ndarray + + def __post_init__(self) -> None: + self.x = np.asarray(self.x, dtype=np.float64).reshape(-1) + self.y = np.asarray(self.y, dtype=np.float64).reshape(-1) + self.z = np.asarray(self.z, dtype=np.float64) + self.valid_mask = np.asarray(self.valid_mask, dtype=bool) + if len(self.x) < 2 or len(self.y) < 2: + raise ValueError("DTM 표고 조회에는 X/Y 축읎 각각 2개 읎상 필요합니닀.") + if self.z.shape != (len(self.y), len(self.x)): + raise ValueError("DTM Z 격자 크Ʞ가 X/Y 축곌 음치하지 않습니닀.") + if self.valid_mask.shape != self.z.shape: + raise ValueError("DTM valid_mask 크Ʞ가 Z 격자와 음치하지 않습니닀.") + if self.x[0] > self.x[-1]: + self.x = self.x[::-1] + self.z = self.z[:, ::-1] + self.valid_mask = self.valid_mask[:, ::-1] + if self.y[0] > self.y[-1]: + self.y = self.y[::-1] + self.z = self.z[::-1, :] + self.valid_mask = self.valid_mask[::-1, :] + self._interpolator = RegularGridInterpolator( + (self.y, self.x), self.z, method="linear", bounds_error=False, fill_value=np.nan + ) + + @classmethod + def from_npz(cls, path: Path | str) -> "DtmGridSampler": + with np.load(Path(path), allow_pickle=False) as data: + return cls(data["x"], data["y"], data["z"], data["valid_mask"]) + + def sample_xy(self, xy: np.ndarray) -> tuple[np.ndarray, np.ndarray]: + xy = np.asarray(xy, dtype=np.float64) + if xy.ndim != 2 or xy.shape[1] != 2: + raise ValueError("표고 조회 좌표는 (N, 2) XY 배엎읎얎알 합니닀.") + if not len(xy): + return np.empty(0, dtype=np.float64), np.empty(0, dtype=bool) + + z = np.asarray( + self._interpolator(np.column_stack([xy[:, 1], xy[:, 0]])), dtype=np.float64 + ) + ix = np.searchsorted(self.x, xy[:, 0], side="right") - 1 + iy = np.searchsorted(self.y, xy[:, 1], side="right") - 1 + inside = (ix >= 0) & (iy >= 0) & (ix < len(self.x) - 1) & (iy < len(self.y) - 1) + valid = np.zeros(len(xy), dtype=bool) + selected = np.flatnonzero(inside) + if len(selected): + sx = ix[selected] + sy = iy[selected] + valid[selected] = ( + self.valid_mask[sy, sx] + & self.valid_mask[sy, sx + 1] + & self.valid_mask[sy + 1, sx] + & self.valid_mask[sy + 1, sx + 1] + & np.isfinite(z[selected]) + ) + z[~valid] = np.nan + return z, valid + + +@dataclass +class CallableSurfaceSampler: + """테슀튞와 얎댑터에 사용할 핚수 êž°ë°˜ sampler.""" + + function: Callable[[np.ndarray], np.ndarray] + + def sample_xy(self, xy: np.ndarray) -> tuple[np.ndarray, np.ndarray]: + values = np.asarray(self.function(np.asarray(xy, dtype=np.float64)), dtype=np.float64) + if values.shape != (len(xy),): + raise ValueError("표고 핚수는 입력 좌표 수와 같은 Ꞟ읎의 배엎을 반환핎알 합니닀.") + valid = np.isfinite(values) + return values, valid + + +@dataclass +class InterpolatedSurfaceSampler: + """불규칙/곡멎 몚덞 볎간Ʞ와 DTM footprint 유횚성을 결합한닀.""" + + interpolator: Callable[[np.ndarray], np.ndarray] + footprint: SurfaceElevationSampler | None = None + + def sample_xy(self, xy: np.ndarray) -> tuple[np.ndarray, np.ndarray]: + xy = np.asarray(xy, dtype=np.float64) + values = np.asarray(self.interpolator(xy), dtype=np.float64).reshape(-1) + valid = np.isfinite(values) + if self.footprint is not None: + _, footprint_valid = self.footprint.sample_xy(xy) + valid &= footprint_valid + values[~valid] = np.nan + return values, valid + + +def build_surface_sampler( + models_dir: Path | str, source_filter: str, method: str, smooth: bool +) -> SurfaceElevationSampler: + """1닚계 확정 몚덞을 종·횡닚용 음ꎄ XY 표고 sampler로 ì—°ë‹€.""" + models_dir = Path(models_dir) + smooth_suffix = "_smooth" if smooth and method in {"dtm", "tin"} else "" + dtm_smooth = models_dir / f"dtm_{source_filter}_smooth.npz" + dtm_original = models_dir / f"dtm_{source_filter}.npz" + dtm_path = dtm_smooth if smooth and dtm_smooth.exists() else dtm_original + if not dtm_path.exists(): + raise FileNotFoundError(f"Ʞ쀀 DTM읎 없습니닀: {dtm_path.name}") + footprint = DtmGridSampler.from_npz(dtm_path) + if method == "dtm": + return footprint + + if method == "tin": + from scipy.interpolate import LinearNDInterpolator + + path = models_dir / f"tin_{source_filter}{smooth_suffix}.npz" + if not path.exists() and smooth_suffix: + path = models_dir / f"tin_{source_filter}.npz" + with np.load(path, allow_pickle=False) as data: + vertices = np.asarray(data["vertices"], dtype=np.float64) + interpolator = LinearNDInterpolator(vertices[:, :2], vertices[:, 2], fill_value=np.nan) + return InterpolatedSurfaceSampler(lambda xy: interpolator(xy), footprint) + + if method == "nurbs": + from scipy.interpolate import RectBivariateSpline + + path = models_dir / f"nurbs_{source_filter}.npz" + with np.load(path, allow_pickle=False) as data: + control_x = np.asarray(data["control_x"], dtype=np.float64) + control_y = np.asarray(data["control_y"], dtype=np.float64) + control_z = np.asarray(data["control_z"], dtype=np.float64) + degree = int(data["degree"][0]) if "degree" in data else 3 + spline = RectBivariateSpline( + control_y, + control_x, + control_z, + kx=min(degree, len(control_y) - 1), + ky=min(degree, len(control_x) - 1), + ) + return InterpolatedSurfaceSampler(lambda xy: spline.ev(xy[:, 1], xy[:, 0]), footprint) + + if method == "implicit": + from scipy.interpolate import RBFInterpolator + + path = models_dir / f"implicit_{source_filter}.npz" + with np.load(path, allow_pickle=False) as data: + centers = np.asarray(data["centers_xy"], dtype=np.float64) + center_z = np.asarray(data["center_z"], dtype=np.float64) + smoothing = float(data["smoothing"][0]) if "smoothing" in data else 0.0 + interpolator = RBFInterpolator( + centers, + center_z, + neighbors=min(64, len(centers)), + smoothing=smoothing, + kernel="thin_plate_spline", + ) + return InterpolatedSurfaceSampler(lambda xy: interpolator(xy), footprint) + + if method == "meshfree": + from scipy.interpolate import LinearNDInterpolator + + path = models_dir / f"meshfree_{source_filter}.npz" + with np.load(path, allow_pickle=False) as data: + points = np.asarray(data["points"], dtype=np.float64) + interpolator = LinearNDInterpolator(points[:, :2], points[:, 2], fill_value=np.nan) + return InterpolatedSurfaceSampler(lambda xy: interpolator(xy), footprint) + + raise ValueError(f"지원하지 않는 지표멎 몚덞입니닀: {method}") diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Engine_Section.py b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Engine_Section.py new file mode 100644 index 0000000..4b72c1c --- /dev/null +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Engine_Section.py @@ -0,0 +1,266 @@ +"""B06 종닚·횡닚 원시 데읎터 생성. + +확정 겜로 폎늬띌읞곌 표고 sampler로 CAD 읞계 가능한 종닚(longitudinal)·횡닚 +(cross) 데읎터륌 생성한닀. BP(0m)부터 station_interval 간격 잡점을 만듀고 +각 잡점에서 겜로 접선의 좌우 방향윌로 횡닚을 샘플링한닀. +""" + +import math +from dataclasses import dataclass +from typing import Any + +import numpy as np + +from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Engine_Sampler import SurfaceElevationSampler +from config.config_system import ( + SECTION_CROSS_HALF_WIDTH_M, + SECTION_CROSS_SAMPLE_INTERVAL_M, + SECTION_INCLUDE_ENDPOINT, + SECTION_LONG_SAMPLE_INTERVAL_M, + SECTION_STATION_INTERVAL_M, +) + +SECTION_SCHEMA_VERSION = 1 + + +@dataclass(frozen=True) +class SectionGenerationOptions: + station_interval_m: float = SECTION_STATION_INTERVAL_M + cross_half_width_m: float = SECTION_CROSS_HALF_WIDTH_M + cross_sample_interval_m: float = SECTION_CROSS_SAMPLE_INTERVAL_M + long_sample_interval_m: float = SECTION_LONG_SAMPLE_INTERVAL_M + include_endpoint: bool = SECTION_INCLUDE_ENDPOINT + + def validate(self) -> None: + values = { + "횡닚 잡점 간격": self.station_interval_m, + "횡닚 좌우 폭": self.cross_half_width_m, + "횡닚 샘플 간격": self.cross_sample_interval_m, + "종닚 샘플 간격": self.long_sample_interval_m, + } + for label, value in values.items(): + if not math.isfinite(value) or value <= 0: + raise ValueError(f"{label}은 0볎닀 큰 유한한 값읎얎알 합니닀.") + if self.cross_sample_interval_m > self.cross_half_width_m * 2: + raise ValueError("횡닚 샘플 간격읎 전첎 횡닚 폭볎닀 큎 수 없습니닀.") + + +def format_station(chainage_m: float) -> str: + """WebCAD 도멎에서도 재사용할 수 있는 STA.k+mmm.mmm 표Ʞ.""" + chainage_m = max(float(chainage_m), 0.0) + km = int(chainage_m // 1000.0) + remainder = chainage_m - km * 1000.0 + return f"STA.{km}+{remainder:07.3f}" + + +def _clean_polyline(polyline: np.ndarray) -> tuple[np.ndarray, np.ndarray]: + points = np.asarray(polyline, dtype=np.float64) + if points.ndim != 2 or points.shape[1] < 2 or len(points) < 2: + raise ValueError("겜로는 최소 2개의 (x, y, z) 좌표로 구성되얎알 합니닀.") + if points.shape[1] == 2: + points = np.column_stack([points, np.full(len(points), np.nan)]) + else: + points = points[:, :3] + if not np.all(np.isfinite(points[:, :2])): + raise ValueError("겜로 XY 좌표에 NaN 또는 Infinity가 있습니닀.") + + distances = np.hypot(np.diff(points[:, 0]), np.diff(points[:, 1])) + keep = np.r_[True, distances > 1e-8] + points = points[keep] + if len(points) < 2: + raise ValueError("수평 Ꞟ읎가 있는 겜로 구간읎 없습니닀.") + distances = np.hypot(np.diff(points[:, 0]), np.diff(points[:, 1])) + chainage = np.r_[0.0, np.cumsum(distances)] + return points, chainage + + +def _chainages(total: float, interval: float, include_endpoint: bool) -> np.ndarray: + values = np.arange(0.0, total + 1e-9, interval, dtype=np.float64) + if not len(values) or abs(values[0]) > 1e-9: + values = np.r_[0.0, values] + if include_endpoint and total - values[-1] > 1e-6: + values = np.r_[values, total] + return values + + +def _interpolate_xy(points: np.ndarray, chainage: np.ndarray, targets: np.ndarray) -> np.ndarray: + return np.column_stack( + [ + np.interp(targets, chainage, points[:, 0]), + np.interp(targets, chainage, points[:, 1]), + ] + ) + + +def _tangent_at( + points: np.ndarray, chainage: np.ndarray, target: float, probe: float +) -> np.ndarray: + total = float(chainage[-1]) + before = max(0.0, target - probe) + after = min(total, target + probe) + if after - before <= 1e-9: + before = max(0.0, target - 1e-3) + after = min(total, target + 1e-3) + pair = _interpolate_xy(points, chainage, np.array([before, after])) + vector = pair[1] - pair[0] + length = float(np.hypot(vector[0], vector[1])) + if length <= 1e-9: + raise ValueError(f"잡점 {target:.3f}m에서 겜로 접선 방향을 계산할 수 없습니닀.") + return vector / length + + +def _float_or_none(value: float) -> float | None: + return round(float(value), 6) if math.isfinite(float(value)) else None + + +def generate_sections( + polyline: np.ndarray | list[list[float]], + sampler: SurfaceElevationSampler, + options: SectionGenerationOptions | None = None, + *, + source_snapshot: dict[str, Any] | None = None, + crs: str | None = None, +) -> dict[str, Any]: + """확정 겜로로 CAD 읞계 가능한 종닚·횡닚 원시 데읎터륌 생성한닀.""" + options = options or SectionGenerationOptions() + options.validate() + points, route_chainage = _clean_polyline(np.asarray(polyline, dtype=np.float64)) + total = float(route_chainage[-1]) + + long_chainage = _chainages(total, options.long_sample_interval_m, True) + long_xy = _interpolate_xy(points, route_chainage, long_chainage) + long_z, long_valid = sampler.sample_xy(long_xy) + + station_chainage = _chainages(total, options.station_interval_m, options.include_endpoint) + station_xy = _interpolate_xy(points, route_chainage, station_chainage) + tangents = np.vstack( + [ + _tangent_at( + points, route_chainage, float(value), max(options.long_sample_interval_m, 0.5) + ) + for value in station_chainage + ] + ) + left_axes = np.column_stack([-tangents[:, 1], tangents[:, 0]]) + + offsets = np.arange( + -options.cross_half_width_m, + options.cross_half_width_m + options.cross_sample_interval_m * 0.5, + options.cross_sample_interval_m, + dtype=np.float64, + ) + offsets = offsets[offsets <= options.cross_half_width_m + 1e-9] + if not np.any(np.isclose(offsets, 0.0, atol=1e-9)): + offsets = np.sort(np.r_[offsets, 0.0]) + + all_cross_xy = ( + station_xy[:, None, :] + left_axes[:, None, :] * offsets[None, :, None] + ).reshape(-1, 2) + all_cross_z, all_cross_valid = sampler.sample_xy(all_cross_xy) + all_cross_z = all_cross_z.reshape(len(station_chainage), len(offsets)) + all_cross_valid = all_cross_valid.reshape(len(station_chainage), len(offsets)) + + stations: list[dict[str, Any]] = [] + cross_sections: list[dict[str, Any]] = [] + for index, value in enumerate(station_chainage): + station_id = f"station_{int(round(float(value) * 1000)):012d}" + kind = "bp" if index == 0 else "ep" if abs(float(value) - total) <= 1e-6 else "regular" + center_index = int(np.argmin(np.abs(offsets))) + center_z = all_cross_z[index, center_index] + tangent = tangents[index] + left = left_axes[index] + azimuth = (math.degrees(math.atan2(tangent[0], tangent[1])) + 360.0) % 360.0 + frame = { + "origin": { + "x": round(float(station_xy[index, 0]), 6), + "y": round(float(station_xy[index, 1]), 6), + "z": _float_or_none(center_z), + }, + "tangent_xy": [round(float(tangent[0]), 9), round(float(tangent[1]), 9)], + "left_xy": [round(float(left[0]), 9), round(float(left[1]), 9)], + "up_xyz": [0.0, 0.0, 1.0], + } + station = { + "station_id": station_id, + "chainage_m": round(float(value), 6), + "label": format_station(float(value)), + "kind": kind, + "center_x": round(float(station_xy[index, 0]), 6), + "center_y": round(float(station_xy[index, 1]), 6), + "center_z": _float_or_none(center_z), + "azimuth_deg": round(azimuth, 6), + "frame": frame, + } + stations.append(station) + + samples = [] + cross_xy_view = all_cross_xy.reshape(len(station_chainage), len(offsets), 2) + for offset_index, offset in enumerate(offsets): + valid = bool(all_cross_valid[index, offset_index]) + xy = cross_xy_view[index, offset_index] + samples.append( + { + "offset_m": round(float(offset), 6), + "x": round(float(xy[0]), 6), + "y": round(float(xy[1]), 6), + "z": _float_or_none(all_cross_z[index, offset_index]) if valid else None, + "elevation_m": _float_or_none(all_cross_z[index, offset_index]) + if valid + else None, + "valid": valid, + } + ) + cross_sections.append({**station, "samples": samples}) + + longitudinal_samples = [ + { + "chainage_m": round(float(chainage), 6), + "x": round(float(xy[0]), 6), + "y": round(float(xy[1]), 6), + "z": _float_or_none(z) if valid else None, + "elevation_m": _float_or_none(z) if valid else None, + "valid": bool(valid), + } + for chainage, xy, z, valid in zip(long_chainage, long_xy, long_z, long_valid) + ] + + finite_z = np.asarray( + [sample["z"] for sample in longitudinal_samples if sample["z"] is not None] + ) + datum = math.floor(float(finite_z.min()) / 10.0) * 10.0 if finite_z.size else None + return { + "schema_version": SECTION_SCHEMA_VERSION, + "status": "completed", + "source": source_snapshot or {}, + "coordinate_reference": { + "crs": crs, + "world_axes": {"x": "project_easting", "y": "project_northing", "z": "elevation"}, + "units": {"horizontal": "m", "vertical": "m", "angle": "degree"}, + }, + "cad_exchange": { + "station_origin": "BP", + "chainage_direction": "BP_to_EP", + "cross_offset_sign": {"negative": "right", "positive": "left"}, + "cross_local_axes": {"x": "offset_m", "y": "elevation_m"}, + "recommended_drawing_datum_m": datum, + }, + "options": { + "station_interval_m": options.station_interval_m, + "cross_half_width_m": options.cross_half_width_m, + "cross_sample_interval_m": options.cross_sample_interval_m, + "long_sample_interval_m": options.long_sample_interval_m, + "include_endpoint": options.include_endpoint, + }, + "longitudinal": { + "length_m": round(total, 6), + "samples": longitudinal_samples, + "stations": stations, + }, + "cross_sections": cross_sections, + "summary": { + "station_count": len(stations), + "cross_sample_count": int(len(stations) * len(offsets)), + "invalid_longitudinal_samples": int((~long_valid).sum()), + "invalid_cross_samples": int((~all_cross_valid).sum()), + }, + } diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Repository.py b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Repository.py new file mode 100644 index 0000000..afa9c89 --- /dev/null +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Repository.py @@ -0,0 +1,145 @@ +"""B06 종횡닚 결곌의 aiomysql Raw SQL ì ‘ê·Œ. + +longitudinal_sections(종닚멎 1걎), cross_sections(잡점별 닀걎) 테읎랔에 +메타데읎터와 상대 겜로륌 Ʞ록한닀. 상섞 샘플 데읎터는 파음에 저장하고 DB에는 +요앜 data(JSON)와 겜로만 Ʞ록한닀. +""" + +import json +from pathlib import PurePosixPath +from typing import Any +from uuid import UUID + +import aiomysql + +_STAGE_ROOT = "B06_wf3_ProfileCross" + + +def _validate_stage_path(relative_path: str) -> str: + normalized = PurePosixPath(relative_path.replace("\\", "/")) + if normalized.is_absolute() or ".." in normalized.parts: + raise ValueError("DB에는 프로젝튞 룚튞 Ʞ쀀 상대 겜로만 저장할 수 있습니닀.") + if not normalized.parts or normalized.parts[0] != _STAGE_ROOT: + raise ValueError(f"B06 산출묌 겜로는 {_STAGE_ROOT} 아래여알 합니닀.") + return normalized.as_posix() + + +async def delete_sections_for_route(connection: aiomysql.Connection, route_id: int) -> None: + """겜로 재생성 전에 Ʞ졎 종횡닚 레윔드륌 삭제한닀 (멱등 재싀행).""" + async with connection.cursor() as cursor: + await cursor.execute("DELETE FROM cross_sections WHERE route_id = %s", (route_id,)) + await cursor.execute("DELETE FROM longitudinal_sections WHERE route_id = %s", (route_id,)) + + +async def create_longitudinal_section( + connection: aiomysql.Connection, + *, + project_id: UUID, + route_id: int, + data: dict[str, Any] | None, + longitudinal_file_path: str, + status: str = "DRAFT", +) -> int: + """종닚멎 메타데읎터륌 저장하고 생성된 ID륌 반환한닀.""" + file_rel = _validate_stage_path(longitudinal_file_path) + async with connection.cursor() as cursor: + await cursor.execute( + """ + INSERT INTO longitudinal_sections ( + project_id, route_id, computed_at, data, longitudinal_file_path, status + ) + VALUES (%s, %s, CURRENT_TIMESTAMP, %s, %s, %s) + """, + ( + str(project_id), + route_id, + json.dumps(data, ensure_ascii=False) if data is not None else None, + file_rel, + status, + ), + ) + new_id = cursor.lastrowid + if not new_id: + raise RuntimeError("longitudinal_sections 레윔드 생성 결곌에 ID가 없습니닀.") + return int(new_id) + + +async def insert_cross_sections( + connection: aiomysql.Connection, + *, + project_id: UUID, + route_id: int, + sections: list[dict[str, Any]], +) -> int: + """잡점별 횡닚멎 레윔드륌 음ꎄ 저장하고 저장 걎수륌 반환한닀. + + 각 section dict: {chainage_m, sequence_num, data, cross_section_file_path, status?} + """ + if not sections: + return 0 + rows = [] + for section in sections: + file_rel = _validate_stage_path(section["cross_section_file_path"]) + data = section.get("data") + rows.append( + ( + str(project_id), + route_id, + section.get("chainage_m"), + section.get("sequence_num"), + json.dumps(data, ensure_ascii=False) if data is not None else None, + file_rel, + section.get("status", "DRAFT"), + ) + ) + async with connection.cursor() as cursor: + await cursor.executemany( + """ + INSERT INTO cross_sections ( + project_id, route_id, chainage_m, sequence_num, + data, cross_section_file_path, status + ) + VALUES (%s, %s, %s, %s, %s, %s, %s) + """, + rows, + ) + return len(rows) + + +async def get_longitudinal_section( + connection: aiomysql.Connection, project_id: UUID, route_id: int +) -> dict[str, Any] | None: + """겜로의 종닚멎 메타데읎터륌 조회한닀 (없윌멎 None).""" + async with connection.cursor() as cursor: + await cursor.execute( + """ + SELECT id, longitudinal_file_path, status, computed_at + FROM longitudinal_sections + WHERE project_id = %s AND route_id = %s + ORDER BY id DESC + LIMIT 1 + """, + (str(project_id), route_id), + ) + row = await cursor.fetchone() + if not row: + return None + return { + "id": int(row[0]), + "longitudinal_file_path": row[1], + "status": row[2], + "computed_at": row[3].isoformat() if row[3] else None, + } + + +async def confirm_sections_for_route(connection: aiomysql.Connection, route_id: int) -> None: + """겜로의 종횡닚멎 상태륌 CONFIRMED로 변겜한닀.""" + async with connection.cursor() as cursor: + await cursor.execute( + "UPDATE longitudinal_sections SET status = 'CONFIRMED' WHERE route_id = %s", + (route_id,), + ) + await cursor.execute( + "UPDATE cross_sections SET status = 'CONFIRMED' WHERE route_id = %s", + (route_id,), + ) diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Router.py b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Router.py new file mode 100644 index 0000000..803cf06 --- /dev/null +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Router.py @@ -0,0 +1,164 @@ +"""B06 종횡닚 생성 FastAPI 띌우터.""" + +import asyncio +import logging +from pathlib import Path +from uuid import UUID + +from fastapi import APIRouter +from fastapi.responses import JSONResponse + +from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path +from B05_wf2_Route.B05_wf2_Route_Repository import get_latest_route +from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Engine import run_section_generation +from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Engine_Section import SectionGenerationOptions +from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Repository import ( + confirm_sections_for_route, + create_longitudinal_section, + delete_sections_for_route, + get_longitudinal_section, + insert_cross_sections, +) +from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Schema import ( + SectionConfirmResponse, + SectionGenerateRequest, + SectionGenerateResponse, + SectionSummaryResponse, +) +from common_util.common_util_storage import resolve_stored_project_path +from config.config_db import get_db_pool + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/projects", tags=["B06 Profile Cross"]) + + +def _build_options(request: SectionGenerateRequest) -> SectionGenerationOptions: + """요청의 옵션(믞지정은 config Ʞ볞값)윌로 SectionGenerationOptions륌 만든닀.""" + defaults = SectionGenerationOptions() + return SectionGenerationOptions( + station_interval_m=request.station_interval_m or defaults.station_interval_m, + cross_half_width_m=request.cross_half_width_m or defaults.cross_half_width_m, + cross_sample_interval_m=request.cross_sample_interval_m or defaults.cross_sample_interval_m, + long_sample_interval_m=request.long_sample_interval_m or defaults.long_sample_interval_m, + include_endpoint=defaults.include_endpoint, + ) + + +@router.post("/{project_id}/sections/generate", response_model=SectionGenerateResponse) +async def generate_sections( + project_id: UUID, request: SectionGenerateRequest +) -> SectionGenerateResponse | JSONResponse: + """확정 겜로에서 종횡닚을 생성·저장하고 DB에 Ʞ록한닀.""" + pool = get_db_pool() + try: + async with pool.acquire() as connection: + stored_path = await get_project_storage_relative_path(connection, project_id) + project_root = Path(resolve_stored_project_path(stored_path)) + + latest = await get_latest_route(connection, project_id) + if not latest or latest["id"] != request.route_id: + return JSONResponse( + status_code=404, + content={"status": "error", "message": "대상 겜로륌 찟을 수 없습니닀."}, + ) + route_data_path = latest["route_data_path"] + + options = _build_options(request) + design = await asyncio.to_thread( + run_section_generation, + project_root, + route_data_path, + request.filter_key, + request.method, + request.smooth, + options=options, + crs=request.crs, + ) + + await connection.begin() + try: + await delete_sections_for_route(connection, request.route_id) + longitudinal_id = await create_longitudinal_section( + connection, + project_id=project_id, + route_id=request.route_id, + data=design["longitudinal"]["data"], + longitudinal_file_path=design["longitudinal"]["file_path"], + ) + await insert_cross_sections( + connection, + project_id=project_id, + route_id=request.route_id, + sections=design["cross_sections"], + ) + await connection.commit() + except Exception: + await connection.rollback() + raise + + return SectionGenerateResponse( + project_id=str(project_id), + route_id=request.route_id, + longitudinal_id=longitudinal_id, + cross_section_count=len(design["cross_sections"]), + length_m=design["longitudinal"]["data"]["length_m"], + longitudinal_file_path=design["longitudinal"]["file_path"], + ) + except LookupError as exc: + return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)}) + except FileNotFoundError as exc: + return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)}) + except (OSError, ValueError) as exc: + return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)}) + except Exception: + logger.exception("B06 종횡닚 생성 싀팚: project_id=%s", project_id) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "종횡닚 생성 처늬 쀑 였류가 발생했습니닀."}, + ) + + +@router.get("/{project_id}/sections/{route_id}", response_model=SectionSummaryResponse) +async def get_sections(project_id: UUID, route_id: int) -> SectionSummaryResponse | JSONResponse: + """겜로의 종닚멎 요앜을 조회한닀.""" + pool = get_db_pool() + try: + async with pool.acquire() as connection: + longitudinal = await get_longitudinal_section(connection, project_id, route_id) + return SectionSummaryResponse( + project_id=str(project_id), route_id=route_id, longitudinal=longitudinal + ) + except Exception: + logger.exception("B06 종횡닚 조회 싀팚: project_id=%s", project_id) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "종횡닚 조회 쀑 였류가 발생했습니닀."}, + ) + + +@router.post("/{project_id}/sections/{route_id}/confirm", response_model=SectionConfirmResponse) +async def confirm_sections(project_id: UUID, route_id: int) -> SectionConfirmResponse | JSONResponse: + """겜로의 종횡닚멎을 확정(CONFIRMED)한닀.""" + pool = get_db_pool() + try: + async with pool.acquire() as connection: + existing = await get_longitudinal_section(connection, project_id, route_id) + if not existing: + return JSONResponse( + status_code=404, + content={"status": "error", "message": "확정할 종횡닚읎 없습니닀."}, + ) + await connection.begin() + try: + await confirm_sections_for_route(connection, route_id) + await connection.commit() + except Exception: + await connection.rollback() + raise + return SectionConfirmResponse(project_id=str(project_id), route_id=route_id) + except Exception: + logger.exception("B06 종횡닚 확정 싀팚: project_id=%s", project_id) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "종횡닚 확정 처늬 쀑 였류가 발생했습니닀."}, + ) diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Schema.py b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Schema.py new file mode 100644 index 0000000..85e088e --- /dev/null +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Schema.py @@ -0,0 +1,53 @@ +"""B06 종횡닚 생성 요청·응답 검슝 몚덞.""" + +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field + + +class SectionGenerateRequest(BaseModel): + """종횡닚 생성 싀행 요청.""" + + model_config = ConfigDict(extra="forbid") + + route_id: int = Field(gt=0, description="종횡닚을 생성할 확정 겜로 routes.id") + filter_key: str = Field(description="지멎 필터 í‚€ (grid_min_z/csf/pmf)") + method: str = Field(default="dtm", description="지표멎 표현") + smooth: bool = Field(default=False) + crs: str | None = Field(default=None, description="좌표계 (예: EPSG:5178)") + + # 잡점/횡닚 옵션 (믞지정 시 config Ʞ볞값) + station_interval_m: float | None = Field(default=None, gt=0) + cross_half_width_m: float | None = Field(default=None, gt=0) + cross_sample_interval_m: float | None = Field(default=None, gt=0) + long_sample_interval_m: float | None = Field(default=None, gt=0) + + +class SectionGenerateResponse(BaseModel): + """종횡닚 생성 결곌.""" + + status: str = "success" + project_id: str + route_id: int + longitudinal_id: int + cross_section_count: int + length_m: float + longitudinal_file_path: str + + +class SectionConfirmResponse(BaseModel): + """종횡닚 확정 결곌.""" + + status: str = "success" + project_id: str + route_id: int + confirmed: bool = True + + +class SectionSummaryResponse(BaseModel): + """종횡닚 요앜 조회 결곌.""" + + status: str = "success" + project_id: str + route_id: int + longitudinal: dict[str, Any] | None = None diff --git a/common_util/common_util_atomic.py b/common_util/common_util_atomic.py new file mode 100644 index 0000000..b593f5d --- /dev/null +++ b/common_util/common_util_atomic.py @@ -0,0 +1,55 @@ +"""바읎너늬·npz 파음을 원자적윌로 저장하는 공통 유틞늬티.""" + +import os +import tempfile +from pathlib import Path + +import numpy as np + + +def atomic_write_bytes(path: str | Path, payload: bytes) -> None: + """같은 디렉터늬의 임시 파음을 교첎하여 바읎튞륌 원자적윌로 저장한닀.""" + target = Path(path) + target.parent.mkdir(parents=True, exist_ok=True) + temporary_path: Path | None = None + try: + with tempfile.NamedTemporaryFile( + mode="wb", + dir=target.parent, + prefix=f".{target.name}.", + suffix=".tmp", + delete=False, + ) as temporary: + temporary.write(payload) + temporary.flush() + os.fsync(temporary.fileno()) + temporary_path = Path(temporary.name) + os.replace(temporary_path, target) + temporary_path = None + finally: + if temporary_path is not None: + temporary_path.unlink(missing_ok=True) + + +def atomic_write_npz(path: str | Path, **arrays: np.ndarray) -> None: + """같은 디렉터늬의 임시 파음을 교첎하여 압축 npz륌 원자적윌로 저장한닀.""" + target = Path(path) + target.parent.mkdir(parents=True, exist_ok=True) + temporary_path: Path | None = None + try: + with tempfile.NamedTemporaryFile( + mode="wb", + dir=target.parent, + prefix=f".{target.name}.", + suffix=".tmp", + delete=False, + ) as temporary: + np.savez_compressed(temporary, **arrays) + temporary.flush() + os.fsync(temporary.fileno()) + temporary_path = Path(temporary.name) + os.replace(temporary_path, target) + temporary_path = None + finally: + if temporary_path is not None: + temporary_path.unlink(missing_ok=True) diff --git a/common_util/common_util_json.py b/common_util/common_util_json.py new file mode 100644 index 0000000..123c98d --- /dev/null +++ b/common_util/common_util_json.py @@ -0,0 +1,35 @@ +"""JSON 파음을 안전하게 저장하는 공통 유틞늬티.""" + +import json +import os +import tempfile +from pathlib import Path +from typing import Any + + +def atomic_write_json(path: str | Path, value: Any) -> None: + """같은 디렉터늬의 임시 파음을 교첎하여 JSON을 원자적윌로 저장한닀.""" + target = Path(path) + target.parent.mkdir(parents=True, exist_ok=True) + temporary_path: Path | None = None + + try: + with tempfile.NamedTemporaryFile( + mode="w", + encoding="utf-8", + dir=target.parent, + prefix=f".{target.name}.", + suffix=".tmp", + delete=False, + ) as temporary: + json.dump(value, temporary, ensure_ascii=False, indent=2) + temporary.write("\n") + temporary.flush() + os.fsync(temporary.fileno()) + temporary_path = Path(temporary.name) + + os.replace(temporary_path, target) + temporary_path = None + finally: + if temporary_path is not None: + temporary_path.unlink(missing_ok=True) diff --git a/common_util/common_util_storage.py b/common_util/common_util_storage.py new file mode 100644 index 0000000..84e8850 --- /dev/null +++ b/common_util/common_util_storage.py @@ -0,0 +1,36 @@ +"""프로젝튞 영구저장소 겜로 유틞늬티.""" + +import os +from pathlib import PurePosixPath + +from config.config_system import PROJECT_STORAGE_STAGE_DIRS, STORAGE_BASE_DIR + + +def get_project_stage_path(project_root: str, stage: str) -> str: + """프로젝튞 룚튞 아래의 허용된 워크플로우 닚계 폎더륌 생성한닀.""" + if stage not in PROJECT_STORAGE_STAGE_DIRS: + raise ValueError(f"허용되지 않은 프로젝튞 저장 닚계입니닀: {stage}") + + root = os.path.abspath(project_root) + path = os.path.abspath(os.path.join(root, stage)) + if os.path.commonpath((root, path)) != root: + raise ValueError("닚계 저장 겜로가 프로젝튞 룚튞륌 벗얎났습니닀.") + + os.makedirs(path, exist_ok=True) + return path + + +def resolve_stored_project_path(relative_path: str) -> str: + """DB의 storage Ʞ쀀 상대 겜로륌 검슝핎 싀제 프로젝튞 겜로로 변환한닀.""" + normalized = PurePosixPath(relative_path.replace("\\", "/")) + if normalized.is_absolute() or ".." in normalized.parts: + raise ValueError("프로젝튞 저장 겜로는 안전한 상대 겜로여알 합니닀.") + if not normalized.parts or normalized.parts[0] != "storage": + raise ValueError("프로젝튞 저장 겜로는 storage/로 시작핎알 합니닀.") + + storage_root = os.path.abspath(STORAGE_BASE_DIR) + path = os.path.abspath(os.path.join(storage_root, *normalized.parts[1:])) + if os.path.commonpath((storage_root, path)) != storage_root or path == storage_root: + raise ValueError("프로젝튞 저장 겜로가 저장소 룚튞륌 벗얎났습니닀.") + os.makedirs(path, exist_ok=True) + return path diff --git a/common_util/common_util_workflow.py b/common_util/common_util_workflow.py new file mode 100644 index 0000000..c204fe0 --- /dev/null +++ b/common_util/common_util_workflow.py @@ -0,0 +1,42 @@ +"""프로젝튞 간 공유되는 워크플로우 상태 유틞늬티.""" + +import json +import threading +from pathlib import Path +from typing import Any + +from common_util.common_util_json import atomic_write_json + +_WORKFLOW_LOCK = threading.Lock() + + +def load_project_workflow(project_root: str | Path) -> dict[str, Any]: + """프로젝튞 룚튞의 workflow.json을 읜고 없윌멎 쎈Ʞ 상태륌 반환한닀.""" + workflow_path = Path(project_root) / "workflow.json" + if not workflow_path.exists(): + return { + "current_stage": "scan", + "completed": [], + "stale_from": None, + "stage1_confirmed": None, + } + + with workflow_path.open("r", encoding="utf-8") as workflow_file: + workflow = json.load(workflow_file) + if not isinstance(workflow, dict): + raise ValueError("workflow.json의 최상위 값은 객첎여알 합니닀.") + return workflow + + +def patch_project_workflow_stale(project_root: str | Path, stale_from: str | None) -> None: + """Ʞ졎 workflow.json의 닀륞 상태륌 볎졎하며 stale_from만 갱신한닀.""" + workflow_path = Path(project_root) / "workflow.json" + if not workflow_path.exists(): + return + + with _WORKFLOW_LOCK: + workflow = load_project_workflow(project_root) + if workflow.get("stale_from") == stale_from: + return + workflow["stale_from"] = stale_from + atomic_write_json(workflow_path, workflow) diff --git a/config/config_db.py b/config/config_db.py index 3c01408..e174030 100644 --- a/config/config_db.py +++ b/config/config_db.py @@ -1,50 +1,55 @@ """ config_db.py -데읎터베읎슀 연결 섀정 (PostgreSQL + asyncpg) +데읎터베읎슀 연결 섀정 (MariaDB + aiomysql) 비동Ʞ 연결 풀 생성 및 ꎀ늬. """ -import asyncpg from typing import Optional + +import aiomysql + from .config_system import ( DB_HOST, - DB_PORT, DB_NAME, - DB_USER, DB_PASSWORD, - DB_POOL_MIN, DB_POOL_MAX, + DB_POOL_MIN, + DB_PORT, + DB_USER, ) # Ꞁ로벌 DB 풀 (앱 시작/종료 시 ꎀ늬) -db_pool: Optional[asyncpg.Pool] = None +db_pool: Optional[aiomysql.Pool] = None -async def init_db_pool() -> asyncpg.Pool: - """PostgreSQL 연결 풀 쎈Ʞ화""" +async def init_db_pool() -> aiomysql.Pool: + """MariaDB 연결 풀 쎈Ʞ화""" global db_pool - db_pool = await asyncpg.create_pool( + db_pool = await aiomysql.create_pool( host=DB_HOST, port=DB_PORT, - database=DB_NAME, + db=DB_NAME, user=DB_USER, password=DB_PASSWORD, - min_size=DB_POOL_MIN, - max_size=DB_POOL_MAX, + minsize=DB_POOL_MIN, + maxsize=DB_POOL_MAX, + autocommit=False, + charset="utf8mb4", ) return db_pool async def close_db_pool() -> None: - """PostgreSQL 연결 풀 종료""" + """MariaDB 연결 풀 종료""" global db_pool if db_pool: - await db_pool.close() + db_pool.close() + await db_pool.wait_closed() db_pool = None -def get_db_pool() -> asyncpg.Pool: +def get_db_pool() -> aiomysql.Pool: """현재 활성 DB 풀 반환. 없윌멎 RuntimeError""" if not db_pool: raise RuntimeError("DB pool not initialized. Call init_db_pool() first.") diff --git a/config/config_frontend.ts b/config/config_frontend.ts index 87dcbf0..3e5543a 100644 --- a/config/config_frontend.ts +++ b/config/config_frontend.ts @@ -19,12 +19,18 @@ export const API_TIMEOUT_MS = 30_000; /** 읞슝 토큰을 저장할 localStorage í‚€ */ export const AUTH_TOKEN_KEY = "frd_auth_token"; +/** B03~B09 워크플로우에서 사용할 현재 프로젝튞 UUID 저장 í‚€ */ +export const CURRENT_PROJECT_ID_KEY = "frd_current_project_id"; + /* ----------------------------------------------------------------------------- * 2. 파음 업로드 제한 (B03_FileInput) * -------------------------------------------------------------------------- */ /** 닚음 파음 최대 크Ʞ (MB) */ export const UPLOAD_MAX_MB = 500; +/** 한 요청에서 선택 가능한 최대 파음 수 */ +export const UPLOAD_MAX_FILES = 20; + /** 허용 확장자 (지형/포읞튞큎띌우드/도멎) */ export const UPLOAD_ALLOWED_EXT = [ ".las", diff --git a/config/config_system.py b/config/config_system.py index 9280032..4b637b1 100644 --- a/config/config_system.py +++ b/config/config_system.py @@ -6,6 +6,7 @@ config_system.py """ import os + from dotenv import load_dotenv load_dotenv() @@ -27,15 +28,15 @@ STATIC_URL = "/static" LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO") # ───────────────────────────────────────────────────────────────────────── -# 3. 데읎터베읎슀 (PostgreSQL + PostGIS) +# 3. 데읎터베읎슀 (MariaDB) # ───────────────────────────────────────────────────────────────────────── DB_HOST = os.getenv("DB_HOST", "localhost") -DB_PORT = int(os.getenv("DB_PORT", "5432")) -DB_NAME = os.getenv("DB_NAME", "forest_road") -DB_USER = os.getenv("DB_USER", "postgres") -DB_PASSWORD = os.getenv("DB_PASSWORD", "postgres") +DB_PORT = int(os.getenv("DB_PORT", "3306")) +DB_NAME = os.getenv("DB_NAME", "aislo_db") +DB_USER = os.getenv("DB_USER", "aislo") +DB_PASSWORD = os.getenv("DB_PASSWORD", "aislo") -# asyncpg 연결 풀 섀정 +# asyncmy 연결 풀 섀정 DB_POOL_MIN = int(os.getenv("DB_POOL_MIN", "5")) DB_POOL_MAX = int(os.getenv("DB_POOL_MAX", "20")) @@ -43,6 +44,8 @@ DB_POOL_MAX = int(os.getenv("DB_POOL_MAX", "20")) # 4. 파음 업로드 제한 # ───────────────────────────────────────────────────────────────────────── UPLOAD_MAX_MB = int(os.getenv("UPLOAD_MAX_MB", "500")) +UPLOAD_MAX_FILES = int(os.getenv("UPLOAD_MAX_FILES", "20")) +UPLOAD_CHUNK_SIZE_BYTES = int(os.getenv("UPLOAD_CHUNK_SIZE_BYTES", str(1024 * 1024))) UPLOAD_ALLOWED_EXT = [".las", ".laz", ".dem", ".tif", ".tiff", ".tfw", ".prj", ".dxf", ".dwg"] # ───────────────────────────────────────────────────────────────────────── @@ -51,22 +54,218 @@ UPLOAD_ALLOWED_EXT = [".las", ".laz", ".dem", ".tif", ".tiff", ".tfw", ".prj", " # Trimesh 메쉬 생성 MESH_GRID_SIZE = float(os.getenv("MESH_GRID_SIZE", "1.0")) # 믞터 닚위 MESH_SMOOTHING_ITERATIONS = int(os.getenv("MESH_SMOOTHING_ITERATIONS", "0")) +SURFACE_LAS_CHUNK_SIZE = int(os.getenv("SURFACE_LAS_CHUNK_SIZE", "500000")) +SURFACE_DEFAULT_RGB_VALUE = int(os.getenv("SURFACE_DEFAULT_RGB_VALUE", "128")) +SURFACE_GRID_CELL_SIZE_M = float(os.getenv("SURFACE_GRID_CELL_SIZE_M", "2.0")) +SURFACE_GRID_HEIGHT_THRESHOLD_M = float(os.getenv("SURFACE_GRID_HEIGHT_THRESHOLD_M", "1.5")) + +# CSF (Cloth Simulation Filter) 지멎 분류 파띌믞터 +SURFACE_CSF_CLOTH_RESOLUTION_M = float(os.getenv("SURFACE_CSF_CLOTH_RESOLUTION_M", "1.5")) +SURFACE_CSF_RIGIDNESS = int(os.getenv("SURFACE_CSF_RIGIDNESS", "1")) +SURFACE_CSF_TIME_STEP = float(os.getenv("SURFACE_CSF_TIME_STEP", "0.65")) +SURFACE_CSF_CLASS_THRESHOLD_M = float(os.getenv("SURFACE_CSF_CLASS_THRESHOLD_M", "0.5")) +SURFACE_CSF_ITERATIONS = int(os.getenv("SURFACE_CSF_ITERATIONS", "150")) +SURFACE_CSF_SLOPE_SMOOTH = os.getenv("SURFACE_CSF_SLOPE_SMOOTH", "True").lower() == "true" +SURFACE_CSF_SLOPE_SMOOTH_THRESHOLD_M = float( + os.getenv("SURFACE_CSF_SLOPE_SMOOTH_THRESHOLD_M", "1.8") +) + +# PMF (Progressive Morphological Filter) 지멎 분류 파띌믞터 +SURFACE_PMF_CELL_SIZE_M = float(os.getenv("SURFACE_PMF_CELL_SIZE_M", "2.0")) +SURFACE_PMF_MAX_WINDOW_SIZE = int(os.getenv("SURFACE_PMF_MAX_WINDOW_SIZE", "40")) +SURFACE_PMF_INITIAL_WINDOW_SIZE = int(os.getenv("SURFACE_PMF_INITIAL_WINDOW_SIZE", "3")) +SURFACE_PMF_SLOPE = float(os.getenv("SURFACE_PMF_SLOPE", "1.0")) +SURFACE_PMF_MAX_DISTANCE_M = float(os.getenv("SURFACE_PMF_MAX_DISTANCE_M", "2.5")) + +# RANSAC (Local plane fitting) 지멎 분류 파띌믞터 +SURFACE_RANSAC_DISTANCE_THRESHOLD_M = float(os.getenv("SURFACE_RANSAC_DISTANCE_THRESHOLD_M", "0.3")) +SURFACE_RANSAC_N = int(os.getenv("SURFACE_RANSAC_N", "3")) +SURFACE_RANSAC_ITERATIONS = int(os.getenv("SURFACE_RANSAC_ITERATIONS", "100")) +SURFACE_RANSAC_LOCAL_GRID_SIZE_M = float(os.getenv("SURFACE_RANSAC_LOCAL_GRID_SIZE_M", "10.0")) +SURFACE_RANSAC_SEED = int(os.getenv("SURFACE_RANSAC_SEED", "42")) + +# ───────────────────────────────────────────────────────────────────────── +# 5-2. 지표멎 몚덞 생성 파띌믞터 (TIN/DTM/NURBS/implicit/meshfree) +# ───────────────────────────────────────────────────────────────────────── +# 빌드 대상 지멎 필터·표현 방식 +SURFACE_MODEL_SOURCE_FILTERS = tuple( + os.getenv("SURFACE_MODEL_SOURCE_FILTERS", "grid_min_z,csf,pmf").split(",") +) +SURFACE_MODEL_PRECOMPUTE = tuple( + os.getenv("SURFACE_MODEL_PRECOMPUTE", "tin,dtm,nurbs,implicit,meshfree").split(",") +) +SURFACE_MODEL_SMOOTHING_METHODS = tuple( + os.getenv("SURFACE_MODEL_SMOOTHING_METHODS", "dtm,tin").split(",") +) +SURFACE_MODEL_SYNC_TIMEOUT_SECONDS = int(os.getenv("SURFACE_MODEL_SYNC_TIMEOUT_SECONDS", "0")) + +# footprint(왞곜) 산출 +SURFACE_FOOTPRINT_RESOLUTION_M = float(os.getenv("SURFACE_FOOTPRINT_RESOLUTION_M", "1.0")) +SURFACE_FOOTPRINT_GAP_CLOSE_M = float(os.getenv("SURFACE_FOOTPRINT_GAP_CLOSE_M", "1.0")) +SURFACE_BOUNDARY_INSET_M = float(os.getenv("SURFACE_BOUNDARY_INSET_M", "1.0")) +SURFACE_KEEP_LARGEST_FOOTPRINT = ( + os.getenv("SURFACE_KEEP_LARGEST_FOOTPRINT", "True").lower() == "true" +) +SURFACE_TILE_SIZE_M = float(os.getenv("SURFACE_TILE_SIZE_M", "50.0")) +SURFACE_MAX_PREVIEW_VERTICES = int(os.getenv("SURFACE_MAX_PREVIEW_VERTICES", "120000")) + +# 표현별 파띌믞터 +SURFACE_TIN_MAX_INPUT_POINTS = int(os.getenv("SURFACE_TIN_MAX_INPUT_POINTS", "200000")) +SURFACE_DTM_GRID_RESOLUTION_M = float(os.getenv("SURFACE_DTM_GRID_RESOLUTION_M", "1.0")) +SURFACE_NURBS_DEGREE = int(os.getenv("SURFACE_NURBS_DEGREE", "3")) +SURFACE_NURBS_PATCH_SIZE_M = float(os.getenv("SURFACE_NURBS_PATCH_SIZE_M", "50.0")) +SURFACE_NURBS_CONTROL_POINTS_PER_AXIS = int( + os.getenv("SURFACE_NURBS_CONTROL_POINTS_PER_AXIS", "16") +) +SURFACE_IMPLICIT_MAX_POINTS_PER_TILE = int( + os.getenv("SURFACE_IMPLICIT_MAX_POINTS_PER_TILE", "20000") +) +SURFACE_IMPLICIT_SMOOTHING = float(os.getenv("SURFACE_IMPLICIT_SMOOTHING", "0.5")) +SURFACE_MESHFREE_MAX_MODEL_POINTS = int(os.getenv("SURFACE_MESHFREE_MAX_MODEL_POINTS", "300000")) +SURFACE_MESHFREE_POINT_RADIUS_M = float(os.getenv("SURFACE_MESHFREE_POINT_RADIUS_M", "0.5")) + +# 슀묎딩 파띌믞터 +SURFACE_SMOOTHING_DTM_SIGMA_M = float(os.getenv("SURFACE_SMOOTHING_DTM_SIGMA_M", "0.5")) +SURFACE_SMOOTHING_DTM_SPLINE_SMOOTH = float(os.getenv("SURFACE_SMOOTHING_DTM_SPLINE_SMOOTH", "0.0")) +SURFACE_SMOOTHING_DTM_PREVIEW_RESOLUTION_M = float( + os.getenv("SURFACE_SMOOTHING_DTM_PREVIEW_RESOLUTION_M", "0.5") +) +SURFACE_SMOOTHING_TIN_TAUBIN_ITERATIONS = int( + os.getenv("SURFACE_SMOOTHING_TIN_TAUBIN_ITERATIONS", "10") +) +SURFACE_SMOOTHING_TIN_TAUBIN_LAMBDA = float(os.getenv("SURFACE_SMOOTHING_TIN_TAUBIN_LAMBDA", "0.5")) +SURFACE_SMOOTHING_TIN_TAUBIN_MU = float(os.getenv("SURFACE_SMOOTHING_TIN_TAUBIN_MU", "-0.53")) + +# 등고선 파띌믞터 +SURFACE_CONTOUR_INTERVAL_M = float(os.getenv("SURFACE_CONTOUR_INTERVAL_M", "5.0")) +SURFACE_CONTOUR_GRID_RESOLUTION_M = float(os.getenv("SURFACE_CONTOUR_GRID_RESOLUTION_M", "1.0")) + + +def build_surface_model_config() -> dict: + """지표멎 몚덞 파읎프띌읞읎 사용하는 config dict륌 조늜한닀.""" + return { + "source_filters": list(SURFACE_MODEL_SOURCE_FILTERS), + "precompute": list(SURFACE_MODEL_PRECOMPUTE), + "smoothing_methods": tuple(SURFACE_MODEL_SMOOTHING_METHODS), + "sync_timeout_seconds": SURFACE_MODEL_SYNC_TIMEOUT_SECONDS, + "footprint_resolution_meters": SURFACE_FOOTPRINT_RESOLUTION_M, + "footprint_gap_close_meters": SURFACE_FOOTPRINT_GAP_CLOSE_M, + "boundary_inset_meters": SURFACE_BOUNDARY_INSET_M, + "keep_largest_footprint": SURFACE_KEEP_LARGEST_FOOTPRINT, + "tile_size_meters": SURFACE_TILE_SIZE_M, + "max_preview_vertices": SURFACE_MAX_PREVIEW_VERTICES, + "tin_max_input_points": SURFACE_TIN_MAX_INPUT_POINTS, + "dtm_grid_resolution_meters": SURFACE_DTM_GRID_RESOLUTION_M, + "nurbs_degree": SURFACE_NURBS_DEGREE, + "nurbs_patch_size_meters": SURFACE_NURBS_PATCH_SIZE_M, + "nurbs_control_points_per_axis": SURFACE_NURBS_CONTROL_POINTS_PER_AXIS, + "implicit_max_points_per_tile": SURFACE_IMPLICIT_MAX_POINTS_PER_TILE, + "implicit_smoothing": SURFACE_IMPLICIT_SMOOTHING, + "meshfree_max_model_points": SURFACE_MESHFREE_MAX_MODEL_POINTS, + "meshfree_point_radius_meters": SURFACE_MESHFREE_POINT_RADIUS_M, + "smoothing_dtm_sigma_meters": SURFACE_SMOOTHING_DTM_SIGMA_M, + "smoothing_dtm_spline_smooth": SURFACE_SMOOTHING_DTM_SPLINE_SMOOTH, + "smoothing_dtm_preview_resolution_meters": SURFACE_SMOOTHING_DTM_PREVIEW_RESOLUTION_M, + "smoothing_tin_taubin_iterations": SURFACE_SMOOTHING_TIN_TAUBIN_ITERATIONS, + "smoothing_tin_taubin_lambda": SURFACE_SMOOTHING_TIN_TAUBIN_LAMBDA, + "smoothing_tin_taubin_mu": SURFACE_SMOOTHING_TIN_TAUBIN_MU, + "contour_interval_meters": SURFACE_CONTOUR_INTERVAL_M, + "contour_grid_resolution_meters": SURFACE_CONTOUR_GRID_RESOLUTION_M, + } + + +# ───────────────────────────────────────────────────────────────────────── +# 5-3. 겜로 섀계 파띌믞터 (B05 WF2) +# ───────────────────────────────────────────────────────────────────────── +# 비용 핚수 가쀑치 +ROUTE_W_DIST = float(os.getenv("ROUTE_W_DIST", "1.0")) +ROUTE_W_GRADE = float(os.getenv("ROUTE_W_GRADE", "2.0")) +ROUTE_W_SIDE = float(os.getenv("ROUTE_W_SIDE", "1.5")) +ROUTE_W_CURVE = float(os.getenv("ROUTE_W_CURVE", "0.5")) +ROUTE_W_AVOID = float(os.getenv("ROUTE_W_AVOID", "10.0")) +ROUTE_WEIGHT_MAX = float(os.getenv("ROUTE_WEIGHT_MAX", "1000.0")) + +# 격자·겜사·비용 제앜 +ROUTE_MAX_GRADE = float(os.getenv("ROUTE_MAX_GRADE", "0.14")) +ROUTE_MAX_GRADE_PAVED = float(os.getenv("ROUTE_MAX_GRADE_PAVED", "0.18")) +ROUTE_GRID_RES_M = float(os.getenv("ROUTE_GRID_RES_M", "2.0")) +ROUTE_AVOID_DEFAULT_RADIUS_M = float(os.getenv("ROUTE_AVOID_DEFAULT_RADIUS_M", "25.0")) +ROUTE_DEFAULT_GRADE_CLASS = os.getenv("ROUTE_DEFAULT_GRADE_CLASS", "trunk") +ROUTE_MAX_COST_CELLS = int(os.getenv("ROUTE_MAX_COST_CELLS", "4000000")) +ROUTE_REQUIRED_POINT_TOLERANCE_M = float(os.getenv("ROUTE_REQUIRED_POINT_TOLERANCE_M", "1.0")) +ROUTE_GRADE_CLASSES = ("trunk", "branch", "work") + +# 임도 등꞉별 종닚겜사 한계 및 최소 곡선반지늄 +FOREST_ROAD_MAX_GRADE = {"trunk": 0.26, "branch": 0.28, "work": 0.40} +FOREST_ROAD_MIN_CURVE_R_M = {"trunk": 12.0, "branch": 10.0, "work": 6.0} + +# 대안(정속겜사) 파띌믞터 +ROUTE_ALT_MIN_GRADE = float(os.getenv("ROUTE_ALT_MIN_GRADE", "0.08")) +ROUTE_ALT_MAX_GRADE = float(os.getenv("ROUTE_ALT_MAX_GRADE", "0.14")) +ROUTE_ALT_GRADE_TOLERANCE = float(os.getenv("ROUTE_ALT_GRADE_TOLERANCE", "0.005")) + +# 지형 skeleton (능선/계곡) 파띌믞터 +SKELETON_VALLEY_ACC_THRESHOLD_CELLS = int(os.getenv("SKELETON_VALLEY_ACC_THRESHOLD_CELLS", "500")) +SKELETON_MAIN_VALLEY_ACC_THRESHOLD_CELLS = int( + os.getenv("SKELETON_MAIN_VALLEY_ACC_THRESHOLD_CELLS", "5000") +) +SKELETON_RIDGE_ACC_THRESHOLD_CELLS = int(os.getenv("SKELETON_RIDGE_ACC_THRESHOLD_CELLS", "500")) +SKELETON_MAIN_RIDGE_ACC_THRESHOLD_CELLS = int( + os.getenv("SKELETON_MAIN_RIDGE_ACC_THRESHOLD_CELLS", "5000") +) +SKELETON_NODE_SPACING_M = float(os.getenv("SKELETON_NODE_SPACING_M", "10.0")) + + +# ───────────────────────────────────────────────────────────────────────── +# 5-4. 종횡닚 생성 파띌믞터 (B06 WF3) +# ───────────────────────────────────────────────────────────────────────── +SECTION_STATION_INTERVAL_M = float(os.getenv("SECTION_STATION_INTERVAL_M", "20.0")) +SECTION_CROSS_HALF_WIDTH_M = float(os.getenv("SECTION_CROSS_HALF_WIDTH_M", "15.0")) +SECTION_CROSS_SAMPLE_INTERVAL_M = float(os.getenv("SECTION_CROSS_SAMPLE_INTERVAL_M", "0.5")) +SECTION_LONG_SAMPLE_INTERVAL_M = float(os.getenv("SECTION_LONG_SAMPLE_INTERVAL_M", "1.0")) +SECTION_INCLUDE_ENDPOINT = os.getenv("SECTION_INCLUDE_ENDPOINT", "True").lower() == "true" -# PostGIS 공간 연산 -SPATIAL_INDEX_ENABLED = os.getenv("SPATIAL_INDEX_ENABLED", "True").lower() == "true" # ───────────────────────────────────────────────────────────────────────── # 6. 저장소 겜로 # ───────────────────────────────────────────────────────────────────────── STORAGE_BASE_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "storage") +PROJECT_STORAGE_STAGE_DIRS = frozenset( + { + "B03_FileInput", + "B04_wf1_Surface", + "B05_wf2_Route", + "B06_wf3_ProfileCross", + "B07_wf4_DesignDetail", + "B08_wf5_Quantity", + "B09_wf6_Estimation", + } +) + # 프로젝튞별 저장 구조: storage/[고객사]/[사용자]/[프로젝튞ID]/ def get_project_storage_path(company: str, user: str, project_id: str) -> str: - """프로젝튞 저장 겜로 생성 (자동 생성)""" - path = os.path.join(STORAGE_BASE_DIR, "projects", company, user, project_id) + """검슝된 식별자로 프로젝튞 저장 겜로륌 생성한닀.""" + segments = (company, user, project_id) + for segment in segments: + if ( + not segment + or segment in {".", ".."} + or os.path.isabs(segment) + or "/" in segment + or "\\" in segment + ): + raise ValueError("저장 겜로 식별자는 비얎 있거나 겜로 구분자륌 포핚할 수 없습니닀.") + + storage_root = os.path.abspath(STORAGE_BASE_DIR) + path = os.path.abspath(os.path.join(storage_root, *segments)) + if os.path.commonpath((storage_root, path)) != storage_root: + raise ValueError("프로젝튞 저장 겜로가 저장소 룚튞륌 벗얎났습니닀.") + os.makedirs(path, exist_ok=True) return path + # ───────────────────────────────────────────────────────────────────────── # 7. 읞슝 (JWT) # ───────────────────────────────────────────────────────────────────────── diff --git a/db_management/001_create_schema.sql b/db_management/001_create_schema.sql new file mode 100644 index 0000000..8cf2695 --- /dev/null +++ b/db_management/001_create_schema.sql @@ -0,0 +1,516 @@ +-- ============================================================================= +-- MariaDB 데읎터베읎슀 슀킀마 생성 SQL (였류 수정 완) +-- Aislo (아읎슬로) — AI + Slotti 산늌 도로 섀계 자동화 시슀템 +-- +-- DB 명: aislo_db +-- DBMS: MariaDB 10.6+ +-- 싀행 순서: +-- 1. DATABASE 생성 (aislo_db) +-- 2. CREATE TABLE (각 테읎랔) +-- 3. CREATE INDEX (읞덱싱) +-- 4. ALTER TABLE ADD CONSTRAINT (왞래킀) +-- ============================================================================= + +-- 0. 데읎터베읎슀 생성 +-- ============================================================================= +CREATE DATABASE IF NOT EXISTS aislo_db + CHARACTER SET utf8mb4 + COLLATE utf8mb4_unicode_ci; + +USE aislo_db; + + +-- 1. 테읎랔 생성 (순서 쀑요: FK ì°žì¡° 테읎랔읎 뚌저 생성되얎알 핹) +-- ============================================================================= + +/* --------- 1-1. 사용자 & 읞슝 귞룹 --------- */ + +-- users: 시슀템 사용자 +CREATE TABLE IF NOT EXISTS users ( + id INT AUTO_INCREMENT PRIMARY KEY, + email VARCHAR(255) NOT NULL UNIQUE, + password_hash VARCHAR(255) NOT NULL, + name VARCHAR(255) NOT NULL, + position VARCHAR(100), -- 직꞉ (곌장, 대늬, 사원) + department VARCHAR(100), -- 부서명 (섀계팀, 영업팀) + phone VARCHAR(20), -- 연띜처 (010-1234-5678) + company_id INT, -- 소속 회사 (FK는 later) + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted_at TIMESTAMP NULL DEFAULT NULL -- 소프튞 삭제 +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- companies: 회사 정볎 +CREATE TABLE IF NOT EXISTS companies ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(255) NOT NULL UNIQUE, + business_registration_number VARCHAR(20) UNIQUE, -- 사업자등록번혞 + business_address VARCHAR(255), -- 사업장 죌소 + business_owner VARCHAR(100), -- 사업죌 읎늄 + business_status VARCHAR(50), -- êž°ì—… 상태 (활동쀑, 폐업, 휎업) + created_by INT, -- 생성자 (FK는 later) + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted_at TIMESTAMP NULL DEFAULT NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + + +/* --------- 1-2. 프로젝튞 귞룹 --------- */ + +-- projects: 섀계 프로젝튞 +CREATE TABLE IF NOT EXISTS projects ( + id CHAR(36) PRIMARY KEY COMMENT 'UUID', + user_id INT NOT NULL, -- 프로젝튞 소유자 (FK는 later) + company_id INT NOT NULL, -- 소속 회사 (FK는 later) + name VARCHAR(255) NOT NULL, + region VARCHAR(100), -- 지역명 (ìšžì§„êµ° ꞈ강송멎) + road_type VARCHAR(100), -- 임도 종류 (간선임도, 지선임도, 산불진화임도, 계류볎전) + project_year INT, -- 사업 연도 + estimated_length_m FLOAT, -- 추정 연장 (m) + memo TEXT, + status VARCHAR(50) DEFAULT 'NEW', -- NEW, FILE_UPLOADED, WF1_ANALYZING, ..., DONE + crs_epsg INT DEFAULT 5178, -- 좌표계 (한국 표쀀) + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted_at TIMESTAMP NULL DEFAULT NULL, + storage_path VARCHAR(500) -- storage/회사/사용자/project_uuid +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- project_versions: 프로젝튞 버전 읎력 +CREATE TABLE IF NOT EXISTS project_versions ( + id INT AUTO_INCREMENT PRIMARY KEY, + project_id CHAR(36) NOT NULL, -- FK는 later + version_num INT NOT NULL, + snapshot_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + status VARCHAR(50), + data JSON, -- 섀계 데읎터 슀냅샷 + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + + +/* --------- 1-3. 파음 & 입력 데읎터 귞룹 --------- */ + +-- input_files: 사용자 업로드 원볞 파음 +CREATE TABLE IF NOT EXISTS input_files ( + id INT AUTO_INCREMENT PRIMARY KEY, + project_id CHAR(36) NOT NULL, -- FK는 later + file_type VARCHAR(50), -- las, tif, tfw, prj, dxf, dwg, other + original_filename VARCHAR(255) NOT NULL, + raw_file_path VARCHAR(500) NOT NULL, -- storage/.../B03_FileInput/input/... + file_size_mb FLOAT, + upload_by INT, -- FK는 later + upload_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + crs_epsg INT, + metadata JSON, -- resolution_m, data_range, ... + status VARCHAR(50) DEFAULT 'UPLOADED' -- UPLOADED, PROCESSED, ARCHIVED +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- processed_point_cloud: 변환된 포읞튞큎띌우드 데읎터 +CREATE TABLE IF NOT EXISTS processed_point_cloud ( + id INT AUTO_INCREMENT PRIMARY KEY, + input_file_id INT NOT NULL, -- FK는 later + project_id CHAR(36) NOT NULL, -- FK는 later + process_type VARCHAR(50), -- filtered, sampled, classified + processed_file_path VARCHAR(500), -- storage/.../B04_wf1_Surface/processed/... + converted_format VARCHAR(50), -- ply, laz, las 등 + converted_file_path VARCHAR(500), -- storage/.../B04_wf1_Surface/processed/... + point_count INT, + min_z FLOAT, + max_z FLOAT, + mean_z FLOAT, + x_min FLOAT, + x_max FLOAT, + y_min FLOAT, + y_max FLOAT, + density_per_sqm FLOAT, + classification_summary JSON, -- ground, vegetation, building, ... + processing_params JSON, + processed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + status VARCHAR(50) DEFAULT 'PROCESSING' -- PROCESSING, COMPLETE, FAILED +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + + +/* --------- 1-4. 지표멎 & 분석 결곌 귞룹 --------- */ + +-- surface_models: WF1 지표멎 분석 결곌 +CREATE TABLE IF NOT EXISTS surface_models ( + id INT AUTO_INCREMENT PRIMARY KEY, + project_id CHAR(36) NOT NULL, -- FK는 later + model_type VARCHAR(50), -- dem_grid, tin, mesh_triangulated, contour_lines + source_file_id INT, -- FK는 later + processed_cloud_id INT, -- FK는 later + status VARCHAR(50) DEFAULT 'PROCESSING', -- PROCESSING, COMPLETE, FAILED + crs_epsg INT, + resolution_m FLOAT, + model_file_path VARCHAR(500), -- storage/.../B04_wf1_Surface/models/... + generation_params JSON, -- filter_type, algorithm, params + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + completed_at TIMESTAMP NULL DEFAULT NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- terrain_layers: WF1 지형 레읎얎 +CREATE TABLE IF NOT EXISTS terrain_layers ( + id INT AUTO_INCREMENT PRIMARY KEY, + surface_model_id INT NOT NULL, -- FK는 later + layer_name VARCHAR(100), -- 지표, 제1ìžµ, 제2ìžµ + geometry_type VARCHAR(50), -- POINTCLOUD, GRID, MESH, CONTOUR + layer_file_path VARCHAR(500), -- storage/.../B04_wf1_Surface/models/... + file_format VARCHAR(50), -- geojson, geotiff, ply, obj + file_size_mb FLOAT, + statistics JSON, -- min_z, max_z, mean_slope, point_count + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + + +/* --------- 1-5. 겜로 & 섀계 귞룹 --------- */ + +-- routes: WF2 겜로 섀계 결곌 +CREATE TABLE IF NOT EXISTS routes ( + id INT AUTO_INCREMENT PRIMARY KEY, + project_id CHAR(36) NOT NULL, -- FK는 later + surface_model_id INT, -- FK는 later + status VARCHAR(50) DEFAULT 'DRAFT', -- DRAFT, CONFIRMED, ARCHIVED + start_chainage_m FLOAT, + end_chainage_m FLOAT, + total_length_m FLOAT, + grade_percent JSON, -- [2.5, 1.8, 3.2, ...] 각 구간 겜사도 + constraints JSON, -- max_grade, min_radius, avoidance_zones + algorithm_params JSON, -- cost_weights, ... + route_data_path VARCHAR(500), -- storage/.../B05_wf2_Route/route/... + computed_at TIMESTAMP NULL DEFAULT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- route_points: 웹 렌더링용 겜로 포읞튞 샘플 +CREATE TABLE IF NOT EXISTS route_points ( + id INT AUTO_INCREMENT PRIMARY KEY, + route_id INT NOT NULL, -- FK는 later + chainage_m FLOAT, + elevation_m FLOAT, + slope_percent FLOAT, + sequence_num INT +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- route_statistics: 겜로 통계 +CREATE TABLE IF NOT EXISTS route_statistics ( + id INT AUTO_INCREMENT PRIMARY KEY, + route_id INT NOT NULL, -- FK는 later + min_slope FLOAT, + max_slope FLOAT, + mean_slope FLOAT, + cut_volume_m3 FLOAT, + fill_volume_m3 FLOAT, + tree_cutting_volume FLOAT, + cost_score FLOAT +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + + +/* --------- 1-6. 종닚멎 & 횡닚멎 귞룹 --------- */ + +-- longitudinal_sections: WF3 종닚멎 +CREATE TABLE IF NOT EXISTS longitudinal_sections ( + id INT AUTO_INCREMENT PRIMARY KEY, + project_id CHAR(36) NOT NULL, -- FK는 later + route_id INT NOT NULL, -- FK는 later + computed_at TIMESTAMP NULL DEFAULT NULL, + data JSON, -- chainages, elevations, grades, design_elevations + longitudinal_file_path VARCHAR(500), -- storage/.../B06_wf3_ProfileCross/longitudinal/... + status VARCHAR(50) DEFAULT 'DRAFT' -- DRAFT, CONFIRMED +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- cross_sections: WF3 횡닚멎 +CREATE TABLE IF NOT EXISTS cross_sections ( + id INT AUTO_INCREMENT PRIMARY KEY, + project_id CHAR(36) NOT NULL, -- FK는 later + route_id INT NOT NULL, -- FK는 later + chainage_m FLOAT, + sequence_num INT, + data JSON, -- left_slope, right_slope, width_m, cut_volume_m3, fill_volume_m3, structures, notes + cross_section_file_path VARCHAR(500), -- storage/.../B06_wf3_ProfileCross/cross_sections/... + status VARCHAR(50) DEFAULT 'DRAFT' -- DRAFT, CONFIRMED +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + + +/* --------- 1-7. 섀계 & 구조묌 귞룹 --------- */ + +-- structures: WF4 상섞 섀계 - 구조묌 +CREATE TABLE IF NOT EXISTS structures ( + id INT AUTO_INCREMENT PRIMARY KEY, + project_id CHAR(36) NOT NULL, -- FK는 later + cross_section_id INT, -- FK는 later + structure_type VARCHAR(100), -- 낙석방지책, 돌붙임, 계간수로, 낙찚공 + chainage_m FLOAT, + location VARCHAR(50), -- LEFT, CENTER, RIGHT + length_m FLOAT, + width_m FLOAT, + height_m FLOAT, + material VARCHAR(100), -- 강재, 윘크늬튞, 목재, 돌 + quantity INT, + unit_price FLOAT, + design_notes JSON, + structure_data_path VARCHAR(500), -- storage/.../B07_wf4_DesignDetail/structures/... + last_modified_by INT, -- FK는 later + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + + +/* --------- 1-8. 수량 산출 귞룹 --------- */ + +-- quantity_items: WF5 수량 산출 +CREATE TABLE IF NOT EXISTS quantity_items ( + id INT AUTO_INCREMENT PRIMARY KEY, + project_id CHAR(36) NOT NULL, -- FK는 later + category VARCHAR(100), -- 토공, 구조묌, 포장, 배수, 녹화, 안전시섀 + item_name VARCHAR(255), + unit VARCHAR(50), -- m3, 개, m, m2 + quantity_design FLOAT, + quantity_actual FLOAT, + unit_price FLOAT, + total_price FLOAT, -- quantity_actual × unit_price + standard_reference VARCHAR(255), + computed_at TIMESTAMP NULL DEFAULT NULL, + quantity_data_path VARCHAR(500), -- storage/.../B08_wf5_Quantity/quantities/... + data JSON -- formula, source, ... +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + + +/* --------- 1-9. 산출묌 & 묞서 귞룹 --------- */ + +-- outputs: WF6 최종 산출묌 섞튞 +CREATE TABLE IF NOT EXISTS outputs ( + id INT AUTO_INCREMENT PRIMARY KEY, + project_id CHAR(36) NOT NULL, -- FK는 later + output_type VARCHAR(100), -- estimation_excel, drawing_dxf, report_pdf, all_bundle + status VARCHAR(50) DEFAULT 'GENERATING', -- GENERATING, COMPLETE, FAILED + generated_by INT, -- FK는 later + generated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + version INT DEFAULT 1, + outputs_directory_path VARCHAR(500), -- storage/.../B09_wf6_Estimation/v1/ + metadata JSON -- template_used, company_name, project_name, total_cost, generation_time_sec +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- output_files: 최종 산출 파음 +CREATE TABLE IF NOT EXISTS output_files ( + id INT AUTO_INCREMENT PRIMARY KEY, + output_id INT NOT NULL, -- FK는 later + file_type VARCHAR(50), -- xlsx, pdf, dxf, dwg, json, zip + original_filename VARCHAR(255), + output_file_path VARCHAR(500), -- storage/.../B09_wf6_Estimation/v1/... + file_size_mb FLOAT, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + download_count INT DEFAULT 0 +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + + +/* --------- 1-10. 변겜 읎력 & 감시 귞룹 --------- */ + +-- audit_logs: 볎안 감시 로귞 +CREATE TABLE IF NOT EXISTS audit_logs ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT, -- FK는 later + action VARCHAR(100), -- CREATE, READ, UPDATE, DELETE, EXPORT, DOWNLOAD + entity_type VARCHAR(100), -- projects, routes, structures, outputs + entity_id VARCHAR(36), + timestamp TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + ip_address VARCHAR(45), + details JSON -- old_value, new_value, reason +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- change_logs: 섀계 변겜 읎력 +-- [★였류 수정]: 왞래킀 ON DELETE SET NULL 조걎을 위핎 changed_by 컬럌을 NULL 가능윌로 변겜 +CREATE TABLE IF NOT EXISTS change_logs ( + id INT AUTO_INCREMENT PRIMARY KEY, + project_id CHAR(36) NOT NULL, -- FK는 later + changed_by INT NULL, -- NOT NULL 에서 NULL로 변겜 완료 + changed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + entity_type VARCHAR(100), -- routes, cross_sections, structures, quantity_items + entity_id INT, + old_value JSON, + new_value JSON, + reason VARCHAR(255) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + + +-- ============================================================================= +-- 2. 왞래킀(FK) 제앜조걎 추가 +-- ============================================================================= + +ALTER TABLE users + ADD CONSTRAINT fk_users_company_id + FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE SET NULL; + +ALTER TABLE companies + ADD CONSTRAINT fk_companies_created_by + FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL; + +ALTER TABLE projects + ADD CONSTRAINT fk_projects_user_id + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE RESTRICT, + ADD CONSTRAINT fk_projects_company_id + FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE RESTRICT; + +ALTER TABLE project_versions + ADD CONSTRAINT fk_project_versions_project_id + FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE; + +ALTER TABLE input_files + ADD CONSTRAINT fk_input_files_project_id + FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE, + ADD CONSTRAINT fk_input_files_upload_by + FOREIGN KEY (upload_by) REFERENCES users(id) ON DELETE SET NULL; + +ALTER TABLE processed_point_cloud + ADD CONSTRAINT fk_processed_point_cloud_input_file_id + FOREIGN KEY (input_file_id) REFERENCES input_files(id) ON DELETE CASCADE, + ADD CONSTRAINT fk_processed_point_cloud_project_id + FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE; + +ALTER TABLE surface_models + ADD CONSTRAINT fk_surface_models_project_id + FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE, + ADD CONSTRAINT fk_surface_models_source_file_id + FOREIGN KEY (source_file_id) REFERENCES input_files(id) ON DELETE SET NULL, + ADD CONSTRAINT fk_surface_models_processed_cloud_id + FOREIGN KEY (processed_cloud_id) REFERENCES processed_point_cloud(id) ON DELETE SET NULL; + +ALTER TABLE terrain_layers + ADD CONSTRAINT fk_terrain_layers_surface_model_id + FOREIGN KEY (surface_model_id) REFERENCES surface_models(id) ON DELETE CASCADE; + +ALTER TABLE routes + ADD CONSTRAINT fk_routes_project_id + FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE, + ADD CONSTRAINT fk_routes_surface_model_id + FOREIGN KEY (surface_model_id) REFERENCES surface_models(id) ON DELETE SET NULL; + +ALTER TABLE route_points + ADD CONSTRAINT fk_route_points_route_id + FOREIGN KEY (route_id) REFERENCES routes(id) ON DELETE CASCADE; + +ALTER TABLE route_statistics + ADD CONSTRAINT fk_route_statistics_route_id + FOREIGN KEY (route_id) REFERENCES routes(id) ON DELETE CASCADE; + +ALTER TABLE longitudinal_sections + ADD CONSTRAINT fk_longitudinal_sections_project_id + FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE, + ADD CONSTRAINT fk_longitudinal_sections_route_id + FOREIGN KEY (route_id) REFERENCES routes(id) ON DELETE CASCADE; + +ALTER TABLE cross_sections + ADD CONSTRAINT fk_cross_sections_project_id + FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE, + ADD CONSTRAINT fk_cross_sections_route_id + FOREIGN KEY (route_id) REFERENCES routes(id) ON DELETE CASCADE; + +ALTER TABLE structures + ADD CONSTRAINT fk_structures_project_id + FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE, + ADD CONSTRAINT fk_structures_cross_section_id + FOREIGN KEY (cross_section_id) REFERENCES cross_sections(id) ON DELETE SET NULL, + ADD CONSTRAINT fk_structures_last_modified_by + FOREIGN KEY (last_modified_by) REFERENCES users(id) ON DELETE SET NULL; + +ALTER TABLE quantity_items + ADD CONSTRAINT fk_quantity_items_project_id + FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE; + +ALTER TABLE outputs + ADD CONSTRAINT fk_outputs_project_id + FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE, + ADD CONSTRAINT fk_outputs_generated_by + FOREIGN KEY (generated_by) REFERENCES users(id) ON DELETE SET NULL; + +ALTER TABLE output_files + ADD CONSTRAINT fk_output_files_output_id + FOREIGN KEY (output_id) REFERENCES outputs(id) ON DELETE CASCADE; + +ALTER TABLE audit_logs + ADD CONSTRAINT fk_audit_logs_user_id + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL; + +ALTER TABLE change_logs + ADD CONSTRAINT fk_change_logs_project_id + FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE, + ADD CONSTRAINT fk_change_logs_changed_by + FOREIGN KEY (changed_by) REFERENCES users(id) ON DELETE SET NULL; + + +-- ============================================================================= +-- 3. 읞덱슀 생성 (성능 최적화) +-- ============================================================================= + +-- 사용자/회사 읞덱싱 +CREATE INDEX idx_users_email ON users(email); +CREATE INDEX idx_users_company_id ON users(company_id); +CREATE INDEX idx_companies_name ON companies(name); + +-- 프로젝튞 읞덱싱 +CREATE INDEX idx_projects_user_id ON projects(user_id); +CREATE INDEX idx_projects_company_id ON projects(company_id); +CREATE INDEX idx_projects_status ON projects(status); +CREATE INDEX idx_projects_created_at ON projects(created_at DESC); +CREATE INDEX idx_projects_storage_path ON projects(storage_path); + +-- 파음 읞덱싱 +CREATE INDEX idx_input_files_project_id ON input_files(project_id); +CREATE INDEX idx_input_files_file_type ON input_files(file_type); +CREATE INDEX idx_input_files_raw_file_path ON input_files(raw_file_path); +CREATE INDEX idx_processed_point_cloud_input_file_id ON processed_point_cloud(input_file_id); +CREATE INDEX idx_processed_point_cloud_project_id ON processed_point_cloud(project_id); +CREATE INDEX idx_processed_point_cloud_converted_file_path ON processed_point_cloud(converted_file_path); + +-- 지표멎 몚덞 읞덱싱 +CREATE INDEX idx_surface_models_project_id ON surface_models(project_id); +CREATE INDEX idx_surface_models_status ON surface_models(status); +CREATE INDEX idx_surface_models_model_file_path ON surface_models(model_file_path); +CREATE INDEX idx_terrain_layers_surface_model_id ON terrain_layers(surface_model_id); +CREATE INDEX idx_terrain_layers_layer_file_path ON terrain_layers(layer_file_path); + +-- 겜로 읞덱싱 +CREATE INDEX idx_routes_project_id ON routes(project_id); +CREATE INDEX idx_routes_status ON routes(status); +CREATE INDEX idx_routes_route_data_path ON routes(route_data_path); +CREATE INDEX idx_route_points_route_id ON route_points(route_id); +CREATE INDEX idx_route_statistics_route_id ON route_statistics(route_id); + +-- 닚멎 읞덱싱 +CREATE INDEX idx_longitudinal_sections_project_id ON longitudinal_sections(project_id); +CREATE INDEX idx_longitudinal_sections_route_id ON longitudinal_sections(route_id); +CREATE INDEX idx_longitudinal_sections_file_path ON longitudinal_sections(longitudinal_file_path); +CREATE INDEX idx_cross_sections_project_id ON cross_sections(project_id); +CREATE INDEX idx_cross_sections_route_id ON cross_sections(route_id); +CREATE INDEX idx_cross_sections_chainage_m ON cross_sections(chainage_m); +CREATE INDEX idx_cross_sections_file_path ON cross_sections(cross_section_file_path); + +-- 구조묌/수량 읞덱싱 +CREATE INDEX idx_structures_project_id ON structures(project_id); +CREATE INDEX idx_structures_cross_section_id ON structures(cross_section_id); +CREATE INDEX idx_structures_file_path ON structures(structure_data_path); +CREATE INDEX idx_quantity_items_project_id ON quantity_items(project_id); +CREATE INDEX idx_quantity_items_category ON quantity_items(category); +CREATE INDEX idx_quantity_items_file_path ON quantity_items(quantity_data_path); + +-- 산출묌 읞덱싱 +CREATE INDEX idx_outputs_project_id ON outputs(project_id); +CREATE INDEX INDEX idx_outputs_version ON outputs(version); +CREATE INDEX idx_outputs_directory_path ON outputs(outputs_directory_path); +CREATE INDEX idx_output_files_output_id ON output_files(output_id); +CREATE INDEX idx_output_files_output_file_path ON output_files(output_file_path); + +-- 감시/읎력 읞덱싱 +CREATE INDEX idx_audit_logs_user_id ON audit_logs(user_id); +CREATE INDEX idx_audit_logs_timestamp ON audit_logs(timestamp DESC); +CREATE INDEX idx_audit_logs_entity_type ON audit_logs(entity_type); +CREATE INDEX idx_change_logs_project_id ON change_logs(project_id); +CREATE INDEX idx_change_logs_changed_by ON change_logs(changed_by); +CREATE INDEX idx_change_logs_changed_at ON change_logs(changed_at DESC); + +-- ============================================================================= +-- 5. 생성 완료 메시지 +-- ============================================================================= + +-- 몚든 테읎랔곌 읞덱슀가 정상적윌로 생성되었윌멎 아래의 SELECT륌 싀행하여 확읞할 수 있습니닀: +-- SELECT table_name FROM information_schema.tables WHERE table_schema='public'; +-- SELECT indexname FROM pg_indexes WHERE schemaname='public'; diff --git a/main.py b/main.py index a3fd495..8c2b093 100644 --- a/main.py +++ b/main.py @@ -9,29 +9,33 @@ FastAPI 애플늬쌀읎션 진입점 - 띌우터 등록 (페읎지별 API) """ -import os -import sys import logging +import os import subprocess -import time -from pathlib import Path -from fastapi import FastAPI -from fastapi.staticfiles import StaticFiles -from fastapi.middleware.cors import CORSMiddleware from contextlib import asynccontextmanager +from pathlib import Path + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles + +from B03_FileInput.B03_FileInput_Router import router as b03_file_input_router +from B04_wf1_Surface.B04_wf1_Surface_Router import router as b04_surface_router +from B05_wf2_Route.B05_wf2_Route_Router import router as b05_route_router +from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Router import router as b06_section_router +from config.config_db import close_db_pool, init_db_pool # 섀정 import from config.config_system import ( + CORS_ORIGINS, + DEBUG, + ENVIRONMENT, + LOG_LEVEL, SERVER_HOST, SERVER_PORT, - DEBUG, STATIC_DIR, STATIC_URL, - LOG_LEVEL, - CORS_ORIGINS, - ENVIRONMENT, ) -from config.config_db import init_db_pool, close_db_pool # 로깅 섀정 logging.basicConfig(level=getattr(logging, LOG_LEVEL)) @@ -41,6 +45,7 @@ logger = logging.getLogger(__name__) # 프론튞엔드 빌드 및 서빙 핚수 # ───────────────────────────────────────────────────────────────────────── + def build_frontend() -> bool: """프론튞엔드 빌드 (npm run build from config/)""" root_dir = Path(__file__).resolve().parent @@ -96,7 +101,7 @@ def serve_frontend_dev() -> None: shell=True, cwd=str(config_dir), stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL + stderr=subprocess.DEVNULL, ) logger.info("✓ 프론튞엔드 개발 서버 백귞띌욎드 싀행") except Exception as e: @@ -192,6 +197,10 @@ async def health(): # app.include_router(a01_router, prefix="/api/a01", tags=["A01_Home"]) # # 나쀑에 각 페읎지별 띌우터가 구현되멎 여Ʞ에 등록. +app.include_router(b03_file_input_router) +app.include_router(b04_surface_router) +app.include_router(b05_route_router) +app.include_router(b06_section_router) # ───────────────────────────────────────────────────────────────────────── # 앱 싀행 diff --git a/requirements.txt b/requirements.txt index a2b8c63..314f263 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,7 @@ fastapi==0.104.1 uvicorn==0.24.0 pydantic==2.5.0 -asyncpg==0.29.0 +aiomysql==0.2.0 python-multipart==0.0.6 python-dotenv==1.0.0 diff --git a/tests/test_b03_file_input_analyze.py b/tests/test_b03_file_input_analyze.py new file mode 100644 index 0000000..75d8134 --- /dev/null +++ b/tests/test_b03_file_input_analyze.py @@ -0,0 +1,110 @@ +import tempfile +import unittest +from pathlib import Path + +import laspy +import numpy as np +import rasterio +from pyproj import CRS +from rasterio.transform import from_origin + +from B03_FileInput.B03_FileInput_Engine_Analyze import ( + analyze_input_metadata, + analyze_las_metadata, + analyze_prj_metadata, + analyze_tfw_metadata, + analyze_tif_metadata, +) + + +class AnalyzeLasMetadataTest(unittest.TestCase): + def test_analyze_las_metadata(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + source = Path(temporary_directory) / "sample.las" + las = laspy.LasData(laspy.LasHeader(point_format=3, version="1.2")) + las.x = np.array([100.0, 102.0]) + las.y = np.array([200.0, 204.0]) + las.z = np.array([10.0, 14.0]) + las.classification = np.array([2, 5], dtype=np.uint8) + las.write(source) + + metadata = analyze_las_metadata(source) + + self.assertEqual(metadata["file"], "sample.las") + self.assertEqual(metadata["point_count"], 2) + self.assertEqual(metadata["bounds"]["x"], [100.0, 102.0]) + self.assertEqual(metadata["bounds"]["y"], [200.0, 204.0]) + self.assertEqual(metadata["bounds"]["z"], [10.0, 14.0]) + self.assertEqual(metadata["classification_summary"], {"2": 1, "5": 1}) + + def test_analyze_prj_metadata(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + source = Path(temporary_directory) / "result.prj" + source.write_text(CRS.from_epsg(5179).to_wkt(), encoding="utf-8") + + metadata = analyze_prj_metadata(source) + + self.assertEqual(metadata["file"], "result.prj") + self.assertEqual(metadata["epsg"], 5179) + self.assertTrue(metadata["is_valid"]) + + source.write_text("invalid wkt", encoding="utf-8") + invalid_metadata = analyze_prj_metadata(source) + self.assertFalse(invalid_metadata["is_valid"]) + self.assertIn("error", invalid_metadata) + + def test_analyze_tfw_metadata(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + source = Path(temporary_directory) / "result.tfw" + source.write_text("0.5\n0\n0\n-0.5\n100\n200\n", encoding="utf-8") + + metadata = analyze_tfw_metadata(source) + + self.assertTrue(metadata["is_valid"]) + self.assertEqual(metadata["pixel_size_x"], 0.5) + self.assertEqual(metadata["pixel_size_y"], -0.5) + self.assertEqual(metadata["origin_x"], 100.0) + self.assertEqual(metadata["origin_y"], 200.0) + + source.write_text("nan\n0\n0\n-0.5\n100\n200\n", encoding="utf-8") + with self.assertRaises(ValueError): + analyze_tfw_metadata(source) + + def test_analyze_tif_metadata(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + source = Path(temporary_directory) / "result.tif" + with rasterio.open( + source, + mode="w", + driver="GTiff", + width=3, + height=2, + count=1, + dtype="float32", + crs="EPSG:5179", + transform=from_origin(100, 200, 2, 2), + nodata=-9999, + ) as dataset: + dataset.write(np.ones((1, 2, 3), dtype=np.float32)) + + metadata = analyze_tif_metadata(source) + + self.assertEqual(metadata["file"], "result.tif") + self.assertEqual(metadata["width"], 3) + self.assertEqual(metadata["height"], 2) + self.assertEqual(metadata["count"], 1) + self.assertEqual(metadata["epsg"], 5179) + self.assertEqual(metadata["resolution"], [2.0, 2.0]) + self.assertEqual(metadata["likely_type"], "dem") + + def test_analyze_input_metadata(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + source = Path(temporary_directory) / "drawing.dxf" + source.write_bytes(b"0\nSECTION\n") + + metadata = analyze_input_metadata(source) + + self.assertEqual( + metadata, + {"file": "drawing.dxf", "extension": "dxf", "size_bytes": 10}, + ) diff --git a/tests/test_b03_file_input_engine.py b/tests/test_b03_file_input_engine.py new file mode 100644 index 0000000..7860246 --- /dev/null +++ b/tests/test_b03_file_input_engine.py @@ -0,0 +1,58 @@ +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +from B03_FileInput.B03_FileInput_Engine import ( + resolve_upload_destination, + save_upload_stream, +) +from B03_FileInput.B03_FileInput_Schema import FileUploadDescriptor + + +class ResolveUploadDestinationTest(unittest.TestCase): + def test_resolve_upload_destination(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + descriptor = FileUploadDescriptor( + original_filename="cloud_merged.LAS", + size_bytes=1024, + ) + + destination = resolve_upload_destination(temporary_directory, descriptor) + + self.assertEqual( + destination, + Path(temporary_directory).resolve() + / "B03_FileInput" + / "input" + / "las" + / "cloud_merged.LAS", + ) + self.assertTrue(destination.parent.is_dir()) + + +class SaveUploadStreamTest(unittest.IsolatedAsyncioTestCase): + async def test_save_upload_stream(self) -> None: + class FakeUpload: + def __init__(self, chunks: list[bytes]) -> None: + self.chunks = iter(chunks) + + async def read(self, _size: int) -> bytes: + return next(self.chunks, b"") + + with tempfile.TemporaryDirectory() as temporary_directory: + destination = Path(temporary_directory) / "cloud.las" + upload = FakeUpload([b"abc", b"def"]) + + written_bytes = await save_upload_stream(upload, destination) # type: ignore[arg-type] + + self.assertEqual(written_bytes, 6) + self.assertEqual(destination.read_bytes(), b"abcdef") + self.assertEqual(list(destination.parent.glob("*.upload")), []) + + with patch("B03_FileInput.B03_FileInput_Engine.UPLOAD_MAX_MB", 0): + with self.assertRaises(ValueError): + await save_upload_stream( # type: ignore[arg-type] + FakeUpload([b"too-large"]), + destination, + ) diff --git a/tests/test_b03_file_input_repository.py b/tests/test_b03_file_input_repository.py new file mode 100644 index 0000000..9b67258 --- /dev/null +++ b/tests/test_b03_file_input_repository.py @@ -0,0 +1,100 @@ +import json +import unittest +from typing import Any +from uuid import UUID + +from B03_FileInput.B03_FileInput_Repository import ( + create_input_file, + get_project_storage_relative_path, +) + + +class FakeCursor: + """asyncmy 컀서 흉낮 (async context manager + execute/fetchone/lastrowid).""" + + def __init__(self, *, lastrowid: int = 0, row: tuple[Any, ...] | None = None) -> None: + self.query = "" + self.arguments: tuple[Any, ...] = () + self.lastrowid = lastrowid + self._row = row + + async def __aenter__(self) -> "FakeCursor": + return self + + async def __aexit__(self, *_exc: Any) -> None: + return None + + async def execute(self, query: str, arguments: tuple[Any, ...] | None = None) -> None: + self.query = query + self.arguments = arguments or () + + async def fetchone(self) -> tuple[Any, ...] | None: + return self._row + + +class FakeConnection: + """asyncmy 컀넥션 흉낮 (cursor() 팩토늬).""" + + def __init__(self, cursor: FakeCursor) -> None: + self._cursor = cursor + + def cursor(self) -> FakeCursor: + return self._cursor + + +class CreateInputFileTest(unittest.IsolatedAsyncioTestCase): + async def test_create_input_file(self) -> None: + cursor = FakeCursor(lastrowid=17) + connection = FakeConnection(cursor) + project_id = UUID("550e8400-e29b-41d4-a716-446655440000") + metadata = {"point_count": 2, "crs": "EPSG:5179"} + + input_file_id = await create_input_file( # type: ignore[arg-type] + connection, + project_id=project_id, + file_type="las", + original_filename="cloud.las", + relative_path="B03_FileInput/input/las/cloud.las", + file_size_bytes=1024 * 1024, + upload_by=3, + crs_epsg=5179, + metadata=metadata, + ) + + self.assertEqual(input_file_id, 17) + self.assertIn("INSERT INTO input_files", cursor.query) + self.assertEqual(cursor.arguments[0], str(project_id)) + self.assertEqual(cursor.arguments[3], "B03_FileInput/input/las/cloud.las") + self.assertEqual(cursor.arguments[4], 1.0) + self.assertEqual(json.loads(cursor.arguments[7]), metadata) + + with self.assertRaises(ValueError): + await create_input_file( # type: ignore[arg-type] + connection, + project_id=project_id, + file_type="las", + original_filename="cloud.las", + relative_path="../outside/cloud.las", + file_size_bytes=1, + upload_by=None, + crs_epsg=None, + metadata={}, + ) + + async def test_get_project_storage_relative_path(self) -> None: + project_id = UUID("550e8400-e29b-41d4-a716-446655440000") + + connection = FakeConnection(FakeCursor(row=("storage/company/user/project-id",))) + relative_path = await get_project_storage_relative_path( # type: ignore[arg-type] + connection, + project_id, + ) + self.assertEqual(relative_path, "storage/company/user/project-id") + + for row in (None, ("../outside",), ("/absolute/path",)): + with self.subTest(row=row): + with self.assertRaises((LookupError, ValueError)): + await get_project_storage_relative_path( # type: ignore[arg-type] + FakeConnection(FakeCursor(row=row)), + project_id, + ) diff --git a/tests/test_b03_file_input_router.py b/tests/test_b03_file_input_router.py new file mode 100644 index 0000000..6a34c7f --- /dev/null +++ b/tests/test_b03_file_input_router.py @@ -0,0 +1,122 @@ +import io +import tempfile +import unittest +from pathlib import Path +from typing import Any +from unittest.mock import patch +from uuid import UUID + +import laspy +import numpy as np + +from B03_FileInput.B03_FileInput_Router import upload_project_files +from B03_FileInput.B03_FileInput_Schema import FileUploadResponse + + +class UploadProjectFilesTest(unittest.IsolatedAsyncioTestCase): + async def test_upload_project_files(self) -> None: + class AsyncContext: + def __init__(self, value: Any = None) -> None: + self.value = value + + async def __aenter__(self) -> Any: + return self.value + + async def __aexit__(self, *_args: Any) -> None: + return None + + class FakeCursor: + def __init__(self, connection: "FakeConnection") -> None: + self.connection = connection + self.lastrowid = 0 + + async def __aenter__(self) -> "FakeCursor": + return self + + async def __aexit__(self, *_args: Any) -> None: + return None + + async def execute(self, query: str, _arguments: Any = None) -> None: + if query.strip().upper().startswith("SELECT"): + self._row: tuple[Any, ...] | None = ("storage/company/user/project-id",) + else: + self.lastrowid = self.connection.next_id + self.connection.next_id += 1 + self._row = None + + async def fetchone(self) -> tuple[Any, ...] | None: + return getattr(self, "_row", None) + + class FakeConnection: + def __init__(self) -> None: + self.next_id = 1 + self.committed = False + self.rolled_back = False + + def cursor(self) -> FakeCursor: + return FakeCursor(self) + + async def begin(self) -> None: + return None + + async def commit(self) -> None: + self.committed = True + + async def rollback(self) -> None: + self.rolled_back = True + + class FakePool: + def __init__(self, connection: FakeConnection) -> None: + self.connection = connection + + def acquire(self) -> AsyncContext: + return AsyncContext(self.connection) + + class FakeUpload: + def __init__(self, filename: str, content: bytes) -> None: + self.filename = filename + self.size = len(content) + self.file = io.BytesIO(content) + self.closed = False + + async def read(self, size: int) -> bytes: + return self.file.read(size) + + async def close(self) -> None: + self.file.close() + self.closed = True + + with tempfile.TemporaryDirectory() as temporary_directory: + project_root = Path(temporary_directory) / "project" + project_root.mkdir() + source = Path(temporary_directory) / "source.las" + las = laspy.LasData(laspy.LasHeader(point_format=3, version="1.2")) + las.x = np.array([1.0]) + las.y = np.array([2.0]) + las.z = np.array([3.0]) + las.write(source) + upload = FakeUpload("cloud.las", source.read_bytes()) + pool = FakePool(FakeConnection()) + + with ( + patch("B03_FileInput.B03_FileInput_Router.get_db_pool", return_value=pool), + patch( + "B03_FileInput.B03_FileInput_Router.resolve_stored_project_path", + return_value=str(project_root), + ), + ): + response = await upload_project_files( # type: ignore[arg-type] + UUID("550e8400-e29b-41d4-a716-446655440000"), + [upload], + ) + + self.assertIsInstance(response, FileUploadResponse) + assert isinstance(response, FileUploadResponse) + self.assertEqual(len(response.files), 1) + self.assertEqual(response.files[0].input_file_id, 1) + self.assertTrue( + (project_root / "B03_FileInput" / "input" / "las" / "cloud.las").is_file() + ) + self.assertTrue((project_root / "B03_FileInput" / "metadata.json").is_file()) + self.assertTrue((project_root / "workflow.json").is_file()) + self.assertTrue(upload.closed) diff --git a/tests/test_b03_file_input_schema.py b/tests/test_b03_file_input_schema.py new file mode 100644 index 0000000..963a1c4 --- /dev/null +++ b/tests/test_b03_file_input_schema.py @@ -0,0 +1,30 @@ +import unittest + +from pydantic import ValidationError + +from B03_FileInput.B03_FileInput_Schema import FileUploadDescriptor +from config.config_system import UPLOAD_MAX_MB + + +class FileUploadDescriptorTest(unittest.TestCase): + def test_file_upload_descriptor(self) -> None: + descriptor = FileUploadDescriptor( + original_filename="cloud_merged.LAS", + size_bytes=1024, + ) + self.assertEqual(descriptor.original_filename, "cloud_merged.LAS") + + invalid_values = ( + {"original_filename": "../cloud.las", "size_bytes": 1024}, + {"original_filename": "nested/cloud.las", "size_bytes": 1024}, + {"original_filename": "cloud.exe", "size_bytes": 1024}, + { + "original_filename": "cloud.las", + "size_bytes": UPLOAD_MAX_MB * 1024 * 1024 + 1, + }, + {"original_filename": "cloud.las", "size_bytes": 0}, + ) + for invalid_value in invalid_values: + with self.subTest(invalid_value=invalid_value): + with self.assertRaises(ValidationError): + FileUploadDescriptor(**invalid_value) diff --git a/tests/test_b04_surface_engine.py b/tests/test_b04_surface_engine.py new file mode 100644 index 0000000..d464fc9 --- /dev/null +++ b/tests/test_b04_surface_engine.py @@ -0,0 +1,64 @@ +import tempfile +import unittest +from pathlib import Path + +import laspy +import numpy as np + +from B04_wf1_Surface.B04_wf1_Surface_Engine import run_surface_analysis +from B04_wf1_Surface.B04_wf1_Surface_Engine_Ground import ( + build_ground_masks, + summarize_masks, +) + + +class GroundMasksTest(unittest.TestCase): + def test_build_and_summarize(self) -> None: + coords = np.linspace(0.0, 20.0, 21) + gx, gy = np.meshgrid(coords, coords) + z = 5.0 + 0.03 * gx + xyz = np.column_stack([gx.ravel(), gy.ravel(), z.ravel()]).astype(np.float64) + bounds = np.array([[0.0, 20.0], [0.0, 20.0], [float(z.min()), float(z.max())]]) + data = {"xyz": xyz, "bounds": bounds} + + masks = build_ground_masks(data, ["grid_min_z"]) + self.assertIn("grid_min_z", masks) + self.assertEqual(masks["grid_min_z"].dtype, bool) + + summary = summarize_masks(data, masks) + self.assertEqual(summary["grid_min_z"]["total_point_count"], len(xyz)) + self.assertGreater(summary["grid_min_z"]["ground_ratio"], 0.9) + + def test_unknown_filter(self) -> None: + data = {"xyz": np.zeros((1, 3)), "bounds": np.zeros((3, 2))} + with self.assertRaises(ValueError): + build_ground_masks(data, ["unknown"]) + + +class RunSurfaceAnalysisTest(unittest.TestCase): + def test_full_pipeline(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) / "proj" + root.mkdir() + coords = np.linspace(0.0, 30.0, 31) + gx, gy = np.meshgrid(coords, coords) + z = 5.0 + 0.04 * gx + 0.02 * gy + src = Path(directory) / "cloud.las" + las = laspy.LasData(laspy.LasHeader(point_format=3, version="1.2")) + las.x, las.y, las.z = gx.ravel(), gy.ravel(), z.ravel() + las.write(src) + + result = run_surface_analysis( + root, src, source_filters=["grid_min_z"], methods=["dtm", "tin"] + ) + + self.assertEqual(result["processed"]["point_count"], 961) + self.assertEqual(result["manifest"]["status"], "completed") + self.assertGreater(result["ground_summary"]["grid_min_z"]["ground_ratio"], 0.9) + model_types = {m["model_type"] for m in result["models"]} + self.assertEqual(model_types, {"dtm", "tin"}) + for model in result["models"]: + self.assertTrue(model["model_file_path"].startswith("B04_wf1_Surface/")) + self.assertTrue(model["layers"]) + # structured.npz가 processed 폎더에 생성됚 + self.assertTrue((root / "B04_wf1_Surface" / "processed" / "structured.npz").is_file()) diff --git a/tests/test_b04_surface_filter_csf.py b/tests/test_b04_surface_filter_csf.py new file mode 100644 index 0000000..7d4730e --- /dev/null +++ b/tests/test_b04_surface_filter_csf.py @@ -0,0 +1,48 @@ +import unittest + +import numpy as np + +from B04_wf1_Surface.B04_wf1_Surface_Engine_Filter_CSF import filter_csf + + +class FilterCsfTest(unittest.TestCase): + def _ground_with_trees(self) -> dict[str, np.ndarray]: + """평탄한 지멎 격자 위에 높읎 솟은 수목 포읞튞륌 얹은 합성 데읎터.""" + coords = np.linspace(0.0, 10.0, 11) + gx, gy = np.meshgrid(coords, coords) + ground = np.column_stack([gx.ravel(), gy.ravel(), np.full(gx.size, 5.0)]) + trees = np.array( + [ + [2.0, 2.0, 15.0], + [5.0, 5.0, 18.0], + [8.0, 8.0, 16.0], + ] + ) + xyz = np.vstack([ground, trees]) + return {"xyz": xyz}, ground.shape[0] + + def test_classifies_ground_and_rejects_trees(self) -> None: + structured, ground_count = self._ground_with_trees() + + mask = filter_csf(structured, cloth_resolution=1.5, class_threshold=0.5) + + self.assertEqual(mask.shape[0], structured["xyz"].shape[0]) + self.assertEqual(mask.dtype, bool) + # 지멎 포읞튞는 대부분 지멎윌로 분류되얎알 한닀. + self.assertGreater(mask[:ground_count].mean(), 0.8) + # 솟은 수목 포읞튞는 지멎에서 제왞되얎알 한닀. + self.assertFalse(mask[ground_count:].any()) + + def test_empty_input(self) -> None: + mask = filter_csf({"xyz": np.zeros((0, 3))}) + self.assertEqual(mask.shape[0], 0) + self.assertEqual(mask.dtype, bool) + + def test_invalid_parameters(self) -> None: + structured, _ = self._ground_with_trees() + with self.assertRaises(ValueError): + filter_csf(structured, cloth_resolution=0.0) + with self.assertRaises(ValueError): + filter_csf(structured, rigidness=5) + with self.assertRaises(ValueError): + filter_csf(structured, iterations=0) diff --git a/tests/test_b04_surface_filter_grid.py b/tests/test_b04_surface_filter_grid.py new file mode 100644 index 0000000..21debc8 --- /dev/null +++ b/tests/test_b04_surface_filter_grid.py @@ -0,0 +1,25 @@ +import unittest + +import numpy as np + +from B04_wf1_Surface.B04_wf1_Surface_Engine_Filter_Grid import filter_grid_min_z + + +class FilterGridMinZTest(unittest.TestCase): + def test_filter_grid_min_z(self) -> None: + structured = { + "xyz": np.array( + [ + [0.0, 0.0, 10.0], + [0.5, 0.5, 11.0], + [0.8, 0.8, 12.0], + ] + ), + "bounds": np.array([[0.0, 0.8], [0.0, 0.8], [10.0, 12.0]]), + } + + mask = filter_grid_min_z(structured, cell_size=2.0, height_threshold=1.5) + + np.testing.assert_array_equal(mask, [True, True, False]) + with self.assertRaises(ValueError): + filter_grid_min_z(structured, cell_size=0) diff --git a/tests/test_b04_surface_filter_pmf.py b/tests/test_b04_surface_filter_pmf.py new file mode 100644 index 0000000..7dbe7f8 --- /dev/null +++ b/tests/test_b04_surface_filter_pmf.py @@ -0,0 +1,42 @@ +import unittest + +import numpy as np + +from B04_wf1_Surface.B04_wf1_Surface_Engine_Filter_PMF import filter_pmf + + +class FilterPmfTest(unittest.TestCase): + def _ground_with_trees(self) -> tuple[dict[str, np.ndarray], int]: + coords = np.linspace(0.0, 40.0, 21) + gx, gy = np.meshgrid(coords, coords) + ground = np.column_stack([gx.ravel(), gy.ravel(), np.full(gx.size, 5.0)]) + trees = np.array( + [ + [10.0, 10.0, 20.0], + [20.0, 20.0, 25.0], + [30.0, 30.0, 22.0], + ] + ) + xyz = np.vstack([ground, trees]) + bounds = np.array([[0.0, 40.0], [0.0, 40.0], [5.0, 25.0]]) + return {"xyz": xyz, "bounds": bounds}, ground.shape[0] + + def test_classifies_ground_and_rejects_trees(self) -> None: + structured, ground_count = self._ground_with_trees() + mask = filter_pmf(structured) + self.assertEqual(mask.shape[0], structured["xyz"].shape[0]) + self.assertEqual(mask.dtype, bool) + self.assertGreater(mask[:ground_count].mean(), 0.9) + self.assertFalse(mask[ground_count:].any()) + + def test_empty_input(self) -> None: + bounds = np.array([[0.0, 1.0], [0.0, 1.0], [0.0, 1.0]]) + mask = filter_pmf({"xyz": np.zeros((0, 3)), "bounds": bounds}) + self.assertEqual(mask.shape[0], 0) + + def test_invalid_parameters(self) -> None: + structured, _ = self._ground_with_trees() + with self.assertRaises(ValueError): + filter_pmf(structured, cell_size=0) + with self.assertRaises(ValueError): + filter_pmf(structured, initial_window_size=0) diff --git a/tests/test_b04_surface_filter_ransac.py b/tests/test_b04_surface_filter_ransac.py new file mode 100644 index 0000000..6859f39 --- /dev/null +++ b/tests/test_b04_surface_filter_ransac.py @@ -0,0 +1,59 @@ +import unittest + +import numpy as np + +from B04_wf1_Surface.B04_wf1_Surface_Engine_Filter_RANSAC import ( + filter_ransac, + fit_plane_ransac, +) + + +class FitPlaneRansacTest(unittest.TestCase): + def test_fits_horizontal_plane(self) -> None: + coords = np.linspace(0.0, 5.0, 6) + gx, gy = np.meshgrid(coords, coords) + plane = np.column_stack([gx.ravel(), gy.ravel(), np.full(gx.size, 2.0)]) + outliers = np.array([[1.0, 1.0, 10.0], [3.0, 3.0, 12.0]]) + points = np.vstack([plane, outliers]) + inliers = fit_plane_ransac(points, distance_threshold=0.3) + self.assertTrue(inliers[: plane.shape[0]].all()) + self.assertFalse(inliers[plane.shape[0] :].any()) + + def test_too_few_points(self) -> None: + points = np.array([[0.0, 0.0, 0.0], [1.0, 1.0, 1.0]]) + inliers = fit_plane_ransac(points) + self.assertTrue(inliers.all()) + + +class FilterRansacTest(unittest.TestCase): + def _ground_with_trees(self) -> tuple[dict[str, np.ndarray], int]: + coords = np.linspace(0.0, 20.0, 11) + gx, gy = np.meshgrid(coords, coords) + ground = np.column_stack([gx.ravel(), gy.ravel(), np.full(gx.size, 3.0)]) + trees = np.array([[5.0, 5.0, 15.0], [15.0, 15.0, 18.0]]) + xyz = np.vstack([ground, trees]) + bounds = np.array([[0.0, 20.0], [0.0, 20.0], [3.0, 18.0]]) + return {"xyz": xyz, "bounds": bounds}, ground.shape[0] + + def test_classifies_ground(self) -> None: + structured, ground_count = self._ground_with_trees() + progress: list[int] = [] + mask = filter_ransac(structured, progress_callback=progress.append) + self.assertEqual(mask.shape[0], structured["xyz"].shape[0]) + self.assertGreater(mask[:ground_count].mean(), 0.9) + # progress는 혞출되며 닚조 슝가하고 마지막에 100윌로 완료된닀. + self.assertTrue(progress) + self.assertEqual(progress, sorted(progress)) + self.assertEqual(progress[-1], 100) + + def test_empty_input(self) -> None: + bounds = np.array([[0.0, 1.0], [0.0, 1.0], [0.0, 1.0]]) + mask = filter_ransac({"xyz": np.zeros((0, 3)), "bounds": bounds}) + self.assertEqual(mask.shape[0], 0) + + def test_invalid_parameters(self) -> None: + structured, _ = self._ground_with_trees() + with self.assertRaises(ValueError): + filter_ransac(structured, local_grid_size=0) + with self.assertRaises(ValueError): + filter_ransac(structured, ransac_n=2) diff --git a/tests/test_b04_surface_pipeline.py b/tests/test_b04_surface_pipeline.py new file mode 100644 index 0000000..2b7cfd7 --- /dev/null +++ b/tests/test_b04_surface_pipeline.py @@ -0,0 +1,61 @@ +import tempfile +import unittest +from pathlib import Path + +import numpy as np + +from B04_wf1_Surface.B04_wf1_Surface_Engine_Pipeline import build_all_terrain_models +from config.config_system import build_surface_model_config + + +class BuildAllTerrainModelsTest(unittest.TestCase): + def _synthetic_slope(self) -> tuple[dict[str, np.ndarray], dict[str, np.ndarray]]: + coords = np.linspace(0.0, 40.0, 41) + gx, gy = np.meshgrid(coords, coords) + z = 5.0 + 0.05 * gx + 0.03 * gy + xyz = np.column_stack([gx.ravel(), gy.ravel(), z.ravel()]).astype(np.float64) + bounds = np.array([[0.0, 40.0], [0.0, 40.0], [float(z.min()), float(z.max())]]) + mask = np.ones(len(xyz), dtype=bool) + return {"xyz": xyz, "bounds": bounds}, {"grid_min_z": mask} + + def test_builds_all_representations(self) -> None: + structured, masks = self._synthetic_slope() + config = build_surface_model_config() + config["source_filters"] = ["grid_min_z"] + progress: list[tuple[int, str]] = [] + + with tempfile.TemporaryDirectory() as directory: + output_dir = Path(directory) / "proj" / "models" + manifest = build_all_terrain_models( + structured, masks, output_dir, config, progress=lambda p, m: progress.append((p, m)) + ) + + self.assertEqual(manifest["status"], "completed") + self.assertEqual(manifest["failure_count"], 0) + methods = manifest["source_filters"]["grid_min_z"]["methods"] + self.assertEqual(set(methods), {"tin", "dtm", "nurbs", "implicit", "meshfree"}) + for meta in methods.values(): + self.assertEqual(meta["status"], "completed") + # DTM/TIN은 슀묎딩까지 완료 + self.assertEqual(methods["dtm"]["smooth"]["status"], "completed") + self.assertEqual(methods["tin"]["smooth"]["status"], "completed") + # 등고선 캐시와 manifest 생성 확읞 + files = list(output_dir.iterdir()) + self.assertTrue(any("contour_" in f.name for f in files)) + self.assertTrue((output_dir / "manifest.json").is_file()) + self.assertEqual(progress[-1][0], 100) + + def test_cache_reuse_on_second_call(self) -> None: + structured, masks = self._synthetic_slope() + config = build_surface_model_config() + config["source_filters"] = ["grid_min_z"] + config["precompute"] = ["dtm"] + + with tempfile.TemporaryDirectory() as directory: + output_dir = Path(directory) / "proj" / "models" + build_all_terrain_models(structured, masks, output_dir, config) + second = build_all_terrain_models(structured, masks, output_dir, config) + self.assertEqual(second["status"], "completed") + self.assertEqual( + second["source_filters"]["grid_min_z"]["methods"]["dtm"]["status"], "completed" + ) diff --git a/tests/test_b04_surface_repository.py b/tests/test_b04_surface_repository.py new file mode 100644 index 0000000..d104e41 --- /dev/null +++ b/tests/test_b04_surface_repository.py @@ -0,0 +1,143 @@ +import json +import unittest +from typing import Any +from uuid import UUID + +from B04_wf1_Surface.B04_wf1_Surface_Repository import ( + create_processed_point_cloud, + create_surface_model, + create_terrain_layer, + list_surface_models, +) + + +class FakeCursor: + def __init__(self, *, lastrowid: int = 0, rows: list[tuple[Any, ...]] | None = None) -> None: + self.query = "" + self.arguments: tuple[Any, ...] = () + self.lastrowid = lastrowid + self._rows = rows or [] + + async def __aenter__(self) -> "FakeCursor": + return self + + async def __aexit__(self, *_exc: Any) -> None: + return None + + async def execute(self, query: str, arguments: tuple[Any, ...] | None = None) -> None: + self.query = query + self.arguments = arguments or () + + async def fetchall(self) -> list[tuple[Any, ...]]: + return self._rows + + +class FakeConnection: + def __init__(self, cursor: FakeCursor) -> None: + self._cursor = cursor + + def cursor(self) -> FakeCursor: + return self._cursor + + +PROJECT_ID = UUID("550e8400-e29b-41d4-a716-446655440000") + + +class ProcessedPointCloudTest(unittest.IsolatedAsyncioTestCase): + async def test_create(self) -> None: + cursor = FakeCursor(lastrowid=11) + new_id = await create_processed_point_cloud( # type: ignore[arg-type] + FakeConnection(cursor), + input_file_id=3, + project_id=PROJECT_ID, + process_type="filtered", + processed_file_path="B04_wf1_Surface/processed/cloud.las", + converted_format="ply", + converted_file_path="B04_wf1_Surface/processed/cloud.ply", + point_count=1000, + bounds={"x_min": 0.0, "x_max": 10.0, "y_min": 0.0, "y_max": 10.0}, + statistics={"min_z": 1.0, "max_z": 5.0, "mean_z": 3.0, "density_per_sqm": 10.0}, + classification_summary={"ground": 800}, + processing_params={"filter": "csf"}, + ) + self.assertEqual(new_id, 11) + self.assertIn("INSERT INTO processed_point_cloud", cursor.query) + self.assertEqual(cursor.arguments[1], str(PROJECT_ID)) + self.assertEqual(cursor.arguments[6], 1000) + self.assertEqual(json.loads(cursor.arguments[15]), {"ground": 800}) + + async def test_rejects_path_outside_stage(self) -> None: + with self.assertRaises(ValueError): + await create_processed_point_cloud( # type: ignore[arg-type] + FakeConnection(FakeCursor(lastrowid=1)), + input_file_id=3, + project_id=PROJECT_ID, + process_type="filtered", + processed_file_path="B05_wf2_Route/x.las", + converted_format=None, + converted_file_path=None, + point_count=None, + bounds=None, + statistics=None, + classification_summary=None, + processing_params=None, + ) + + +class SurfaceModelTest(unittest.IsolatedAsyncioTestCase): + async def test_create(self) -> None: + cursor = FakeCursor(lastrowid=22) + new_id = await create_surface_model( # type: ignore[arg-type] + FakeConnection(cursor), + project_id=PROJECT_ID, + model_type="dtm_grid", + source_file_id=3, + processed_cloud_id=11, + crs_epsg=5178, + resolution_m=1.0, + model_file_path="B04_wf1_Surface/models/dtm.npz", + generation_params={"algorithm": "dtm"}, + ) + self.assertEqual(new_id, 22) + self.assertIn("INSERT INTO surface_models", cursor.query) + self.assertEqual(cursor.arguments[7], "B04_wf1_Surface/models/dtm.npz") + + +class TerrainLayerTest(unittest.IsolatedAsyncioTestCase): + async def test_create(self) -> None: + cursor = FakeCursor(lastrowid=33) + new_id = await create_terrain_layer( # type: ignore[arg-type] + FakeConnection(cursor), + surface_model_id=22, + layer_name="지표", + geometry_type="GRID", + layer_file_path="B04_wf1_Surface/models/layer_ground.geojson", + file_format="geojson", + file_size_mb=1.5, + statistics={"point_count": 500}, + ) + self.assertEqual(new_id, 33) + self.assertIn("INSERT INTO terrain_layers", cursor.query) + + +class ListSurfaceModelsTest(unittest.IsolatedAsyncioTestCase): + async def test_list(self) -> None: + import datetime + + rows = [ + ( + 22, + "dtm_grid", + "COMPLETE", + 1.0, + "B04_wf1_Surface/models/dtm.npz", + datetime.datetime(2026, 7, 5, 12, 0, 0), + ), + ] + result = await list_surface_models( # type: ignore[arg-type] + FakeConnection(FakeCursor(rows=rows)), PROJECT_ID + ) + self.assertEqual(len(result), 1) + self.assertEqual(result[0]["id"], 22) + self.assertEqual(result[0]["model_type"], "dtm_grid") + self.assertTrue(result[0]["created_at"].startswith("2026-07-05")) diff --git a/tests/test_b04_surface_structurize.py b/tests/test_b04_surface_structurize.py new file mode 100644 index 0000000..456db53 --- /dev/null +++ b/tests/test_b04_surface_structurize.py @@ -0,0 +1,40 @@ +import tempfile +import unittest +from pathlib import Path + +import laspy +import numpy as np + +from B04_wf1_Surface.B04_wf1_Surface_Engine_Structurize import structurize_las + + +class StructurizeLasTest(unittest.TestCase): + def test_structurize_las(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + source = Path(temporary_directory) / "source.las" + output_dir = Path(temporary_directory) / "processed" + las = laspy.LasData(laspy.LasHeader(point_format=3, version="1.2")) + las.x = np.array([100.0, 102.0]) + las.y = np.array([200.0, 204.0]) + las.z = np.array([10.0, 14.0]) + las.intensity = np.array([7, 9], dtype=np.uint16) + las.red = np.array([65535, 256], dtype=np.uint16) + las.green = np.array([32768, 512], dtype=np.uint16) + las.blue = np.array([0, 768], dtype=np.uint16) + las.classification = np.array([2, 5], dtype=np.uint8) + las.write(source) + progress: list[int] = [] + + target = structurize_las(source, output_dir, progress.append) + + self.assertEqual(target, output_dir / "structured.npz") + self.assertEqual(progress[-1], 100) + with np.load(target) as structured: + np.testing.assert_allclose( + structured["xyz"], + np.array([[100.0, 200.0, 10.0], [102.0, 204.0, 14.0]]), + ) + np.testing.assert_array_equal(structured["intensity"], [7, 9]) + np.testing.assert_array_equal(structured["rgb"], [[255, 128, 0], [1, 2, 3]]) + np.testing.assert_array_equal(structured["classification"], [2, 5]) + np.testing.assert_array_equal(structured["total_points"], [2]) diff --git a/tests/test_b05_route_engine.py b/tests/test_b05_route_engine.py new file mode 100644 index 0000000..125502e --- /dev/null +++ b/tests/test_b05_route_engine.py @@ -0,0 +1,64 @@ +import json +import tempfile +import unittest +from pathlib import Path + +import numpy as np + +from B04_wf1_Surface.B04_wf1_Surface_Engine_Pipeline import build_all_terrain_models +from B05_wf2_Route.B05_wf2_Route_Engine import run_route_design +from B05_wf2_Route.B05_wf2_Route_Router import router +from config.config_system import build_surface_model_config + + +class RouteEngineTest(unittest.TestCase): + def _make_dtm(self, root: Path) -> None: + models = root / "B04_wf1_Surface" / "models" + coords = np.linspace(0.0, 60.0, 61) + gx, gy = np.meshgrid(coords, coords) + z = 5.0 + 0.03 * gx + 0.02 * gy + xyz = np.column_stack([gx.ravel(), gy.ravel(), z.ravel()]).astype(np.float64) + bounds = np.array([[0.0, 60.0], [0.0, 60.0], [float(z.min()), float(z.max())]]) + cfg = build_surface_model_config() + cfg["source_filters"] = ["grid_min_z"] + cfg["precompute"] = ["dtm"] + build_all_terrain_models( + {"xyz": xyz, "bounds": bounds}, + {"grid_min_z": np.ones(len(xyz), dtype=bool)}, + models, + cfg, + ) + + def test_run_route_design(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) / "proj" + self._make_dtm(root) + points = { + "bp": {"x": 5.0, "y": 5.0}, + "ep": {"x": 55.0, "y": 55.0}, + "cp": [], + "ap": [], + "fp": [], + } + design = run_route_design(root, "grid_min_z", "dtm", False, points, {}) + + # GeoJSON 저장 확읞 + self.assertEqual(design["route_data_path"], "B05_wf2_Route/route/route_main.geojson") + geojson_file = root / design["route_data_path"] + self.assertTrue(geojson_file.is_file()) + geo = json.loads(geojson_file.read_text(encoding="utf-8")) + self.assertEqual(geo["geometry"]["type"], "LineString") + self.assertGreater(len(geo["geometry"]["coordinates"]), 2) + + # 렌더링 샘플 & 통계 + self.assertTrue(design["render_points"]) + self.assertEqual(design["render_points"][0]["sequence_num"], 0) + self.assertIsNotNone(design["statistics"]["max_slope"]) + self.assertTrue(design["solver_result"]["required_points_ok"]) + + +class RouterRegistrationTest(unittest.TestCase): + def test_routes_registered(self) -> None: + paths = {r.path for r in router.routes} + self.assertIn("/api/projects/{project_id}/route/solve", paths) + self.assertIn("/api/projects/{project_id}/route/confirm", paths) diff --git a/tests/test_b05_route_repository.py b/tests/test_b05_route_repository.py new file mode 100644 index 0000000..8ba3ddc --- /dev/null +++ b/tests/test_b05_route_repository.py @@ -0,0 +1,148 @@ +import json +import unittest +from typing import Any +from uuid import UUID + +from B05_wf2_Route.B05_wf2_Route_Repository import ( + confirm_route, + create_route, + create_route_statistics, + get_latest_route, + insert_route_points, +) + + +class FakeCursor: + def __init__(self, *, lastrowid: int = 0, row: tuple[Any, ...] | None = None) -> None: + self.query = "" + self.arguments: Any = () + self.many_rows: list[tuple[Any, ...]] = [] + self.lastrowid = lastrowid + self._row = row + + async def __aenter__(self) -> "FakeCursor": + return self + + async def __aexit__(self, *_exc: Any) -> None: + return None + + async def execute(self, query: str, arguments: Any = None) -> None: + self.query = query + self.arguments = arguments or () + + async def executemany(self, query: str, rows: list[tuple[Any, ...]]) -> None: + self.query = query + self.many_rows = rows + + async def fetchone(self) -> tuple[Any, ...] | None: + return self._row + + +class FakeConnection: + def __init__(self, cursor: FakeCursor) -> None: + self._cursor = cursor + + def cursor(self) -> FakeCursor: + return self._cursor + + +PROJECT_ID = UUID("550e8400-e29b-41d4-a716-446655440000") + + +class CreateRouteTest(unittest.IsolatedAsyncioTestCase): + async def test_create(self) -> None: + cursor = FakeCursor(lastrowid=7) + route_id = await create_route( # type: ignore[arg-type] + FakeConnection(cursor), + project_id=PROJECT_ID, + surface_model_id=22, + total_length_m=100.0, + start_chainage_m=0.0, + end_chainage_m=100.0, + grade_percent=[2.5, 1.8], + constraints={"max_grade": 0.14}, + algorithm_params={"weights": {"dist": 1.0}}, + route_data_path="B05_wf2_Route/route/route_main.geojson", + ) + self.assertEqual(route_id, 7) + self.assertIn("INSERT INTO routes", cursor.query) + self.assertEqual(cursor.arguments[0], str(PROJECT_ID)) + self.assertEqual(json.loads(cursor.arguments[6]), [2.5, 1.8]) + + async def test_rejects_path_outside_stage(self) -> None: + with self.assertRaises(ValueError): + await create_route( # type: ignore[arg-type] + FakeConnection(FakeCursor(lastrowid=1)), + project_id=PROJECT_ID, + surface_model_id=None, + total_length_m=None, + start_chainage_m=None, + end_chainage_m=None, + grade_percent=None, + constraints=None, + algorithm_params=None, + route_data_path="B04_wf1_Surface/x.geojson", + ) + + +class RoutePointsTest(unittest.IsolatedAsyncioTestCase): + async def test_insert_many(self) -> None: + cursor = FakeCursor() + count = await insert_route_points( # type: ignore[arg-type] + FakeConnection(cursor), + 7, + [ + {"chainage_m": 0.0, "elevation_m": 5.0, "slope_percent": 0.0, "sequence_num": 0}, + {"chainage_m": 10.0, "elevation_m": 5.3, "slope_percent": 3.0, "sequence_num": 1}, + ], + ) + self.assertEqual(count, 2) + self.assertEqual(len(cursor.many_rows), 2) + self.assertEqual(cursor.many_rows[0][0], 7) + + async def test_insert_empty(self) -> None: + count = await insert_route_points(FakeConnection(FakeCursor()), 7, []) # type: ignore[arg-type] + self.assertEqual(count, 0) + + +class RouteStatisticsTest(unittest.IsolatedAsyncioTestCase): + async def test_create(self) -> None: + cursor = FakeCursor(lastrowid=3) + stat_id = await create_route_statistics( # type: ignore[arg-type] + FakeConnection(cursor), + route_id=7, + min_slope=0.0, + max_slope=7.0, + mean_slope=3.5, + cost_score=12.3, + ) + self.assertEqual(stat_id, 3) + self.assertIn("INSERT INTO route_statistics", cursor.query) + + +class LatestRouteTest(unittest.IsolatedAsyncioTestCase): + async def test_found(self) -> None: + import datetime + + row = ( + 7, + "CONFIRMED", + 100.0, + "B05_wf2_Route/route/route_main.geojson", + datetime.datetime(2026, 7, 5, 12, 0, 0), + ) + result = await get_latest_route(FakeConnection(FakeCursor(row=row)), PROJECT_ID) # type: ignore[arg-type] + self.assertEqual(result["id"], 7) + self.assertEqual(result["status"], "CONFIRMED") + + async def test_none(self) -> None: + result = await get_latest_route(FakeConnection(FakeCursor(row=None)), PROJECT_ID) # type: ignore[arg-type] + self.assertIsNone(result) + + +class ConfirmRouteTest(unittest.IsolatedAsyncioTestCase): + async def test_confirm(self) -> None: + cursor = FakeCursor() + await confirm_route(FakeConnection(cursor), 7) # type: ignore[arg-type] + self.assertIn("UPDATE routes SET status = 'CONFIRMED'", cursor.query) + self.assertEqual(cursor.arguments[0], 7) diff --git a/tests/test_b05_route_ridgevalley.py b/tests/test_b05_route_ridgevalley.py new file mode 100644 index 0000000..4f3be82 --- /dev/null +++ b/tests/test_b05_route_ridgevalley.py @@ -0,0 +1,44 @@ +import math +import unittest + +from B05_wf2_Route.B05_wf2_Route_Engine_RidgeValley import ( + _fillet_alignment, + _turn_angle, + resolve_grade_bounds, +) + + +class ResolveGradeBoundsTest(unittest.TestCase): + def test_defaults(self) -> None: + gb = resolve_grade_bounds({}) + self.assertAlmostEqual(gb["min_uphill_grade"], 0.08) + self.assertAlmostEqual(gb["max_uphill_grade"], 0.14) + # 엣지용은 방향 쀑 더 엄격한 값 + self.assertAlmostEqual(gb["max_grade_for_edges"], 0.14) + self.assertAlmostEqual(gb["min_grade_for_edges"], 0.08) + + def test_override(self) -> None: + gb = resolve_grade_bounds({"max_uphill_grade": 0.10, "max_downhill_grade": 0.12}) + self.assertAlmostEqual(gb["max_grade_for_edges"], 0.10) + + +class TurnAngleTest(unittest.TestCase): + def test_straight_is_zero(self) -> None: + self.assertAlmostEqual(_turn_angle([0, 0], [1, 0], [2, 0]), 0.0, places=6) + + def test_right_angle(self) -> None: + self.assertAlmostEqual(_turn_angle([0, 0], [1, 0], [1, 1]), math.pi / 2, places=6) + + +class FilletAlignmentTest(unittest.TestCase): + def test_straight_nodes_preserved(self) -> None: + nodes = [[0, 0, 0], [10, 0, 1], [20, 0, 2]] + poly, radii = _fillet_alignment(nodes, 12.0, 2.0) + self.assertGreaterEqual(len(poly), 2) + # 직선읎므로 시작·끝 좌표 볎졎 + self.assertAlmostEqual(poly[0][0], 0.0) + self.assertAlmostEqual(poly[-1][0], 20.0) + + def test_single_node(self) -> None: + poly, radii = _fillet_alignment([[0, 0, 0]], 12.0, 2.0) + self.assertEqual(len(poly), 1) diff --git a/tests/test_b05_route_skeleton.py b/tests/test_b05_route_skeleton.py new file mode 100644 index 0000000..2a397f6 --- /dev/null +++ b/tests/test_b05_route_skeleton.py @@ -0,0 +1,51 @@ +import unittest + +import numpy as np + +from B05_wf2_Route.B05_wf2_Route_Engine_Skeleton import ( + d8_flow_accumulation_numpy, + extract_skeleton_from_grid, +) + + +class D8FlowAccumulationTest(unittest.TestCase): + def test_valley_concentrates_flow(self) -> None: + # V자 계곡: 쀑앙 ì—Ž(x=20)읎 가장 낮닀 → 흐늄읎 쀑앙에 몚읞닀. + coords = np.linspace(0, 40, 41) + gx, gy = np.meshgrid(coords, coords) + z = 10.0 + np.abs(gx - 20.0) * 0.3 + valid = np.ones_like(z, dtype=bool) + + acc = d8_flow_accumulation_numpy(z, valid) + # 누적 최대 셀은 쀑앙 ì—Ž(index 20) 귌처여알 한닀. + _, max_col = np.unravel_index(acc.argmax(), acc.shape) + self.assertTrue(18 <= max_col <= 22) + self.assertGreater(acc.max(), 10.0) + + def test_flat_grid_uniform(self) -> None: + z = np.full((10, 10), 5.0) + valid = np.ones_like(z, dtype=bool) + acc = d8_flow_accumulation_numpy(z, valid) + # 평지는 하강 읎웃읎 없얎 각 셀 누적읎 1. + self.assertTrue(np.allclose(acc, 1.0)) + + +class ExtractSkeletonTest(unittest.TestCase): + def test_valley_detected_with_low_threshold(self) -> None: + coords = np.linspace(0, 40, 41) + gx, gy = np.meshgrid(coords, coords) + z = 10.0 + np.abs(gx - 20.0) * 0.3 + valid = np.ones_like(z, dtype=bool) + thresholds = { + "valley_acc": 5.0, + "main_valley_acc": 30.0, + "ridge_acc": 5.0, + "main_ridge_acc": 30.0, + } + result = extract_skeleton_from_grid( + coords, coords, z, valid, 1.0, thresholds=thresholds, use_whitebox=False + ) + self.assertEqual(set(result), {"main_ridge", "minor_ridge", "main_valley", "minor_valley"}) + # 계곡 큎래슀에 최소 하나의 polyline읎 검출되얎알 한닀. + valley_total = len(result["main_valley"]) + len(result["minor_valley"]) + self.assertGreater(valley_total, 0) diff --git a/tests/test_b05_route_solver.py b/tests/test_b05_route_solver.py new file mode 100644 index 0000000..8bb4b94 --- /dev/null +++ b/tests/test_b05_route_solver.py @@ -0,0 +1,103 @@ +import tempfile +import unittest +from pathlib import Path + +import numpy as np + +from B04_wf1_Surface.B04_wf1_Surface_Engine_Pipeline import build_all_terrain_models +from B05_wf2_Route.B05_wf2_Route_Engine_Geometry import ( + circumradius_2d, + point_to_polyline_dist_2d, + single_segment_dijkstra, +) +from B05_wf2_Route.B05_wf2_Route_Engine_Solver import solve_optimal_route +from config.config_system import build_surface_model_config + + +class GeometryTest(unittest.TestCase): + def test_circumradius_straight_line_is_inf(self) -> None: + r = circumradius_2d([0, 0], [1, 0], [2, 0]) + self.assertEqual(r, float("inf")) + + def test_circumradius_right_angle(self) -> None: + r = circumradius_2d([0, 0], [1, 0], [1, 1]) + self.assertTrue(np.isfinite(r) and r > 0) + + def test_point_to_polyline_distance(self) -> None: + poly = [[0, 0], [10, 0]] + self.assertAlmostEqual(point_to_polyline_dist_2d(5, 3, poly), 3.0, places=6) + + def test_dijkstra_on_flat_grid(self) -> None: + coords = np.linspace(0.0, 10.0, 11) + z = np.full((11, 11), 5.0) + valid = np.ones((11, 11), dtype=bool) + dz_dx = np.zeros((11, 11)) + dz_dy = np.zeros((11, 11)) + weights = {"dist": 1.0, "grade": 2.0, "side": 1.5, "curve": 0.5, "avoid": 10.0} + path = single_segment_dijkstra( + 0, + 0, + 10, + 10, + coords, + coords, + z, + valid, + dz_dx, + dz_dy, + [], + weights, + 0.14, + 1.0, + 12.0, + ) + self.assertTrue(path) + self.assertEqual(path[0], (0, 0)) + self.assertEqual(path[-1], (10, 10)) + + +class SolveOptimalRouteTest(unittest.TestCase): + def _make_dtm(self, root: Path) -> None: + models = root / "B04_wf1_Surface" / "models" + coords = np.linspace(0.0, 60.0, 61) + gx, gy = np.meshgrid(coords, coords) + z = 5.0 + 0.03 * gx + 0.02 * gy + xyz = np.column_stack([gx.ravel(), gy.ravel(), z.ravel()]).astype(np.float64) + bounds = np.array([[0.0, 60.0], [0.0, 60.0], [float(z.min()), float(z.max())]]) + cfg = build_surface_model_config() + cfg["source_filters"] = ["grid_min_z"] + cfg["precompute"] = ["dtm"] + build_all_terrain_models( + {"xyz": xyz, "bounds": bounds}, + {"grid_min_z": np.ones(len(xyz), dtype=bool)}, + models, + cfg, + ) + + def test_diagonal_route(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) / "proj" + self._make_dtm(root) + points = { + "bp": {"x": 5.0, "y": 5.0}, + "ep": {"x": 55.0, "y": 55.0}, + "cp": [], + "ap": [], + "fp": [], + } + result = solve_optimal_route(root, "grid_min_z", False, points, {}, method="dtm") + + self.assertGreater(len(result["polyline"]), 2) + self.assertTrue(result["required_points_ok"]) + self.assertEqual(len(result["segments"]), 1) + # 대각선 50m×50m → Ꞟ읎 ≈ 70.7m + self.assertAlmostEqual(result["metrics"]["length_m"], 70.71, delta=5.0) + + def test_missing_bp_returns_empty(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) / "proj" + self._make_dtm(root) + points = {"bp": None, "ep": {"x": 55.0, "y": 55.0}, "cp": [], "ap": [], "fp": []} + result = solve_optimal_route(root, "grid_min_z", False, points, {}, method="dtm") + self.assertEqual(result["polyline"], []) + self.assertFalse(result["required_points_ok"]) diff --git a/tests/test_b06_profilecross_section.py b/tests/test_b06_profilecross_section.py new file mode 100644 index 0000000..476af5f --- /dev/null +++ b/tests/test_b06_profilecross_section.py @@ -0,0 +1,71 @@ +import unittest + +import numpy as np + +from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Engine_Sampler import ( + CallableSurfaceSampler, + DtmGridSampler, +) +from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Engine_Section import ( + SectionGenerationOptions, + format_station, + generate_sections, +) + + +class FormatStationTest(unittest.TestCase): + def test_format(self) -> None: + self.assertEqual(format_station(0.0), "STA.0+000.000") + self.assertEqual(format_station(1234.5), "STA.1+234.500") + self.assertEqual(format_station(20.0), "STA.0+020.000") + + +class DtmGridSamplerTest(unittest.TestCase): + def test_sample_inside_and_outside(self) -> None: + x = np.array([0.0, 1.0, 2.0]) + y = np.array([0.0, 1.0, 2.0]) + z = np.array([[0.0, 1.0, 2.0], [1.0, 2.0, 3.0], [2.0, 3.0, 4.0]]) + valid = np.ones((3, 3), dtype=bool) + sampler = DtmGridSampler(x, y, z, valid) + + zq, vq = sampler.sample_xy(np.array([[0.5, 0.5], [10.0, 10.0]])) + self.assertTrue(vq[0]) + self.assertFalse(vq[1]) # 격자 밖 + self.assertAlmostEqual(zq[0], 1.0) # 읎쀑선형 볎간 + + def test_invalid_shape(self) -> None: + with self.assertRaises(ValueError): + DtmGridSampler(np.array([0.0]), np.array([0.0, 1.0]), np.zeros((2, 1)), np.ones((2, 1))) + + +class GenerateSectionsTest(unittest.TestCase): + def test_straight_route(self) -> None: + sampler = CallableSurfaceSampler(lambda xy: 5.0 + 0.05 * xy[:, 0] + 0.02 * xy[:, 1]) + polyline = [[float(x), 0.0, 5.0 + 0.05 * x] for x in range(0, 101, 10)] + + result = generate_sections( + polyline, + sampler, + SectionGenerationOptions(station_interval_m=20.0, cross_half_width_m=10.0), + ) + + self.assertEqual(result["status"], "completed") + self.assertAlmostEqual(result["longitudinal"]["length_m"], 100.0) + self.assertEqual(result["summary"]["station_count"], 6) + self.assertEqual(len(result["cross_sections"]), 6) + # 횡닚 샘플: ±10m륌 0.5m 간격 → 41개 + self.assertEqual(len(result["cross_sections"][0]["samples"]), 41) + # BP/EP 종류 표Ʞ + self.assertEqual(result["longitudinal"]["stations"][0]["kind"], "bp") + self.assertEqual(result["longitudinal"]["stations"][-1]["kind"], "ep") + + def test_invalid_options(self) -> None: + sampler = CallableSurfaceSampler(lambda xy: np.zeros(len(xy))) + polyline = [[0.0, 0.0, 0.0], [10.0, 0.0, 0.0]] + with self.assertRaises(ValueError): + generate_sections(polyline, sampler, SectionGenerationOptions(station_interval_m=0.0)) + + def test_too_short_polyline(self) -> None: + sampler = CallableSurfaceSampler(lambda xy: np.zeros(len(xy))) + with self.assertRaises(ValueError): + generate_sections([[0.0, 0.0, 0.0]], sampler) diff --git a/tests/test_common_util_json.py b/tests/test_common_util_json.py new file mode 100644 index 0000000..b3b633a --- /dev/null +++ b/tests/test_common_util_json.py @@ -0,0 +1,18 @@ +import json +import tempfile +import unittest +from pathlib import Path + +from common_util.common_util_json import atomic_write_json + + +class AtomicWriteJsonTest(unittest.TestCase): + def test_atomic_write_json(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + target = Path(temporary_directory) / "nested" / "result.json" + value = {"status": "완료", "items": [1, 2, 3]} + + atomic_write_json(target, value) + + self.assertEqual(json.loads(target.read_text(encoding="utf-8")), value) + self.assertEqual(list(target.parent.glob("*.tmp")), []) diff --git a/tests/test_common_util_workflow.py b/tests/test_common_util_workflow.py new file mode 100644 index 0000000..9d1f97e --- /dev/null +++ b/tests/test_common_util_workflow.py @@ -0,0 +1,63 @@ +import json +import tempfile +import unittest +from pathlib import Path + +from common_util.common_util_workflow import ( + load_project_workflow, + patch_project_workflow_stale, +) + + +class LoadProjectWorkflowTest(unittest.TestCase): + def test_load_project_workflow(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + project_root = Path(temporary_directory) + + self.assertEqual( + load_project_workflow(project_root), + { + "current_stage": "scan", + "completed": [], + "stale_from": None, + "stage1_confirmed": None, + }, + ) + + saved = {"current_stage": "route", "completed": ["scan"]} + (project_root / "workflow.json").write_text( + json.dumps(saved, ensure_ascii=False), + encoding="utf-8", + ) + self.assertEqual(load_project_workflow(project_root), saved) + + (project_root / "workflow.json").write_text("[]", encoding="utf-8") + with self.assertRaises(ValueError): + load_project_workflow(project_root) + + def test_patch_project_workflow_stale(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + project_root = Path(temporary_directory) + workflow_path = project_root / "workflow.json" + + patch_project_workflow_stale(project_root, "route") + self.assertFalse(workflow_path.exists()) + + saved = { + "current_stage": "surface", + "completed": ["scan"], + "stale_from": None, + "stage1_confirmed": {"method": "tin"}, + } + workflow_path.write_text( + json.dumps(saved, ensure_ascii=False), + encoding="utf-8", + ) + + patch_project_workflow_stale(project_root, "route") + + updated = json.loads(workflow_path.read_text(encoding="utf-8")) + self.assertEqual(updated["stale_from"], "route") + self.assertEqual(updated["current_stage"], saved["current_stage"]) + self.assertEqual(updated["completed"], saved["completed"]) + self.assertEqual(updated["stage1_confirmed"], saved["stage1_confirmed"]) diff --git a/tests/test_config_system_storage.py b/tests/test_config_system_storage.py new file mode 100644 index 0000000..889b29e --- /dev/null +++ b/tests/test_config_system_storage.py @@ -0,0 +1,59 @@ +import os +import tempfile +import unittest +from unittest.mock import patch + +from common_util.common_util_storage import ( + get_project_stage_path, + resolve_stored_project_path, +) +from config.config_system import get_project_storage_path + + +class ProjectStoragePathTest(unittest.TestCase): + def test_get_project_storage_path(self) -> None: + with tempfile.TemporaryDirectory() as storage_root: + with patch("config.config_system.STORAGE_BASE_DIR", storage_root): + result = get_project_storage_path("회사", "사용자", "project-id") + + self.assertEqual( + result, + os.path.join(storage_root, "회사", "사용자", "project-id"), + ) + self.assertTrue(os.path.isdir(result)) + self.assertNotIn(f"{os.sep}projects{os.sep}", result) + + for invalid_segment in ("", ".", "..", "../escape", "nested/path", "nested\\path"): + with self.subTest(invalid_segment=invalid_segment): + with self.assertRaises(ValueError): + get_project_storage_path(invalid_segment, "사용자", "project-id") + + def test_get_project_stage_path(self) -> None: + with tempfile.TemporaryDirectory() as project_root: + result = get_project_stage_path(project_root, "B03_FileInput") + + self.assertEqual(result, os.path.join(project_root, "B03_FileInput")) + self.assertTrue(os.path.isdir(result)) + + for invalid_stage in ("", "B02_ProjRegister", "../escape"): + with self.subTest(invalid_stage=invalid_stage): + with self.assertRaises(ValueError): + get_project_stage_path(project_root, invalid_stage) + + def test_resolve_stored_project_path(self) -> None: + with tempfile.TemporaryDirectory() as storage_root: + with patch("common_util.common_util_storage.STORAGE_BASE_DIR", storage_root): + result = resolve_stored_project_path("storage/company/user/project-id") + expected = os.path.join(storage_root, "company", "user", "project-id") + + self.assertEqual(result, expected) + self.assertTrue(os.path.isdir(result)) + + for invalid_path in ("company/user/project", "../outside", "storage"): + with self.subTest(invalid_path=invalid_path): + with self.assertRaises(ValueError): + resolve_stored_project_path(invalid_path) + + +if __name__ == "__main__": + unittest.main() diff --git a/ui_template/ui_template_locale.ts b/ui_template/ui_template_locale.ts index 41be913..83fa18b 100644 --- a/ui_template/ui_template_locale.ts +++ b/ui_template/ui_template_locale.ts @@ -408,6 +408,32 @@ export const ui_locales = { "지형·포읞튞큎띌우드·도멎 파음을 업로드하섞요.", "Upload terrain, point cloud, and drawing files.", ], + B03_File_Select_Label: ["입력 파음 선택", "Select input files"], + B03_File_Select_Hint: [ + "LAS/LAZ 1개륌 포핚핎 ꎀ렚 PRJ, TFW, TIF 또는 도멎 파음을 선택하섞요.", + "Select exactly one LAS/LAZ file with related PRJ, TFW, TIF, or drawing files.", + ], + B03_File_Selected_Title: ["선택한 파음", "Selected files"], + B03_File_Selected_Empty: ["선택한 파음읎 없습니닀.", "No files selected."], + B03_File_Upload_Button: ["파음 업로드", "Upload files"], + B03_File_Error_Project: [ + "현재 프로젝튞가 선택되지 않았습니닀. 프로젝튞륌 뚌저 생성하거나 선택하섞요.", + "No current project is selected. Create or select a project first.", + ], + B03_File_Error_Required: ["업로드할 파음을 선택하섞요.", "Select files to upload."], + B03_File_Error_Count: [ + "한 번에 업로드할 수 있는 파음 수륌 쎈곌했습니닀.", + "Too many files were selected for one upload.", + ], + B03_File_Error_Las: [ + "LAS 또는 LAZ 파음을 정확히 1개 선택하섞요.", + "Select exactly one LAS or LAZ file.", + ], + B03_File_Error_Extension: ["허용되지 않은 파음 형식입니닀.", "Unsupported file type."], + B03_File_Error_Size: ["파음 크Ʞ 제한을 쎈곌했습니닀.", "File size limit exceeded."], + B03_File_Upload_Success: ["입력 파음 업로드륌 완료했습니닀.", "Input files uploaded."], + B03_File_Upload_Failed: ["파음 업로드에 싀팚했습니닀.", "File upload failed."], + B03_File_Result_Path: ["저장 겜로", "Stored path"], /* --- B04_wf1_Surface 지표멎 몚덞 분석 --- */ B04_Surface_Title: ["1ì°š · 지표멎 몚덞 분석", "Step 1 · Surface Analysis"],