merge: 메인 폴더 브랜치(B03·B04·B05)를 보조 워크트리로 받는다
This commit is contained in:
@@ -14,7 +14,7 @@ import { createButton, createInputField, showToast } from "@ui/ui_template_eleme
|
||||
import { table, text } from "@ui/ui_template_general_blocks";
|
||||
import { L } from "./B01_Dashboard_UI_Common";
|
||||
|
||||
/** 파일 확장자 = 보관함 슬롯 종류(csv·las·prj·tfw·tif). */
|
||||
/** 파일 확장자 = 보관함 슬롯 종류(csv·shp 세트·las·prj·tfw·tif). */
|
||||
export function tempFileType(fileName: string): string {
|
||||
const index = fileName.lastIndexOf(".");
|
||||
return index >= 0 ? fileName.slice(index + 1).toLowerCase() : "";
|
||||
@@ -54,7 +54,7 @@ export function openTempFileModal(options: TempModalOptions): void {
|
||||
const picker = document.createElement("input");
|
||||
picker.type = "file";
|
||||
picker.multiple = true;
|
||||
picker.accept = ".csv,.las,.laz,.tif,.tfw,.prj";
|
||||
picker.accept = ".csv,.shp,.shx,.dbf,.cpg,.las,.laz,.tif,.tfw,.prj";
|
||||
picker.className = "b01-temp__hidden-input";
|
||||
|
||||
const pickRow = document.createElement("div");
|
||||
|
||||
@@ -177,6 +177,8 @@ export interface UploadOverviewFile {
|
||||
file_size_mb: number;
|
||||
status: string;
|
||||
uploaded_at: string | null;
|
||||
/** 저장 경로 — PRJ 두 장(노선/지형)을 카드에 되돌릴 때 이것으로 가린다. */
|
||||
relative_path: string | null;
|
||||
}
|
||||
|
||||
export interface UploadOverviewSession {
|
||||
|
||||
@@ -11,6 +11,34 @@ from B03_FileInput.B03_FileInput_Schema import FileUploadDescriptor
|
||||
from common_util.common_util_storage import get_project_stage_path
|
||||
from config.config_system import CHUNK_TEMP_DIR, UPLOAD_CHUNK_SIZE_BYTES, UPLOAD_MAX_MB
|
||||
|
||||
# shapefile은 형제 파일이 **같은 폴더·같은 basename**이라야 열린다. 확장자별 폴더에
|
||||
# 흩어 두면 노선 자체를 못 읽는다. 그래서 세트는 `input/shp/`에 통째로 모은다.
|
||||
# 부수 효과가 하나 더 있다 — 노선 PRJ가 이 폴더 안에 남으므로 지형 PRJ(`input/prj/`)와
|
||||
# 파일명 정렬 운에 기대지 않고 갈린다 (2026-08-31).
|
||||
_SHAPEFILE_FOLDER = "shp"
|
||||
_SHAPEFILE_OWNED_EXT = frozenset({"shp", "shx", "dbf", "cpg"})
|
||||
|
||||
|
||||
def reconcile_shapefile_members(stage_root: Path) -> list[Path]:
|
||||
"""먼저 도착해 다른 폴더에 앉은 shapefile 형제 파일을 `input/shp/`로 모은다.
|
||||
|
||||
파일이 오는 순서를 강제할 수 없다 — PRJ가 SHP보다 먼저 오면 확장자 규칙만으로는
|
||||
`input/prj/`에 앉는다. SHP가 들어온 시점에 같은 basename의 형제를 끌어온다.
|
||||
"""
|
||||
input_root = stage_root / "input"
|
||||
shapefile_dir = input_root / _SHAPEFILE_FOLDER
|
||||
if not shapefile_dir.exists():
|
||||
return []
|
||||
moved: list[Path] = []
|
||||
for shape_path in shapefile_dir.glob("*.shp"):
|
||||
for extension in ("shx", "dbf", "cpg", "prj"):
|
||||
stray = input_root / extension / f"{shape_path.stem}.{extension}"
|
||||
target = shapefile_dir / stray.name
|
||||
if stray.exists() and not target.exists():
|
||||
shutil.move(str(stray), str(target))
|
||||
moved.append(target)
|
||||
return moved
|
||||
|
||||
|
||||
def resolve_upload_destination(
|
||||
project_root: str | Path,
|
||||
@@ -18,8 +46,18 @@ def resolve_upload_destination(
|
||||
) -> Path:
|
||||
"""검증된 파일의 B03 입력 저장 경로를 생성해 반환한다."""
|
||||
stage_root = Path(get_project_stage_path(str(project_root), "B03_FileInput")).resolve()
|
||||
file_type = Path(descriptor.original_filename).suffix.lower().lstrip(".")
|
||||
destination = (stage_root / "input" / file_type / descriptor.original_filename).resolve()
|
||||
reconcile_shapefile_members(stage_root)
|
||||
|
||||
source_name = Path(descriptor.original_filename)
|
||||
file_type = source_name.suffix.lower().lstrip(".")
|
||||
folder = file_type
|
||||
if file_type in _SHAPEFILE_OWNED_EXT:
|
||||
folder = _SHAPEFILE_FOLDER
|
||||
elif file_type == "prj":
|
||||
# 노선 shapefile의 짝 PRJ만 세트 폴더로. 지형 PRJ는 `input/prj/`에 남는다.
|
||||
if (stage_root / "input" / _SHAPEFILE_FOLDER / f"{source_name.stem}.shp").exists():
|
||||
folder = _SHAPEFILE_FOLDER
|
||||
destination = (stage_root / "input" / folder / descriptor.original_filename).resolve()
|
||||
|
||||
if os.path.commonpath((stage_root, destination)) != str(stage_root):
|
||||
raise ValueError("업로드 저장 경로가 B03 단계 폴더를 벗어났습니다.")
|
||||
|
||||
@@ -396,6 +396,10 @@ def analyze_input_metadata(path: str | Path) -> dict[str, Any]:
|
||||
extension = source.suffix.lower()
|
||||
if extension == ".csv":
|
||||
return analyze_planned_route_csv(source)
|
||||
if extension == ".shp":
|
||||
from B03_FileInput.B03_FileInput_Engine_Shapefile import analyze_shapefile_metadata
|
||||
|
||||
return analyze_shapefile_metadata(source)
|
||||
if extension in {".las", ".laz"}:
|
||||
return analyze_las_metadata(source)
|
||||
if extension == ".prj":
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
"""계획노선 shapefile(.shp) 판독 — 기하·속성·좌표계.
|
||||
|
||||
GDAL/geopandas를 쓰지 않고 ESRI Shapefile 규격을 직접 읽는다. 업로드 **직후**
|
||||
메타데이터를 내야 하는데 그 시점엔 형제 파일(.shx/.dbf/.prj)이 아직 다 도착하지
|
||||
않았을 수 있고, GDAL은 한 짝이라도 비면 열기 자체를 실패하기 때문이다. 규격 파싱은
|
||||
.shp 하나만으로 기하를 읽어 낸다.
|
||||
|
||||
좌표계는 EPSG 코드로 가리지 않는다 — 짝 PRJ 원문을 `crs_input_from_prj()`에 넘겨
|
||||
"EPSG:n" 또는 **원문 WKT**를 그대로 변환기 입력으로 쓴다(2026-08-31 사용자 확정).
|
||||
"""
|
||||
|
||||
import logging
|
||||
import struct
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_HEADER_BYTES = 100
|
||||
_FILE_CODE = 9994
|
||||
|
||||
# 규격상 폴리라인 계열만 계획노선으로 받는다. Z/M 변형도 XY는 같은 자리에 있다.
|
||||
_POLYLINE_TYPES = {3: "PolyLine", 13: "PolyLineZ", 23: "PolyLineM"}
|
||||
_SHAPE_TYPE_NAMES = {
|
||||
0: "Null",
|
||||
1: "Point",
|
||||
3: "PolyLine",
|
||||
5: "Polygon",
|
||||
8: "MultiPoint",
|
||||
11: "PointZ",
|
||||
13: "PolyLineZ",
|
||||
15: "PolygonZ",
|
||||
18: "MultiPointZ",
|
||||
21: "PointM",
|
||||
23: "PolyLineM",
|
||||
25: "PolygonM",
|
||||
28: "MultiPointM",
|
||||
31: "MultiPatch",
|
||||
}
|
||||
|
||||
|
||||
def _normalize_codepage(text: str) -> str | None:
|
||||
""".cpg 내용을 파이썬 인코딩 이름으로 바꾼다.
|
||||
|
||||
실물은 `949` 한 줄만 들어 있다(2026-08-31 원청 자료) — `CP949`도 `EUC-KR`도
|
||||
아니라서 그대로 넘기면 LookupError가 난다.
|
||||
"""
|
||||
value = (text or "").strip()
|
||||
if not value:
|
||||
return None
|
||||
if value.isdigit():
|
||||
return f"cp{value}"
|
||||
upper = value.upper().replace("-", "").replace("_", "")
|
||||
if upper in {"ANSI", "OEM", "SYSTEM"}:
|
||||
return "cp949"
|
||||
return value
|
||||
|
||||
|
||||
def shapefile_encoding(path: Path) -> str:
|
||||
"""짝 .cpg에 적힌 인코딩. 없으면 국내 자료 관례대로 CP949."""
|
||||
cpg_path = path.with_suffix(".cpg")
|
||||
if cpg_path.exists():
|
||||
try:
|
||||
encoding = _normalize_codepage(cpg_path.read_text(encoding="ascii", errors="ignore"))
|
||||
if encoding:
|
||||
"".encode(encoding) # 이름이 실재하는지 확인 — 없으면 LookupError
|
||||
return encoding
|
||||
except (OSError, LookupError):
|
||||
logger.warning("shapefile .cpg 인코딩을 해석하지 못했습니다: %s", cpg_path.name)
|
||||
return "cp949"
|
||||
|
||||
|
||||
def shapefile_crs_input(path: Path) -> str | None:
|
||||
"""짝 .prj를 `Transformer.from_crs` 입력 문자열로 정규화해 돌려준다."""
|
||||
prj_path = path.with_suffix(".prj")
|
||||
if not prj_path.exists():
|
||||
return None
|
||||
from common_util.common_util_crs import crs_input_from_prj
|
||||
|
||||
return crs_input_from_prj(prj_path.read_text(encoding="utf-8", errors="ignore"))
|
||||
|
||||
|
||||
def shapefile_epsg_label(path: Path) -> int | None:
|
||||
"""짝 .prj의 EPSG **라벨**. 변환에는 쓰지 않는다 — 메타 표시·로그용."""
|
||||
prj_path = path.with_suffix(".prj")
|
||||
if not prj_path.exists():
|
||||
return None
|
||||
from pyproj import CRS
|
||||
|
||||
from common_util.common_util_crs import identify_epsg
|
||||
|
||||
text = prj_path.read_text(encoding="utf-8", errors="ignore")
|
||||
try:
|
||||
return identify_epsg(CRS.from_wkt(text), text)
|
||||
except Exception: # pragma: no cover — WKT 불량은 라벨 없음으로 흘린다
|
||||
return None
|
||||
|
||||
|
||||
def _read_header(blob: bytes) -> dict[str, Any]:
|
||||
if len(blob) < _HEADER_BYTES:
|
||||
raise ValueError("shapefile 헤더가 100바이트에 못 미칩니다.")
|
||||
file_code = struct.unpack(">i", blob[0:4])[0]
|
||||
if file_code != _FILE_CODE:
|
||||
raise ValueError("shapefile 파일 코드가 규격(9994)과 다릅니다.")
|
||||
declared_bytes = struct.unpack(">i", blob[24:28])[0] * 2
|
||||
shape_type = struct.unpack("<i", blob[32:36])[0]
|
||||
x_min, y_min, x_max, y_max = struct.unpack("<4d", blob[36:68])
|
||||
z_min, z_max = struct.unpack("<2d", blob[68:84])
|
||||
return {
|
||||
"shape_type": shape_type,
|
||||
"shape_type_name": _SHAPE_TYPE_NAMES.get(shape_type, f"Unknown({shape_type})"),
|
||||
"declared_bytes": declared_bytes,
|
||||
"bounds": {
|
||||
"x_min": x_min,
|
||||
"x_max": x_max,
|
||||
"y_min": y_min,
|
||||
"y_max": y_max,
|
||||
"z_min": z_min,
|
||||
"z_max": z_max,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _read_polyline_record(blob: bytes, offset: int, content_bytes: int) -> list[list[tuple]]:
|
||||
"""폴리라인 레코드 하나를 파트별 정점 목록으로 푼다."""
|
||||
record_type = struct.unpack("<i", blob[offset : offset + 4])[0]
|
||||
if record_type == 0: # Null shape — 규격상 건너뛴다
|
||||
return []
|
||||
if record_type not in _POLYLINE_TYPES:
|
||||
raise ValueError(f"계획노선은 폴리라인이어야 합니다(받은 형상: {record_type}).")
|
||||
|
||||
cursor = offset + 4 + 32 # 형상종류 + 레코드 bbox
|
||||
part_count, point_count = struct.unpack("<2i", blob[cursor : cursor + 8])
|
||||
cursor += 8
|
||||
parts = list(struct.unpack(f"<{part_count}i", blob[cursor : cursor + part_count * 4]))
|
||||
cursor += part_count * 4
|
||||
flat_xy = struct.unpack(f"<{point_count * 2}d", blob[cursor : cursor + point_count * 16])
|
||||
cursor += point_count * 16
|
||||
|
||||
zs: tuple[float, ...] = ()
|
||||
if record_type == 13:
|
||||
cursor += 16 # Z 범위
|
||||
end = cursor + point_count * 8
|
||||
if end - offset <= content_bytes:
|
||||
zs = struct.unpack(f"<{point_count}d", blob[cursor:end])
|
||||
|
||||
points = [
|
||||
(flat_xy[index * 2], flat_xy[index * 2 + 1], zs[index] if zs else 0.0)
|
||||
for index in range(point_count)
|
||||
]
|
||||
boundaries = [*parts, point_count]
|
||||
return [points[boundaries[i] : boundaries[i + 1]] for i in range(part_count)]
|
||||
|
||||
|
||||
def read_shapefile_parts(path: str | Path) -> list[list[tuple]]:
|
||||
""".shp의 모든 폴리라인 파트를 (x, y, z) 정점 목록으로 읽는다."""
|
||||
source = Path(path)
|
||||
blob = source.read_bytes()
|
||||
header = _read_header(blob)
|
||||
if header["shape_type"] not in _POLYLINE_TYPES and header["shape_type"] != 0:
|
||||
raise ValueError(
|
||||
f"계획노선 shapefile은 폴리라인이어야 합니다(받은 형상: {header['shape_type_name']})."
|
||||
)
|
||||
|
||||
limit = min(len(blob), header["declared_bytes"] or len(blob))
|
||||
parts: list[list[tuple]] = []
|
||||
offset = _HEADER_BYTES
|
||||
while offset + 8 <= limit:
|
||||
content_bytes = struct.unpack(">i", blob[offset + 4 : offset + 8])[0] * 2
|
||||
if content_bytes <= 0:
|
||||
break
|
||||
parts.extend(_read_polyline_record(blob, offset + 8, content_bytes))
|
||||
offset += 8 + content_bytes
|
||||
return [part for part in parts if len(part) >= 2]
|
||||
|
||||
|
||||
def read_shapefile_attributes(path: str | Path) -> dict[str, str]:
|
||||
"""짝 .dbf 첫 레코드의 속성. 없거나 못 읽으면 빈 dict."""
|
||||
dbf_path = Path(path).with_suffix(".dbf")
|
||||
if not dbf_path.exists():
|
||||
return {}
|
||||
encoding = shapefile_encoding(Path(path))
|
||||
try:
|
||||
blob = dbf_path.read_bytes()
|
||||
header_bytes, record_bytes = struct.unpack("<2H", blob[8:12])
|
||||
fields: list[tuple[str, int]] = []
|
||||
cursor = 32
|
||||
while cursor < header_bytes - 1 and blob[cursor] != 0x0D:
|
||||
descriptor = blob[cursor : cursor + 32]
|
||||
name = descriptor[0:11].split(b"\x00")[0].decode(encoding, errors="replace").strip()
|
||||
fields.append((name, descriptor[16]))
|
||||
cursor += 32
|
||||
record = blob[header_bytes : header_bytes + record_bytes]
|
||||
if not record:
|
||||
return {}
|
||||
values: dict[str, str] = {}
|
||||
position = 1 # 첫 바이트는 삭제 표시
|
||||
for name, width in fields:
|
||||
raw = record[position : position + width]
|
||||
values[name] = raw.decode(encoding, errors="replace").strip()
|
||||
position += width
|
||||
return values
|
||||
except (OSError, struct.error, ValueError):
|
||||
logger.warning("shapefile .dbf 속성을 읽지 못했습니다: %s", dbf_path.name)
|
||||
return {}
|
||||
|
||||
|
||||
def _route_name_from_attributes(attributes: dict[str, str], fallback: str) -> str:
|
||||
for key in ("대상지", "노선명", "route_name", "NAME", "name"):
|
||||
value = attributes.get(key)
|
||||
if value:
|
||||
return value
|
||||
return fallback
|
||||
|
||||
|
||||
def analyze_shapefile_metadata(path: str | Path) -> dict[str, Any]:
|
||||
"""계획노선 shapefile의 B03 메타데이터를 만든다."""
|
||||
source = Path(path)
|
||||
header = _read_header(source.read_bytes()[:_HEADER_BYTES])
|
||||
parts = read_shapefile_parts(source)
|
||||
attributes = read_shapefile_attributes(source)
|
||||
point_count = sum(len(part) for part in parts)
|
||||
missing = [
|
||||
extension for extension in (".shx", ".dbf") if not source.with_suffix(extension).exists()
|
||||
]
|
||||
|
||||
return {
|
||||
"file": source.name,
|
||||
"extension": "shp",
|
||||
"size_bytes": source.stat().st_size,
|
||||
"purpose": "planned_route",
|
||||
"route_name": _route_name_from_attributes(attributes, source.stem),
|
||||
"shape_type": header["shape_type_name"],
|
||||
"part_count": len(parts),
|
||||
"point_count": point_count,
|
||||
"epsg": shapefile_epsg_label(source),
|
||||
"crs_input": shapefile_crs_input(source),
|
||||
"encoding": shapefile_encoding(source),
|
||||
"attributes": attributes,
|
||||
"missing_members": missing,
|
||||
"bounds": header["bounds"],
|
||||
"start_point": list(parts[0][0]) if parts else None,
|
||||
"end_point": list(parts[-1][-1]) if parts else None,
|
||||
}
|
||||
@@ -65,11 +65,19 @@ async def get_project_input_readiness(
|
||||
connection: aiomysql.Connection,
|
||||
project_id: UUID,
|
||||
) -> tuple[set[str], int | None, int | None]:
|
||||
"""업로드 파일 유형, 최신 포인트클라우드 입력 ID, 최신 계획노선 CSV 입력 ID를 반환한다."""
|
||||
"""업로드 파일 유형, 최신 포인트클라우드 입력 ID, 최신 계획노선 입력 ID를 반환한다.
|
||||
|
||||
계획노선은 CSV 또는 shapefile이다. 둘 다 있으면 shapefile을 고른다 —
|
||||
`find_planned_route_file()`의 우선순위와 같아야 WF1 입력과 실제 판독 대상이 갈리지 않는다.
|
||||
|
||||
PRJ는 노선용·지형용 두 장이 온다. DB `file_type`은 둘 다 `prj`라 그대로 세면 노선
|
||||
PRJ 하나로 필수가 채워진다 — 프로젝트 좌표계를 정하는 것은 **지형 PRJ**이므로,
|
||||
노선 세트 폴더(`input/shp/`)에 있는 PRJ는 `route_prj`로 갈라 센다(2026-08-31).
|
||||
"""
|
||||
async with connection.cursor(aiomysql.DictCursor) as cursor:
|
||||
await cursor.execute(
|
||||
"""
|
||||
SELECT id, LOWER(file_type) AS file_type
|
||||
SELECT id, LOWER(file_type) AS file_type, raw_file_path
|
||||
FROM input_files
|
||||
WHERE project_id = %s AND status IN ('UPLOADED', 'PROCESSED')
|
||||
ORDER BY id DESC
|
||||
@@ -78,17 +86,27 @@ async def get_project_input_readiness(
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
|
||||
file_types = {str(row["file_type"]) for row in rows if row.get("file_type")}
|
||||
file_types: set[str] = set()
|
||||
for row in rows:
|
||||
file_type = str(row.get("file_type") or "")
|
||||
if not file_type:
|
||||
continue
|
||||
if file_type == "prj" and "/input/shp/" in str(row.get("raw_file_path") or ""):
|
||||
file_type = "route_prj"
|
||||
file_types.add(file_type)
|
||||
point_cloud_id = next(
|
||||
(int(row["id"]) for row in rows if str(row.get("file_type") or "") in {"las", "laz"}),
|
||||
None,
|
||||
)
|
||||
# LAS 없는 설계(2026-08-30)의 WF1 입력 — 계획노선 CSV가 분석 원천이 된다.
|
||||
route_csv_id = next(
|
||||
# LAS 없는 설계(2026-08-30)의 WF1 입력 — 계획노선 파일이 분석 원천이 된다.
|
||||
route_id = next(
|
||||
(int(row["id"]) for row in rows if str(row.get("file_type") or "") == "shp"),
|
||||
None,
|
||||
) or next(
|
||||
(int(row["id"]) for row in rows if str(row.get("file_type") or "") == "csv"),
|
||||
None,
|
||||
)
|
||||
return file_types, point_cloud_id, route_csv_id
|
||||
return file_types, point_cloud_id, route_id
|
||||
|
||||
|
||||
async def get_project_storage_relative_path(
|
||||
@@ -309,7 +327,7 @@ async def list_project_input_files(
|
||||
await cursor.execute(
|
||||
"""
|
||||
SELECT f.id, f.file_type, f.original_filename, f.file_size_mb, f.status,
|
||||
f.upload_at
|
||||
f.upload_at, f.raw_file_path
|
||||
FROM input_files f
|
||||
INNER JOIN (
|
||||
SELECT MAX(id) AS id
|
||||
|
||||
@@ -12,15 +12,22 @@ import aiomysql
|
||||
from config.config_system import TEMP_UPLOAD_RETENTION_DAYS
|
||||
|
||||
# 묶음이 "완료"로 넘어가려면 있어야 하는 파일 종류. B03 필수 슬롯과 같은 기준이다.
|
||||
REQUIRED_TEMP_FILE_TYPES = frozenset({"csv", "prj", "tfw"})
|
||||
REQUIRED_TEMP_FILE_TYPES = frozenset({"prj", "tfw"})
|
||||
POINT_CLOUD_FILE_TYPES = frozenset({"las", "laz"})
|
||||
# 계획노선은 CSV 또는 shapefile 중 하나 (2026-08-31).
|
||||
ROUTE_FILE_TYPES = frozenset({"csv", "shp"})
|
||||
SHAPEFILE_REQUIRED_TYPES = frozenset({"shx", "dbf"})
|
||||
|
||||
|
||||
def is_batch_required_complete(file_types: set[str]) -> bool:
|
||||
"""필수 파일(csv·prj·tfw + las/laz 1종)이 모두 찼는지."""
|
||||
return REQUIRED_TEMP_FILE_TYPES.issubset(file_types) and bool(
|
||||
file_types & POINT_CLOUD_FILE_TYPES
|
||||
)
|
||||
"""필수 파일(계획노선 1종 + prj·tfw + las/laz 1종)이 모두 찼는지."""
|
||||
if not REQUIRED_TEMP_FILE_TYPES.issubset(file_types):
|
||||
return False
|
||||
if not file_types & ROUTE_FILE_TYPES:
|
||||
return False
|
||||
if "shp" in file_types and not SHAPEFILE_REQUIRED_TYPES.issubset(file_types):
|
||||
return False
|
||||
return bool(file_types & POINT_CLOUD_FILE_TYPES)
|
||||
|
||||
|
||||
async def create_temp_batch(
|
||||
|
||||
@@ -76,8 +76,13 @@ _ANALYSIS_RUNNING_MESSAGE = "이 프로젝트는 지금 분석 중입니다. 끝
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/projects", tags=["B03 File Input"])
|
||||
|
||||
_REQUIRED_FILE_TYPES = frozenset({"csv", "prj", "tfw"})
|
||||
_REQUIRED_FILE_TYPES = frozenset({"prj", "tfw"})
|
||||
_POINT_CLOUD_FILE_TYPES = frozenset({"las", "laz"})
|
||||
# 계획노선은 CSV 또는 shapefile 중 하나면 된다 (2026-08-31 — 원청 정식 노선이 shapefile).
|
||||
_ROUTE_FILE_TYPES = frozenset({"csv", "shp"})
|
||||
# shapefile은 이것들이 다 있어야 열린다. `.cpg`는 없으면 CP949로 읽으므로 필수가 아니다.
|
||||
# `route_prj`는 노선 세트 폴더에 있는 PRJ — 지형 PRJ(`prj`)와 따로 센다.
|
||||
_SHAPEFILE_REQUIRED_TYPES = frozenset({"shx", "dbf", "route_prj"})
|
||||
|
||||
|
||||
def _total_chunks(size_bytes: int, chunk_size_bytes: int) -> int:
|
||||
@@ -95,6 +100,11 @@ def _is_point_cloud_result(result: UploadedFileResult) -> bool:
|
||||
|
||||
def _missing_required_file_types(file_types: set[str], las_free: bool = False) -> list[str]:
|
||||
missing = sorted(_REQUIRED_FILE_TYPES - file_types)
|
||||
if not file_types.intersection(_ROUTE_FILE_TYPES):
|
||||
missing.append("csv/shp")
|
||||
# shapefile로 왔으면 형제 파일이 다 있어야 노선을 읽는다.
|
||||
if "shp" in file_types:
|
||||
missing.extend(sorted(_SHAPEFILE_REQUIRED_TYPES - file_types))
|
||||
# LAS 없는 설계(도엽등고선 기반, 2026-08-30)는 LAS 필수를 면제한다.
|
||||
if not las_free and not file_types.intersection(_POINT_CLOUD_FILE_TYPES):
|
||||
missing.append("las/laz")
|
||||
@@ -164,7 +174,7 @@ async def _complete_file_input_if_ready(
|
||||
if not las_free:
|
||||
raise ValueError("LAS 또는 LAZ 입력 파일을 찾을 수 없습니다.")
|
||||
if route_csv_input_id is None:
|
||||
raise ValueError("계획 노선 CSV 입력 파일을 찾을 수 없습니다.")
|
||||
raise ValueError("계획 노선 입력 파일(CSV 또는 shapefile)을 찾을 수 없습니다.")
|
||||
# 자료가 갈렸으니 옛 계산 결과(파일 + DB)를 지우고 진행 표시도 되돌린다. 남겨 두면
|
||||
# 아직 다시 만들어지지 않은 뒷단계 화면이 옛 결과를 새 자료 것인 양 보여준다.
|
||||
stored_path = await get_project_storage_relative_path(connection, project_id)
|
||||
@@ -810,6 +820,7 @@ async def get_project_upload_overview(
|
||||
file_size_mb=float(row["file_size_mb"] or 0.0),
|
||||
status=str(row["status"]),
|
||||
uploaded_at=str(row["upload_at"]) if row.get("upload_at") else None,
|
||||
relative_path=(str(row["raw_file_path"]) if row.get("raw_file_path") else None),
|
||||
)
|
||||
for row in files
|
||||
],
|
||||
|
||||
@@ -128,6 +128,9 @@ class UploadOverviewFile(BaseModel):
|
||||
file_size_mb: float
|
||||
status: str
|
||||
uploaded_at: str | None = None
|
||||
# PRJ는 노선용·지형용 두 장이 온다. 확장자로는 못 가리므로 저장 폴더로 가린다
|
||||
# (노선 세트는 `B03_FileInput/input/shp/`에 모인다, 2026-08-31).
|
||||
relative_path: str | None = None
|
||||
|
||||
|
||||
class UploadOverviewSession(BaseModel):
|
||||
|
||||
@@ -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"]),
|
||||
|
||||
@@ -44,6 +44,11 @@ import {
|
||||
getExtension,
|
||||
initializeSlots,
|
||||
makeSessionKey,
|
||||
planSlotAssignments,
|
||||
ROUTE_SLOTS,
|
||||
SHAPEFILE_DEPENDENT_SLOTS,
|
||||
slotConfigs,
|
||||
TERRAIN_SLOTS,
|
||||
type FileSlot,
|
||||
type FileSlotState,
|
||||
type StoredUploadSession,
|
||||
@@ -193,10 +198,21 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
pageError.textContent = validation ?? "";
|
||||
}
|
||||
|
||||
/** 확장자 줄 — 지금 필수인지에 따라 "· 선택" 꼬리표가 붙고 떨어진다. */
|
||||
function renderExtensionLabel(card: HTMLElement, state: FileSlotState): void {
|
||||
const extLabel = state.extensions.join(", ");
|
||||
const target = card.querySelector(".b03-file__card-ext");
|
||||
if (!target) return;
|
||||
target.textContent = isSlotRequired(state)
|
||||
? extLabel
|
||||
: `${extLabel} · ${L("B03_File_Card_Optional")}`;
|
||||
}
|
||||
|
||||
function renderSlot(slot: FileSlot): void {
|
||||
const state = slots.get(slot);
|
||||
const card = cardMap.get(slot);
|
||||
if (!state || !card) return;
|
||||
renderExtensionLabel(card, state);
|
||||
|
||||
const fileName = card.querySelector<HTMLSpanElement>(
|
||||
".b03-file__file-name",
|
||||
@@ -290,12 +306,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
file: File,
|
||||
targetSlot?: FileSlot,
|
||||
): Promise<void> {
|
||||
const extension = getExtension(file.name);
|
||||
const state = targetSlot
|
||||
? slots.get(targetSlot)
|
||||
: Array.from(slots.values()).find((candidate) =>
|
||||
candidate.extensions.includes(extension),
|
||||
);
|
||||
const state = targetSlot ? slots.get(targetSlot) : undefined;
|
||||
if (!state) {
|
||||
pageError.textContent = `${L("B03_File_Error_Extension")} ${file.name}`;
|
||||
return;
|
||||
@@ -330,6 +341,13 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
state.etaSeconds = null;
|
||||
state.error = undefined;
|
||||
renderSlot(state.slot);
|
||||
// 노선 도형이 CSV↔shapefile로 바뀌면 형제 카드의 필수 표시도 따라 바뀐다.
|
||||
if (state.slot === "csv") renderRouteDependentSlots();
|
||||
}
|
||||
|
||||
function renderRouteDependentSlots(): void {
|
||||
for (const slot of SHAPEFILE_DEPENDENT_SLOTS) renderSlot(slot);
|
||||
updateUploadButton();
|
||||
}
|
||||
|
||||
function onFileSelected(
|
||||
@@ -353,14 +371,17 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
// 개수는 "고른 파일 수"가 아니라 **최종적으로 차는 슬롯 수**로 센다.
|
||||
// 같은 슬롯을 다시 고르는 것은 교체라 개수가 늘지 않는다 — 더하기로 세면 5개를 고른
|
||||
// 뒤 파일 선택 영역으로 하나만 바꾸려 해도 초과로 막힌다(2026-08-08).
|
||||
// 파일 하나에 카드 하나다. `.prj`만 확장자로 안 갈리므로 노선 도형과 basename이
|
||||
// 같은지로 노선/지형 좌표계 카드를 정한다(2026-08-31 사용자 지시).
|
||||
const routeFile = slots.get("csv")?.file?.name;
|
||||
const assignments = planSlotAssignments(
|
||||
files,
|
||||
slotConfigs(),
|
||||
routeFile ? routeFile.replace(/\.[^.]*$/, "") : undefined,
|
||||
);
|
||||
const occupied = new Set(selectedStates().map((state) => state.slot));
|
||||
for (const file of files) {
|
||||
const extension = getExtension(file.name);
|
||||
const slot =
|
||||
targetSlot ??
|
||||
Array.from(slots.values()).find((candidate) =>
|
||||
candidate.extensions.includes(extension),
|
||||
)?.slot;
|
||||
for (const item of assignments) {
|
||||
const slot = targetSlot ?? item.slot;
|
||||
if (slot) occupied.add(slot);
|
||||
}
|
||||
if (occupied.size > UPLOAD_MAX_FILES) {
|
||||
@@ -369,7 +390,14 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
}
|
||||
pageError.textContent = blocked ? L("B03_File_Error_LasFreeBlocked") : "";
|
||||
void (async () => {
|
||||
for (const file of files) await assignFileToSlot(file, targetSlot);
|
||||
for (const item of assignments) {
|
||||
const slot = targetSlot ?? item.slot;
|
||||
if (!slot) {
|
||||
pageError.textContent = `${L("B03_File_Error_Extension")} ${item.file.name}`;
|
||||
continue;
|
||||
}
|
||||
await assignFileToSlot(item.file, slot);
|
||||
}
|
||||
await detectPausedUploads();
|
||||
})();
|
||||
}
|
||||
@@ -388,6 +416,27 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
state.etaSeconds = null;
|
||||
state.error = undefined;
|
||||
renderSlot(slot);
|
||||
if (slot === "csv") renderRouteDependentSlots();
|
||||
}
|
||||
|
||||
/** 노선 도형이 shapefile인가 — 로컬 선택과 서버 정본을 함께 본다. */
|
||||
function routeIsShapefile(): boolean {
|
||||
const state = slots.get("csv");
|
||||
const name = state?.file?.name ?? state?.serverUploaded?.name;
|
||||
return getExtension(name ?? "") === ".shp";
|
||||
}
|
||||
|
||||
/**
|
||||
* 이 카드가 지금 필수인가.
|
||||
*
|
||||
* shapefile 형제 카드(.shx/.dbf/노선 .prj)는 노선 도형이 shapefile일 때만 필수다 —
|
||||
* CSV 한 장으로 넣는 흐름을 막으면 안 된다(2026-08-31).
|
||||
*/
|
||||
function isSlotRequired(state: FileSlotState): boolean {
|
||||
if (SHAPEFILE_DEPENDENT_SLOTS.includes(state.slot))
|
||||
return routeIsShapefile();
|
||||
if (state.slot === "las_laz") return !lasFreeDesign;
|
||||
return state.isRequired;
|
||||
}
|
||||
|
||||
function validateSlots(): string | null {
|
||||
@@ -399,11 +448,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
// 재접속 후 파일 하나만 교체 업로드하는 흐름을 막지 않는다(2026-08-04 사용자 지시).
|
||||
const missingRequired = Array.from(slots.values()).some(
|
||||
(state) =>
|
||||
state.isRequired &&
|
||||
!state.file &&
|
||||
!state.serverUploaded &&
|
||||
// LAS 없는 설계면 포인트클라우드 카드는 필수에서 뺀다.
|
||||
!(lasFreeDesign && state.slot === "las_laz"),
|
||||
isSlotRequired(state) && !state.file && !state.serverUploaded,
|
||||
);
|
||||
if (missingRequired) return L("B03_File_Error_RequiredSlots");
|
||||
if (!lasFreeDesign) {
|
||||
@@ -435,9 +480,20 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
for (const state of slots.values()) state.serverUploaded = undefined;
|
||||
for (const file of overview.files) {
|
||||
const extension = `.${file.file_type.toLowerCase()}`;
|
||||
const state = Array.from(slots.values()).find((candidate) =>
|
||||
candidate.extensions.includes(extension),
|
||||
);
|
||||
// PRJ 두 장은 확장자가 같다 — 노선 세트는 `input/shp/`에 모여 있으므로
|
||||
// 저장 경로로 가린다(2026-08-31).
|
||||
const inRouteSet = (file.relative_path ?? "").includes("/input/shp/");
|
||||
const slot: FileSlot | undefined =
|
||||
extension === ".prj"
|
||||
? inRouteSet
|
||||
? "route_prj"
|
||||
: "prj"
|
||||
: Array.from(slots.values()).find(
|
||||
(candidate) =>
|
||||
candidate.slot !== "route_prj" &&
|
||||
candidate.extensions.includes(extension),
|
||||
)?.slot;
|
||||
const state = slot ? slots.get(slot) : undefined;
|
||||
if (state) {
|
||||
state.serverUploaded = {
|
||||
name: file.original_filename,
|
||||
@@ -480,11 +536,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
card.querySelector(".b03-file__card-label")!.textContent = L(
|
||||
state.labelKey,
|
||||
);
|
||||
// 지형 래스터만 선택 항목이라 확장자 옆에 표시해 둔다.
|
||||
const extLabel = state.extensions.join(", ");
|
||||
card.querySelector(".b03-file__card-ext")!.textContent = state.isRequired
|
||||
? extLabel
|
||||
: `${extLabel} · ${L("B03_File_Card_Optional")}`;
|
||||
renderExtensionLabel(card, state);
|
||||
const input = card.querySelector<HTMLInputElement>(
|
||||
".b03-file__slot-input",
|
||||
)!;
|
||||
@@ -512,15 +564,24 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
function createCardGroup(
|
||||
title: string,
|
||||
groupSlots: readonly FileSlot[],
|
||||
modifier?: string,
|
||||
hint?: string,
|
||||
): HTMLElement {
|
||||
const group = document.createElement("section");
|
||||
group.className = "b03-file__group";
|
||||
if (modifier) group.classList.add(modifier);
|
||||
if (title) {
|
||||
const groupTitle = document.createElement("h3");
|
||||
groupTitle.className = "b03-file__group-title";
|
||||
groupTitle.textContent = title;
|
||||
group.append(groupTitle);
|
||||
}
|
||||
if (hint) {
|
||||
const groupHint = document.createElement("p");
|
||||
groupHint.className = "b03-file__group-hint";
|
||||
groupHint.textContent = hint;
|
||||
group.append(groupHint);
|
||||
}
|
||||
const content = document.createElement("div");
|
||||
content.className = "b03-file__group-content";
|
||||
for (const slot of groupSlots) {
|
||||
@@ -768,15 +829,21 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
resultList,
|
||||
);
|
||||
|
||||
// 계획노선과 지형 자료는 지형 래스터(tif)만 빼면 모두 필수라 따로 묶지 않는다
|
||||
// (2026-08-08 사용자 지시).
|
||||
const inputsGroup = createCardGroup(L("B03_File_Group_Inputs"), [
|
||||
"csv",
|
||||
"las_laz",
|
||||
"prj",
|
||||
"tfw",
|
||||
"tif",
|
||||
]);
|
||||
// 자료의 출처가 둘로 갈린다 — 원청이 준 계획노선, 측량이 준 지형(LAS·래스터).
|
||||
// 좌표계 파일(.prj)도 각각 하나씩 오므로 컨테이너를 나눠야 어느 칸에 무엇을 넣는지
|
||||
// 화면만 보고 안다(2026-08-31 사용자 지시).
|
||||
const routeGroup = createCardGroup(
|
||||
L("B03_File_Group_Route"),
|
||||
ROUTE_SLOTS,
|
||||
"b03-file__group--route",
|
||||
L("B03_File_Group_Route_Hint"),
|
||||
);
|
||||
const terrainGroup = createCardGroup(
|
||||
L("B03_File_Group_Terrain"),
|
||||
TERRAIN_SLOTS,
|
||||
"b03-file__group--terrain",
|
||||
L("B03_File_Group_Terrain_Hint"),
|
||||
);
|
||||
|
||||
// LAS 없는 설계 토글 — 켜면 포인트클라우드 카드를 비활성화하고 필수에서 뺀다.
|
||||
const lasFreeRow = document.createElement("label");
|
||||
@@ -814,10 +881,22 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
pageError.textContent = "";
|
||||
});
|
||||
|
||||
const cardsContainer = document.createElement("div");
|
||||
cardsContainer.className =
|
||||
// LAS 토글은 지형 컨테이너의 것이다 — 켜면 그 안의 포인트클라우드 카드만 잠긴다.
|
||||
terrainGroup.append(lasFreeRow);
|
||||
|
||||
const routePanel = document.createElement("div");
|
||||
routePanel.className =
|
||||
"b03-file__control-panel b03-file__cards-container-panel";
|
||||
cardsContainer.append(lasFreeRow, inputsGroup);
|
||||
routePanel.append(routeGroup);
|
||||
|
||||
const terrainPanel = document.createElement("div");
|
||||
terrainPanel.className =
|
||||
"b03-file__control-panel b03-file__cards-container-panel";
|
||||
terrainPanel.append(terrainGroup);
|
||||
|
||||
const cardsContainer = document.createElement("div");
|
||||
cardsContainer.className = "b03-file__columns";
|
||||
cardsContainer.append(routePanel, terrainPanel);
|
||||
|
||||
const workflowState = activeProjectId
|
||||
? await fetchWorkflowState(activeProjectId).catch(() => undefined)
|
||||
|
||||
@@ -180,12 +180,27 @@
|
||||
gap: var(--spacing-32);
|
||||
}
|
||||
|
||||
/* 계획노선 | 지형(LAS) — 컨테이너를 둘로 나눈다(2026-08-31 사용자 지시).
|
||||
노선은 카드 5장(shapefile 한 벌), 지형은 4장이라 폭을 5:4 비슷하게 준다. */
|
||||
.b03-file__columns {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--spacing-24);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.b03-file__group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-20);
|
||||
}
|
||||
|
||||
.b03-file__group-hint {
|
||||
font-size: var(--text-body-sm, 14px);
|
||||
color: var(--color-slate, #615e6e);
|
||||
margin: calc(var(--spacing-8) * -1) 0 0 0;
|
||||
}
|
||||
|
||||
.b03-file__group-title {
|
||||
font-size: var(--text-subheading, 24px);
|
||||
color: var(--color-deep-iris, #26114a);
|
||||
@@ -195,7 +210,7 @@
|
||||
|
||||
.b03-file__group-content {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr); /* 3열 구조 */
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--spacing-24);
|
||||
}
|
||||
|
||||
@@ -260,14 +275,17 @@
|
||||
border-color: var(--color-danger, #dc2626);
|
||||
}
|
||||
|
||||
/* 카드가 좁아졌다(컨테이너 2열 × 카드 2열, 2026-08-31). 격자로 고정하면 제목이
|
||||
글자 단위로 접히므로, 자리가 모자라면 배지·삭제 버튼이 다음 줄로 내려가게 한다. */
|
||||
.b03-file__card-header {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto auto; /* 아이콘 및 닫기 버튼 주변 확보 */
|
||||
gap: var(--spacing-12);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-8);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.b03-file__card-icon {
|
||||
flex: 0 0 auto;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: var(--radius-icons, 8px);
|
||||
@@ -284,6 +302,7 @@
|
||||
|
||||
.b03-file__card-heading {
|
||||
min-width: 0;
|
||||
flex: 1 1 55%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
@@ -294,6 +313,11 @@
|
||||
color: var(--color-deep-iris, #26114a);
|
||||
font-size: var(--text-body-sm, 14px);
|
||||
font-weight: var(--font-weight-medium, 500);
|
||||
/* 한글은 글자 단위로 끊기므로 어절을 지켜 준다 — "계획
|
||||
노선
|
||||
도형" 방지. */
|
||||
word-break: keep-all;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.b03-file__card-ext,
|
||||
@@ -306,6 +330,7 @@
|
||||
.b03-file__card-badge-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-left: auto;
|
||||
margin-right: var(--spacing-4);
|
||||
}
|
||||
|
||||
@@ -463,9 +488,10 @@
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 1280px) {
|
||||
.b03-file__group-content {
|
||||
grid-template-columns: repeat(2, 1fr); /* 좁은 창에서는 2열 */
|
||||
@media (max-width: 1440px) {
|
||||
/* 좁아지면 두 컨테이너를 위아래로 쌓는다 — 카드가 눌려 글자가 접히는 것을 막는다. */
|
||||
.b03-file__columns {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,45 @@
|
||||
import { ui_locales } from "@ui/ui_template_locale";
|
||||
|
||||
export type FileSlot = "csv" | "las_laz" | "prj" | "tfw" | "tif" | "dxf";
|
||||
/**
|
||||
* 카드 한 장 = 파일 한 개. 계획노선 shapefile은 파일이 다섯이므로 카드도 다섯이다
|
||||
* (2026-08-31 사용자 지시) — 어느 파일이 왔고 어느 것이 비었는지 화면에서 바로 보인다.
|
||||
* `route_prj`(노선 좌표계)와 `prj`(지형 좌표계)는 확장자가 같아 basename으로 가른다.
|
||||
*/
|
||||
export type FileSlot =
|
||||
| "csv"
|
||||
| "shx"
|
||||
| "dbf"
|
||||
| "cpg"
|
||||
| "route_prj"
|
||||
| "las_laz"
|
||||
| "prj"
|
||||
| "tfw"
|
||||
| "tif"
|
||||
| "dxf";
|
||||
|
||||
/** 왼쪽(계획노선) 컨테이너에 놓이는 슬롯. */
|
||||
export const ROUTE_SLOTS: readonly FileSlot[] = [
|
||||
"csv",
|
||||
"shx",
|
||||
"dbf",
|
||||
"cpg",
|
||||
"route_prj",
|
||||
];
|
||||
|
||||
/** 오른쪽(지형·LAS) 컨테이너에 놓이는 슬롯. */
|
||||
export const TERRAIN_SLOTS: readonly FileSlot[] = [
|
||||
"las_laz",
|
||||
"prj",
|
||||
"tfw",
|
||||
"tif",
|
||||
];
|
||||
|
||||
/** 노선 도형이 shapefile일 때 함께 있어야 하는 슬롯(.cpg는 없으면 CP949). */
|
||||
export const SHAPEFILE_DEPENDENT_SLOTS: readonly FileSlot[] = [
|
||||
"shx",
|
||||
"dbf",
|
||||
"route_prj",
|
||||
];
|
||||
export type UploadStatus = "pending" | "uploading" | "completed" | "failed";
|
||||
|
||||
export interface SlotConfig {
|
||||
@@ -45,9 +84,37 @@ const SLOT_CONFIGS: readonly SlotConfig[] = [
|
||||
slot: "csv",
|
||||
labelKey: "B03_File_Slot_PlannedRoute",
|
||||
icon: "⌁",
|
||||
extensions: [".csv"],
|
||||
extensions: [".csv", ".shp"],
|
||||
isRequired: true,
|
||||
},
|
||||
{
|
||||
slot: "shx",
|
||||
labelKey: "B03_File_Slot_RouteIndex",
|
||||
icon: "⋮",
|
||||
extensions: [".shx"],
|
||||
isRequired: false,
|
||||
},
|
||||
{
|
||||
slot: "dbf",
|
||||
labelKey: "B03_File_Slot_RouteAttribute",
|
||||
icon: "▤",
|
||||
extensions: [".dbf"],
|
||||
isRequired: false,
|
||||
},
|
||||
{
|
||||
slot: "cpg",
|
||||
labelKey: "B03_File_Slot_RouteEncoding",
|
||||
icon: "⌨",
|
||||
extensions: [".cpg"],
|
||||
isRequired: false,
|
||||
},
|
||||
{
|
||||
slot: "route_prj",
|
||||
labelKey: "B03_File_Slot_RouteProjection",
|
||||
icon: "◈",
|
||||
extensions: [".prj"],
|
||||
isRequired: false,
|
||||
},
|
||||
{
|
||||
slot: "las_laz",
|
||||
labelKey: "B03_File_Slot_PointCloud",
|
||||
@@ -83,6 +150,48 @@ export function getExtension(fileName: string): string {
|
||||
return index >= 0 ? fileName.slice(index).toLowerCase() : "";
|
||||
}
|
||||
|
||||
export function getBaseName(fileName: string): string {
|
||||
const index = fileName.lastIndexOf(".");
|
||||
return index >= 0 ? fileName.slice(0, index) : fileName;
|
||||
}
|
||||
|
||||
/**
|
||||
* 고른 파일을 카드(슬롯)에 배정한다.
|
||||
*
|
||||
* `.prj`만 확장자로 갈리지 않는다 — 노선 좌표계와 지형 좌표계가 같은 확장자다.
|
||||
* **노선 도형(.shp)과 basename이 같은 것**만 노선 좌표계 카드로 보내고, 나머지는
|
||||
* 지형 좌표계 카드로 보낸다. `routeStem`은 이미 골라 둔 노선 도형의 basename으로,
|
||||
* 노선 PRJ를 나중에 따로 추가하는 경우를 받아 준다.
|
||||
*/
|
||||
export function planSlotAssignments(
|
||||
files: readonly File[],
|
||||
slotConfigs: readonly SlotConfig[],
|
||||
routeStem?: string,
|
||||
): { file: File; slot?: FileSlot }[] {
|
||||
const batchStem = files
|
||||
.filter((file) => getExtension(file.name) === ".shp")
|
||||
.map((file) => getBaseName(file.name))[0];
|
||||
const stem = batchStem ?? routeStem;
|
||||
return files.map((file) => {
|
||||
const extension = getExtension(file.name);
|
||||
if (extension === ".prj") {
|
||||
const isRoute = stem !== undefined && getBaseName(file.name) === stem;
|
||||
return { file, slot: (isRoute ? "route_prj" : "prj") as FileSlot };
|
||||
}
|
||||
const config = slotConfigs.find(
|
||||
(candidate) =>
|
||||
candidate.slot !== "route_prj" &&
|
||||
candidate.extensions.includes(extension),
|
||||
);
|
||||
return { file, slot: config?.slot };
|
||||
});
|
||||
}
|
||||
|
||||
/** 슬롯 설정 목록 — 배정 규칙이 카드 정의와 같은 것을 쓰도록 밖으로 연다. */
|
||||
export function slotConfigs(): readonly SlotConfig[] {
|
||||
return SLOT_CONFIGS;
|
||||
}
|
||||
|
||||
export function formatBytes(bytes: number): string {
|
||||
const gb = bytes / 1024 / 1024 / 1024;
|
||||
if (gb >= 1) return `${gb.toFixed(2)} GB`;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": "지원하지 않는 지면 필터입니다."},
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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` 입력 문자열로 정규화한다.
|
||||
|
||||
|
||||
@@ -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]):
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -276,8 +276,8 @@ export const ui_locales_b1 = {
|
||||
],
|
||||
B03_File_Select_Label: ["입력 파일 선택", "Select input files"],
|
||||
B03_File_Select_Hint: [
|
||||
"계획노선 CSV, LAS/LAZ 1개, PRJ, TFW를 선택하세요. TIF는 선택 사항입니다.",
|
||||
"Select a planned-route CSV, one LAS/LAZ, PRJ, and TFW. TIF is optional.",
|
||||
"계획노선(CSV 또는 shapefile 5개)과 LAS/LAZ 1개, 지형 PRJ·TFW를 함께 고르면 카드에 나뉩니다. TIF는 선택 사항입니다.",
|
||||
"Pick the planned route (a CSV or the five shapefile files), one LAS/LAZ, and the terrain PRJ and TFW together — they are sorted into cards. TIF is optional.",
|
||||
],
|
||||
B03_File_Selected_Title: ["선택한 파일", "Selected files"],
|
||||
B03_File_Selected_Empty: ["선택한 파일이 없습니다.", "No files selected."],
|
||||
@@ -330,9 +330,23 @@ export const ui_locales_b1 = {
|
||||
B03_File_Result_Path: ["저장 경로", "Stored path"],
|
||||
B03_File_Group_Required: ["필수 파일", "Required files"],
|
||||
B03_File_Group_Optional: ["선택 파일", "Optional files"],
|
||||
/* 지형 래스터만 선택 항목이라 계획노선·지형 자료를 한 묶음으로 둔다(2026-08-08). */
|
||||
/* 자료 출처가 갈려 컨테이너를 둘로 나눈다 — 계획노선 / 지형(LAS)(2026-08-31). */
|
||||
B03_File_Group_Inputs: ["입력 자료", "Input files"],
|
||||
B03_File_Slot_PlannedRoute: ["계획노선 좌표", "Planned Route Coordinates"],
|
||||
B03_File_Group_Route: ["계획노선 자료", "Planned route files"],
|
||||
B03_File_Group_Terrain: ["지형 자료 (LAS)", "Terrain files (LAS)"],
|
||||
B03_File_Group_Route_Hint: [
|
||||
"shapefile은 파일 5개가 한 벌입니다. CSV 한 장으로 넣어도 됩니다.",
|
||||
"A shapefile is a set of five files. A single CSV also works.",
|
||||
],
|
||||
B03_File_Group_Terrain_Hint: [
|
||||
"여기의 PRJ·TFW는 지형 자료의 좌표계입니다 — 노선 PRJ와 별개입니다.",
|
||||
"The PRJ and TFW here describe the terrain data, separate from the route PRJ.",
|
||||
],
|
||||
B03_File_Slot_PlannedRoute: ["계획노선 도형", "Planned Route Geometry"],
|
||||
B03_File_Slot_RouteIndex: ["노선 도형 색인", "Route Shape Index"],
|
||||
B03_File_Slot_RouteAttribute: ["노선 속성", "Route Attributes"],
|
||||
B03_File_Slot_RouteEncoding: ["노선 속성 인코딩", "Route Attribute Encoding"],
|
||||
B03_File_Slot_RouteProjection: ["노선 좌표계", "Route Projection"],
|
||||
B03_File_Slot_PointCloud: ["포인트클라우드", "Point Cloud"],
|
||||
B03_File_LasFree_Toggle: [
|
||||
"LAS 없이 설계 (도엽등고선 기반)",
|
||||
@@ -342,7 +356,7 @@ export const ui_locales_b1 = {
|
||||
"포인트클라우드 없이 1:5,000 수치지형도 등고선으로 지형을 만듭니다.",
|
||||
"Terrain is built from 1:5,000 map sheet contours without a point cloud.",
|
||||
],
|
||||
B03_File_Slot_Projection: ["좌표계 정의", "Projection"],
|
||||
B03_File_Slot_Projection: ["지형 좌표계", "Terrain Projection"],
|
||||
B03_File_Slot_RasterCoord: ["래스터 좌표", "Raster Coord 1"],
|
||||
B03_File_Slot_TerrainDem: ["지형 래스터", "Terrain DEM"],
|
||||
B03_File_Slot_CadDrawing: ["CAD 도면", "CAD Drawing"],
|
||||
@@ -385,8 +399,9 @@ export const ui_locales_b1 = {
|
||||
"A file for this slot is already selected.",
|
||||
],
|
||||
B03_File_Error_RequiredSlots: [
|
||||
"필수 파일(계획노선 CSV, LAS/LAZ, PRJ, TFW)을 모두 선택하세요.",
|
||||
"Select all required files: planned-route CSV, LAS/LAZ, PRJ, and TFW.",
|
||||
"필수 카드를 모두 채우세요 — 계획노선(CSV 또는 shapefile 한 벌), LAS/LAZ, 지형 PRJ·TFW.",
|
||||
"Fill every required card: the planned route (a CSV or a full shapefile set), "
|
||||
+ "LAS/LAZ, and the terrain PRJ and TFW.",
|
||||
],
|
||||
B03_File_Error_SlotType: [
|
||||
"선택한 파일 유형이 이 카드와 맞지 않습니다.",
|
||||
|
||||
Reference in New Issue
Block a user