Merge remote-tracking branch 'origin/feat/b03-route-shapefile' into feat/b07-cover-template
# Conflicts: # B03_FileInput/B03_FileInput_Service_Chain.py
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");
|
||||
|
||||
@@ -177,6 +177,8 @@ export interface UploadOverviewFile {
|
||||
file_size_mb: number;
|
||||
status: string;
|
||||
uploaded_at: string | null;
|
||||
/** 저장 경로 — PRJ 두 장(노선/지형)을 카드에 되돌릴 때 이것으로 가린다. */
|
||||
relative_path: string | null;
|
||||
}
|
||||
|
||||
export interface UploadOverviewSession {
|
||||
|
||||
@@ -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,11 +65,19 @@ 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 입력과 실제 판독 대상이 갈리지 않는다.
|
||||
|
||||
PRJ는 노선용·지형용 두 장이 온다. DB `file_type`은 둘 다 `prj`라 그대로 세면 노선
|
||||
PRJ 하나로 필수가 채워진다 — 프로젝트 좌표계를 정하는 것은 **지형 PRJ**이므로,
|
||||
노선 세트 폴더(`input/shp/`)에 있는 PRJ는 `route_prj`로 갈라 센다(2026-08-31).
|
||||
"""
|
||||
async with connection.cursor(aiomysql.DictCursor) as cursor:
|
||||
await cursor.execute(
|
||||
"""
|
||||
SELECT id, LOWER(file_type) AS file_type
|
||||
SELECT id, LOWER(file_type) AS file_type, raw_file_path
|
||||
FROM input_files
|
||||
WHERE project_id = %s AND status IN ('UPLOADED', 'PROCESSED')
|
||||
ORDER BY id DESC
|
||||
@@ -78,17 +86,27 @@ async def get_project_input_readiness(
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
|
||||
file_types = {str(row["file_type"]) for row in rows if row.get("file_type")}
|
||||
file_types: set[str] = set()
|
||||
for row in rows:
|
||||
file_type = str(row.get("file_type") or "")
|
||||
if not file_type:
|
||||
continue
|
||||
if file_type == "prj" and "/input/shp/" in str(row.get("raw_file_path") or ""):
|
||||
file_type = "route_prj"
|
||||
file_types.add(file_type)
|
||||
point_cloud_id = next(
|
||||
(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(
|
||||
@@ -309,7 +327,7 @@ async def list_project_input_files(
|
||||
await cursor.execute(
|
||||
"""
|
||||
SELECT f.id, f.file_type, f.original_filename, f.file_size_mb, f.status,
|
||||
f.upload_at
|
||||
f.upload_at, f.raw_file_path
|
||||
FROM input_files f
|
||||
INNER JOIN (
|
||||
SELECT MAX(id) AS id
|
||||
|
||||
@@ -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,13 @@ _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로 읽으므로 필수가 아니다.
|
||||
# `route_prj`는 노선 세트 폴더에 있는 PRJ — 지형 PRJ(`prj`)와 따로 센다.
|
||||
_SHAPEFILE_REQUIRED_TYPES = frozenset({"shx", "dbf", "route_prj"})
|
||||
|
||||
|
||||
def _total_chunks(size_bytes: int, chunk_size_bytes: int) -> int:
|
||||
@@ -95,6 +100,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 +174,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)
|
||||
@@ -810,6 +820,7 @@ async def get_project_upload_overview(
|
||||
file_size_mb=float(row["file_size_mb"] or 0.0),
|
||||
status=str(row["status"]),
|
||||
uploaded_at=str(row["upload_at"]) if row.get("upload_at") else None,
|
||||
relative_path=(str(row["raw_file_path"]) if row.get("raw_file_path") else None),
|
||||
)
|
||||
for row in files
|
||||
],
|
||||
|
||||
@@ -128,6 +128,9 @@ class UploadOverviewFile(BaseModel):
|
||||
file_size_mb: float
|
||||
status: str
|
||||
uploaded_at: str | None = None
|
||||
# PRJ는 노선용·지형용 두 장이 온다. 확장자로는 못 가리므로 저장 폴더로 가린다
|
||||
# (노선 세트는 `B03_FileInput/input/shp/`에 모인다, 2026-08-31).
|
||||
relative_path: str | None = None
|
||||
|
||||
|
||||
class UploadOverviewSession(BaseModel):
|
||||
|
||||
@@ -28,9 +28,9 @@ logger = logging.getLogger(__name__)
|
||||
def _planned_route_points_in_project_crs(
|
||||
project_root: Path, surface: dict[str, Any] | None = None
|
||||
) -> list[dict[str, float]] | None:
|
||||
"""계획노선 CSV를 읽어 프로젝트 좌표계(m) 점 목록으로 돌려준다. 없으면 None.
|
||||
"""계획노선 파일(CSV·shapefile)을 읽어 프로젝트 좌표계(m) 점 목록으로 돌려준다.
|
||||
|
||||
B04 `/planned-route` 조회와 같은 규칙 — CSV가 제 좌표계(crs_epsg)를 적어 두었고
|
||||
B04 `/planned-route` 조회와 같은 규칙 — 파일이 제 좌표계를 싣고 있고
|
||||
프로젝트 좌표계와 다르면 한 번 옮긴다.
|
||||
|
||||
`surface`(확정 필터·방식·스무딩)를 받으면 지표면이 덮지 못하는 구간을 잘라 낸다.
|
||||
@@ -41,7 +41,7 @@ def _planned_route_points_in_project_crs(
|
||||
from common_util.common_util_route_geometry import (
|
||||
densify_route,
|
||||
find_planned_route_file,
|
||||
read_planned_route_csv,
|
||||
read_planned_route,
|
||||
trim_route_to_surface,
|
||||
)
|
||||
from config.config_system import (
|
||||
@@ -51,12 +51,12 @@ def _planned_route_points_in_project_crs(
|
||||
)
|
||||
|
||||
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,11 @@ import {
|
||||
getExtension,
|
||||
initializeSlots,
|
||||
makeSessionKey,
|
||||
planSlotAssignments,
|
||||
ROUTE_SLOTS,
|
||||
SHAPEFILE_DEPENDENT_SLOTS,
|
||||
slotConfigs,
|
||||
TERRAIN_SLOTS,
|
||||
type FileSlot,
|
||||
type FileSlotState,
|
||||
type StoredUploadSession,
|
||||
@@ -193,10 +198,21 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
pageError.textContent = validation ?? "";
|
||||
}
|
||||
|
||||
/** 확장자 줄 — 지금 필수인지에 따라 "· 선택" 꼬리표가 붙고 떨어진다. */
|
||||
function renderExtensionLabel(card: HTMLElement, state: FileSlotState): void {
|
||||
const extLabel = state.extensions.join(", ");
|
||||
const target = card.querySelector(".b03-file__card-ext");
|
||||
if (!target) return;
|
||||
target.textContent = isSlotRequired(state)
|
||||
? extLabel
|
||||
: `${extLabel} · ${L("B03_File_Card_Optional")}`;
|
||||
}
|
||||
|
||||
function renderSlot(slot: FileSlot): void {
|
||||
const state = slots.get(slot);
|
||||
const card = cardMap.get(slot);
|
||||
if (!state || !card) return;
|
||||
renderExtensionLabel(card, state);
|
||||
|
||||
const fileName = card.querySelector<HTMLSpanElement>(
|
||||
".b03-file__file-name",
|
||||
@@ -290,12 +306,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
file: File,
|
||||
targetSlot?: FileSlot,
|
||||
): Promise<void> {
|
||||
const extension = getExtension(file.name);
|
||||
const state = targetSlot
|
||||
? slots.get(targetSlot)
|
||||
: Array.from(slots.values()).find((candidate) =>
|
||||
candidate.extensions.includes(extension),
|
||||
);
|
||||
const state = targetSlot ? slots.get(targetSlot) : undefined;
|
||||
if (!state) {
|
||||
pageError.textContent = `${L("B03_File_Error_Extension")} ${file.name}`;
|
||||
return;
|
||||
@@ -330,6 +341,13 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
state.etaSeconds = null;
|
||||
state.error = undefined;
|
||||
renderSlot(state.slot);
|
||||
// 노선 도형이 CSV↔shapefile로 바뀌면 형제 카드의 필수 표시도 따라 바뀐다.
|
||||
if (state.slot === "csv") renderRouteDependentSlots();
|
||||
}
|
||||
|
||||
function renderRouteDependentSlots(): void {
|
||||
for (const slot of SHAPEFILE_DEPENDENT_SLOTS) renderSlot(slot);
|
||||
updateUploadButton();
|
||||
}
|
||||
|
||||
function onFileSelected(
|
||||
@@ -353,14 +371,17 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
// 개수는 "고른 파일 수"가 아니라 **최종적으로 차는 슬롯 수**로 센다.
|
||||
// 같은 슬롯을 다시 고르는 것은 교체라 개수가 늘지 않는다 — 더하기로 세면 5개를 고른
|
||||
// 뒤 파일 선택 영역으로 하나만 바꾸려 해도 초과로 막힌다(2026-08-08).
|
||||
// 파일 하나에 카드 하나다. `.prj`만 확장자로 안 갈리므로 노선 도형과 basename이
|
||||
// 같은지로 노선/지형 좌표계 카드를 정한다(2026-08-31 사용자 지시).
|
||||
const routeFile = slots.get("csv")?.file?.name;
|
||||
const assignments = planSlotAssignments(
|
||||
files,
|
||||
slotConfigs(),
|
||||
routeFile ? routeFile.replace(/\.[^.]*$/, "") : undefined,
|
||||
);
|
||||
const occupied = new Set(selectedStates().map((state) => state.slot));
|
||||
for (const file of files) {
|
||||
const extension = getExtension(file.name);
|
||||
const slot =
|
||||
targetSlot ??
|
||||
Array.from(slots.values()).find((candidate) =>
|
||||
candidate.extensions.includes(extension),
|
||||
)?.slot;
|
||||
for (const item of assignments) {
|
||||
const slot = targetSlot ?? item.slot;
|
||||
if (slot) occupied.add(slot);
|
||||
}
|
||||
if (occupied.size > UPLOAD_MAX_FILES) {
|
||||
@@ -369,7 +390,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);
|
||||
for (const item of assignments) {
|
||||
const slot = targetSlot ?? item.slot;
|
||||
if (!slot) {
|
||||
pageError.textContent = `${L("B03_File_Error_Extension")} ${item.file.name}`;
|
||||
continue;
|
||||
}
|
||||
await assignFileToSlot(item.file, slot);
|
||||
}
|
||||
await detectPausedUploads();
|
||||
})();
|
||||
}
|
||||
@@ -388,6 +416,27 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
state.etaSeconds = null;
|
||||
state.error = undefined;
|
||||
renderSlot(slot);
|
||||
if (slot === "csv") renderRouteDependentSlots();
|
||||
}
|
||||
|
||||
/** 노선 도형이 shapefile인가 — 로컬 선택과 서버 정본을 함께 본다. */
|
||||
function routeIsShapefile(): boolean {
|
||||
const state = slots.get("csv");
|
||||
const name = state?.file?.name ?? state?.serverUploaded?.name;
|
||||
return getExtension(name ?? "") === ".shp";
|
||||
}
|
||||
|
||||
/**
|
||||
* 이 카드가 지금 필수인가.
|
||||
*
|
||||
* shapefile 형제 카드(.shx/.dbf/노선 .prj)는 노선 도형이 shapefile일 때만 필수다 —
|
||||
* CSV 한 장으로 넣는 흐름을 막으면 안 된다(2026-08-31).
|
||||
*/
|
||||
function isSlotRequired(state: FileSlotState): boolean {
|
||||
if (SHAPEFILE_DEPENDENT_SLOTS.includes(state.slot))
|
||||
return routeIsShapefile();
|
||||
if (state.slot === "las_laz") return !lasFreeDesign;
|
||||
return state.isRequired;
|
||||
}
|
||||
|
||||
function validateSlots(): string | null {
|
||||
@@ -399,11 +448,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
// 재접속 후 파일 하나만 교체 업로드하는 흐름을 막지 않는다(2026-08-04 사용자 지시).
|
||||
const missingRequired = Array.from(slots.values()).some(
|
||||
(state) =>
|
||||
state.isRequired &&
|
||||
!state.file &&
|
||||
!state.serverUploaded &&
|
||||
// LAS 없는 설계면 포인트클라우드 카드는 필수에서 뺀다.
|
||||
!(lasFreeDesign && state.slot === "las_laz"),
|
||||
isSlotRequired(state) && !state.file && !state.serverUploaded,
|
||||
);
|
||||
if (missingRequired) return L("B03_File_Error_RequiredSlots");
|
||||
if (!lasFreeDesign) {
|
||||
@@ -435,9 +480,20 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
for (const state of slots.values()) state.serverUploaded = undefined;
|
||||
for (const file of overview.files) {
|
||||
const extension = `.${file.file_type.toLowerCase()}`;
|
||||
const state = Array.from(slots.values()).find((candidate) =>
|
||||
candidate.extensions.includes(extension),
|
||||
);
|
||||
// PRJ 두 장은 확장자가 같다 — 노선 세트는 `input/shp/`에 모여 있으므로
|
||||
// 저장 경로로 가린다(2026-08-31).
|
||||
const inRouteSet = (file.relative_path ?? "").includes("/input/shp/");
|
||||
const slot: FileSlot | undefined =
|
||||
extension === ".prj"
|
||||
? inRouteSet
|
||||
? "route_prj"
|
||||
: "prj"
|
||||
: Array.from(slots.values()).find(
|
||||
(candidate) =>
|
||||
candidate.slot !== "route_prj" &&
|
||||
candidate.extensions.includes(extension),
|
||||
)?.slot;
|
||||
const state = slot ? slots.get(slot) : undefined;
|
||||
if (state) {
|
||||
state.serverUploaded = {
|
||||
name: file.original_filename,
|
||||
@@ -480,11 +536,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
card.querySelector(".b03-file__card-label")!.textContent = L(
|
||||
state.labelKey,
|
||||
);
|
||||
// 지형 래스터만 선택 항목이라 확장자 옆에 표시해 둔다.
|
||||
const extLabel = state.extensions.join(", ");
|
||||
card.querySelector(".b03-file__card-ext")!.textContent = state.isRequired
|
||||
? extLabel
|
||||
: `${extLabel} · ${L("B03_File_Card_Optional")}`;
|
||||
renderExtensionLabel(card, state);
|
||||
const input = card.querySelector<HTMLInputElement>(
|
||||
".b03-file__slot-input",
|
||||
)!;
|
||||
@@ -512,15 +564,24 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
function createCardGroup(
|
||||
title: string,
|
||||
groupSlots: readonly FileSlot[],
|
||||
modifier?: string,
|
||||
hint?: string,
|
||||
): HTMLElement {
|
||||
const group = document.createElement("section");
|
||||
group.className = "b03-file__group";
|
||||
if (modifier) group.classList.add(modifier);
|
||||
if (title) {
|
||||
const groupTitle = document.createElement("h3");
|
||||
groupTitle.className = "b03-file__group-title";
|
||||
groupTitle.textContent = title;
|
||||
group.append(groupTitle);
|
||||
}
|
||||
if (hint) {
|
||||
const groupHint = document.createElement("p");
|
||||
groupHint.className = "b03-file__group-hint";
|
||||
groupHint.textContent = hint;
|
||||
group.append(groupHint);
|
||||
}
|
||||
const content = document.createElement("div");
|
||||
content.className = "b03-file__group-content";
|
||||
for (const slot of groupSlots) {
|
||||
@@ -768,15 +829,21 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
resultList,
|
||||
);
|
||||
|
||||
// 계획노선과 지형 자료는 지형 래스터(tif)만 빼면 모두 필수라 따로 묶지 않는다
|
||||
// (2026-08-08 사용자 지시).
|
||||
const inputsGroup = createCardGroup(L("B03_File_Group_Inputs"), [
|
||||
"csv",
|
||||
"las_laz",
|
||||
"prj",
|
||||
"tfw",
|
||||
"tif",
|
||||
]);
|
||||
// 자료의 출처가 둘로 갈린다 — 원청이 준 계획노선, 측량이 준 지형(LAS·래스터).
|
||||
// 좌표계 파일(.prj)도 각각 하나씩 오므로 컨테이너를 나눠야 어느 칸에 무엇을 넣는지
|
||||
// 화면만 보고 안다(2026-08-31 사용자 지시).
|
||||
const routeGroup = createCardGroup(
|
||||
L("B03_File_Group_Route"),
|
||||
ROUTE_SLOTS,
|
||||
"b03-file__group--route",
|
||||
L("B03_File_Group_Route_Hint"),
|
||||
);
|
||||
const terrainGroup = createCardGroup(
|
||||
L("B03_File_Group_Terrain"),
|
||||
TERRAIN_SLOTS,
|
||||
"b03-file__group--terrain",
|
||||
L("B03_File_Group_Terrain_Hint"),
|
||||
);
|
||||
|
||||
// LAS 없는 설계 토글 — 켜면 포인트클라우드 카드를 비활성화하고 필수에서 뺀다.
|
||||
const lasFreeRow = document.createElement("label");
|
||||
@@ -814,10 +881,22 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
pageError.textContent = "";
|
||||
});
|
||||
|
||||
const cardsContainer = document.createElement("div");
|
||||
cardsContainer.className =
|
||||
// LAS 토글은 지형 컨테이너의 것이다 — 켜면 그 안의 포인트클라우드 카드만 잠긴다.
|
||||
terrainGroup.append(lasFreeRow);
|
||||
|
||||
const routePanel = document.createElement("div");
|
||||
routePanel.className =
|
||||
"b03-file__control-panel b03-file__cards-container-panel";
|
||||
cardsContainer.append(lasFreeRow, inputsGroup);
|
||||
routePanel.append(routeGroup);
|
||||
|
||||
const terrainPanel = document.createElement("div");
|
||||
terrainPanel.className =
|
||||
"b03-file__control-panel b03-file__cards-container-panel";
|
||||
terrainPanel.append(terrainGroup);
|
||||
|
||||
const cardsContainer = document.createElement("div");
|
||||
cardsContainer.className = "b03-file__columns";
|
||||
cardsContainer.append(routePanel, terrainPanel);
|
||||
|
||||
const workflowState = activeProjectId
|
||||
? await fetchWorkflowState(activeProjectId).catch(() => undefined)
|
||||
|
||||
@@ -180,12 +180,27 @@
|
||||
gap: var(--spacing-32);
|
||||
}
|
||||
|
||||
/* 계획노선 | 지형(LAS) — 컨테이너를 둘로 나눈다(2026-08-31 사용자 지시).
|
||||
노선은 카드 5장(shapefile 한 벌), 지형은 4장이라 폭을 5:4 비슷하게 준다. */
|
||||
.b03-file__columns {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--spacing-24);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.b03-file__group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-20);
|
||||
}
|
||||
|
||||
.b03-file__group-hint {
|
||||
font-size: var(--text-body-sm, 14px);
|
||||
color: var(--color-slate, #615e6e);
|
||||
margin: calc(var(--spacing-8) * -1) 0 0 0;
|
||||
}
|
||||
|
||||
.b03-file__group-title {
|
||||
font-size: var(--text-subheading, 24px);
|
||||
color: var(--color-deep-iris, #26114a);
|
||||
@@ -195,7 +210,7 @@
|
||||
|
||||
.b03-file__group-content {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr); /* 3열 구조 */
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--spacing-24);
|
||||
}
|
||||
|
||||
@@ -260,14 +275,17 @@
|
||||
border-color: var(--color-danger, #dc2626);
|
||||
}
|
||||
|
||||
/* 카드가 좁아졌다(컨테이너 2열 × 카드 2열, 2026-08-31). 격자로 고정하면 제목이
|
||||
글자 단위로 접히므로, 자리가 모자라면 배지·삭제 버튼이 다음 줄로 내려가게 한다. */
|
||||
.b03-file__card-header {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto auto; /* 아이콘 및 닫기 버튼 주변 확보 */
|
||||
gap: var(--spacing-12);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-8);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.b03-file__card-icon {
|
||||
flex: 0 0 auto;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: var(--radius-icons, 8px);
|
||||
@@ -284,6 +302,7 @@
|
||||
|
||||
.b03-file__card-heading {
|
||||
min-width: 0;
|
||||
flex: 1 1 55%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
@@ -294,6 +313,11 @@
|
||||
color: var(--color-deep-iris, #26114a);
|
||||
font-size: var(--text-body-sm, 14px);
|
||||
font-weight: var(--font-weight-medium, 500);
|
||||
/* 한글은 글자 단위로 끊기므로 어절을 지켜 준다 — "계획
|
||||
노선
|
||||
도형" 방지. */
|
||||
word-break: keep-all;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.b03-file__card-ext,
|
||||
@@ -306,6 +330,7 @@
|
||||
.b03-file__card-badge-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-left: auto;
|
||||
margin-right: var(--spacing-4);
|
||||
}
|
||||
|
||||
@@ -463,9 +488,10 @@
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 1280px) {
|
||||
.b03-file__group-content {
|
||||
grid-template-columns: repeat(2, 1fr); /* 좁은 창에서는 2열 */
|
||||
@media (max-width: 1440px) {
|
||||
/* 좁아지면 두 컨테이너를 위아래로 쌓는다 — 카드가 눌려 글자가 접히는 것을 막는다. */
|
||||
.b03-file__columns {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,45 @@
|
||||
import { ui_locales } from "@ui/ui_template_locale";
|
||||
|
||||
export type FileSlot = "csv" | "las_laz" | "prj" | "tfw" | "tif" | "dxf";
|
||||
/**
|
||||
* 카드 한 장 = 파일 한 개. 계획노선 shapefile은 파일이 다섯이므로 카드도 다섯이다
|
||||
* (2026-08-31 사용자 지시) — 어느 파일이 왔고 어느 것이 비었는지 화면에서 바로 보인다.
|
||||
* `route_prj`(노선 좌표계)와 `prj`(지형 좌표계)는 확장자가 같아 basename으로 가른다.
|
||||
*/
|
||||
export type FileSlot =
|
||||
| "csv"
|
||||
| "shx"
|
||||
| "dbf"
|
||||
| "cpg"
|
||||
| "route_prj"
|
||||
| "las_laz"
|
||||
| "prj"
|
||||
| "tfw"
|
||||
| "tif"
|
||||
| "dxf";
|
||||
|
||||
/** 왼쪽(계획노선) 컨테이너에 놓이는 슬롯. */
|
||||
export const ROUTE_SLOTS: readonly FileSlot[] = [
|
||||
"csv",
|
||||
"shx",
|
||||
"dbf",
|
||||
"cpg",
|
||||
"route_prj",
|
||||
];
|
||||
|
||||
/** 오른쪽(지형·LAS) 컨테이너에 놓이는 슬롯. */
|
||||
export const TERRAIN_SLOTS: readonly FileSlot[] = [
|
||||
"las_laz",
|
||||
"prj",
|
||||
"tfw",
|
||||
"tif",
|
||||
];
|
||||
|
||||
/** 노선 도형이 shapefile일 때 함께 있어야 하는 슬롯(.cpg는 없으면 CP949). */
|
||||
export const SHAPEFILE_DEPENDENT_SLOTS: readonly FileSlot[] = [
|
||||
"shx",
|
||||
"dbf",
|
||||
"route_prj",
|
||||
];
|
||||
export type UploadStatus = "pending" | "uploading" | "completed" | "failed";
|
||||
|
||||
export interface SlotConfig {
|
||||
@@ -45,9 +84,37 @@ const SLOT_CONFIGS: readonly SlotConfig[] = [
|
||||
slot: "csv",
|
||||
labelKey: "B03_File_Slot_PlannedRoute",
|
||||
icon: "⌁",
|
||||
extensions: [".csv"],
|
||||
extensions: [".csv", ".shp"],
|
||||
isRequired: true,
|
||||
},
|
||||
{
|
||||
slot: "shx",
|
||||
labelKey: "B03_File_Slot_RouteIndex",
|
||||
icon: "⋮",
|
||||
extensions: [".shx"],
|
||||
isRequired: false,
|
||||
},
|
||||
{
|
||||
slot: "dbf",
|
||||
labelKey: "B03_File_Slot_RouteAttribute",
|
||||
icon: "▤",
|
||||
extensions: [".dbf"],
|
||||
isRequired: false,
|
||||
},
|
||||
{
|
||||
slot: "cpg",
|
||||
labelKey: "B03_File_Slot_RouteEncoding",
|
||||
icon: "⌨",
|
||||
extensions: [".cpg"],
|
||||
isRequired: false,
|
||||
},
|
||||
{
|
||||
slot: "route_prj",
|
||||
labelKey: "B03_File_Slot_RouteProjection",
|
||||
icon: "◈",
|
||||
extensions: [".prj"],
|
||||
isRequired: false,
|
||||
},
|
||||
{
|
||||
slot: "las_laz",
|
||||
labelKey: "B03_File_Slot_PointCloud",
|
||||
@@ -83,6 +150,48 @@ 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 고른 파일을 카드(슬롯)에 배정한다.
|
||||
*
|
||||
* `.prj`만 확장자로 갈리지 않는다 — 노선 좌표계와 지형 좌표계가 같은 확장자다.
|
||||
* **노선 도형(.shp)과 basename이 같은 것**만 노선 좌표계 카드로 보내고, 나머지는
|
||||
* 지형 좌표계 카드로 보낸다. `routeStem`은 이미 골라 둔 노선 도형의 basename으로,
|
||||
* 노선 PRJ를 나중에 따로 추가하는 경우를 받아 준다.
|
||||
*/
|
||||
export function planSlotAssignments(
|
||||
files: readonly File[],
|
||||
slotConfigs: readonly SlotConfig[],
|
||||
routeStem?: string,
|
||||
): { file: File; slot?: FileSlot }[] {
|
||||
const batchStem = files
|
||||
.filter((file) => getExtension(file.name) === ".shp")
|
||||
.map((file) => getBaseName(file.name))[0];
|
||||
const stem = batchStem ?? routeStem;
|
||||
return files.map((file) => {
|
||||
const extension = getExtension(file.name);
|
||||
if (extension === ".prj") {
|
||||
const isRoute = stem !== undefined && getBaseName(file.name) === stem;
|
||||
return { file, slot: (isRoute ? "route_prj" : "prj") as FileSlot };
|
||||
}
|
||||
const config = slotConfigs.find(
|
||||
(candidate) =>
|
||||
candidate.slot !== "route_prj" &&
|
||||
candidate.extensions.includes(extension),
|
||||
);
|
||||
return { file, slot: config?.slot };
|
||||
});
|
||||
}
|
||||
|
||||
/** 슬롯 설정 목록 — 배정 규칙이 카드 정의와 같은 것을 쓰도록 밖으로 연다. */
|
||||
export function slotConfigs(): readonly SlotConfig[] {
|
||||
return SLOT_CONFIGS;
|
||||
}
|
||||
|
||||
export function formatBytes(bytes: number): string {
|
||||
const gb = bytes / 1024 / 1024 / 1024;
|
||||
if (gb >= 1) return `${gb.toFixed(2)} GB`;
|
||||
|
||||
@@ -301,11 +301,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
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ from B04_PreProcess.B04_PreProcess_Engine_Watershed_Grid import GridSpec
|
||||
from B05_Profile.B05_Profile_Repository import get_surface_crs_epsg
|
||||
from common_util.common_util_route_geometry import (
|
||||
find_planned_route_file,
|
||||
read_planned_route_csv,
|
||||
read_planned_route,
|
||||
)
|
||||
from common_util.common_util_storage import resolve_stored_project_path
|
||||
from config.config_db import get_db_pool
|
||||
@@ -80,7 +80,7 @@ async def _resolve_epsg(project_id: UUID, stored_path: str) -> str:
|
||||
epsg = await get_surface_crs_epsg(connection, project_id, 0)
|
||||
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
|
||||
source = (planned.epsg if planned else None) or epsg or 5186
|
||||
return f"EPSG:{source}"
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ from B05_Profile.B05_Profile_Repository import get_surface_crs_epsg
|
||||
from common_util.common_util_route_geometry import (
|
||||
StructureCandidate,
|
||||
find_planned_route_file,
|
||||
read_planned_route_csv,
|
||||
read_planned_route,
|
||||
)
|
||||
from common_util.common_util_storage import resolve_stored_project_path
|
||||
from common_util.common_util_wamis_rainfall import (
|
||||
@@ -172,7 +172,7 @@ async def _prepare(project_id: UUID) -> dict[str, Any] | JSONResponse:
|
||||
status_code=404,
|
||||
content={"status": "error", "message": "계획 노선 파일이 업로드되지 않았습니다."},
|
||||
)
|
||||
planned = read_planned_route_csv(route_file)
|
||||
planned = read_planned_route(route_file)
|
||||
if planned is None or len(planned.vertices) < 2:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
@@ -246,7 +246,7 @@ def _route_center_lonlat(stored_path: str, fallback_epsg: int | None) -> tuple[f
|
||||
route_file = find_planned_route_file(_route_input_dir(stored_path))
|
||||
if route_file is None:
|
||||
return None
|
||||
planned = read_planned_route_csv(route_file)
|
||||
planned = read_planned_route(route_file)
|
||||
if planned is None or not planned.vertices:
|
||||
return None
|
||||
middle = planned.vertices[len(planned.vertices) // 2]
|
||||
|
||||
@@ -10,6 +10,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from pyproj import CRS
|
||||
|
||||
@@ -123,6 +124,48 @@ def identify_epsg(crs: CRS, wkt_text: str | None = None) -> int | None:
|
||||
return None
|
||||
|
||||
|
||||
_TERRAIN_DATA_GLOBS = ("las/*", "laz/*", "tif/*", "tfw/*")
|
||||
|
||||
|
||||
def find_project_prj(project_root: Path) -> Path | None:
|
||||
"""프로젝트 **작업 좌표계**를 정하는 PRJ를 고른다.
|
||||
|
||||
자료가 둘 이상의 좌표계로 들어온다(실측 2026-08-31 — 노선 shapefile은 UTM-K,
|
||||
지형은 동부원점 Bessel). 서피스 격자가 모델좌표의 주인이므로 **지형 PRJ**를 쓴다.
|
||||
노선 PRJ는 shapefile 세트 폴더(`input/shp/`)에 있어 여기서 섞이지 않는다.
|
||||
|
||||
`input/prj/`에도 PRJ가 여럿 쌓인다 — 자료를 다시 올려도 파일명이 다르면 옛 PRJ가
|
||||
남기 때문이다. 이름 정렬로 고르면 잔재를 집는다(실측 2026-08-31: 옛 `result.prj`
|
||||
(5187)가 새 `용화.prj`(5176)보다 앞서 뽑혀 노선이 Y로 100,000m 어긋났고 B05
|
||||
경로 계산이 400으로 실패했다). 그래서 **지금 쓰는 지형 자료(LAS·TIF·TFW)와
|
||||
basename이 같은 PRJ**를 먼저 찾고, 못 찾으면 가장 최근 것을 쓴다.
|
||||
"""
|
||||
prj_dir = project_root / "B03_FileInput" / "input" / "prj"
|
||||
candidates = sorted(prj_dir.glob("*.prj")) if prj_dir.is_dir() else []
|
||||
if candidates:
|
||||
if len(candidates) == 1:
|
||||
return candidates[0]
|
||||
input_root = project_root / "B03_FileInput" / "input"
|
||||
terrain_stems = {
|
||||
path.stem
|
||||
for pattern in _TERRAIN_DATA_GLOBS
|
||||
for path in input_root.glob(pattern)
|
||||
if path.is_file()
|
||||
}
|
||||
paired = [path for path in candidates if path.stem in terrain_stems]
|
||||
pool = paired or candidates
|
||||
chosen = max(pool, key=lambda path: path.stat().st_mtime)
|
||||
if not paired:
|
||||
logger.warning(
|
||||
"지형 자료와 짝이 되는 PRJ를 찾지 못해 가장 최근 PRJ를 씁니다: %s", chosen.name
|
||||
)
|
||||
return chosen
|
||||
remainder = sorted(project_root.glob("B03_FileInput/**/*.prj"))
|
||||
# 노선 세트 폴더의 PRJ는 노선 좌표계라 프로젝트 좌표계가 될 수 없다.
|
||||
remainder = [path for path in remainder if path.parent.name != "shp"] or remainder
|
||||
return remainder[0] if remainder else None
|
||||
|
||||
|
||||
def crs_input_from_prj(prj_text: str) -> str | None:
|
||||
"""PRJ 텍스트를 `Transformer.from_crs` 입력 문자열로 정규화한다.
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ from common_util.common_util_route_geometry import (
|
||||
RouteVertex,
|
||||
build_route_vertices,
|
||||
find_planned_route_file,
|
||||
read_planned_route_csv,
|
||||
read_planned_route,
|
||||
)
|
||||
from common_util.common_util_route_profile import Z_SOURCE_CSV, resolve_route_profile
|
||||
from common_util.common_util_storage import resolve_stored_project_path
|
||||
@@ -114,7 +114,7 @@ async def load_drainage_context(project_id: UUID) -> tuple[DrainageContext | Non
|
||||
def _read_planned_route(project_root: Path):
|
||||
"""원청 계획노선 CSV를 찾아 읽는다(파일 접근이라 스레드에서 돈다)."""
|
||||
path = find_planned_route_file(project_root / _INPUT_SUBDIR)
|
||||
return read_planned_route_csv(path) if path else None
|
||||
return read_planned_route(path) if path else None
|
||||
|
||||
|
||||
def _open_sampler(project_root: Path, surface_params: dict[str, Any]):
|
||||
|
||||
@@ -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
|
||||
@@ -62,6 +63,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:
|
||||
@@ -111,17 +116,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 trim_route_to_surface(
|
||||
|
||||
@@ -35,7 +35,9 @@ export const CURRENT_PROJECT_ID_KEY = "frd_current_project_id";
|
||||
export const UPLOAD_MAX_MB = 30 * 1024;
|
||||
|
||||
/** 한 요청에서 선택 가능한 최대 파일 수 */
|
||||
export const UPLOAD_MAX_FILES = 5;
|
||||
// 한 번에 채울 수 있는 **카드 수**. 계획노선 shapefile이 카드 5장을 쓰므로
|
||||
// 노선 5 + 지형 4 = 9가 최대다(2026-08-31).
|
||||
export const UPLOAD_MAX_FILES = 10;
|
||||
|
||||
/** 청크 업로드 단위 (MB) */
|
||||
export const UPLOAD_CHUNK_SIZE_MB = 1024;
|
||||
@@ -47,7 +49,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"))
|
||||
|
||||
|
||||
@@ -276,8 +276,8 @@ export const ui_locales_b1 = {
|
||||
],
|
||||
B03_File_Select_Label: ["입력 파일 선택", "Select input files"],
|
||||
B03_File_Select_Hint: [
|
||||
"계획노선 CSV, LAS/LAZ 1개, PRJ, TFW를 선택하세요. TIF는 선택 사항입니다.",
|
||||
"Select a planned-route CSV, one LAS/LAZ, PRJ, and TFW. TIF is optional.",
|
||||
"계획노선(CSV 또는 shapefile 5개)과 LAS/LAZ 1개, 지형 PRJ·TFW를 함께 고르면 카드에 나뉩니다. TIF는 선택 사항입니다.",
|
||||
"Pick the planned route (a CSV or the five shapefile files), one LAS/LAZ, and the terrain PRJ and TFW together — they are sorted into cards. TIF is optional.",
|
||||
],
|
||||
B03_File_Selected_Title: ["선택한 파일", "Selected files"],
|
||||
B03_File_Selected_Empty: ["선택한 파일이 없습니다.", "No files selected."],
|
||||
@@ -330,9 +330,23 @@ export const ui_locales_b1 = {
|
||||
B03_File_Result_Path: ["저장 경로", "Stored path"],
|
||||
B03_File_Group_Required: ["필수 파일", "Required files"],
|
||||
B03_File_Group_Optional: ["선택 파일", "Optional files"],
|
||||
/* 지형 래스터만 선택 항목이라 계획노선·지형 자료를 한 묶음으로 둔다(2026-08-08). */
|
||||
/* 자료 출처가 갈려 컨테이너를 둘로 나눈다 — 계획노선 / 지형(LAS)(2026-08-31). */
|
||||
B03_File_Group_Inputs: ["입력 자료", "Input files"],
|
||||
B03_File_Slot_PlannedRoute: ["계획노선 좌표", "Planned Route Coordinates"],
|
||||
B03_File_Group_Route: ["계획노선 자료", "Planned route files"],
|
||||
B03_File_Group_Terrain: ["지형 자료 (LAS)", "Terrain files (LAS)"],
|
||||
B03_File_Group_Route_Hint: [
|
||||
"shapefile은 파일 5개가 한 벌입니다. CSV 한 장으로 넣어도 됩니다.",
|
||||
"A shapefile is a set of five files. A single CSV also works.",
|
||||
],
|
||||
B03_File_Group_Terrain_Hint: [
|
||||
"여기의 PRJ·TFW는 지형 자료의 좌표계입니다 — 노선 PRJ와 별개입니다.",
|
||||
"The PRJ and TFW here describe the terrain data, separate from the route PRJ.",
|
||||
],
|
||||
B03_File_Slot_PlannedRoute: ["계획노선 도형", "Planned Route Geometry"],
|
||||
B03_File_Slot_RouteIndex: ["노선 도형 색인", "Route Shape Index"],
|
||||
B03_File_Slot_RouteAttribute: ["노선 속성", "Route Attributes"],
|
||||
B03_File_Slot_RouteEncoding: ["노선 속성 인코딩", "Route Attribute Encoding"],
|
||||
B03_File_Slot_RouteProjection: ["노선 좌표계", "Route Projection"],
|
||||
B03_File_Slot_PointCloud: ["포인트클라우드", "Point Cloud"],
|
||||
B03_File_LasFree_Toggle: [
|
||||
"LAS 없이 설계 (도엽등고선 기반)",
|
||||
@@ -342,7 +356,7 @@ export const ui_locales_b1 = {
|
||||
"포인트클라우드 없이 1:5,000 수치지형도 등고선으로 지형을 만듭니다.",
|
||||
"Terrain is built from 1:5,000 map sheet contours without a point cloud.",
|
||||
],
|
||||
B03_File_Slot_Projection: ["좌표계 정의", "Projection"],
|
||||
B03_File_Slot_Projection: ["지형 좌표계", "Terrain Projection"],
|
||||
B03_File_Slot_RasterCoord: ["래스터 좌표", "Raster Coord 1"],
|
||||
B03_File_Slot_TerrainDem: ["지형 래스터", "Terrain DEM"],
|
||||
B03_File_Slot_CadDrawing: ["CAD 도면", "CAD Drawing"],
|
||||
@@ -385,8 +399,9 @@ export const ui_locales_b1 = {
|
||||
"A file for this slot is already selected.",
|
||||
],
|
||||
B03_File_Error_RequiredSlots: [
|
||||
"필수 파일(계획노선 CSV, LAS/LAZ, PRJ, TFW)을 모두 선택하세요.",
|
||||
"Select all required files: planned-route CSV, LAS/LAZ, PRJ, and TFW.",
|
||||
"필수 카드를 모두 채우세요 — 계획노선(CSV 또는 shapefile 한 벌), LAS/LAZ, 지형 PRJ·TFW.",
|
||||
"Fill every required card: the planned route (a CSV or a full shapefile set), "
|
||||
+ "LAS/LAZ, and the terrain PRJ and TFW.",
|
||||
],
|
||||
B03_File_Error_SlotType: [
|
||||
"선택한 파일 유형이 이 카드와 맞지 않습니다.",
|
||||
|
||||
Reference in New Issue
Block a user