Merge remote-tracking branch 'origin/sub_desktop_1' into sub_desktop_1
This commit is contained in:
@@ -56,7 +56,7 @@ SHEET_SOURCE_FILTER = "sheet"
|
||||
|
||||
|
||||
def _load_features_metric(
|
||||
processed_dir: Path, epsg: int, filename: str = _CONTOUR_FILE
|
||||
processed_dir: Path, crs: str, filename: str = _CONTOUR_FILE
|
||||
) -> list[dict[str, Any]]:
|
||||
"""병합 도엽 레이어(WGS84)를 읽어 사업지 CRS(m)로 재투영한다."""
|
||||
path = processed_dir / filename
|
||||
@@ -72,7 +72,7 @@ def _load_features_metric(
|
||||
features = data.get("features")
|
||||
if not isinstance(features, list):
|
||||
return []
|
||||
transformer = Transformer.from_crs("EPSG:4326", f"EPSG:{epsg}", always_xy=True)
|
||||
transformer = Transformer.from_crs("EPSG:4326", crs, always_xy=True)
|
||||
|
||||
def _map(coords: Any) -> Any:
|
||||
if not isinstance(coords, list):
|
||||
@@ -98,6 +98,28 @@ def _load_features_metric(
|
||||
return converted
|
||||
|
||||
|
||||
def _scene_bounds(project_root: Path, bounds: np.ndarray) -> np.ndarray:
|
||||
"""프리뷰 메시를 놓을 **화면 원점 기준 상자**.
|
||||
|
||||
`write_glb()`는 넘겨받은 상자의 중심을 원점으로 삼아 정점을 옮긴다. 모델마다 자기
|
||||
범위를 주면 도엽 서피스와 라이다 지표면이 서로 다른 원점에 서서, 겹쳐 보기·계획선이
|
||||
어긋난다(2026-09-01 실측: 수평 7.85m·높이 1.28m). 그래서 라이다 포인트 상자가 있으면
|
||||
그 상자를 같이 쓴다 — 화면이 기준으로 삼는 상자(`setReferenceBounds`)와 같은 값이다.
|
||||
라이다가 없는 사업지(도엽만)는 기준이 이 서피스뿐이라 자기 상자를 그대로 쓴다.
|
||||
"""
|
||||
structured = project_root / "B04_PreProcess" / "processed" / "structured.npz"
|
||||
if not structured.is_file():
|
||||
return bounds
|
||||
try:
|
||||
with np.load(structured) as data:
|
||||
if "bounds" not in data:
|
||||
return bounds
|
||||
return np.asarray(data["bounds"], dtype=float)
|
||||
except (OSError, ValueError) as exc:
|
||||
logger.warning("도엽 서피스: 라이다 상자를 읽지 못해 자기 범위로 놓습니다 — %s", exc)
|
||||
return bounds
|
||||
|
||||
|
||||
def _preview_mesh(
|
||||
x: np.ndarray, y: np.ndarray, z: np.ndarray, valid: np.ndarray
|
||||
) -> tuple[np.ndarray, np.ndarray]:
|
||||
@@ -125,6 +147,7 @@ def _write_smoothed(
|
||||
z: np.ndarray,
|
||||
valid: np.ndarray,
|
||||
bounds: np.ndarray,
|
||||
scene: np.ndarray,
|
||||
) -> None:
|
||||
"""`{stem}_smooth.npz`·`_smooth_preview.glb`를 만든다 — LAS DTM 스무딩과 같은 절차.
|
||||
|
||||
@@ -180,10 +203,11 @@ def _write_smoothed(
|
||||
z=sz,
|
||||
valid_mask=svalid,
|
||||
bounds=bounds,
|
||||
scene_bounds=scene,
|
||||
resolution=np.array([step], np.float32),
|
||||
)
|
||||
vertices, faces = _preview_mesh(sx, sy, np.nan_to_num(sz, nan=float(bounds[2, 0])), svalid)
|
||||
write_glb(models_dir / f"{stem}_smooth_preview.glb", vertices, faces, bounds)
|
||||
write_glb(models_dir / f"{stem}_smooth_preview.glb", vertices, faces, scene)
|
||||
|
||||
|
||||
def _rasterize_contour_levels(spec: Any, features: list[dict[str, Any]]) -> np.ndarray:
|
||||
@@ -339,6 +363,8 @@ def _write_method_model(
|
||||
[float(finite_z.min()), float(finite_z.max())],
|
||||
]
|
||||
)
|
||||
# 표고 격자 상자(`bounds`)는 절취 범위 그대로 두고, 화면 원점만 라이다와 맞춘다.
|
||||
scene = _scene_bounds(project_root, bounds)
|
||||
atomic_npz(
|
||||
model_path,
|
||||
x=x_coords,
|
||||
@@ -346,11 +372,12 @@ def _write_method_model(
|
||||
z=z_grid,
|
||||
valid_mask=valid_grid,
|
||||
bounds=bounds,
|
||||
scene_bounds=scene,
|
||||
resolution=np.array([SHEET_SURFACE_GRID_M], np.float32),
|
||||
)
|
||||
vertices, faces = _preview_mesh(x_coords, y_coords, z_grid, valid_grid)
|
||||
write_glb(preview_path, vertices, faces, bounds)
|
||||
_write_smoothed(models_dir, stem, x_coords, y_coords, z_grid, valid_grid, bounds)
|
||||
write_glb(preview_path, vertices, faces, scene)
|
||||
_write_smoothed(models_dir, stem, x_coords, y_coords, z_grid, valid_grid, bounds, scene)
|
||||
return {
|
||||
"model_type": "dtm",
|
||||
"source_filter": source_filter,
|
||||
@@ -381,7 +408,7 @@ def build_sheet_surface_model(
|
||||
processed_dir: Path,
|
||||
models_dir: Path,
|
||||
route_xy: np.ndarray,
|
||||
epsg: int,
|
||||
crs: str,
|
||||
methods: list[str] | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""도엽등고선으로 방식별 DTM npz·프리뷰 glb를 만들고 등록용 dict 목록을 돌려준다.
|
||||
@@ -390,10 +417,10 @@ def build_sheet_surface_model(
|
||||
화면에서 바꿔 보며 정해야 한다(2026-08-30 사용자 지시). 실패하면 빈 목록 —
|
||||
호출측은 분석을 계속한다(도엽 미확보 지역 폴백).
|
||||
|
||||
`route_xy`: (N, 2) 노선 정점 XY(사업지 CRS, m).
|
||||
`route_xy`: (N, 2) 노선 정점 XY(사업지 CRS, m), `crs`: 그 좌표계(pyproj 입력 문자열).
|
||||
"""
|
||||
started = time.monotonic()
|
||||
features = _load_features_metric(processed_dir, epsg)
|
||||
features = _load_features_metric(processed_dir, crs)
|
||||
if not features:
|
||||
return []
|
||||
|
||||
@@ -456,22 +483,20 @@ def build_sheet_surface_from_route(
|
||||
`methods`를 주지 않으면 `SHEET_SURFACE_METHODS` 전체를 만든다 — 관리자가 화면에서
|
||||
한 방식을 요청할 때 그 목록만 넘긴다.
|
||||
"""
|
||||
from common_util.common_util_route_geometry import (
|
||||
find_planned_route_file,
|
||||
read_planned_route,
|
||||
)
|
||||
from B04_PreProcess.B04_PreProcess_Engine_Extent import project_epsg_from_prj
|
||||
from common_util.common_util_route_geometry import load_design_route
|
||||
|
||||
route_file = find_planned_route_file(project_root / "B03_FileInput" / "input")
|
||||
if route_file is None:
|
||||
logger.warning("도엽 서피스: 계획 노선 파일이 없습니다.")
|
||||
return []
|
||||
planned = read_planned_route(route_file)
|
||||
# 노선 CSV의 `crs_epsg` 열은 표시용 라벨이라 LAS(.prj) 좌표계와 다를 수 있다
|
||||
# (2026-09-01 실측: 라벨 5179, 실제 5176 — 도엽 서피스만 딴 자리에 만들어졌다).
|
||||
# `load_design_route()`가 .prj 좌표계로 재투영해 주므로 라이다 지표면과 한 자리에 선다.
|
||||
planned = load_design_route(project_root)
|
||||
if planned is None or len(planned.vertices) < 2:
|
||||
logger.warning("도엽 서피스: 계획 노선 파일을 읽지 못했습니다: %s", route_file.name)
|
||||
logger.warning("도엽 서피스: 계획 노선 파일을 읽지 못했습니다.")
|
||||
return []
|
||||
crs = planned.crs_input or project_epsg_from_prj(project_root)
|
||||
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, methods
|
||||
project_root, processed_dir, models_dir, route_xy, crs, methods
|
||||
)
|
||||
|
||||
|
||||
@@ -527,7 +552,9 @@ def run_sheet_surface_analysis(
|
||||
)
|
||||
|
||||
_report(70, "surface_model", "도엽등고선 3D 서피스 생성 중")
|
||||
models = build_sheet_surface_model(project_root, processed_dir, models_dir, route_xy, epsg)
|
||||
models = build_sheet_surface_model(
|
||||
project_root, processed_dir, models_dir, route_xy, f"EPSG:{epsg}"
|
||||
)
|
||||
if not models:
|
||||
raise ValueError("도엽등고선으로 지표면을 만들지 못했습니다 — 도엽 확보를 확인하세요.")
|
||||
|
||||
|
||||
@@ -312,7 +312,10 @@ async def put_pipe_points(
|
||||
save_pipe_points, context.stored_path, signature, points, context.vertices
|
||||
)
|
||||
await asyncio.to_thread(
|
||||
save_detail_basins, context.stored_path, _basin_features(context, detail, points)
|
||||
save_detail_basins,
|
||||
context.stored_path,
|
||||
_basin_features(context, detail, points),
|
||||
context.crs,
|
||||
)
|
||||
result = _payload(project_id, context, detail, points, saved=True)
|
||||
result["saved_count"] = saved
|
||||
|
||||
@@ -139,22 +139,22 @@ async def get_surface_model_contour(
|
||||
interval,
|
||||
time.monotonic() - generation_started,
|
||||
)
|
||||
# 화면은 이 상자의 중심을 원점 삼아 등고선을 놓는다 — 메시(glb)를 만들 때 쓴
|
||||
# 상자와 같아야 등고선이 지형 위에 얹힌다. 도엽 서피스는 라이다와 원점을 맞추려
|
||||
# `scene_bounds`를 따로 갖고 있으므로 그 값이 있으면 먼저 쓴다(2026-09-01).
|
||||
with np.load(contour_model_path) as model_data:
|
||||
if "bounds" in model_data:
|
||||
if "scene_bounds" in model_data:
|
||||
model_bounds = np.asarray(model_data["scene_bounds"], dtype=float)
|
||||
elif "bounds" in model_data:
|
||||
model_bounds = np.asarray(model_data["bounds"], dtype=float)
|
||||
bounds_payload = {
|
||||
"x": model_bounds[0].tolist(),
|
||||
"y": model_bounds[1].tolist(),
|
||||
"z": model_bounds[2].tolist(),
|
||||
}
|
||||
else:
|
||||
with np.load(structured_path) as structured:
|
||||
model_bounds = np.asarray(structured["bounds"], dtype=float)
|
||||
bounds_payload = {
|
||||
"x": model_bounds[0].tolist(),
|
||||
"y": model_bounds[1].tolist(),
|
||||
"z": model_bounds[2].tolist(),
|
||||
}
|
||||
bounds_payload = {
|
||||
"x": model_bounds[0].tolist(),
|
||||
"y": model_bounds[1].tolist(),
|
||||
"z": model_bounds[2].tolist(),
|
||||
}
|
||||
payload = {
|
||||
"extractor_version": CONTOUR_EXTRACTOR_VERSION,
|
||||
"project_id": str(project_id),
|
||||
|
||||
@@ -25,10 +25,7 @@ from B04_PreProcess.B04_PreProcess_Engine_Watershed_Export import STAGES, draina
|
||||
from B04_PreProcess.B04_PreProcess_Engine_Watershed_Flow import polygonize_labels
|
||||
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,
|
||||
)
|
||||
from common_util.common_util_route_geometry import load_design_route
|
||||
from common_util.common_util_storage import resolve_stored_project_path
|
||||
from config.config_db import get_db_pool
|
||||
|
||||
@@ -74,15 +71,25 @@ def _load_routing(stored_path: str) -> dict[str, Any] | None:
|
||||
|
||||
|
||||
async def _resolve_epsg(project_id: UUID, stored_path: str) -> str:
|
||||
"""분석에 쓰인 좌표계를 그대로 되찾는다(노선 파일이 명시하면 그 값 우선)."""
|
||||
"""분석에 쓰인 좌표계를 그대로 되찾는다.
|
||||
|
||||
격자 산출물은 `load_design_route()`가 맞춘 **사업지(.prj) 좌표계**에 있다. 노선 CSV의
|
||||
`crs_epsg` 열은 표시용 라벨이라 그 값을 쓰면 좌표가 딴 곳으로 간다
|
||||
(2026-09-01 실측: 라벨 5179, 실제 5176 — 유입 폴리곤이 1,500km 밖에 찍혔다).
|
||||
"""
|
||||
from B04_PreProcess.B04_PreProcess_Engine_Extent import project_epsg_from_prj
|
||||
|
||||
project_root = Path(resolve_stored_project_path(stored_path))
|
||||
planned = load_design_route(project_root)
|
||||
if planned is not None and planned.crs_input:
|
||||
return planned.crs_input
|
||||
prj_crs = project_epsg_from_prj(project_root)
|
||||
if prj_crs:
|
||||
return prj_crs
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection:
|
||||
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(route_file) if route_file else None
|
||||
source = (planned.epsg if planned else None) or epsg or 5186
|
||||
return f"EPSG:{source}"
|
||||
return f"EPSG:{epsg or 5186}"
|
||||
|
||||
|
||||
def _collect_inflow(
|
||||
|
||||
@@ -59,6 +59,8 @@ export interface RouteSolveRequest {
|
||||
balance_segment_length_m?: number | null;
|
||||
start_elevation_offset_m?: number | null;
|
||||
end_elevation_offset_m?: number | null;
|
||||
/** 횡단배수 최소 계획고 강제 — 기본 해제(2026-09-01 사용자 지시). */
|
||||
enforce_pipe_clearance?: boolean | null;
|
||||
}
|
||||
|
||||
/** 계획선 산출 요약 (실패 시 null) */
|
||||
@@ -140,6 +142,7 @@ export interface RouteLatestResponse {
|
||||
balance_segment_length_m?: number | null;
|
||||
start_elevation_offset_m?: number | null;
|
||||
end_elevation_offset_m?: number | null;
|
||||
enforce_pipe_clearance?: boolean | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
|
||||
@@ -61,6 +61,10 @@ class GradeDesignOptions:
|
||||
balance_segment_length_m: float | None = None
|
||||
start_elevation_offset_m: float = 0.0
|
||||
end_elevation_offset_m: float = 0.0
|
||||
# 횡단배수 최소고를 계획선에 강제할지(2026-09-01 사용자 지시). 켜면 배관 자리 계획고를
|
||||
# 시설 여유(`facility_clearance_m`)만큼 들어 올리고 그 아래로 내리는 편집도 막는다.
|
||||
# 기본은 해제 — 자동설계가 배수 자리마다 계획고를 잡아당겨 제어가 어려웠다.
|
||||
enforce_pipe_clearance: bool = False
|
||||
warnings: tuple[str, ...] = field(default_factory=tuple)
|
||||
|
||||
def validate(self) -> None:
|
||||
@@ -95,6 +99,7 @@ class GradeDesignOptions:
|
||||
"balance_segment_length_m": self.balance_segment_length_m,
|
||||
"start_elevation_offset_m": self.start_elevation_offset_m,
|
||||
"end_elevation_offset_m": self.end_elevation_offset_m,
|
||||
"enforce_pipe_clearance": self.enforce_pipe_clearance,
|
||||
}
|
||||
|
||||
|
||||
@@ -211,6 +216,13 @@ def resolve_grade_options(
|
||||
requested.get("end_elevation_offset_m"), stored.get("end_elevation_offset_m"), 0.0
|
||||
)
|
||||
),
|
||||
enforce_pipe_clearance=bool(
|
||||
_pick(
|
||||
requested.get("enforce_pipe_clearance"),
|
||||
stored.get("enforce_pipe_clearance"),
|
||||
False,
|
||||
)
|
||||
),
|
||||
warnings=tuple(warnings),
|
||||
)
|
||||
|
||||
|
||||
@@ -380,6 +380,7 @@ def rebuild_alignment_profile(
|
||||
terrain_type=str(criteria.get("terrain_type") or "normal"),
|
||||
paved=policy.paved,
|
||||
main_direction=str(criteria.get("main_direction") or "auto"),
|
||||
enforce_pipe_clearance=bool(criteria.get("enforce_pipe_clearance") or False),
|
||||
)
|
||||
direction = str(criteria.get("resolved_main_direction") or "none")
|
||||
return alignment, _profile_entry(
|
||||
|
||||
@@ -368,8 +368,14 @@ def run_section_generation(
|
||||
# 1차 계획선의 변화점 = 배수유역도가 확정한 배관 배치 측점(측점 생성과 같은 목록).
|
||||
pipe_chainages=[pipe.chainage_m for pipe in pipes],
|
||||
# 시설 제원이 요구하는 최소 여유(관경+토피 등) — 계획선이 그만큼 들려야 관·구체가
|
||||
# 들어갈 자리가 생긴다(2026-08-23 사용자 지시).
|
||||
pipe_clearances=dict(pipe_anchor_clearances(pipes)),
|
||||
# 들어갈 자리가 생긴다(2026-08-23 사용자 지시). 다만 이 들어올림이 배수 자리마다
|
||||
# 계획고를 잡아당겨 제어가 어려워, 적용 여부를 사용자 스위치로 뺐다(2026-09-01
|
||||
# 사용자 지시 — 기본 해제). 산식은 그대로이고 켜고 끄는 것만 바뀐다.
|
||||
pipe_clearances=(
|
||||
dict(pipe_anchor_clearances(pipes))
|
||||
if grade_options and grade_options.enforce_pipe_clearance
|
||||
else None
|
||||
),
|
||||
)
|
||||
# 계획선 경사 기반 포장 제안(법정 상한 초과 측점) — 비치명적.
|
||||
try:
|
||||
|
||||
@@ -105,6 +105,11 @@ class RouteSolveRequest(BaseModel):
|
||||
balance_segment_length_m: float | None = Field(default=None, gt=0)
|
||||
start_elevation_offset_m: float | None = None
|
||||
end_elevation_offset_m: float | None = None
|
||||
# 횡단배수 최소고 강제 여부(2026-09-01 사용자 지시). 기본 해제 — 켜면 계획선이 배관
|
||||
# 자리에서 시설 여유만큼 들리고, 그 아래로 내리는 편집도 화면에서 막힌다.
|
||||
enforce_pipe_clearance: bool | None = Field(
|
||||
default=None, description="횡단배수 최소 계획고 강제 (기본 해제)"
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_choices(self) -> "RouteSolveRequest":
|
||||
@@ -132,6 +137,7 @@ class RouteSolveRequest(BaseModel):
|
||||
"balance_segment_length_m": self.balance_segment_length_m,
|
||||
"start_elevation_offset_m": self.start_elevation_offset_m,
|
||||
"end_elevation_offset_m": self.end_elevation_offset_m,
|
||||
"enforce_pipe_clearance": self.enforce_pipe_clearance,
|
||||
}
|
||||
|
||||
def points_data(self) -> dict[str, Any]:
|
||||
|
||||
@@ -445,7 +445,8 @@ export async function saveCorridorIfDirty(projectId: string, routeId: number): P
|
||||
if (ok) entry.dirty = false;
|
||||
}
|
||||
|
||||
/** Page 훅 — 현재 종횡단 정본 그대로 코리도를 확보해 뷰어에 반영(실패 시 제거). */
|
||||
/** Page 훅 — 현재 종횡단 정본 그대로 코리도를 확보해 뷰어에 반영(실패 시 제거).
|
||||
* 끝나는 시점을 [3D 업데이트] 버튼이 기다려야 해 Promise를 돌려준다(2026-09-01). */
|
||||
export function refreshCorridor(
|
||||
viewer: { setCorridor: (build: CorridorBuildResult | null) => void },
|
||||
projectId: string,
|
||||
@@ -453,9 +454,9 @@ export function refreshCorridor(
|
||||
detail: SectionDetailResponse,
|
||||
routePoints: RoutePoint[],
|
||||
designSamples?: ProfileSamples,
|
||||
): void {
|
||||
if (!routeId) return;
|
||||
void ensureCorridor(projectId, routeId, detail, routePoints, designSamples)
|
||||
): Promise<void> {
|
||||
if (!routeId) return Promise.resolve();
|
||||
return ensureCorridor(projectId, routeId, detail, routePoints, designSamples)
|
||||
.then((build) => viewer.setCorridor(build))
|
||||
.catch(() => viewer.setCorridor(null));
|
||||
}
|
||||
|
||||
@@ -95,26 +95,10 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
|
||||
// [초기선 복원] 시 그래프의 배관 투영도 지운다(관 정본은 배수유역 초기화가 맡는다).
|
||||
() => bridge.clearProjectedStations(),
|
||||
{
|
||||
// 계획선 편집 프리뷰가 횡단 설계선을 갱신하면 3D 코리도도 재빌드(2026-08-23).
|
||||
// 연타·드래그 중 프리뷰 응답마다 전체 파이프라인(빌드+스냅+클립)을 돌리면
|
||||
// 프리즈가 오므로, 편집이 잦아든 뒤 1회만 돌린다(2026-08-23 사용자 보고).
|
||||
onCrossDesignsUpdated: () => {
|
||||
window.clearTimeout(corridorRefreshTimer);
|
||||
corridorRefreshTimer = window.setTimeout(() => {
|
||||
if (currentSectionDetail && latest?.route?.id && (latest.route_points?.length ?? 0) > 1) {
|
||||
refreshCorridor(
|
||||
viewer,
|
||||
activeProjectId,
|
||||
latest.route.id,
|
||||
currentSectionDetail,
|
||||
latest.route_points,
|
||||
profilePanel.alignmentSamples() ?? undefined,
|
||||
);
|
||||
// 측점 바·라벨·램프도 계획고를 따라 움직여야 한다(2026-08-23 지적 ③).
|
||||
renderStationLines(currentSectionDetail);
|
||||
}
|
||||
}, 500);
|
||||
},
|
||||
// 계획선 편집 프리뷰가 횡단 설계선을 갱신해도 3D는 **자동으로 따라오지 않는다**
|
||||
// (2026-09-01 사용자 지시). 디바운스 자동 갱신조차 편집 중 조작을 무겁게 만들어,
|
||||
// 갱신은 상단 [3D 업데이트] 버튼으로 모았다. 여기서는 밀린 편집이 있다는 표시만 한다.
|
||||
onCrossDesignsUpdated: () => panel.setCorridorPending(true),
|
||||
// 관 매설 목록 → 그래프 배관 측점선·통합 목록을 한 방향으로 맞춘다.
|
||||
// 정본은 배수유역도의 관 지점이며, 화면 목록은 그것을 실체화한 것이다.
|
||||
onPipesChanged: (pipes) => {
|
||||
@@ -184,8 +168,6 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
|
||||
let latest: RouteLatestResponse | null = null;
|
||||
let roadWidths = DEFAULT_ROAD_WIDTHS;
|
||||
let currentSectionDetail: SectionDetailResponse | null = null;
|
||||
/** 계획선 편집 중 코리도 재빌드 디바운스 — 연타 프리즈 방지(2026-08-23). */
|
||||
let corridorRefreshTimer = 0;
|
||||
/** [예상형상] 표시 상태 — 측점 바를 계획고 위로 올릴지 판단한다(패널 기본 ON). */
|
||||
let corridorVisible = true;
|
||||
let routeReady = false;
|
||||
@@ -282,6 +264,22 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
|
||||
onSurfaceGrayscale: viewer.setSurfaceGrayscale,
|
||||
onView: viewer.setView,
|
||||
onResetView: () => viewer.setView("top"),
|
||||
// [3D 업데이트](2026-09-01) — 밀린 계획선 편집을 예상형상·측점선에 한 번에 반영한다.
|
||||
onCorridorRefresh: async () => {
|
||||
if (!currentSectionDetail || !latest?.route?.id) return;
|
||||
const routePoints = latest.route_points ?? [];
|
||||
if (routePoints.length <= 1) return;
|
||||
await refreshCorridor(
|
||||
viewer,
|
||||
activeProjectId,
|
||||
latest.route.id,
|
||||
currentSectionDetail,
|
||||
routePoints,
|
||||
profilePanel.alignmentSamples() ?? undefined,
|
||||
);
|
||||
// 측점 바·라벨·램프도 계획고를 따라 움직여야 한다(2026-08-23 지적 ③).
|
||||
renderStationLines(currentSectionDetail);
|
||||
},
|
||||
onMovePoint: viewer.beginMoveSelected,
|
||||
onDeletePoint: viewer.markers.deleteSelected,
|
||||
onRadiusChange: (radius) => viewer.markers.updateSelected({ radius_m: radius }),
|
||||
@@ -333,6 +331,8 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
|
||||
function markStale(): void {
|
||||
if (restoring || !routeReady) return;
|
||||
profilePanel.setGradeLimit(panel.gradeLimitPct());
|
||||
// 횡단배수 최소고 강제 체크는 재계산 없이 편집 가드에만 즉시 반영된다(2026-09-01).
|
||||
profilePanel.setEnforceMinCover(panel.values().enforcePipeClearance);
|
||||
if (currentSectionDetail) renderStationLines(currentSectionDetail);
|
||||
}
|
||||
|
||||
@@ -386,6 +386,8 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
|
||||
minTangentLength: next.route_params?.min_tangent_length_m ?? undefined,
|
||||
startElevationOffset: next.route_params?.start_elevation_offset_m ?? undefined,
|
||||
endElevationOffset: next.route_params?.end_elevation_offset_m ?? undefined,
|
||||
enforcePipeClearance:
|
||||
(next.route_params?.enforce_pipe_clearance as boolean | undefined) ?? undefined,
|
||||
});
|
||||
viewer.markers.setPoints(restorePoints(next));
|
||||
}
|
||||
@@ -454,6 +456,8 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
|
||||
// 다를 수 있다 — 그릴 때마다 현재 기준으로 맞춘다(2026-08-19). 이렇게 하지 않으면
|
||||
// 진입 직후 상단 표시줄이 옛 상한을 그대로 보여 준다.
|
||||
profilePanel.setGradeLimit(panel.gradeLimitPct());
|
||||
// 횡단배수 최소고 강제 여부도 그릴 때마다 현재 설정으로 맞춘다(2026-09-01).
|
||||
profilePanel.setEnforceMinCover(panel.values().enforcePipeClearance);
|
||||
profilePanel.setStationDisplay(panel.stationDisplayOffset());
|
||||
profilePanel.setIrregularStations(bridge.irregularStations());
|
||||
renderStationLines(detail);
|
||||
@@ -462,7 +466,7 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
|
||||
// renderLatest 후 재렌더가 오므로 그때 그린다(반복 빌드 방지).
|
||||
const routePoints = latest?.route_points ?? [];
|
||||
if (routePoints.length > 1) {
|
||||
refreshCorridor(
|
||||
void refreshCorridor(
|
||||
viewer,
|
||||
activeProjectId,
|
||||
routeId ?? latest?.route?.id,
|
||||
@@ -470,6 +474,8 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
|
||||
routePoints,
|
||||
profilePanel.alignmentSamples() ?? undefined,
|
||||
);
|
||||
// 방금 정본 그대로 그렸으므로 [3D 업데이트] 대기 표시를 지운다(2026-09-01).
|
||||
panel.setCorridorPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -599,6 +605,7 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
|
||||
min_tangent_length_m: values.minTangentLength,
|
||||
start_elevation_offset_m: values.startElevationOffset,
|
||||
end_elevation_offset_m: values.endElevationOffset,
|
||||
enforce_pipe_clearance: values.enforcePipeClearance,
|
||||
});
|
||||
// 새 경로는 측점 구성이 달라지므로 이전 상단측 변경분을 폐기한다(자동 판정 재사용).
|
||||
uphillOverrides.clear();
|
||||
|
||||
@@ -32,6 +32,9 @@ export interface RoutePanelValues {
|
||||
minTangentLength: number | null;
|
||||
startElevationOffset: number | null;
|
||||
endElevationOffset: number | null;
|
||||
/** 횡단배수 최소 계획고를 계획선·편집에 강제할지 — 기본 해제(2026-09-01 사용자 지시).
|
||||
* 해제해도 부족분 경고는 그대로 뜬다. 강제만 풀고 판단은 사용자에게 맡긴다. */
|
||||
enforcePipeClearance: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -102,6 +105,9 @@ interface PanelCallbacks {
|
||||
onSurfaceGrayscale: (grayscale: boolean) => void;
|
||||
onView: (view: "iso" | "top" | "front" | "side") => void;
|
||||
onResetView: () => void;
|
||||
/** [3D 업데이트] — 계획선 편집을 3D 예상형상·측점선에 한 번에 반영(2026-09-01 사용자
|
||||
* 지시). 편집마다 따라오던 자동 갱신을 없애고 이 버튼으로만 돌린다. */
|
||||
onCorridorRefresh: () => void | Promise<void>;
|
||||
onMovePoint: () => void;
|
||||
onDeletePoint: () => void;
|
||||
onRadiusChange: (radius: number) => void;
|
||||
@@ -226,15 +232,36 @@ export function createRoutePanel(callbacks: PanelCallbacks) {
|
||||
// 기본 켜짐(2026-08-23 사용자 확정) — 예상형상 색이 지형 고도색에 묻히지 않는다.
|
||||
toggleButton("흑백 지형", true, callbacks.onSurfaceGrayscale),
|
||||
);
|
||||
// [3D 업데이트](2026-09-01 사용자 지시) — 계획선을 고칠 때마다 3D가 따라오면 조작이
|
||||
// 무거워, 갱신을 이 버튼으로 모았다. 편집이 쌓이면 is-pending으로 알리고, 누르는 동안은
|
||||
// 이 버튼만 잠근다(화면 전체 잠금 금지 — CLAUDE.md 5장).
|
||||
const corridorRefresh = button(
|
||||
"3D 업데이트",
|
||||
() => {
|
||||
corridorRefresh.disabled = true;
|
||||
corridorRefresh.textContent = "갱신 중…";
|
||||
void Promise.resolve(callbacks.onCorridorRefresh()).finally(() => {
|
||||
corridorRefresh.disabled = false;
|
||||
corridorRefresh.textContent = "3D 업데이트";
|
||||
corridorRefresh.classList.remove("is-pending");
|
||||
});
|
||||
},
|
||||
"glass",
|
||||
);
|
||||
corridorRefresh.title =
|
||||
"계획선 편집을 3D 예상형상·측점선에 반영합니다.\n편집이 쌓이면 버튼에 표시가 붙습니다.";
|
||||
const separator1 = document.createElement("span");
|
||||
separator1.className = "b05-route__view-separator";
|
||||
const separator2 = separator1.cloneNode() as HTMLSpanElement;
|
||||
const separator3 = separator1.cloneNode() as HTMLSpanElement;
|
||||
viewControls.append(
|
||||
viewButtons,
|
||||
separator1,
|
||||
visibilityButtons,
|
||||
separator2,
|
||||
button("뷰 초기화", callbacks.onResetView, "glass"),
|
||||
separator3,
|
||||
corridorRefresh,
|
||||
);
|
||||
|
||||
// 등고선 간격 행 — 「페이지 설정」 섹션에 들어간다(2026-08-18 컨테이너 병합).
|
||||
@@ -373,6 +400,13 @@ export function createRoutePanel(callbacks: PanelCallbacks) {
|
||||
const terrainType = terrainField.select;
|
||||
// 역기울기(5%) 상한 방향은 서버가 지반 형상에서 자동 판정(main_direction="auto")하므로
|
||||
// 수동 선택 UI는 두지 않는다. 노선 균형 구역 길이도 자동 산출 기본값(전체 1구역)에 맡긴다.
|
||||
// 횡단배수 최소고 강제(2026-09-01 사용자 지시) — 기본 해제. 켜면 자동 계획선이 배관
|
||||
// 자리를 시설 여유만큼 들어 올리고, 그 아래로 내리는 편집도 막힌다. 꺼도 부족분 경고는
|
||||
// 종단 상단 표시줄에 계속 뜬다.
|
||||
const enforcePipeClearance = checkbox("횡단배수 최소고 강제", false);
|
||||
enforcePipeClearance.title =
|
||||
"켜면 계획선이 배수관·BOX암거·세월교 자리에서 관경(구체 높이)+토피 0.5m 만큼 들립니다.\n" +
|
||||
"끄면 들어 올리지 않고 부족분은 경고로만 알립니다(기본).";
|
||||
const maxGradePct = numberField("최대 종단기울기 (%)");
|
||||
const minVerticalRadius = numberField("종단곡선 최소 반경 (m)");
|
||||
const minTangentLength = numberField("최소 직선 길이 (m)");
|
||||
@@ -405,6 +439,7 @@ export function createRoutePanel(callbacks: PanelCallbacks) {
|
||||
speedField.root,
|
||||
terrainField.root,
|
||||
criteriaNote,
|
||||
enforcePipeClearance.wrapper,
|
||||
gradeAdvanced,
|
||||
);
|
||||
|
||||
@@ -531,6 +566,7 @@ export function createRoutePanel(callbacks: PanelCallbacks) {
|
||||
minTangentLength,
|
||||
startElevationOffset,
|
||||
endElevationOffset,
|
||||
enforcePipeClearance,
|
||||
];
|
||||
inputElements.forEach((input) => input.addEventListener("change", callbacks.onInputChange));
|
||||
|
||||
@@ -557,6 +593,10 @@ export function createRoutePanel(callbacks: PanelCallbacks) {
|
||||
stationDisplayOffset,
|
||||
/** 지금 적용되는 종단기울기 상한(%) — 그래프 위반 판정 즉시 반영용(2026-08-19). */
|
||||
gradeLimitPct,
|
||||
/** [3D 업데이트] 대기 표시 — 편집이 3D에 아직 안 들어갔음을 버튼에 알린다. */
|
||||
setCorridorPending(pending: boolean) {
|
||||
corridorRefresh.classList.toggle("is-pending", pending);
|
||||
},
|
||||
values(): RoutePanelValues {
|
||||
return {
|
||||
contourInterval: Number(contourInterval.value) || 1,
|
||||
@@ -579,6 +619,7 @@ export function createRoutePanel(callbacks: PanelCallbacks) {
|
||||
minTangentLength: parseOptional(minTangentLength),
|
||||
startElevationOffset: parseOptional(startElevationOffset),
|
||||
endElevationOffset: parseOptional(endElevationOffset),
|
||||
enforcePipeClearance: enforcePipeClearance.checked,
|
||||
};
|
||||
},
|
||||
restore(values: Partial<RoutePanelValues>) {
|
||||
@@ -611,6 +652,8 @@ export function createRoutePanel(callbacks: PanelCallbacks) {
|
||||
startElevationOffset.value = String(values.startElevationOffset);
|
||||
if (values.endElevationOffset != null)
|
||||
endElevationOffset.value = String(values.endElevationOffset);
|
||||
if (values.enforcePipeClearance != null)
|
||||
enforcePipeClearance.checked = values.enforcePipeClearance;
|
||||
syncCriteria();
|
||||
},
|
||||
setSelected(point: PlacedRoutePoint | null) {
|
||||
|
||||
@@ -189,6 +189,8 @@ export function createRouteProfilePanel(
|
||||
// 관 목록이 바뀌면 종단 테이블의 "배관" 구조물 라인도 같이 맞춘다(정본은 관 지점 파일).
|
||||
/** 횡단배수 최소 계획고 대상(배수관·BOX암거) — 관 목록이 바뀔 때 갱신한다. */
|
||||
let minCoverTargets: MinCoverPoint[] = [];
|
||||
/** 최소고를 편집에서 강제할지 — 사이드 「페이지 설정」 체크박스를 따라온다(기본 해제). */
|
||||
let enforceMinCover = false;
|
||||
const drainagePanel = createDrainagePanel({
|
||||
onPipesChanged: (pipes) => {
|
||||
// 횡단배수 최소 계획고(2026-08-23) — 관경·구체높이가 바뀌면 경고도 다시 본다.
|
||||
@@ -502,6 +504,7 @@ export function createRouteProfilePanel(
|
||||
stationInterval: () => stationInterval,
|
||||
irregularStations: () => irregularStations,
|
||||
minCoverTargets: () => minCoverTargets,
|
||||
enforceMinCover: () => enforceMinCover,
|
||||
structures: () => structures,
|
||||
structureTypes: () => structureTypes,
|
||||
selectedStationId: () => selectedStationId,
|
||||
@@ -624,6 +627,10 @@ export function createRouteProfilePanel(
|
||||
/** 종단기울기 상한(%)을 즉시 갈아 끼운다 — 페이지 설정의 지형 구분·등급·세부
|
||||
* 입력을 바꾼 순간 위반 표시와 편집 차단 기준이 따라와야 한다(2026-08-19
|
||||
* 사용자 지시 6). 계획선 자체는 서버 재계산 몫이라 건드리지 않는다. */
|
||||
/** 횡단배수 최소고를 편집에서 강제할지(사이드 체크박스). 경고 표시는 무관하게 유지된다. */
|
||||
setEnforceMinCover(enforce: boolean) {
|
||||
enforceMinCover = enforce;
|
||||
},
|
||||
setGradeLimit(maxGradePct: number) {
|
||||
if (!base || !Number.isFinite(maxGradePct) || maxGradePct <= 0) return;
|
||||
if (Math.abs(base.policy.max_grade_pct - maxGradePct) < 1e-9) return;
|
||||
|
||||
@@ -68,6 +68,8 @@ export interface ProfileRenderContext {
|
||||
irregularStations: () => IrregularStation[];
|
||||
/** 횡단배수 최소 계획고 대상(시설·제원 반영) — 편집 차단 가드가 쓴다. */
|
||||
minCoverTargets: () => MinCoverPoint[];
|
||||
/** 최소고를 편집에서 강제할지 — 꺼져 있으면 차단하지 않는다(2026-09-01, 기본 해제). */
|
||||
enforceMinCover: () => boolean;
|
||||
structures: () => StructureInstance[];
|
||||
structureTypes: () => StructureType[];
|
||||
selectedStationId: () => string | null;
|
||||
@@ -292,6 +294,9 @@ export function renderProfile(ctx: ProfileRenderContext): void {
|
||||
};
|
||||
const blocksMinCover = (next: AlignmentEdits): boolean => {
|
||||
if (!base) return false;
|
||||
// 최소고 강제가 꺼져 있으면 막지 않는다(2026-09-01 사용자 지시 — 기본 해제).
|
||||
// 부족분은 상단 표시줄 경고(minCoverWarningText)로 계속 알린다.
|
||||
if (!ctx.enforceMinCover()) return false;
|
||||
const targets = ctx.minCoverTargets();
|
||||
if (!targets.length) return false;
|
||||
// 판정점 = 제어점 z(라운드 중심). 곡선 샘플로 재면 이웃 틸팅이 라운드 형상만
|
||||
|
||||
@@ -208,6 +208,23 @@
|
||||
background: color-mix(in srgb, var(--color-border) 75%, transparent);
|
||||
}
|
||||
|
||||
/* [3D 업데이트] 대기 표시 — 계획선 편집이 3D에 아직 안 들어갔음을 점으로 알린다. */
|
||||
.b05-route__view-controls .ui-btn.is-pending {
|
||||
position: relative;
|
||||
border-color: var(--color-warning, #d98a00);
|
||||
}
|
||||
|
||||
.b05-route__view-controls .ui-btn.is-pending::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-warning, #d98a00);
|
||||
}
|
||||
|
||||
.b05-route__panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -385,6 +385,9 @@ def _regeneration_grade_options(stage_params: dict[str, Any] | None) -> GradeDes
|
||||
"balance_segment_length_m",
|
||||
"start_elevation_offset_m",
|
||||
"end_elevation_offset_m",
|
||||
# 횡단배수 최소고 강제 스위치도 확정 당시 값을 따라야 한다 — 빠뜨리면 재생성이
|
||||
# 기본(해제)으로 돌아가 켜 둔 계획선이 내려앉는다(2026-09-01).
|
||||
"enforce_pipe_clearance",
|
||||
)
|
||||
if stage_params.get(key) is not None
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ from B07_DesignDetail.B07_DesignDetail_Schema import (
|
||||
DesignDrawingItem,
|
||||
)
|
||||
from common_util.common_util_drainage_pipes import detail_basins_path
|
||||
from common_util.common_util_route_geometry import find_planned_route_file, read_planned_route
|
||||
from common_util.common_util_route_profile import design_elevation_from_longitudinal
|
||||
from config.config_system import DRAWING_SCALE_BASIN
|
||||
|
||||
@@ -123,19 +124,46 @@ def _write_manifest(project_root: Path, manifest: dict[str, Any]) -> None:
|
||||
temporary.replace(path)
|
||||
|
||||
|
||||
def _geojson_features(path: Path) -> list[dict[str, Any]]:
|
||||
"""GeoJSON 피처 목록만 읽는다. 파일이 없거나 깨졌으면 빈 목록."""
|
||||
def _geojson_payload(path: Path) -> dict[str, Any]:
|
||||
"""GeoJSON 전체를 읽는다. 파일이 없거나 깨졌으면 빈 dict."""
|
||||
if not path.is_file():
|
||||
return []
|
||||
return {}
|
||||
try:
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
logger.warning("B07 유역도: GeoJSON을 읽지 못했습니다 — %s", path)
|
||||
return []
|
||||
features = payload.get("features") if isinstance(payload, dict) else None
|
||||
return {}
|
||||
return payload if isinstance(payload, dict) else {}
|
||||
|
||||
|
||||
def _geojson_features(path: Path) -> list[dict[str, Any]]:
|
||||
"""GeoJSON 피처 목록만 읽는다. 파일이 없거나 깨졌으면 빈 목록."""
|
||||
features = _geojson_payload(path).get("features")
|
||||
return features if isinstance(features, list) else []
|
||||
|
||||
|
||||
def _basins_crs(context: Any, payload: dict[str, Any]) -> str:
|
||||
"""저장본을 미터로 되돌릴 좌표계.
|
||||
|
||||
저장할 때 쓴 좌표계를 파일이 갖고 있으면 그 값이다. 없으면 좌표계 기록 이전에 저장된
|
||||
파일이라 노선 CSV의 EPSG 라벨로 쓰였다 — 그 라벨로 되돌려야 왕복이 맞는다
|
||||
(2026-09-01). 라벨을 못 읽으면 현재 사업지 좌표계로 둔다.
|
||||
"""
|
||||
stored = payload.get("crs_input")
|
||||
if isinstance(stored, str) and stored:
|
||||
return stored
|
||||
route_file = find_planned_route_file(context.project_root / "B03_FileInput" / "input")
|
||||
planned = read_planned_route(route_file) if route_file else None
|
||||
if planned is not None and planned.epsg:
|
||||
logger.warning(
|
||||
"B07 유역도: 좌표계 기록이 없는 옛 저장본 — 노선 CSV 라벨 EPSG:%s로 되돌립니다. "
|
||||
"B04에서 유역을 다시 확정하면 현재 좌표계로 새로 남습니다.",
|
||||
planned.epsg,
|
||||
)
|
||||
return f"EPSG:{planned.epsg}"
|
||||
return context.crs
|
||||
|
||||
|
||||
def _geometry_lines(geometry: Any) -> list[list[tuple[float, float]]]:
|
||||
"""LineString·MultiLineString·Polygon을 점열 목록으로 편다."""
|
||||
if not isinstance(geometry, dict):
|
||||
@@ -222,7 +250,10 @@ def watershed_source(context: Any) -> dict[str, Any]:
|
||||
|
||||
저장본은 전부 WGS84라 여기서 미터 좌표로 되돌린다(B04가 저장할 때와 반대 방향).
|
||||
"""
|
||||
to_metric = Transformer.from_crs("EPSG:4326", f"EPSG:{context.epsg}", always_xy=True)
|
||||
basins_payload = _geojson_payload(detail_basins_path(context.stored_path))
|
||||
to_metric = Transformer.from_crs(
|
||||
"EPSG:4326", _basins_crs(context, basins_payload), always_xy=True
|
||||
)
|
||||
|
||||
def metric(point: tuple[float, float]) -> tuple[float, float]:
|
||||
x, y = to_metric.transform(point[0], point[1])
|
||||
@@ -230,7 +261,7 @@ def watershed_source(context: Any) -> dict[str, Any]:
|
||||
|
||||
route_xy = [(vertex.x, vertex.y) for vertex in context.vertices]
|
||||
basins: list[dict[str, Any]] = []
|
||||
for feature in _geojson_features(detail_basins_path(context.stored_path)):
|
||||
for feature in basins_payload.get("features") or []:
|
||||
properties = feature.get("properties") or {}
|
||||
if properties.get("kind") != "detail_basin":
|
||||
continue
|
||||
|
||||
@@ -52,7 +52,9 @@ class DrainageContext:
|
||||
project_root: Path
|
||||
vertices: list[RouteVertex] = field(default_factory=list)
|
||||
z_source: str = Z_SOURCE_CSV
|
||||
epsg: int = 5186
|
||||
# 사업지 좌표계 — pyproj 입력 문자열(`EPSG:n` 또는 .prj WKT). 노선 CSV의 `crs_epsg`
|
||||
# 열은 표시용 라벨이라 실좌표계와 다를 수 있다(2026-09-01 실측: 라벨 5179, 실제 5176).
|
||||
crs: str = "EPSG:5186"
|
||||
route_id: int | None = None
|
||||
to_lonlat: Callable[[float, float], tuple[float, float]] = lambda x, y: (x, y)
|
||||
|
||||
@@ -96,15 +98,17 @@ async def load_drainage_context(project_id: UUID) -> tuple[DrainageContext | Non
|
||||
sampler=sampler,
|
||||
)
|
||||
|
||||
epsg = int(planned.epsg or db_epsg or 5186)
|
||||
transformer = Transformer.from_crs(f"EPSG:{epsg}", "EPSG:4326", always_xy=True)
|
||||
# 노선을 실제로 담고 있는 좌표계를 쓴다 — `load_design_route()`가 .prj 좌표계로
|
||||
# 재투영하며 `crs_input`만 갱신하고 `epsg` 라벨은 CSV 값 그대로 남긴다.
|
||||
crs = planned.crs_input or f"EPSG:{planned.epsg or db_epsg or 5186}"
|
||||
transformer = Transformer.from_crs(crs, "EPSG:4326", always_xy=True)
|
||||
return (
|
||||
DrainageContext(
|
||||
stored_path=stored_path,
|
||||
project_root=project_root,
|
||||
vertices=vertices,
|
||||
z_source=z_source,
|
||||
epsg=epsg,
|
||||
crs=crs,
|
||||
route_id=int(route["id"]) if route else None,
|
||||
to_lonlat=lambda x, y: transformer.transform(x, y),
|
||||
),
|
||||
|
||||
@@ -351,11 +351,16 @@ def clear_pipe_points(stored_path: str) -> bool:
|
||||
return removed
|
||||
|
||||
|
||||
def save_detail_basins(stored_path: str, features: list[dict[str, Any]]) -> Path:
|
||||
"""세부유역을 GeoJSON으로 남긴다(파생물 — 관 지점만 있으면 언제든 다시 만든다)."""
|
||||
def save_detail_basins(stored_path: str, features: list[dict[str, Any]], crs: str) -> Path:
|
||||
"""세부유역을 GeoJSON으로 남긴다(파생물 — 관 지점만 있으면 언제든 다시 만든다).
|
||||
|
||||
`crs`는 좌표를 WGS84로 바꿀 때 쓴 **사업지 좌표계**다. 되읽는 쪽(B07 유역도)이 같은
|
||||
좌표계로 되돌려야 하는데, 예전에는 이 값을 안 남겨 노선 CSV의 EPSG 라벨로 되돌렸다
|
||||
(2026-09-01: 라벨과 실좌표계가 갈린 프로젝트에서 유역이 딴 자리로 갔다).
|
||||
"""
|
||||
path = detail_basins_path(stored_path)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
atomic_write_json(path, {"type": "FeatureCollection", "features": features})
|
||||
atomic_write_json(path, {"type": "FeatureCollection", "crs_input": crs, "features": features})
|
||||
logger.info("배수유역: 세부유역 %d개를 저장했습니다 (%s).", len(features), path.name)
|
||||
return path
|
||||
|
||||
|
||||
Reference in New Issue
Block a user