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:
2026-09-01 15:34:07 +09:00
25 changed files with 841 additions and 109 deletions
+2 -2
View File
@@ -14,7 +14,7 @@ import { createButton, createInputField, showToast } from "@ui/ui_template_eleme
import { table, text } from "@ui/ui_template_general_blocks";
import { L } from "./B01_Dashboard_UI_Common";
/** 파일 확장자 = 보관함 슬롯 종류(csv·las·prj·tfw·tif). */
/** 파일 확장자 = 보관함 슬롯 종류(csv·shp 세트·las·prj·tfw·tif). */
export function tempFileType(fileName: string): string {
const index = fileName.lastIndexOf(".");
return index >= 0 ? fileName.slice(index + 1).toLowerCase() : "";
@@ -54,7 +54,7 @@ export function openTempFileModal(options: TempModalOptions): void {
const picker = document.createElement("input");
picker.type = "file";
picker.multiple = true;
picker.accept = ".csv,.las,.laz,.tif,.tfw,.prj";
picker.accept = ".csv,.shp,.shx,.dbf,.cpg,.las,.laz,.tif,.tfw,.prj";
picker.className = "b01-temp__hidden-input";
const pickRow = document.createElement("div");
+2
View File
@@ -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 {
+40 -2
View File
@@ -11,6 +11,34 @@ from B03_FileInput.B03_FileInput_Schema import FileUploadDescriptor
from common_util.common_util_storage import get_project_stage_path
from config.config_system import CHUNK_TEMP_DIR, UPLOAD_CHUNK_SIZE_BYTES, UPLOAD_MAX_MB
# shapefile은 형제 파일이 **같은 폴더·같은 basename**이라야 열린다. 확장자별 폴더에
# 흩어 두면 노선 자체를 못 읽는다. 그래서 세트는 `input/shp/`에 통째로 모은다.
# 부수 효과가 하나 더 있다 — 노선 PRJ가 이 폴더 안에 남으므로 지형 PRJ(`input/prj/`)와
# 파일명 정렬 운에 기대지 않고 갈린다 (2026-08-31).
_SHAPEFILE_FOLDER = "shp"
_SHAPEFILE_OWNED_EXT = frozenset({"shp", "shx", "dbf", "cpg"})
def reconcile_shapefile_members(stage_root: Path) -> list[Path]:
"""먼저 도착해 다른 폴더에 앉은 shapefile 형제 파일을 `input/shp/`로 모은다.
파일이 오는 순서를 강제할 수 없다 — PRJ가 SHP보다 먼저 오면 확장자 규칙만으로는
`input/prj/`에 앉는다. SHP가 들어온 시점에 같은 basename의 형제를 끌어온다.
"""
input_root = stage_root / "input"
shapefile_dir = input_root / _SHAPEFILE_FOLDER
if not shapefile_dir.exists():
return []
moved: list[Path] = []
for shape_path in shapefile_dir.glob("*.shp"):
for extension in ("shx", "dbf", "cpg", "prj"):
stray = input_root / extension / f"{shape_path.stem}.{extension}"
target = shapefile_dir / stray.name
if stray.exists() and not target.exists():
shutil.move(str(stray), str(target))
moved.append(target)
return moved
def resolve_upload_destination(
project_root: str | Path,
@@ -18,8 +46,18 @@ def resolve_upload_destination(
) -> Path:
"""검증된 파일의 B03 입력 저장 경로를 생성해 반환한다."""
stage_root = Path(get_project_stage_path(str(project_root), "B03_FileInput")).resolve()
file_type = Path(descriptor.original_filename).suffix.lower().lstrip(".")
destination = (stage_root / "input" / file_type / descriptor.original_filename).resolve()
reconcile_shapefile_members(stage_root)
source_name = Path(descriptor.original_filename)
file_type = source_name.suffix.lower().lstrip(".")
folder = file_type
if file_type in _SHAPEFILE_OWNED_EXT:
folder = _SHAPEFILE_FOLDER
elif file_type == "prj":
# 노선 shapefile의 짝 PRJ만 세트 폴더로. 지형 PRJ는 `input/prj/`에 남는다.
if (stage_root / "input" / _SHAPEFILE_FOLDER / f"{source_name.stem}.shp").exists():
folder = _SHAPEFILE_FOLDER
destination = (stage_root / "input" / folder / descriptor.original_filename).resolve()
if os.path.commonpath((stage_root, destination)) != str(stage_root):
raise ValueError("업로드 저장 경로가 B03 단계 폴더를 벗어났습니다.")
@@ -396,6 +396,10 @@ def analyze_input_metadata(path: str | Path) -> dict[str, Any]:
extension = source.suffix.lower()
if extension == ".csv":
return analyze_planned_route_csv(source)
if extension == ".shp":
from B03_FileInput.B03_FileInput_Engine_Shapefile import analyze_shapefile_metadata
return analyze_shapefile_metadata(source)
if extension in {".las", ".laz"}:
return analyze_las_metadata(source)
if extension == ".prj":
@@ -0,0 +1,244 @@
"""계획노선 shapefile(.shp) 판독 — 기하·속성·좌표계.
GDAL/geopandas를 쓰지 않고 ESRI Shapefile 규격을 직접 읽는다. 업로드 **직후**
메타데이터를 내야 하는데 그 시점엔 형제 파일(.shx/.dbf/.prj)이 아직 다 도착하지
않았을 수 있고, GDAL은 한 짝이라도 비면 열기 자체를 실패하기 때문이다. 규격 파싱은
.shp 하나만으로 기하를 읽어 낸다.
좌표계는 EPSG 코드로 가리지 않는다 — 짝 PRJ 원문을 `crs_input_from_prj()`에 넘겨
"EPSG:n" 또는 **원문 WKT**를 그대로 변환기 입력으로 쓴다(2026-08-31 사용자 확정).
"""
import logging
import struct
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
_HEADER_BYTES = 100
_FILE_CODE = 9994
# 규격상 폴리라인 계열만 계획노선으로 받는다. Z/M 변형도 XY는 같은 자리에 있다.
_POLYLINE_TYPES = {3: "PolyLine", 13: "PolyLineZ", 23: "PolyLineM"}
_SHAPE_TYPE_NAMES = {
0: "Null",
1: "Point",
3: "PolyLine",
5: "Polygon",
8: "MultiPoint",
11: "PointZ",
13: "PolyLineZ",
15: "PolygonZ",
18: "MultiPointZ",
21: "PointM",
23: "PolyLineM",
25: "PolygonM",
28: "MultiPointM",
31: "MultiPatch",
}
def _normalize_codepage(text: str) -> str | None:
""".cpg 내용을 파이썬 인코딩 이름으로 바꾼다.
실물은 `949` 한 줄만 들어 있다(2026-08-31 원청 자료) — `CP949`도 `EUC-KR`도
아니라서 그대로 넘기면 LookupError가 난다.
"""
value = (text or "").strip()
if not value:
return None
if value.isdigit():
return f"cp{value}"
upper = value.upper().replace("-", "").replace("_", "")
if upper in {"ANSI", "OEM", "SYSTEM"}:
return "cp949"
return value
def shapefile_encoding(path: Path) -> str:
"""짝 .cpg에 적힌 인코딩. 없으면 국내 자료 관례대로 CP949."""
cpg_path = path.with_suffix(".cpg")
if cpg_path.exists():
try:
encoding = _normalize_codepage(cpg_path.read_text(encoding="ascii", errors="ignore"))
if encoding:
"".encode(encoding) # 이름이 실재하는지 확인 — 없으면 LookupError
return encoding
except (OSError, LookupError):
logger.warning("shapefile .cpg 인코딩을 해석하지 못했습니다: %s", cpg_path.name)
return "cp949"
def shapefile_crs_input(path: Path) -> str | None:
"""짝 .prj를 `Transformer.from_crs` 입력 문자열로 정규화해 돌려준다."""
prj_path = path.with_suffix(".prj")
if not prj_path.exists():
return None
from common_util.common_util_crs import crs_input_from_prj
return crs_input_from_prj(prj_path.read_text(encoding="utf-8", errors="ignore"))
def shapefile_epsg_label(path: Path) -> int | None:
"""짝 .prj의 EPSG **라벨**. 변환에는 쓰지 않는다 — 메타 표시·로그용."""
prj_path = path.with_suffix(".prj")
if not prj_path.exists():
return None
from pyproj import CRS
from common_util.common_util_crs import identify_epsg
text = prj_path.read_text(encoding="utf-8", errors="ignore")
try:
return identify_epsg(CRS.from_wkt(text), text)
except Exception: # pragma: no cover — WKT 불량은 라벨 없음으로 흘린다
return None
def _read_header(blob: bytes) -> dict[str, Any]:
if len(blob) < _HEADER_BYTES:
raise ValueError("shapefile 헤더가 100바이트에 못 미칩니다.")
file_code = struct.unpack(">i", blob[0:4])[0]
if file_code != _FILE_CODE:
raise ValueError("shapefile 파일 코드가 규격(9994)과 다릅니다.")
declared_bytes = struct.unpack(">i", blob[24:28])[0] * 2
shape_type = struct.unpack("<i", blob[32:36])[0]
x_min, y_min, x_max, y_max = struct.unpack("<4d", blob[36:68])
z_min, z_max = struct.unpack("<2d", blob[68:84])
return {
"shape_type": shape_type,
"shape_type_name": _SHAPE_TYPE_NAMES.get(shape_type, f"Unknown({shape_type})"),
"declared_bytes": declared_bytes,
"bounds": {
"x_min": x_min,
"x_max": x_max,
"y_min": y_min,
"y_max": y_max,
"z_min": z_min,
"z_max": z_max,
},
}
def _read_polyline_record(blob: bytes, offset: int, content_bytes: int) -> list[list[tuple]]:
"""폴리라인 레코드 하나를 파트별 정점 목록으로 푼다."""
record_type = struct.unpack("<i", blob[offset : offset + 4])[0]
if record_type == 0: # Null shape — 규격상 건너뛴다
return []
if record_type not in _POLYLINE_TYPES:
raise ValueError(f"계획노선은 폴리라인이어야 합니다(받은 형상: {record_type}).")
cursor = offset + 4 + 32 # 형상종류 + 레코드 bbox
part_count, point_count = struct.unpack("<2i", blob[cursor : cursor + 8])
cursor += 8
parts = list(struct.unpack(f"<{part_count}i", blob[cursor : cursor + part_count * 4]))
cursor += part_count * 4
flat_xy = struct.unpack(f"<{point_count * 2}d", blob[cursor : cursor + point_count * 16])
cursor += point_count * 16
zs: tuple[float, ...] = ()
if record_type == 13:
cursor += 16 # Z 범위
end = cursor + point_count * 8
if end - offset <= content_bytes:
zs = struct.unpack(f"<{point_count}d", blob[cursor:end])
points = [
(flat_xy[index * 2], flat_xy[index * 2 + 1], zs[index] if zs else 0.0)
for index in range(point_count)
]
boundaries = [*parts, point_count]
return [points[boundaries[i] : boundaries[i + 1]] for i in range(part_count)]
def read_shapefile_parts(path: str | Path) -> list[list[tuple]]:
""".shp의 모든 폴리라인 파트를 (x, y, z) 정점 목록으로 읽는다."""
source = Path(path)
blob = source.read_bytes()
header = _read_header(blob)
if header["shape_type"] not in _POLYLINE_TYPES and header["shape_type"] != 0:
raise ValueError(
f"계획노선 shapefile은 폴리라인이어야 합니다(받은 형상: {header['shape_type_name']})."
)
limit = min(len(blob), header["declared_bytes"] or len(blob))
parts: list[list[tuple]] = []
offset = _HEADER_BYTES
while offset + 8 <= limit:
content_bytes = struct.unpack(">i", blob[offset + 4 : offset + 8])[0] * 2
if content_bytes <= 0:
break
parts.extend(_read_polyline_record(blob, offset + 8, content_bytes))
offset += 8 + content_bytes
return [part for part in parts if len(part) >= 2]
def read_shapefile_attributes(path: str | Path) -> dict[str, str]:
"""짝 .dbf 첫 레코드의 속성. 없거나 못 읽으면 빈 dict."""
dbf_path = Path(path).with_suffix(".dbf")
if not dbf_path.exists():
return {}
encoding = shapefile_encoding(Path(path))
try:
blob = dbf_path.read_bytes()
header_bytes, record_bytes = struct.unpack("<2H", blob[8:12])
fields: list[tuple[str, int]] = []
cursor = 32
while cursor < header_bytes - 1 and blob[cursor] != 0x0D:
descriptor = blob[cursor : cursor + 32]
name = descriptor[0:11].split(b"\x00")[0].decode(encoding, errors="replace").strip()
fields.append((name, descriptor[16]))
cursor += 32
record = blob[header_bytes : header_bytes + record_bytes]
if not record:
return {}
values: dict[str, str] = {}
position = 1 # 첫 바이트는 삭제 표시
for name, width in fields:
raw = record[position : position + width]
values[name] = raw.decode(encoding, errors="replace").strip()
position += width
return values
except (OSError, struct.error, ValueError):
logger.warning("shapefile .dbf 속성을 읽지 못했습니다: %s", dbf_path.name)
return {}
def _route_name_from_attributes(attributes: dict[str, str], fallback: str) -> str:
for key in ("대상지", "노선명", "route_name", "NAME", "name"):
value = attributes.get(key)
if value:
return value
return fallback
def analyze_shapefile_metadata(path: str | Path) -> dict[str, Any]:
"""계획노선 shapefile의 B03 메타데이터를 만든다."""
source = Path(path)
header = _read_header(source.read_bytes()[:_HEADER_BYTES])
parts = read_shapefile_parts(source)
attributes = read_shapefile_attributes(source)
point_count = sum(len(part) for part in parts)
missing = [
extension for extension in (".shx", ".dbf") if not source.with_suffix(extension).exists()
]
return {
"file": source.name,
"extension": "shp",
"size_bytes": source.stat().st_size,
"purpose": "planned_route",
"route_name": _route_name_from_attributes(attributes, source.stem),
"shape_type": header["shape_type_name"],
"part_count": len(parts),
"point_count": point_count,
"epsg": shapefile_epsg_label(source),
"crs_input": shapefile_crs_input(source),
"encoding": shapefile_encoding(source),
"attributes": attributes,
"missing_members": missing,
"bounds": header["bounds"],
"start_point": list(parts[0][0]) if parts else None,
"end_point": list(parts[-1][-1]) if parts else None,
}
+25 -7
View File
@@ -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 -5
View File
@@ -12,15 +12,22 @@ import aiomysql
from config.config_system import TEMP_UPLOAD_RETENTION_DAYS
# 묶음이 "완료"로 넘어가려면 있어야 하는 파일 종류. B03 필수 슬롯과 같은 기준이다.
REQUIRED_TEMP_FILE_TYPES = frozenset({"csv", "prj", "tfw"})
REQUIRED_TEMP_FILE_TYPES = frozenset({"prj", "tfw"})
POINT_CLOUD_FILE_TYPES = frozenset({"las", "laz"})
# 계획노선은 CSV 또는 shapefile 중 하나 (2026-08-31).
ROUTE_FILE_TYPES = frozenset({"csv", "shp"})
SHAPEFILE_REQUIRED_TYPES = frozenset({"shx", "dbf"})
def is_batch_required_complete(file_types: set[str]) -> bool:
"""필수 파일(csv·prj·tfw + las/laz 1종)이 모두 찼는지."""
return REQUIRED_TEMP_FILE_TYPES.issubset(file_types) and bool(
file_types & POINT_CLOUD_FILE_TYPES
)
"""필수 파일(계획노선 1종 + prj·tfw + las/laz 1종)이 모두 찼는지."""
if not REQUIRED_TEMP_FILE_TYPES.issubset(file_types):
return False
if not file_types & ROUTE_FILE_TYPES:
return False
if "shp" in file_types and not SHAPEFILE_REQUIRED_TYPES.issubset(file_types):
return False
return bool(file_types & POINT_CLOUD_FILE_TYPES)
async def create_temp_batch(
+13 -2
View File
@@ -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
],
+3
View File
@@ -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):
+5 -5
View File
@@ -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
+118 -39
View File
@@ -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)
+33 -7
View File
@@ -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;
}
}
+111 -2
View File
@@ -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`;
+9 -4
View File
@@ -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
+3 -3
View File
@@ -250,7 +250,7 @@ async def get_planned_route(project_id: UUID) -> dict[str, Any] | JSONResponse:
from B04_PreProcess.B04_PreProcess_Engine_Extent import project_epsg_from_prj
from common_util.common_util_route_geometry import (
find_planned_route_file,
read_planned_route_csv,
read_planned_route,
)
pool = get_db_pool()
@@ -259,7 +259,7 @@ async def get_planned_route(project_id: UUID) -> dict[str, Any] | JSONResponse:
stored_path = await get_project_storage_relative_path(connection, project_id)
project_root = Path(resolve_stored_project_path(stored_path))
route_file = find_planned_route_file(project_root / "B03_FileInput" / "input")
planned = read_planned_route_csv(route_file) if route_file else None
planned = read_planned_route(route_file) if route_file else None
if planned is None or len(planned.vertices) < 2:
return {"status": "success", "points": []}
@@ -267,7 +267,7 @@ async def get_planned_route(project_id: UUID) -> dict[str, Any] | JSONResponse:
# 그것이 프로젝트 좌표계와 다르면 여기서 한 번 옮긴다.
target_epsg = project_epsg_from_prj(project_root)
points = [(float(vertex.x), float(vertex.y)) for vertex in planned.vertices]
source_epsg = f"EPSG:{planned.epsg}" if planned.epsg else target_epsg
source_epsg = planned.crs_input or target_epsg
if source_epsg != target_epsg:
from pyproj import Transformer
@@ -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]
+43
View File
@@ -10,6 +10,7 @@ from __future__ import annotations
import logging
import re
from pathlib import Path
from pyproj import CRS
@@ -123,6 +124,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` 입력 문자열로 정규화한다.
+2 -2
View File
@@ -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]):
+95 -7
View File
@@ -4,7 +4,7 @@
정의한다. 어느 한쪽 페이지 폴더에 두면 반대 방향 import가 생긴다.
노선 원천은 가지다.
· B03에 업로드된 **계획 노선 파일**(CSV) 배수유역 분석의 입력
· B03에 업로드된 **계획 노선 파일**(CSV·shapefile) 배수유역 분석의 입력
· DB `route_points` B05에서 탐색·확정한 노선
같은 `RouteVertex` 목록으로 바꿔 아래 함수들이 그대로 받는다.
"""
@@ -14,6 +14,7 @@ from __future__ import annotations
import csv
import logging
import math
import struct
from dataclasses import dataclass
from pathlib import Path
from typing import Any
@@ -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(
+19 -2
View File
@@ -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
View File
@@ -70,9 +70,26 @@ DB_POOL_MAX = int(os.getenv("DB_POOL_MAX", "20"))
# 4. 파일 업로드 제한
# ─────────────────────────────────────────────────────────────────────────
UPLOAD_MAX_MB = int(os.getenv("UPLOAD_MAX_MB", str(30 * 1024)))
UPLOAD_MAX_FILES = int(os.getenv("UPLOAD_MAX_FILES", "5"))
# 한 번에 보낼 수 있는 **파일 수**. 화면의 슬롯 수(5)와 다르다 — 계획노선 shapefile은
# 한 슬롯에 5개(.shp/.shx/.dbf/.cpg/.prj)가 한 벌로 들어오기 때문이다(2026-08-31).
UPLOAD_MAX_FILES = int(os.getenv("UPLOAD_MAX_FILES", "10"))
UPLOAD_CHUNK_SIZE_BYTES = int(os.getenv("UPLOAD_CHUNK_SIZE_BYTES", str(1024 * 1024 * 1024)))
UPLOAD_ALLOWED_EXT = [".csv", ".las", ".laz", ".tif", ".tfw", ".prj", ".dxf"]
UPLOAD_ALLOWED_EXT = [
".csv",
".shp",
".shx",
".dbf",
".cpg",
".las",
".laz",
".tif",
".tfw",
".prj",
".dxf",
]
# 계획노선 shapefile 한 벌. GDAL이 열려면 같은 basename으로 한 폴더에 있어야 한다.
SHAPEFILE_MEMBER_EXT = (".shp", ".shx", ".dbf", ".cpg", ".prj")
SHAPEFILE_REQUIRED_EXT = (".shp", ".shx", ".dbf")
CHUNK_TEMP_DIR = os.getenv("CHUNK_TEMP_DIR", "B03_FileInput/chunks_temp")
CHUNK_RETENTION_HOURS = int(os.getenv("CHUNK_RETENTION_HOURS", "24"))
+22 -7
View File
@@ -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: [
"선택한 파일 유형이 이 카드와 맞지 않습니다.",