merge: 메인 폴더 브랜치(B03·B04·B05)를 보조 워크트리로 받는다

This commit is contained in:
2026-09-01 15:59:14 +09:00
29 changed files with 1162 additions and 146 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):
+24 -27
View File
@@ -25,31 +25,26 @@ from fastapi.responses import JSONResponse
logger = logging.getLogger(__name__)
def _planned_route_points_in_project_crs(project_root: Path) -> list[dict[str, float]] | None:
"""계획노선 CSV를 읽어 프로젝트 좌표계(m) 점 목록으로 돌려준다. 없으면 None.
def _planned_route_points_in_project_crs(
project_root: Path, surface: dict[str, Any] | None = None
) -> list[dict[str, float]] | None:
"""설계용 계획노선을 프로젝트 좌표계(m) 점 목록으로 돌려준다. 없으면 None.
B04 `/planned-route` 조회와 같은 규칙 — CSV가 제 좌표계(crs_epsg)를 적어 두었고
프로젝트 좌표계와 다르면 한 번 옮긴다.
읽기·좌표계 변환·트림·조밀화는 `load_design_route()` 한 곳에서 한다 — 배수유역·유입도
같은 함수를 쓰므로 여기만 트림되는 일이 없다.
"""
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,
)
from common_util.common_util_route_geometry import load_design_route
route_file = find_planned_route_file(project_root / "B03_FileInput" / "input")
planned = read_planned_route_csv(route_file) if route_file else None
if planned is None or len(planned.vertices) < 2:
planned = load_design_route(project_root, surface)
if planned is None:
if surface:
logger.warning(
"자동 설계 체인 중단(설계 노선 없음): 계획노선을 읽지 못했거나 라이다 측량"
" 범위와 겹치지 않습니다 — %s",
project_root.name,
)
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
if source_epsg.upper() != target_epsg.upper():
from pyproj import Transformer
transformer = Transformer.from_crs(source_epsg, target_epsg, always_xy=True)
points = [transformer.transform(x, y) for x, y in points]
return [{"x": x, "y": y} for x, y in points]
return [{"x": v.x, "y": v.y} for v in planned.vertices]
async def _prepare_drainage_pipes_and_reprofile(
@@ -172,16 +167,18 @@ async def run_auto_design_chain(
# 이 체인이 끝나기 전에는 B05·B06에 들어가면 안 된다 — 반쯤 계산된 화면을 만지면
# 그 편집이 섞인 채 초기값이 찍힌다(2026-08-29 사용자 확정, CLAUDE.md 5장).
mark_designing(project_root)
points = _planned_route_points_in_project_crs(project_root)
# WF1이 **실제로 확정한** 선택값(stage 1 스냅샷)을 그대로 쓴다. config 기본값을
# 쓰면 LAS 없이 설계한 프로젝트에서 있지도 않은 모델을 가리켜 404로 체인이
# 끊긴다(2026-08-30 실사고). 노선 트림도 이 지표면을 기준으로 한다.
async with pool.acquire() as connection:
defaults = await get_surface_confirmation_params(connection, str(project_id))
points = _planned_route_points_in_project_crs(project_root, defaults)
if not points:
logger.warning("자동 설계 체인 중단(계획노선 CSV 없음): project_id=%s", project_id)
return None
# 3) B05 경로 계산 — WF1이 **실제로 확정한** 선택값(stage 1 스냅샷)을 그대로 쓴다.
# config 기본값(csf/dtm)을 쓰면 LAS 없이 설계한 프로젝트에서 있지도 않은 모델을
# 가리켜 404로 체인이 끊긴다(2026-08-30 실사고).
async with pool.acquire() as connection:
defaults = await get_surface_confirmation_params(connection, str(project_id))
# 3) B05 경로 계산
request = RouteSolveRequest(
filter_key=str(defaults["source_filter"]),
method=str(defaults["method"]),
+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`;
+18 -6
View File
@@ -23,7 +23,11 @@ from B04_PreProcess.B04_PreProcess_Engine_Pipeline import build_all_terrain_mode
from B04_PreProcess.B04_PreProcess_Engine_Structurize import structurize_las
from common_util.common_util_atomic import atomic_write_npz
from common_util.common_util_json import atomic_write_json
from config.config_system import SURFACE_GROUND_RATIO_WARN, build_surface_model_config
from config.config_system import (
SHEET_SURFACE_AUTO_METHODS,
SURFACE_GROUND_RATIO_WARN,
build_surface_model_config,
)
logger = logging.getLogger(__name__)
@@ -262,7 +266,10 @@ def run_surface_analysis(
build_sheet_surface_from_route,
)
sheet_models = build_sheet_surface_from_route(project_root, processed_dir, models_dir)
# 자동 전처리는 기본 방식 하나만 만든다 — 나머지는 관리자가 화면에서 고를 때.
sheet_models = build_sheet_surface_from_route(
project_root, processed_dir, models_dir, list(SHEET_SURFACE_AUTO_METHODS)
)
except Exception as exc:
logger.warning("도엽등고선 서피스 생성 실패: %s", exc)
@@ -301,11 +308,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"
@@ -446,25 +446,32 @@ def build_sheet_surface_model(
def build_sheet_surface_from_route(
project_root: Path, processed_dir: Path, models_dir: Path
project_root: Path,
processed_dir: Path,
models_dir: Path,
methods: list[str] | None = None,
) -> list[dict[str, Any]]:
"""B03 업로드 계획노선 CSV를 찾아 방식 도엽 서피스를 만든다. 없으면 빈 목록."""
"""B03 업로드 계획노선을 찾아 지정한 방식 도엽 서피스를 만든다. 없으면 빈 목록.
`methods` 주지 않으면 `SHEET_SURFACE_METHODS` 전체를 만든다 관리자가 화면에서
방식을 요청할 목록만 넘긴다.
"""
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 []
route_xy = np.array([(v.x, v.y) for v in planned.vertices], dtype=np.float64)
return build_sheet_surface_model(
project_root, processed_dir, models_dir, route_xy, planned.epsg or 5186
project_root, processed_dir, models_dir, route_xy, planned.epsg or 5186, methods
)
@@ -478,7 +485,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 +497,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
+4 -1
View File
@@ -22,6 +22,7 @@ from B04_PreProcess.B04_PreProcess_Engine_Extent import (
planned_route_bounds,
project_epsg_from_prj,
)
from B04_PreProcess.B04_PreProcess_Engine_Ground import available_filters
from B04_PreProcess.B04_PreProcess_Repository import (
clear_confirmed_surface_models,
get_input_file,
@@ -325,7 +326,9 @@ async def get_surface_point_cloud(
source_path = structured_path
if filter is not None:
if filter not in {"grid_min_z", "csf", "pmf"}:
# 필터 목록은 Ground 의 등록부 하나에서 나온다 — 여기 따로 적어 두면
# 필터가 늘 때마다 이 화면만 400으로 막힌다(2026-09-01 실사고).
if filter not in available_filters():
return JSONResponse(
status_code=400,
content={"status": "error", "message": "지원하지 않는 지면 필터입니다."},
+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,9 +39,11 @@ 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,
load_design_route,
read_planned_route,
)
from common_util.common_util_storage import resolve_stored_project_path
from common_util.common_util_surface_confirmation import get_surface_confirmation_params
from common_util.common_util_wamis_rainfall import (
build_rainfall_table,
ensure_contour_cache,
@@ -160,30 +162,35 @@ async def _prepare(project_id: UUID) -> dict[str, Any] | JSONResponse:
노선은 **B03에 업로드된 계획 노선 파일**에서 읽는다 B05의 확정 경로가 아니다.
배수유역 분석은 노선 설계보다 먼저 끝나 있어야 하기 때문이다(2026-07-31 사용자 지시).
다만 설계 계통과 **같은 노선**이어야 한다 `load_design_route()` 지표면 구간을
잘라 프로젝트 좌표계로 돌려준다. 원본을 그대로 쓰면 유역·관이 확정 노선 밖에도
찍히고 좌표계마저 갈린다(2026-09-01 실측: 관은 5179, 노선은 5176이었다).
"""
pool = get_db_pool()
async with pool.acquire() as connection:
stored_path = await get_project_storage_relative_path(connection, project_id)
epsg = await get_surface_crs_epsg(connection, project_id, 0)
surface_params = await get_surface_confirmation_params(connection, str(project_id))
project_root = Path(resolve_stored_project_path(stored_path))
route_file = find_planned_route_file(_route_input_dir(stored_path))
if route_file is None:
return JSONResponse(
status_code=404,
content={"status": "error", "message": "계획 노선 파일이 업로드되지 않았습니다."},
)
planned = read_planned_route_csv(route_file)
planned = await asyncio.to_thread(load_design_route, project_root, surface_params)
if planned is None or len(planned.vertices) < 2:
return JSONResponse(
status_code=400,
content={
"status": "error",
"message": f"계획 노선 파일을 읽지 못했습니다: {route_file.name}",
"message": f"계획 노선을 읽지 못했거나 지표면과 겹치지 않습니다: {route_file.name}",
},
)
# 노선 파일이 CRS를 명시하면 그 값을 따른다. 도엽 재투영도 같은 좌표계로 맞춘다.
source_crs = f"EPSG:{planned.epsg or epsg or 5186}"
# 설계 계통과 같은 프로젝트 좌표계로 맞춘다. 도엽 재투영도 좌표계로 다.
source_crs = planned.crs_input or f"EPSG:{epsg or 5186}"
to_lonlat_transformer = Transformer.from_crs(source_crs, "EPSG:4326", always_xy=True)
to_metric_transformer = Transformer.from_crs("EPSG:4326", source_crs, always_xy=True)
@@ -246,7 +253,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]
+9
View File
@@ -26,6 +26,7 @@ import {
analyzeSurface,
confirmSurfaceModel,
fetchConfirmedSurface,
fetchPlannedRoute,
fetchSurfacePointCloud,
fetchSurfaceStatus,
listSurfaceInputFiles,
@@ -528,6 +529,14 @@ export async function renderB04Surface(root: HTMLElement): Promise<void> {
terrainViewer.setContourInterval(confirmed.contour_interval_m);
renderInputFiles(inputs.files);
renderStatus(status);
// 3D 좌표 환산 기준은 확정 서피스에서 먼저 받는다(수 KB). 포인트클라우드는 수십 MB라
// 늦거나 실패할 수 있는데, 그때 노선까지 같이 사라지면 안 된다.
if (confirmed.bounds) terrainViewer.setReferenceBounds(confirmed.bounds);
// 계획노선을 3D 최고 표고 평면에도 얹는다 — 라이다가 노선을 어디까지 덮는지
// 평면상으로 바로 보인다(2026-09-01 사용자 지시).
void fetchPlannedRoute(projectId)
.then((route) => terrainViewer.setRoute(route.points ?? []))
.catch(() => terrainViewer.setRoute([]));
viewer.setLoading("포인트 데이터 로딩 중…");
try {
pointCloud = await fetchSurfacePointCloud(
@@ -6,6 +6,8 @@ import { API_BASE_URL } from "@config/config_frontend";
import { fetchCachedBytes, fetchCachedJson } from "../A00_Common/b_asset_cache";
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
import { createProgressCircle } from "@ui/ui_template_progress";
// 계획선 색은 2D 지도·B05 배수유역도와 한 곳에서 나온다 — 같은 선을 다른 색으로 그리지 않는다.
import { routeLineColor } from "./B04_PreProcess_UI_MapRender";
import type {
SurfaceBounds,
SurfaceModelSummary,
@@ -37,6 +39,8 @@ export interface SurfaceTerrainViewer {
smoothingField: HTMLElement;
render: (projectId: string, models: readonly SurfaceModelSummary[]) => void;
setReferenceBounds: (bounds: SurfaceBounds) => void;
/** 계획노선(사업지 좌표계 m)을 3D 최고 표고 평면에 그린다. 빈 목록이면 걷어낸다. */
setRoute: (points: ReadonlyArray<{ x: number; y: number }>) => void;
setSelection: (sourceFilter: string, method: string) => void;
/** 다른 모델(예: 라이다 지표면)을 반투명으로 겹쳐 본다. 빈 문자열이면 걷어낸다. */
showOverlay: (
@@ -265,6 +269,13 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
contourGroup.visible = contourCheck.checked;
scene.add(contourGroup);
// 계획노선 — 지표면에 드리우지 않고 **데이터 최고 표고 평면**에 수평으로 얹는다
// (2026-09-01 사용자 확정). 노선과 측량 범위가 평면상 어디서 어긋나는지 보려는 것이라
// 지형을 따라 오르내리면 오히려 판단이 어렵다.
const routeGroup = new THREE.Group();
scene.add(routeGroup);
let routePoints: ReadonlyArray<{ x: number; y: number }> = [];
let terrainMesh: THREE.Object3D | null = null;
const labelElements: HTMLDivElement[] = [];
// 라벨 목록이 바뀌거나 표시 옵션을 껐다 켰을 때는 카메라가 그대로여도 다시 배치해야 한다.
@@ -382,8 +393,39 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
legendBar.style.display = "none";
}
function drawRoute(): void {
while (routeGroup.children.length > 0) {
const child = routeGroup.children[0];
routeGroup.remove(child);
if (child instanceof THREE.Line) {
child.geometry.dispose();
(child.material as THREE.Material).dispose();
}
}
if (routePoints.length < 2 || !referenceBounds) return;
// 뷰어 좌표 규약은 백엔드 scene_vertices와 같다: x, 높이, -y.
const cx = (referenceBounds.x_min + referenceBounds.x_max) / 2;
const cy = (referenceBounds.y_min + referenceBounds.y_max) / 2;
const cz = (referenceBounds.z_min + referenceBounds.z_max) / 2;
const planeY = referenceBounds.z_max - cz;
const vertices = routePoints.map(
(point) => new THREE.Vector3(point.x - cx, planeY, -(point.y - cy)),
);
const material = new THREE.LineBasicMaterial({
color: new THREE.Color(routeLineColor()),
});
routeGroup.add(
new THREE.Line(new THREE.BufferGeometry().setFromPoints(vertices), material),
);
// 노선은 지형 로딩과 따로 도착한다. 지형이 이미 떠 있으면 노선까지 담도록 다시 맞춘다.
if (terrainMesh) fitCamera(terrainMesh);
}
const getFitParams = (object: THREE.Object3D) => {
const box = new THREE.Box3().setFromObject(object);
// 계획노선은 최고 표고 평면에 있어 지형 상자 위·밖으로 걸친다. 지형만 보고 맞추면
// 노선이 화면 밖으로 밀려 보이지 않는다 — 프레임에 같이 넣는다.
if (routeGroup.children.length > 0) box.expandByObject(routeGroup);
const center = box.getCenter(new THREE.Vector3());
const size = box.getSize(new THREE.Vector3());
const span = Math.max(size.x, size.y, size.z, 1);
@@ -846,6 +888,11 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
},
setReferenceBounds(bounds) {
referenceBounds = bounds;
drawRoute();
},
setRoute(points) {
routePoints = points;
drawRoute();
},
setSelection(sourceFilter, method) {
activeFilter = sourceFilter;
+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` 입력 문자열로 정규화한다.
+8 -8
View File
@@ -30,8 +30,7 @@ from B06_Section.B06_Section_Repository import get_longitudinal_section
from common_util.common_util_route_geometry import (
RouteVertex,
build_route_vertices,
find_planned_route_file,
read_planned_route_csv,
load_design_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
@@ -82,9 +81,11 @@ async def load_drainage_context(project_id: UUID) -> tuple[DrainageContext | Non
surface_params = await get_surface_confirmation_params(connection, str(project_id))
project_root = Path(resolve_stored_project_path(stored_path))
planned = await asyncio.to_thread(_read_planned_route, project_root)
# 설계 계통과 **같은 노선**을 쓴다 — 지표면 밖 구간을 자른 뒤의 노선이다. 원본을 그대로
# 쓰면 유역·관이 확정 노선 밖에도 찍혀 종단 계획선이 그 관을 버린다(2026-09-01).
planned = await asyncio.to_thread(_read_planned_route, project_root, surface_params)
if planned is None or len(planned.vertices) < 2:
return None, "원청 계획노선 CSV를 읽지 못했습니다. B03에서 노선 파일을 확인하세요."
return None, "계획노선 읽지 못했습니다. B03에서 노선 파일을 확인하세요."
sampler = await asyncio.to_thread(_open_sampler, project_root, surface_params)
vertices, z_source = await asyncio.to_thread(
@@ -111,10 +112,9 @@ 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
def _read_planned_route(project_root: Path, surface_params: dict[str, Any] | None = None):
"""설계용 계획노선 읽는다(파일 접근이라 스레드에서 돈다)."""
return load_design_route(project_root, surface_params)
def _open_sampler(project_root: Path, surface_params: dict[str, Any]):
+1 -1
View File
@@ -55,11 +55,11 @@ from config.config_system import (
DRAINAGE_DITCH_SAMPLE_M,
DRAINAGE_FLOW_AREA_RATIO,
DRAINAGE_MANNING_N,
DRAINAGE_RECOMMEND_DIAMETERS_MM,
DRAINAGE_PIPE_MAX_SPACING_M,
DRAINAGE_PIPE_MIN_SPACING_M,
DRAINAGE_PIPE_SLOPE_DEG,
DRAINAGE_RAINFALL_FILENAME,
DRAINAGE_RECOMMEND_DIAMETERS_MM,
DRAINAGE_RUNOFF_COEFFICIENT,
DRAINAGE_TC_MIN_MINUTES,
DRAINAGE_VELOCITY_MAX_MS,
+285 -6
View File
@@ -4,7 +4,7 @@
정의한다. 어느 한쪽 페이지 폴더에 두면 반대 방향 import가 생긴다.
노선 원천은 가지다.
· B03에 업로드된 **계획 노선 파일**(CSV) 배수유역 분석의 입력
· B03에 업로드된 **계획 노선 파일**(CSV·shapefile) 배수유역 분석의 입력
· DB `route_points` B05에서 탐색·확정한 노선
같은 `RouteVertex` 목록으로 바꿔 아래 함수들이 그대로 받는다.
"""
@@ -14,12 +14,15 @@ from __future__ import annotations
import csv
import logging
import math
import struct
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from shapely.geometry import LineString, Point, shape
from config.config_system import SURFACE_ROUTE_EDGE_TRIM_M
logger = logging.getLogger(__name__)
# 계획 노선 CSV 열 이름 후보. B03이 여러 형식을 받게 되므로 흔한 표기를 모두 받아 준다.
@@ -60,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:
@@ -109,17 +116,289 @@ 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
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 load_design_route(
project_root: Path, surface_params: dict[str, Any] | None = None
) -> PlannedRoute | None:
"""설계가 쓸 계획노선 한 벌을 만든다 — 읽기·좌표계 변환·트림·조밀화를 여기서 끝낸다.
노선을 읽는 곳이 여럿이라(체인·배수유역·유입·도엽) 각자 읽으면 트림이 적용된 곳과
곳이 갈린다. 실제로 그렇게 갈려 측점이 트림 (2,136m) 기준으로 찍히고
확정 노선(1,070m) 어긋나 종단 계획선이 직선으로 나왔다(2026-09-01).
**설계 계통은 전부 함수를 지난다.**
`surface_params`(확정 필터·방식·스무딩) 주면 지표면이 덮지 못하는 구간을 잘라 내고,
B05 격자 탐색이 계획노선을 바꾸지 않도록 정점 간격을 직결 문턱 아래로 좁힌다.
주지 않으면 읽어서 좌표계만 맞춘 원본을 돌려준다.
"""
from B04_PreProcess.B04_PreProcess_Engine_Extent import project_epsg_from_prj
from config.config_system import (
ROUTE_DIRECT_LINK_CELL_FACTOR,
ROUTE_GRID_RES_M,
ROUTE_PLANNED_DENSIFY_SAFETY,
)
return candidates[0] if candidates else None
route_file = find_planned_route_file(project_root / "B03_FileInput" / "input")
planned = read_planned_route(route_file) if route_file else None
if planned is None or len(planned.vertices) < 2:
return None
target_crs = project_epsg_from_prj(project_root)
points = [(float(v.x), float(v.y)) for v in planned.vertices]
source_crs = planned.crs_input or target_crs
if source_crs.upper() != target_crs.upper():
from pyproj import Transformer
transformer = Transformer.from_crs(source_crs, target_crs, always_xy=True)
points = [transformer.transform(x, y) for x, y in points]
if surface_params:
from common_util.common_util_surface_sampler import build_surface_sampler
try:
sampler = build_surface_sampler(
project_root / "B04_PreProcess" / "models",
str(surface_params["source_filter"]),
str(surface_params["method"]),
bool(surface_params["smooth"]),
)
except (FileNotFoundError, KeyError, OSError) as exc:
logger.warning("설계 노선: 지표면을 열지 못해 트림을 건너뜁니다 — %s", exc)
else:
points = trim_route_to_surface(points, sampler)
points = densify_route(
points,
ROUTE_DIRECT_LINK_CELL_FACTOR * ROUTE_GRID_RES_M * ROUTE_PLANNED_DENSIFY_SAFETY,
)
if len(points) < 2:
return None
return replace_vertices(planned, points, crs_input=target_crs)
def replace_vertices(
planned: PlannedRoute, points: list[tuple[float, float]], *, crs_input: str | None = None
) -> PlannedRoute:
"""XY 목록으로 노선 정점을 갈아 끼우고 누가거리를 다시 센다."""
vertices: list[RouteVertex] = []
cumulative = 0.0
previous: tuple[float, float] | None = None
for x, y in points:
if previous is not None:
cumulative += math.dist(previous, (x, y))
vertices.append(RouteVertex(x=float(x), y=float(y), z=0.0, chainage_m=cumulative))
previous = (x, y)
return PlannedRoute(
vertices=vertices,
epsg=planned.epsg,
name=planned.name,
source=planned.source,
crs_input=crs_input or planned.crs_input,
)
def trim_route_to_surface(
points: list[tuple[float, float]],
sampler: Any,
edge_trim_m: float = SURFACE_ROUTE_EDGE_TRIM_M,
) -> list[tuple[float, float]]:
"""지표면이 덮는 구간만 남기고 계획노선을 자른다.
판정은 sampler가 돌려주는 `valid` 확정 DTM의 valid_mask가 불규칙한 실제
외곽이다(bounds 사각형이 아니다). 가장 연속 유효 구간을 남긴다.
`edge_trim_m` **잘라 끝에만** 적용한다. 서피스 가장자리는 밀도가 떨어져
지반고가 미덥기 때문이다. 노선 본래 끝점이 서피스 안이면 깎지 않는다 멀쩡한
구간을 짧게 만들 이유가 없다 (2026-09-01 사용자 확정).
전부 유효하면 입력을 그대로 돌려준다. 남는 구간이 2 미만이면 목록.
"""
import numpy as np
if len(points) < 2:
return list(points)
xy = np.asarray(points, dtype=np.float64)
try:
_, valid = sampler.sample_xy(xy)
except (ValueError, OSError) as exc:
logger.warning("노선 트림: 지표면 샘플링 실패 — %s", exc)
return list(points)
valid = np.asarray(valid, dtype=bool)
if valid.all():
return list(points)
if not valid.any():
logger.warning("노선 트림: 노선 전체가 지표면 밖입니다.")
return []
# 가장 긴 연속 유효 구간 — 가장자리에서 한두 점이 튀어도 본 구간을 잃지 않는다.
best_start = best_end = start = -1
for index, ok in enumerate([*valid.tolist(), False]):
if ok and start < 0:
start = index
elif not ok and start >= 0:
if index - start > best_end - best_start:
best_start, best_end = start, index
start = -1
kept = [(float(x), float(y)) for x, y in xy[best_start:best_end]]
trim_head = best_start > 0
trim_tail = best_end < len(xy)
kept = _trim_ends(kept, edge_trim_m if trim_head else 0.0, edge_trim_m if trim_tail else 0.0)
logger.info(
"노선 트림: 정점 %d개 → %d개 (지표면 밖 %d개, 가장자리 여유 %.0fm %s)",
len(points),
len(kept),
int((~valid).sum()),
edge_trim_m,
"앞뒤" if trim_head and trim_tail else ("" if trim_head else ""),
)
return kept
def densify_route(
points: list[tuple[float, float]], max_spacing_m: float
) -> list[tuple[float, float]]:
"""원래 정점은 모두 남기고, 간격이 `max_spacing_m`을 넘는 구간에만 점을 끼워 넣는다.
**평면 형상은 바뀌지 않는다** 같은 직선 위에 점을 찍을 뿐이다.
B05 경로 탐색은 제어점 간격이 `ROUTE_DIRECT_LINK_CELL_FACTOR × ROUTE_GRID_RES_M`
이하일 때만 격자 탐색을 건너뛰고 원좌표를 그대로 잇는다
(`B05_Profile_Engine_Solver.py:394`). 예정노선처럼 정점이 성긴 (용화 평균 17.6m)
문턱을 넘어 탐색을 타고, 급경사 현에서 "통과 경로 없음"으로 끊긴다.
간격만 좁혀 주면 계획노선이 손대지 않은 그대로 채택된다 (2026-09-01 사용자 확정).
"""
if len(points) < 2 or max_spacing_m <= 0:
return list(points)
dense: list[tuple[float, float]] = [points[0]]
for start, end in zip(points, points[1:]):
distance = math.dist(start, end)
steps = int(math.ceil(distance / max_spacing_m))
for step in range(1, steps):
ratio = step / steps
dense.append(
(
start[0] + (end[0] - start[0]) * ratio,
start[1] + (end[1] - start[1]) * ratio,
)
)
dense.append(end)
return dense
def _trim_ends(
points: list[tuple[float, float]], head_m: float, tail_m: float
) -> list[tuple[float, float]]:
"""폴리라인 앞뒤에서 지정 길이만큼 잘라 낸다. 남는 게 2점 미만이면 빈 목록."""
if len(points) < 2 or (head_m <= 0 and tail_m <= 0):
return points
line = LineString(points)
start = min(head_m, line.length)
end = max(start, line.length - tail_m)
if end - start <= 0:
logger.warning("노선 트림: 여유를 깎고 나니 남는 구간이 없습니다.")
return []
cumulative = 0.0
kept: list[tuple[float, float]] = [line.interpolate(start).coords[0]]
for index in range(1, len(points)):
cumulative += math.dist(points[index - 1], points[index])
if start < cumulative < end:
kept.append(points[index])
kept.append(line.interpolate(end).coords[0])
return kept if len(kept) >= 2 else []
def build_route_vertices(points: list[dict[str, Any]]) -> list[RouteVertex]:
+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
+35 -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"))
@@ -137,6 +154,11 @@ SURFACE_CLASSIFIED_GROUND_MIN_RATIO = float(
# 지면점 비율이 이 값 미만이면 필터가 사실상 실패한 것으로 보고 WARNING을 남긴다.
SURFACE_GROUND_RATIO_WARN = float(os.getenv("SURFACE_GROUND_RATIO_WARN", "0.01"))
# 계획노선이 지표면 밖으로 나가 잘릴 때, 잘린 쪽 끝에서 더 깎을 길이(m).
# 서피스 가장자리는 점 밀도가 떨어져 외곽선이 불규칙하다 — 경계에 딱 붙여 자르면
# 그 구간 지반고가 못 미덥다 (2026-09-01 사용자 확정).
SURFACE_ROUTE_EDGE_TRIM_M = float(os.getenv("SURFACE_ROUTE_EDGE_TRIM_M", "30.0"))
# ─────────────────────────────────────────────────────────────────────────
# 5-2. 지표면 모델 생성 파라미터 (TIN/DTM/NURBS/implicit/meshfree)
# ─────────────────────────────────────────────────────────────────────────
@@ -216,6 +238,14 @@ SHEET_SURFACE_METHODS = [
]
# 확정에 쓸 기본 방식 — 라플라스 (2026-08-30 사용자 확정).
SHEET_SURFACE_DEFAULT_METHOD = os.getenv("SHEET_SURFACE_DEFAULT_METHOD", "laplace")
# 자동 전처리가 만드는 도엽 방식 — 기본 하나뿐이다. 여섯 방식을 매번 다 만들면
# WF1의 대부분(용화 실측 891초 중 655초)을 여기서 쓴다. 나머지는 관리자가 B04에서
# 그 방식을 고를 때 만든다 (2026-09-01 사용자 확정).
SHEET_SURFACE_AUTO_METHODS = [
method.strip()
for method in os.getenv("SHEET_SURFACE_AUTO_METHODS", SHEET_SURFACE_DEFAULT_METHOD).split(",")
if method.strip()
]
# 일반 사용자 WF1 자동 확정 기본값
SURFACE_CONFIRM_DEFAULT_FILTER = os.getenv("SURFACE_CONFIRM_DEFAULT_FILTER", "csf")
@@ -280,6 +310,9 @@ ROUTE_REQUIRED_POINT_TOLERANCE_M = float(os.getenv("ROUTE_REQUIRED_POINT_TOLERAN
# 제어점 쌍이 이 배수×비용면 셀보다 가까우면 격자 탐색 없이 직결한다 — 원청 계획노선
# 보존 (2026-08-30 사용자 확정. 조밀 기준선은 격자 중간점이 못 끼어들어 평면 불변).
ROUTE_DIRECT_LINK_CELL_FACTOR = float(os.getenv("ROUTE_DIRECT_LINK_CELL_FACTOR", "2.0"))
# 예정노선을 자동 체인에 넘기기 전, 직결 문턱의 이 비율까지 정점 간격을 좁힌다.
# 문턱과 정확히 같게 두면 부동소수 오차 한 번에 탐색으로 넘어가 노선이 바뀐다.
ROUTE_PLANNED_DENSIFY_SAFETY = float(os.getenv("ROUTE_PLANNED_DENSIFY_SAFETY", "0.9"))
# 임도 종류 — 현행 규칙(별표2)의 3종. `branch`(지선)는 규칙에서 폐지됐으나 기존
# 저장분이 남아 있어 값으로는 계속 받는다(화면 선택지에서는 뺀다, 2026-08-19).
ROUTE_GRADE_CLASSES = ("trunk", "fire", "work", "branch")
+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: [
"선택한 파일 유형이 이 카드와 맞지 않습니다.",