diff --git a/B04_PreProcess/B04_PreProcess_Engine_SheetSurface.py b/B04_PreProcess/B04_PreProcess_Engine_SheetSurface.py index cd6646ad..6e3d1c3d 100644 --- a/B04_PreProcess/B04_PreProcess_Engine_SheetSurface.py +++ b/B04_PreProcess/B04_PreProcess_Engine_SheetSurface.py @@ -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, diff --git a/B04_PreProcess/B04_PreProcess_Router_Basins.py b/B04_PreProcess/B04_PreProcess_Router_Basins.py index 0ce290bc..cb696cc1 100644 --- a/B04_PreProcess/B04_PreProcess_Router_Basins.py +++ b/B04_PreProcess/B04_PreProcess_Router_Basins.py @@ -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 diff --git a/B04_PreProcess/B04_PreProcess_Router_Contour.py b/B04_PreProcess/B04_PreProcess_Router_Contour.py index e1243963..4c1ea20f 100644 --- a/B04_PreProcess/B04_PreProcess_Router_Contour.py +++ b/B04_PreProcess/B04_PreProcess_Router_Contour.py @@ -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), diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Support.py b/B07_DesignDetail/B07_DesignDetail_Router_Support.py index 6309b34b..f4e399d0 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router_Support.py +++ b/B07_DesignDetail/B07_DesignDetail_Router_Support.py @@ -41,6 +41,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 @@ -107,19 +108,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): @@ -206,7 +234,10 @@ def watershed_source(context: Any) -> dict[str, Any]: 저장본은 전부 WGS84라 여기서 미터 좌표로 되돌린다(B04가 저장할 때와 반대 방향). """ - to_metric = Transformer.from_crs("EPSG:4326", context.crs, 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]) @@ -214,7 +245,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 diff --git a/common_util/common_util_drainage_pipes.py b/common_util/common_util_drainage_pipes.py index 11f88350..dab60e37 100644 --- a/common_util/common_util_drainage_pipes.py +++ b/common_util/common_util_drainage_pipes.py @@ -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