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:
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
)),
|
||||
|
||||
@@ -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`;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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` 입력 문자열로 정규화한다.
|
||||
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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
@@ -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"))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user