feat(B03): 계획노선 shapefile 입력과 PRJ 2개 분리를 지원한다

원청 정식 계획노선이 shapefile(UTM-K)로, 지형이 별도 PRJ(동부원점 Bessel)로
들어오는데 입력 경로가 shapefile 확장자를 막고 PRJ를 프로젝트당 1개로 전제했다.

- 업로드 허용에 .shp/.shx/.dbf/.cpg 추가, 한 번에 보낼 파일 수 5 -> 10
- B03_FileInput_Engine_Shapefile: ESRI 규격 직접 파싱(GDAL 미사용). 형제 파일이
  아직 안 왔어도 .shp 하나로 기하를 읽는다. .cpg 내용이 949뿐인 실물을 CP949로
  정규화해 한글 속성을 살린다.
- 노선 판독을 read_planned_route로 일원화(CSV/shapefile), PlannedRoute에
  crs_input 추가 - 변환 입력은 EPSG 코드가 아니라 crs_input_from_prj가 주는
  값(EPSG:n 또는 원문 WKT)이다. 실물 PRJ 2종 모두 to_epsg가 None이다.
- shapefile 세트를 input/shp/ 한 폴더에 모은다(GDAL 요건). 노선 PRJ가 그 안에
  남으므로 지형 PRJ(input/prj/)와 파일명 정렬 운에 기대지 않고 갈린다.
  find_project_prj가 지형 PRJ를 프로젝트 좌표계로 고른다.
- 필수 세트를 노선 1종(csv 또는 shp) + prj + tfw로 완화, shp면 shx/dbf 동반 필수.
- UI: 확장자 단독 슬롯 매칭을 basename 그룹핑으로 바꿔 노선 PRJ와 지형 PRJ가
  같은 슬롯을 다투지 않게 하고, 노선 슬롯이 파일 한 벌을 담아 함께 전송한다.

자체검증: tmp/tests/test_route_shapefile_input.py 9개 통과, tsc --noEmit 통과,
ruff check/format 통과. 전체 스위트 잔여 실패 11건은 HEAD 사본(git archive)에서
동일하게 재현되는 기존 실패다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-31 19:28:34 +09:00
co-authored by Claude Opus 5
parent 9bc722dfd6
commit fe72ea041b
18 changed files with 582 additions and 48 deletions
+2 -2
View File
@@ -14,7 +14,7 @@ import { createButton, createInputField, showToast } from "@ui/ui_template_eleme
import { table, text } from "@ui/ui_template_general_blocks";
import { L } from "./B01_Dashboard_UI_Common";
/** 파일 확장자 = 보관함 슬롯 종류(csv·las·prj·tfw·tif). */
/** 파일 확장자 = 보관함 슬롯 종류(csv·shp 세트·las·prj·tfw·tif). */
export function tempFileType(fileName: string): string {
const index = fileName.lastIndexOf(".");
return index >= 0 ? fileName.slice(index + 1).toLowerCase() : "";
@@ -54,7 +54,7 @@ export function openTempFileModal(options: TempModalOptions): void {
const picker = document.createElement("input");
picker.type = "file";
picker.multiple = true;
picker.accept = ".csv,.las,.laz,.tif,.tfw,.prj";
picker.accept = ".csv,.shp,.shx,.dbf,.cpg,.las,.laz,.tif,.tfw,.prj";
picker.className = "b01-temp__hidden-input";
const pickRow = document.createElement("div");
+40 -2
View File
@@ -11,6 +11,34 @@ from B03_FileInput.B03_FileInput_Schema import FileUploadDescriptor
from common_util.common_util_storage import get_project_stage_path
from config.config_system import CHUNK_TEMP_DIR, UPLOAD_CHUNK_SIZE_BYTES, UPLOAD_MAX_MB
# shapefile은 형제 파일이 **같은 폴더·같은 basename**이라야 열린다. 확장자별 폴더에
# 흩어 두면 노선 자체를 못 읽는다. 그래서 세트는 `input/shp/`에 통째로 모은다.
# 부수 효과가 하나 더 있다 — 노선 PRJ가 이 폴더 안에 남으므로 지형 PRJ(`input/prj/`)와
# 파일명 정렬 운에 기대지 않고 갈린다 (2026-08-31).
_SHAPEFILE_FOLDER = "shp"
_SHAPEFILE_OWNED_EXT = frozenset({"shp", "shx", "dbf", "cpg"})
def reconcile_shapefile_members(stage_root: Path) -> list[Path]:
"""먼저 도착해 다른 폴더에 앉은 shapefile 형제 파일을 `input/shp/`로 모은다.
파일이 오는 순서를 강제할 수 없다 — PRJ가 SHP보다 먼저 오면 확장자 규칙만으로는
`input/prj/`에 앉는다. SHP가 들어온 시점에 같은 basename의 형제를 끌어온다.
"""
input_root = stage_root / "input"
shapefile_dir = input_root / _SHAPEFILE_FOLDER
if not shapefile_dir.exists():
return []
moved: list[Path] = []
for shape_path in shapefile_dir.glob("*.shp"):
for extension in ("shx", "dbf", "cpg", "prj"):
stray = input_root / extension / f"{shape_path.stem}.{extension}"
target = shapefile_dir / stray.name
if stray.exists() and not target.exists():
shutil.move(str(stray), str(target))
moved.append(target)
return moved
def resolve_upload_destination(
project_root: str | Path,
@@ -18,8 +46,18 @@ def resolve_upload_destination(
) -> 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()
reconcile_shapefile_members(stage_root)
source_name = Path(descriptor.original_filename)
file_type = source_name.suffix.lower().lstrip(".")
folder = file_type
if file_type in _SHAPEFILE_OWNED_EXT:
folder = _SHAPEFILE_FOLDER
elif file_type == "prj":
# 노선 shapefile의 짝 PRJ만 세트 폴더로. 지형 PRJ는 `input/prj/`에 남는다.
if (stage_root / "input" / _SHAPEFILE_FOLDER / f"{source_name.stem}.shp").exists():
folder = _SHAPEFILE_FOLDER
destination = (stage_root / "input" / folder / descriptor.original_filename).resolve()
if os.path.commonpath((stage_root, destination)) != str(stage_root):
raise ValueError("업로드 저장 경로가 B03 단계 폴더를 벗어났습니다.")
@@ -396,6 +396,10 @@ def analyze_input_metadata(path: str | Path) -> dict[str, Any]:
extension = source.suffix.lower()
if extension == ".csv":
return analyze_planned_route_csv(source)
if extension == ".shp":
from B03_FileInput.B03_FileInput_Engine_Shapefile import analyze_shapefile_metadata
return analyze_shapefile_metadata(source)
if extension in {".las", ".laz"}:
return analyze_las_metadata(source)
if extension == ".prj":
@@ -0,0 +1,244 @@
"""계획노선 shapefile(.shp) 판독 — 기하·속성·좌표계.
GDAL/geopandas를 쓰지 않고 ESRI Shapefile 규격을 직접 읽는다. 업로드 **직후**
메타데이터를 내야 하는데 그 시점엔 형제 파일(.shx/.dbf/.prj)이 아직 다 도착하지
않았을 수 있고, GDAL은 한 짝이라도 비면 열기 자체를 실패하기 때문이다. 규격 파싱은
.shp 하나만으로 기하를 읽어 낸다.
좌표계는 EPSG 코드로 가리지 않는다 — 짝 PRJ 원문을 `crs_input_from_prj()`에 넘겨
"EPSG:n" 또는 **원문 WKT**를 그대로 변환기 입력으로 쓴다(2026-08-31 사용자 확정).
"""
import logging
import struct
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
_HEADER_BYTES = 100
_FILE_CODE = 9994
# 규격상 폴리라인 계열만 계획노선으로 받는다. Z/M 변형도 XY는 같은 자리에 있다.
_POLYLINE_TYPES = {3: "PolyLine", 13: "PolyLineZ", 23: "PolyLineM"}
_SHAPE_TYPE_NAMES = {
0: "Null",
1: "Point",
3: "PolyLine",
5: "Polygon",
8: "MultiPoint",
11: "PointZ",
13: "PolyLineZ",
15: "PolygonZ",
18: "MultiPointZ",
21: "PointM",
23: "PolyLineM",
25: "PolygonM",
28: "MultiPointM",
31: "MultiPatch",
}
def _normalize_codepage(text: str) -> str | None:
""".cpg 내용을 파이썬 인코딩 이름으로 바꾼다.
실물은 `949` 한 줄만 들어 있다(2026-08-31 원청 자료) — `CP949`도 `EUC-KR`도
아니라서 그대로 넘기면 LookupError가 난다.
"""
value = (text or "").strip()
if not value:
return None
if value.isdigit():
return f"cp{value}"
upper = value.upper().replace("-", "").replace("_", "")
if upper in {"ANSI", "OEM", "SYSTEM"}:
return "cp949"
return value
def shapefile_encoding(path: Path) -> str:
"""짝 .cpg에 적힌 인코딩. 없으면 국내 자료 관례대로 CP949."""
cpg_path = path.with_suffix(".cpg")
if cpg_path.exists():
try:
encoding = _normalize_codepage(cpg_path.read_text(encoding="ascii", errors="ignore"))
if encoding:
"".encode(encoding) # 이름이 실재하는지 확인 — 없으면 LookupError
return encoding
except (OSError, LookupError):
logger.warning("shapefile .cpg 인코딩을 해석하지 못했습니다: %s", cpg_path.name)
return "cp949"
def shapefile_crs_input(path: Path) -> str | None:
"""짝 .prj를 `Transformer.from_crs` 입력 문자열로 정규화해 돌려준다."""
prj_path = path.with_suffix(".prj")
if not prj_path.exists():
return None
from common_util.common_util_crs import crs_input_from_prj
return crs_input_from_prj(prj_path.read_text(encoding="utf-8", errors="ignore"))
def shapefile_epsg_label(path: Path) -> int | None:
"""짝 .prj의 EPSG **라벨**. 변환에는 쓰지 않는다 — 메타 표시·로그용."""
prj_path = path.with_suffix(".prj")
if not prj_path.exists():
return None
from pyproj import CRS
from common_util.common_util_crs import identify_epsg
text = prj_path.read_text(encoding="utf-8", errors="ignore")
try:
return identify_epsg(CRS.from_wkt(text), text)
except Exception: # pragma: no cover — WKT 불량은 라벨 없음으로 흘린다
return None
def _read_header(blob: bytes) -> dict[str, Any]:
if len(blob) < _HEADER_BYTES:
raise ValueError("shapefile 헤더가 100바이트에 못 미칩니다.")
file_code = struct.unpack(">i", blob[0:4])[0]
if file_code != _FILE_CODE:
raise ValueError("shapefile 파일 코드가 규격(9994)과 다릅니다.")
declared_bytes = struct.unpack(">i", blob[24:28])[0] * 2
shape_type = struct.unpack("<i", blob[32:36])[0]
x_min, y_min, x_max, y_max = struct.unpack("<4d", blob[36:68])
z_min, z_max = struct.unpack("<2d", blob[68:84])
return {
"shape_type": shape_type,
"shape_type_name": _SHAPE_TYPE_NAMES.get(shape_type, f"Unknown({shape_type})"),
"declared_bytes": declared_bytes,
"bounds": {
"x_min": x_min,
"x_max": x_max,
"y_min": y_min,
"y_max": y_max,
"z_min": z_min,
"z_max": z_max,
},
}
def _read_polyline_record(blob: bytes, offset: int, content_bytes: int) -> list[list[tuple]]:
"""폴리라인 레코드 하나를 파트별 정점 목록으로 푼다."""
record_type = struct.unpack("<i", blob[offset : offset + 4])[0]
if record_type == 0: # Null shape — 규격상 건너뛴다
return []
if record_type not in _POLYLINE_TYPES:
raise ValueError(f"계획노선은 폴리라인이어야 합니다(받은 형상: {record_type}).")
cursor = offset + 4 + 32 # 형상종류 + 레코드 bbox
part_count, point_count = struct.unpack("<2i", blob[cursor : cursor + 8])
cursor += 8
parts = list(struct.unpack(f"<{part_count}i", blob[cursor : cursor + part_count * 4]))
cursor += part_count * 4
flat_xy = struct.unpack(f"<{point_count * 2}d", blob[cursor : cursor + point_count * 16])
cursor += point_count * 16
zs: tuple[float, ...] = ()
if record_type == 13:
cursor += 16 # Z 범위
end = cursor + point_count * 8
if end - offset <= content_bytes:
zs = struct.unpack(f"<{point_count}d", blob[cursor:end])
points = [
(flat_xy[index * 2], flat_xy[index * 2 + 1], zs[index] if zs else 0.0)
for index in range(point_count)
]
boundaries = [*parts, point_count]
return [points[boundaries[i] : boundaries[i + 1]] for i in range(part_count)]
def read_shapefile_parts(path: str | Path) -> list[list[tuple]]:
""".shp의 모든 폴리라인 파트를 (x, y, z) 정점 목록으로 읽는다."""
source = Path(path)
blob = source.read_bytes()
header = _read_header(blob)
if header["shape_type"] not in _POLYLINE_TYPES and header["shape_type"] != 0:
raise ValueError(
f"계획노선 shapefile은 폴리라인이어야 합니다(받은 형상: {header['shape_type_name']})."
)
limit = min(len(blob), header["declared_bytes"] or len(blob))
parts: list[list[tuple]] = []
offset = _HEADER_BYTES
while offset + 8 <= limit:
content_bytes = struct.unpack(">i", blob[offset + 4 : offset + 8])[0] * 2
if content_bytes <= 0:
break
parts.extend(_read_polyline_record(blob, offset + 8, content_bytes))
offset += 8 + content_bytes
return [part for part in parts if len(part) >= 2]
def read_shapefile_attributes(path: str | Path) -> dict[str, str]:
"""짝 .dbf 첫 레코드의 속성. 없거나 못 읽으면 빈 dict."""
dbf_path = Path(path).with_suffix(".dbf")
if not dbf_path.exists():
return {}
encoding = shapefile_encoding(Path(path))
try:
blob = dbf_path.read_bytes()
header_bytes, record_bytes = struct.unpack("<2H", blob[8:12])
fields: list[tuple[str, int]] = []
cursor = 32
while cursor < header_bytes - 1 and blob[cursor] != 0x0D:
descriptor = blob[cursor : cursor + 32]
name = descriptor[0:11].split(b"\x00")[0].decode(encoding, errors="replace").strip()
fields.append((name, descriptor[16]))
cursor += 32
record = blob[header_bytes : header_bytes + record_bytes]
if not record:
return {}
values: dict[str, str] = {}
position = 1 # 첫 바이트는 삭제 표시
for name, width in fields:
raw = record[position : position + width]
values[name] = raw.decode(encoding, errors="replace").strip()
position += width
return values
except (OSError, struct.error, ValueError):
logger.warning("shapefile .dbf 속성을 읽지 못했습니다: %s", dbf_path.name)
return {}
def _route_name_from_attributes(attributes: dict[str, str], fallback: str) -> str:
for key in ("대상지", "노선명", "route_name", "NAME", "name"):
value = attributes.get(key)
if value:
return value
return fallback
def analyze_shapefile_metadata(path: str | Path) -> dict[str, Any]:
"""계획노선 shapefile의 B03 메타데이터를 만든다."""
source = Path(path)
header = _read_header(source.read_bytes()[:_HEADER_BYTES])
parts = read_shapefile_parts(source)
attributes = read_shapefile_attributes(source)
point_count = sum(len(part) for part in parts)
missing = [
extension for extension in (".shx", ".dbf") if not source.with_suffix(extension).exists()
]
return {
"file": source.name,
"extension": "shp",
"size_bytes": source.stat().st_size,
"purpose": "planned_route",
"route_name": _route_name_from_attributes(attributes, source.stem),
"shape_type": header["shape_type_name"],
"part_count": len(parts),
"point_count": point_count,
"epsg": shapefile_epsg_label(source),
"crs_input": shapefile_crs_input(source),
"encoding": shapefile_encoding(source),
"attributes": attributes,
"missing_members": missing,
"bounds": header["bounds"],
"start_point": list(parts[0][0]) if parts else None,
"end_point": list(parts[-1][-1]) if parts else None,
}
+11 -4
View File
@@ -65,7 +65,11 @@ async def get_project_input_readiness(
connection: aiomysql.Connection,
project_id: UUID,
) -> tuple[set[str], int | None, int | None]:
"""업로드 파일 유형, 최신 포인트클라우드 입력 ID, 최신 계획노선 CSV 입력 ID를 반환한다."""
"""업로드 파일 유형, 최신 포인트클라우드 입력 ID, 최신 계획노선 입력 ID를 반환한다.
계획노선은 CSV 또는 shapefile이다. 둘 다 있으면 shapefile을 고른다 —
`find_planned_route_file()`의 우선순위와 같아야 WF1 입력과 실제 판독 대상이 갈리지 않는다.
"""
async with connection.cursor(aiomysql.DictCursor) as cursor:
await cursor.execute(
"""
@@ -83,12 +87,15 @@ async def get_project_input_readiness(
(int(row["id"]) for row in rows if str(row.get("file_type") or "") in {"las", "laz"}),
None,
)
# LAS 없는 설계(2026-08-30)의 WF1 입력 — 계획노선 CSV가 분석 원천이 된다.
route_csv_id = next(
# LAS 없는 설계(2026-08-30)의 WF1 입력 — 계획노선 파일이 분석 원천이 된다.
route_id = next(
(int(row["id"]) for row in rows if str(row.get("file_type") or "") == "shp"),
None,
) or next(
(int(row["id"]) for row in rows if str(row.get("file_type") or "") == "csv"),
None,
)
return file_types, point_cloud_id, route_csv_id
return file_types, point_cloud_id, route_id
async def get_project_storage_relative_path(
+12 -5
View File
@@ -12,15 +12,22 @@ import aiomysql
from config.config_system import TEMP_UPLOAD_RETENTION_DAYS
# 묶음이 "완료"로 넘어가려면 있어야 하는 파일 종류. B03 필수 슬롯과 같은 기준이다.
REQUIRED_TEMP_FILE_TYPES = frozenset({"csv", "prj", "tfw"})
REQUIRED_TEMP_FILE_TYPES = frozenset({"prj", "tfw"})
POINT_CLOUD_FILE_TYPES = frozenset({"las", "laz"})
# 계획노선은 CSV 또는 shapefile 중 하나 (2026-08-31).
ROUTE_FILE_TYPES = frozenset({"csv", "shp"})
SHAPEFILE_REQUIRED_TYPES = frozenset({"shx", "dbf"})
def is_batch_required_complete(file_types: set[str]) -> bool:
"""필수 파일(csv·prj·tfw + las/laz 1종)이 모두 찼는지."""
return REQUIRED_TEMP_FILE_TYPES.issubset(file_types) and bool(
file_types & POINT_CLOUD_FILE_TYPES
)
"""필수 파일(계획노선 1종 + prj·tfw + las/laz 1종)이 모두 찼는지."""
if not REQUIRED_TEMP_FILE_TYPES.issubset(file_types):
return False
if not file_types & ROUTE_FILE_TYPES:
return False
if "shp" in file_types and not SHAPEFILE_REQUIRED_TYPES.issubset(file_types):
return False
return bool(file_types & POINT_CLOUD_FILE_TYPES)
async def create_temp_batch(
+11 -2
View File
@@ -76,8 +76,12 @@ _ANALYSIS_RUNNING_MESSAGE = "이 프로젝트는 지금 분석 중입니다. 끝
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/projects", tags=["B03 File Input"])
_REQUIRED_FILE_TYPES = frozenset({"csv", "prj", "tfw"})
_REQUIRED_FILE_TYPES = frozenset({"prj", "tfw"})
_POINT_CLOUD_FILE_TYPES = frozenset({"las", "laz"})
# 계획노선은 CSV 또는 shapefile 중 하나면 된다 (2026-08-31 — 원청 정식 노선이 shapefile).
_ROUTE_FILE_TYPES = frozenset({"csv", "shp"})
# shapefile은 이 셋이 다 있어야 열린다. .cpg는 없으면 CP949로 읽으므로 필수가 아니다.
_SHAPEFILE_REQUIRED_TYPES = frozenset({"shx", "dbf"})
def _total_chunks(size_bytes: int, chunk_size_bytes: int) -> int:
@@ -95,6 +99,11 @@ def _is_point_cloud_result(result: UploadedFileResult) -> bool:
def _missing_required_file_types(file_types: set[str], las_free: bool = False) -> list[str]:
missing = sorted(_REQUIRED_FILE_TYPES - file_types)
if not file_types.intersection(_ROUTE_FILE_TYPES):
missing.append("csv/shp")
# shapefile로 왔으면 형제 파일이 다 있어야 노선을 읽는다.
if "shp" in file_types:
missing.extend(sorted(_SHAPEFILE_REQUIRED_TYPES - file_types))
# LAS 없는 설계(도엽등고선 기반, 2026-08-30)는 LAS 필수를 면제한다.
if not las_free and not file_types.intersection(_POINT_CLOUD_FILE_TYPES):
missing.append("las/laz")
@@ -164,7 +173,7 @@ async def _complete_file_input_if_ready(
if not las_free:
raise ValueError("LAS 또는 LAZ 입력 파일을 찾을 수 없습니다.")
if route_csv_input_id is None:
raise ValueError("계획 노선 CSV 입력 파일을 찾을 수 없습니다.")
raise ValueError("계획 노선 입력 파일(CSV 또는 shapefile)을 찾을 수 없습니다.")
# 자료가 갈렸으니 옛 계산 결과(파일 + DB)를 지우고 진행 표시도 되돌린다. 남겨 두면
# 아직 다시 만들어지지 않은 뒷단계 화면이 옛 결과를 새 자료 것인 양 보여준다.
stored_path = await get_project_storage_relative_path(connection, project_id)
+3 -3
View File
@@ -34,16 +34,16 @@ def _planned_route_points_in_project_crs(project_root: Path) -> list[dict[str, f
from B04_PreProcess.B04_PreProcess_Engine_Extent import project_epsg_from_prj
from common_util.common_util_route_geometry import (
find_planned_route_file,
read_planned_route_csv,
read_planned_route,
)
route_file = find_planned_route_file(project_root / "B03_FileInput" / "input")
planned = read_planned_route_csv(route_file) if route_file else None
planned = read_planned_route(route_file) if route_file else None
if planned is None or len(planned.vertices) < 2:
return None
target_epsg = project_epsg_from_prj(project_root)
points = [(float(vertex.x), float(vertex.y)) for vertex in planned.vertices]
source_epsg = f"EPSG:{planned.epsg}" if planned.epsg else target_epsg
source_epsg = planned.crs_input or target_epsg
if source_epsg.upper() != target_epsg.upper():
from pyproj import Transformer
+40 -6
View File
@@ -44,6 +44,7 @@ import {
getExtension,
initializeSlots,
makeSessionKey,
splitShapefileSelection,
type FileSlot,
type FileSlotState,
type StoredUploadSession,
@@ -289,6 +290,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
async function assignFileToSlot(
file: File,
targetSlot?: FileSlot,
companions: File[] = [],
): Promise<void> {
const extension = getExtension(file.name);
const state = targetSlot
@@ -323,6 +325,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
}
state.file = file;
state.companions = companions.length ? companions : undefined;
state.uploadSessionId = undefined;
state.uploadStatus = "pending";
state.progressBytes = 0;
@@ -353,8 +356,12 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
// 개수는 "고른 파일 수"가 아니라 **최종적으로 차는 슬롯 수**로 센다.
// 같은 슬롯을 다시 고르는 것은 교체라 개수가 늘지 않는다 — 더하기로 세면 5개를 고른
// 뒤 파일 선택 영역으로 하나만 바꾸려 해도 초과로 막힌다(2026-08-08).
// 계획노선 shapefile은 파일 한 벌이 슬롯 하나로 간다 — 확장자만 보면 노선 PRJ가
// 지형 PRJ 슬롯을 덮어쓴다(2026-08-31).
const { shapefile, rest } = splitShapefileSelection(files);
const occupied = new Set(selectedStates().map((state) => state.slot));
for (const file of files) {
if (shapefile) occupied.add(targetSlot ?? "csv");
for (const file of rest) {
const extension = getExtension(file.name);
const slot =
targetSlot ??
@@ -369,7 +376,14 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
}
pageError.textContent = blocked ? L("B03_File_Error_LasFreeBlocked") : "";
void (async () => {
for (const file of files) await assignFileToSlot(file, targetSlot);
if (shapefile) {
await assignFileToSlot(
shapefile.primary,
targetSlot,
shapefile.companions,
);
}
for (const file of rest) await assignFileToSlot(file, targetSlot);
await detectPausedUploads();
})();
}
@@ -381,6 +395,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
localStorage.removeItem(makeSessionKey(activeProjectId, state.file));
}
state.file = undefined;
state.companions = undefined;
state.uploadSessionId = undefined;
state.uploadStatus = "pending";
state.progressBytes = 0;
@@ -665,13 +680,32 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
setUploading(true);
const uploaded: UploadedFileResult[] = [];
try {
for (let index = 0; index < targetStates.length; index += 1) {
const state = targetStates[index];
// 슬롯 하나가 파일 여럿일 수 있다(계획노선 shapefile 세트) — 완료 신호는 **마지막
// 파일 한 건**에만 붙어야 하므로 슬롯이 아니라 파일 단위로 펼쳐서 센다.
const jobs: { state: FileSlotState; file: File }[] = [];
for (const state of targetStates) {
for (const companion of state.companions ?? [])
jobs.push({ state, file: companion });
if (state.file) jobs.push({ state, file: state.file });
}
for (let index = 0; index < jobs.length; index += 1) {
const { state, file } = jobs[index];
// 동반 파일은 진행률·세션을 대표 파일과 섞지 않도록 임시 상태로 올린다.
const uploadState =
file === state.file
? state
: {
...state,
file,
companions: undefined,
uploadSessionId: undefined,
progressBytes: 0,
};
uploaded.push(
...(await uploadOneFile(
activeProjectId,
state,
index === targetStates.length - 1,
uploadState,
index === jobs.length - 1,
() => renderSlot(state.slot),
lasFreeDesign,
)),
+45 -1
View File
@@ -13,6 +13,12 @@ export interface SlotConfig {
export interface FileSlotState extends SlotConfig {
file?: File;
/**
* shapefile의 (.shx/.dbf/.cpg/.prj).
* GDAL이 (2026-08-31).
* (`file`) .shp이고, .
*/
companions?: File[];
uploadSessionId?: string;
uploadStatus: UploadStatus;
progressBytes: number;
@@ -45,7 +51,7 @@ const SLOT_CONFIGS: readonly SlotConfig[] = [
slot: "csv",
labelKey: "B03_File_Slot_PlannedRoute",
icon: "⌁",
extensions: [".csv"],
extensions: [".csv", ".shp"],
isRequired: true,
},
{
@@ -83,6 +89,44 @@ export function getExtension(fileName: string): string {
return index >= 0 ? fileName.slice(index).toLowerCase() : "";
}
export function getBaseName(fileName: string): string {
const index = fileName.lastIndexOf(".");
return index >= 0 ? fileName.slice(0, index) : fileName;
}
/** shapefile 동반 파일. `.prj`가 여기 들어 있어 지형 PRJ와 basename으로 갈린다. */
const SHAPEFILE_COMPANION_EXT = [".shx", ".dbf", ".cpg", ".prj"];
/**
* shapefile .
*
* PRJ와 PRJ가 . `.shp`
* **basename이 ** , `.prj` .
*/
export function splitShapefileSelection(files: readonly File[]): {
shapefile: { primary: File; companions: File[] } | null;
rest: File[];
} {
const primary = files.find((file) => getExtension(file.name) === ".shp");
if (!primary) return { shapefile: null, rest: [...files] };
const stem = getBaseName(primary.name);
const companions: File[] = [];
const rest: File[] = [];
for (const file of files) {
if (file === primary) continue;
const extension = getExtension(file.name);
if (
SHAPEFILE_COMPANION_EXT.includes(extension) &&
getBaseName(file.name) === stem
) {
companions.push(file);
} else {
rest.push(file);
}
}
return { shapefile: { primary, companions }, rest };
}
export function formatBytes(bytes: number): string {
const gb = bytes / 1024 / 1024 / 1024;
if (gb >= 1) return `${gb.toFixed(2)} GB`;
+9 -4
View File
@@ -279,11 +279,16 @@ def download_geodata(
실패해도 예외를 밖으로 던지지 않는다 분석 본체를 막지 않는다.
"""
try:
# 입력 파일과 같은 폴더의 .prj를 우선 사용 (PLAN E-1: 전체 재귀 glob 제거)
prj_candidates = sorted(prj_search_dir.glob("*.prj")) or sorted(
project_root.glob("B03_FileInput/**/*.prj")
# 입력 파일과 같은 폴더의 .prj를 우선 사용 (PLAN E-1: 전체 재귀 glob 제거).
# 없으면 지형 PRJ — 노선 shapefile 세트의 PRJ가 섞이지 않도록 고른다(2026-08-31).
from common_util.common_util_crs import find_project_prj
prj_candidates = sorted(prj_search_dir.glob("*.prj"))
prj_path = (
prj_candidates[0]
if prj_candidates
else (find_project_prj(project_root) or project_root / "result.prj")
)
prj_path = prj_candidates[0] if prj_candidates else project_root / "result.prj"
from B04_PreProcess.B04_PreProcess_Engine_Extent import (
download_extent,
@@ -144,10 +144,16 @@ def planned_route_bounds(project_root: Path, target_epsg: str) -> dict[str, floa
def project_epsg_from_prj(project_root: Path) -> str:
"""프로젝트 PRJ에서 좌표계를 읽는다. 없으면 중부원점(EPSG:5186)."""
"""프로젝트 PRJ에서 좌표계를 읽는다. 없으면 중부원점(EPSG:5186).
PRJ가 이상 올라오므로(노선 세트 + 지형) 지형 PRJ를 고른다 `find_project_prj`.
"""
from common_util.common_util_crs import find_project_prj
from .B04_PreProcess_Engine_VWorld import get_epsg_from_prj
for prj_path in sorted(project_root.glob("B03_FileInput/**/*.prj")):
prj_path = find_project_prj(project_root)
if prj_path is not None:
return get_epsg_from_prj(prj_path.read_text(encoding="utf-8", errors="ignore"))
return "EPSG:5186"
@@ -451,14 +451,14 @@ def build_sheet_surface_from_route(
"""B03 업로드 계획노선 CSV를 찾아 방식별 도엽 서피스를 만든다. 없으면 빈 목록."""
from common_util.common_util_route_geometry import (
find_planned_route_file,
read_planned_route_csv,
read_planned_route,
)
route_file = find_planned_route_file(project_root / "B03_FileInput" / "input")
if route_file is None:
logger.warning("도엽 서피스: 계획 노선 파일이 없습니다.")
return []
planned = read_planned_route_csv(route_file)
planned = read_planned_route(route_file)
if planned is None or len(planned.vertices) < 2:
logger.warning("도엽 서피스: 계획 노선 파일을 읽지 못했습니다: %s", route_file.name)
return []
@@ -478,7 +478,7 @@ def run_sheet_surface_analysis(
반환 형식은 `run_surface_analysis()` 같다(save_surface_analysis_to_db 호환).
"""
from common_util.common_util_route_geometry import read_planned_route_csv
from common_util.common_util_route_geometry import read_planned_route
def _report(percent: int, stage: str, message: str) -> None:
if on_progress is not None:
@@ -490,7 +490,7 @@ def run_sheet_surface_analysis(
processed_dir.mkdir(parents=True, exist_ok=True)
models_dir.mkdir(parents=True, exist_ok=True)
planned = read_planned_route_csv(route_csv_path)
planned = read_planned_route(route_csv_path)
if planned is None or len(planned.vertices) < 2:
raise ValueError(f"계획 노선 파일을 읽지 못했습니다: {route_csv_path.name}")
epsg = planned.epsg or 5186
+3 -3
View File
@@ -250,7 +250,7 @@ async def get_planned_route(project_id: UUID) -> dict[str, Any] | JSONResponse:
from B04_PreProcess.B04_PreProcess_Engine_Extent import project_epsg_from_prj
from common_util.common_util_route_geometry import (
find_planned_route_file,
read_planned_route_csv,
read_planned_route,
)
pool = get_db_pool()
@@ -259,7 +259,7 @@ async def get_planned_route(project_id: UUID) -> dict[str, Any] | JSONResponse:
stored_path = await get_project_storage_relative_path(connection, project_id)
project_root = Path(resolve_stored_project_path(stored_path))
route_file = find_planned_route_file(project_root / "B03_FileInput" / "input")
planned = read_planned_route_csv(route_file) if route_file else None
planned = read_planned_route(route_file) if route_file else None
if planned is None or len(planned.vertices) < 2:
return {"status": "success", "points": []}
@@ -267,7 +267,7 @@ async def get_planned_route(project_id: UUID) -> dict[str, Any] | JSONResponse:
# 그것이 프로젝트 좌표계와 다르면 여기서 한 번 옮긴다.
target_epsg = project_epsg_from_prj(project_root)
points = [(float(vertex.x), float(vertex.y)) for vertex in planned.vertices]
source_epsg = f"EPSG:{planned.epsg}" if planned.epsg else target_epsg
source_epsg = planned.crs_input or target_epsg
if source_epsg != target_epsg:
from pyproj import Transformer
+16
View File
@@ -10,6 +10,7 @@ from __future__ import annotations
import logging
import re
from pathlib import Path
from pyproj import CRS
@@ -123,6 +124,21 @@ def identify_epsg(crs: CRS, wkt_text: str | None = None) -> int | None:
return None
def find_project_prj(project_root: Path) -> Path | None:
"""프로젝트 **작업 좌표계**를 정하는 PRJ를 고른다.
자료가 이상의 좌표계로 들어온다(실측 2026-08-31 노선 shapefile은 UTM-K,
지형은 동부원점 Bessel). 서피스 격자가 모델좌표의 주인이므로 지형 PRJ가 우선이고,
노선 PRJ는 shapefile 세트 폴더(`input/shp/`) 안에 있어 여기서 섞이지 않는다.
파일명 정렬 순서에 기대던 `glob()[0]` 대신한다.
"""
terrain = sorted(project_root.glob("B03_FileInput/input/prj/*.prj"))
if terrain:
return terrain[0]
remainder = sorted(project_root.glob("B03_FileInput/**/*.prj"))
return remainder[0] if remainder else None
def crs_input_from_prj(prj_text: str) -> str | None:
"""PRJ 텍스트를 `Transformer.from_crs` 입력 문자열로 정규화한다.
+95 -7
View File
@@ -4,7 +4,7 @@
정의한다. 어느 한쪽 페이지 폴더에 두면 반대 방향 import가 생긴다.
노선 원천은 가지다.
· B03에 업로드된 **계획 노선 파일**(CSV) 배수유역 분석의 입력
· B03에 업로드된 **계획 노선 파일**(CSV·shapefile) 배수유역 분석의 입력
· DB `route_points` B05에서 탐색·확정한 노선
같은 `RouteVertex` 목록으로 바꿔 아래 함수들이 그대로 받는다.
"""
@@ -14,6 +14,7 @@ from __future__ import annotations
import csv
import logging
import math
import struct
from dataclasses import dataclass
from pathlib import Path
from typing import Any
@@ -60,6 +61,10 @@ class PlannedRoute:
epsg: int | None
name: str | None
source: Path
# `Transformer.from_crs` 입력 문자열("EPSG:n" 또는 **원문 WKT**). 좌표계를 EPSG 코드로
# 가리면 라벨이 안 붙는 PRJ가 통째로 막히므로, 변환은 언제나 이 값으로 한다.
# `epsg`는 표시·로그용 라벨일 뿐이다 (2026-08-31 사용자 확정).
crs_input: str | None = None
@property
def line(self) -> LineString:
@@ -109,17 +114,100 @@ def read_planned_route_csv(path: Path) -> PlannedRoute | None:
logger.info(
"계획 노선 %s: 정점 %d개, 연장 %.0fm, EPSG %s", path.name, len(vertices), cumulative, epsg
)
return PlannedRoute(vertices=vertices, epsg=epsg, name=name, source=path)
return PlannedRoute(
vertices=vertices,
epsg=epsg,
name=name,
source=path,
crs_input=f"EPSG:{epsg}" if epsg else None,
)
def read_planned_route_shapefile(path: Path) -> PlannedRoute | None:
"""계획 노선 shapefile을 읽어 정점 목록으로 바꾼다.
원청 자료는 **2차원 폴리라인**이라 z가 없다. 지반고는 확정 서피스에서 뽑으므로
(`build_surface_sampler`) 여기서는 0으로 채운다 CSV의 z와 같은 취급이다.
파트가 여럿이면 가장 것을 노선으로 본다.
"""
from B03_FileInput.B03_FileInput_Engine_Shapefile import (
read_shapefile_attributes,
read_shapefile_parts,
shapefile_crs_input,
shapefile_epsg_label,
)
try:
parts = read_shapefile_parts(path)
except (OSError, ValueError, struct.error) as error:
logger.warning("계획 노선 shapefile을 읽지 못했습니다: %s (%s)", path, error)
return None
if not parts:
logger.warning("계획 노선 shapefile에 폴리라인이 없습니다: %s", path)
return None
points = max(parts, key=len)
if len(points) < 2:
logger.warning("계획 노선 shapefile에 좌표가 2점 미만입니다: %s", path)
return None
vertices: list[RouteVertex] = []
cumulative = 0.0
previous: tuple[float, float] | None = None
for x, y, z in points:
if previous is not None:
cumulative += math.dist(previous, (x, y))
vertices.append(RouteVertex(x=x, y=y, z=z, chainage_m=cumulative))
previous = (x, y)
attributes = read_shapefile_attributes(path)
name = next(
(
attributes[key]
for key in ("대상지", "노선명", "route_name", "name")
if attributes.get(key)
),
path.stem,
)
epsg = shapefile_epsg_label(path)
logger.info(
"계획 노선 %s: 정점 %d개, 연장 %.0fm, EPSG %s(라벨)",
path.name,
len(vertices),
cumulative,
epsg,
)
return PlannedRoute(
vertices=vertices,
epsg=epsg,
name=name,
source=path,
crs_input=shapefile_crs_input(path),
)
def read_planned_route(path: Path) -> PlannedRoute | None:
"""계획 노선 파일을 확장자에 맞는 판독기로 읽는다(CSV·shapefile)."""
if path.suffix.lower() == ".shp":
return read_planned_route_shapefile(path)
return read_planned_route_csv(path)
def find_planned_route_file(input_dir: Path) -> Path | None:
"""B03 입력 폴더에서 계획 노선 파일을 찾는다. 여러 개면 가장 최근 것."""
"""B03 입력 폴더에서 계획 노선 파일을 찾는다.
shapefile을 CSV보다 우선한다 원청 정식 노선이 shapefile로 오고, CSV는 예전
자료거나 좌표만 뽑아 보조본인 경우가 많다. 같은 종류가 여럿이면 가장 최근 .
"""
if not input_dir.exists():
return None
candidates = sorted(
input_dir.rglob("*.csv"), key=lambda item: item.stat().st_mtime, reverse=True
)
return candidates[0] if candidates else None
for pattern in ("*.shp", "*.csv"):
candidates = sorted(
input_dir.rglob(pattern), key=lambda item: item.stat().st_mtime, reverse=True
)
if candidates:
return candidates[0]
return None
def build_route_vertices(points: list[dict[str, Any]]) -> list[RouteVertex]:
+16 -1
View File
@@ -42,7 +42,22 @@ export const PROGRESS_UPDATE_INTERVAL_MS = 10_000;
export const SERVICE_WORKER_PATH = "/assets/B03_FileInput_ServiceWorker.js";
/** 허용 확장자 (계획노선/지형/포인트클라우드/도면) */
export const UPLOAD_ALLOWED_EXT = [".csv", ".las", ".laz", ".tif", ".tfw", ".prj", ".dxf"] as const;
export const UPLOAD_ALLOWED_EXT = [
".csv",
".shp",
".shx",
".dbf",
".cpg",
".las",
".laz",
".tif",
".tfw",
".prj",
".dxf",
] as const;
/** 계획노선 shapefile 한 벌 — 노선 슬롯에 통째로 담긴다. */
export const SHAPEFILE_MEMBER_EXT = [".shp", ".shx", ".dbf", ".cpg", ".prj"] as const;
/* -----------------------------------------------------------------------------
* 3. WebCAD / 3D
+19 -2
View File
@@ -70,9 +70,26 @@ DB_POOL_MAX = int(os.getenv("DB_POOL_MAX", "20"))
# 4. 파일 업로드 제한
# ─────────────────────────────────────────────────────────────────────────
UPLOAD_MAX_MB = int(os.getenv("UPLOAD_MAX_MB", str(30 * 1024)))
UPLOAD_MAX_FILES = int(os.getenv("UPLOAD_MAX_FILES", "5"))
# 한 번에 보낼 수 있는 **파일 수**. 화면의 슬롯 수(5)와 다르다 — 계획노선 shapefile은
# 한 슬롯에 5개(.shp/.shx/.dbf/.cpg/.prj)가 한 벌로 들어오기 때문이다(2026-08-31).
UPLOAD_MAX_FILES = int(os.getenv("UPLOAD_MAX_FILES", "10"))
UPLOAD_CHUNK_SIZE_BYTES = int(os.getenv("UPLOAD_CHUNK_SIZE_BYTES", str(1024 * 1024 * 1024)))
UPLOAD_ALLOWED_EXT = [".csv", ".las", ".laz", ".tif", ".tfw", ".prj", ".dxf"]
UPLOAD_ALLOWED_EXT = [
".csv",
".shp",
".shx",
".dbf",
".cpg",
".las",
".laz",
".tif",
".tfw",
".prj",
".dxf",
]
# 계획노선 shapefile 한 벌. GDAL이 열려면 같은 basename으로 한 폴더에 있어야 한다.
SHAPEFILE_MEMBER_EXT = (".shp", ".shx", ".dbf", ".cpg", ".prj")
SHAPEFILE_REQUIRED_EXT = (".shp", ".shx", ".dbf")
CHUNK_TEMP_DIR = os.getenv("CHUNK_TEMP_DIR", "B03_FileInput/chunks_temp")
CHUNK_RETENTION_HOURS = int(os.getenv("CHUNK_RETENTION_HOURS", "24"))