From b50559d7999436c59022ee653da820938060102e Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 1 Sep 2026 17:39:19 +0900 Subject: [PATCH 1/3] =?UTF-8?q?fix(B04):=20=EB=85=B8=EC=84=A0=20CSV?= =?UTF-8?q?=EC=9D=98=20EPSG=20=EB=9D=BC=EB=B2=A8=20=EB=8C=80=EC=8B=A0=20?= =?UTF-8?q?=EC=82=AC=EC=97=85=EC=A7=80=20=EC=8B=A4=EC=A2=8C=ED=91=9C?= =?UTF-8?q?=EA=B3=84=EB=A1=9C=20=ED=99=94=EB=A9=B4=20=EC=A2=8C=ED=91=9C?= =?UTF-8?q?=EB=A5=BC=20=EB=A7=8C=EB=93=A0=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 상세 배수유역 폴리곤이 지도에 안 그려졌다. 노선 CSV `crs_epsg` 열(라벨 5179)을 좌표계로 써서, 사업지 좌표계(.prj EPSG:5176)로 계산된 격자 산출물을 잘못 역투영한 탓이다 — 유역이 lon 119.79 / lat 23.08(대만 남쪽 바다)로 나가 화면 밖이었다. `load_design_route()`는 노선을 .prj 좌표계로 재투영하며 `crs_input`만 갱신하고 `epsg` 라벨은 CSV 값 그대로 둔다(2026-08-31 확정). 그 라벨을 좌표계로 쓰던 자리를 모두 `crs_input`(= .prj 좌표계)으로 바꿨다. - common_util_drainage_context: DrainageContext.epsg(int) → crs(str, pyproj 입력). 상세 배수유역·관 지점 응답 좌표가 노선 위로 돌아온다. - B07_DesignDetail_Router_Support: context.crs 그대로 사용. - SheetSurface.build_sheet_surface_from_route: load_design_route로 노선을 읽어 도엽 서피스를 라이다 지표면과 같은 좌표계에 만든다(기존엔 5179 격자로 만들어져 라이다 DTM과 다른 프레임에 놓였다). LAS 없는 WF1 경로는 라벨·좌표가 한 벌이라 그대로. - Router_Inflow._resolve_epsg: 격자 산출물과 같은 .prj 좌표계로 역투영. 검증: tmp/tests/test_drainage_crs.py 신규(용화 샘플 회귀) + tmp/tests 전체 37 passed. build_detail 실행 결과 유역 11개 폴리곤이 lon 129.0947~129.0988 / lat 36.8107~36.8127 (노선 위)로 나온다 — 수정 전 같은 좌표는 119.7872 / 23.0817이었다. Co-Authored-By: Claude Opus 5 (1M context) --- .../B04_PreProcess_Engine_SheetSurface.py | 34 +++++++++---------- .../B04_PreProcess_Router_Inflow.py | 27 +++++++++------ .../B07_DesignDetail_Router_Support.py | 2 +- common_util/common_util_drainage_context.py | 12 ++++--- 4 files changed, 43 insertions(+), 32 deletions(-) diff --git a/B04_PreProcess/B04_PreProcess_Engine_SheetSurface.py b/B04_PreProcess/B04_PreProcess_Engine_SheetSurface.py index dd52ea62..cd6646ad 100644 --- a/B04_PreProcess/B04_PreProcess_Engine_SheetSurface.py +++ b/B04_PreProcess/B04_PreProcess_Engine_SheetSurface.py @@ -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): @@ -381,7 +381,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 +390,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 +456,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 +525,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("도엽등고선으로 지표면을 만들지 못했습니다 — 도엽 확보를 확인하세요.") diff --git a/B04_PreProcess/B04_PreProcess_Router_Inflow.py b/B04_PreProcess/B04_PreProcess_Router_Inflow.py index ab520978..9d72165a 100644 --- a/B04_PreProcess/B04_PreProcess_Router_Inflow.py +++ b/B04_PreProcess/B04_PreProcess_Router_Inflow.py @@ -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( diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Support.py b/B07_DesignDetail/B07_DesignDetail_Router_Support.py index da5d0526..6309b34b 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router_Support.py +++ b/B07_DesignDetail/B07_DesignDetail_Router_Support.py @@ -206,7 +206,7 @@ def watershed_source(context: Any) -> dict[str, Any]: 저장본은 전부 WGS84라 여기서 미터 좌표로 되돌린다(B04가 저장할 때와 반대 방향). """ - to_metric = Transformer.from_crs("EPSG:4326", f"EPSG:{context.epsg}", always_xy=True) + to_metric = Transformer.from_crs("EPSG:4326", context.crs, always_xy=True) def metric(point: tuple[float, float]) -> tuple[float, float]: x, y = to_metric.transform(point[0], point[1]) diff --git a/common_util/common_util_drainage_context.py b/common_util/common_util_drainage_context.py index 364704b7..bb71200f 100644 --- a/common_util/common_util_drainage_context.py +++ b/common_util/common_util_drainage_context.py @@ -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), ), From 0a1ebcfbb5acec239032b9637d90a24af719f967 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 1 Sep 2026 17:52:27 +0900 Subject: [PATCH 2/3] =?UTF-8?q?fix(B04):=20=EB=8F=84=EC=97=BD=20=EC=84=9C?= =?UTF-8?q?=ED=94=BC=EC=8A=A4=EB=A5=BC=20=EB=9D=BC=EC=9D=B4=EB=8B=A4?= =?UTF-8?q?=EC=99=80=20=EA=B0=99=EC=9D=80=20=EC=9B=90=EC=A0=90=EC=97=90=20?= =?UTF-8?q?=EB=86=93=EA=B3=A0,=20=EC=9C=A0=EC=97=AD=20=EC=A0=80=EC=9E=A5?= =?UTF-8?q?=EB=B3=B8=EC=97=90=20=EC=A2=8C=ED=91=9C=EA=B3=84=EB=A5=BC=20?= =?UTF-8?q?=EB=82=A8=EA=B8=B4=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ① 3D 서피스 겹쳐보기 어긋남 — `write_glb()`는 넘겨받은 상자의 중심을 원점으로 삼는데 모델마다 자기 상자를 넘겨 도엽 서피스와 라이다 지표면이 다른 원점에 섰다 (2f940d8a 실측: 수평 7.85m·높이 1.28m). 도엽 서피스가 라이다 포인트 상자 (`structured.npz`, 화면 `setReferenceBounds`와 같은 값)를 화면 원점으로 쓰게 했다. 표고 격자 상자(`bounds`)는 절취 범위 그대로 두고 `scene_bounds`를 npz에 따로 남긴다 — 등고선 API도 이 값을 먼저 써서 등고선이 메시 위에 얹힌다. 라이다 없는 사업지는 기준이 자기뿐이라 종전대로 자기 상자를 쓴다. ② 세부유역 저장본 좌표계 — `04_detailed_basins.geojson`에 변환에 쓴 좌표계를 `crs_input`으로 남기고, B07 유역도가 그 값으로 되돌린다. 기록이 없는 옛 저장본은 노선 CSV의 EPSG 라벨로 쓰였으므로 그 라벨로 되돌린다(경고 로그 + 재확정 안내). 검증: tmp/tests 42 passed (신규 test_scene_origin_and_basin_crs.py 5건). 패치 코드로 도엽 서피스를 다시 만들어 GLB 정점 상자를 대조 — sheet [-426.58, -68.95, -386.41]~[411.42, 71.52, 379.59], csf [-172.12, -44.06, -199.69]~[184.11, 3.22, 165.72] 로 같은 원점. 수정 전 sheet는 [-419.5, -70.24, -383.0]~[418.5, 70.24, 383.0] (자기 중심)였다. Co-Authored-By: Claude Opus 5 (1M context) --- .../B04_PreProcess_Engine_SheetSurface.py | 33 ++++++++++++-- .../B04_PreProcess_Router_Basins.py | 5 ++- .../B04_PreProcess_Router_Contour.py | 22 ++++----- .../B07_DesignDetail_Router_Support.py | 45 ++++++++++++++++--- common_util/common_util_drainage_pipes.py | 11 +++-- 5 files changed, 91 insertions(+), 25 deletions(-) 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 From e823293e4041eba652e012554a394628700ba1dc Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 1 Sep 2026 18:09:43 +0900 Subject: [PATCH 3/3] =?UTF-8?q?feat(B05):=20=ED=9A=A1=EB=8B=A8=EB=B0=B0?= =?UTF-8?q?=EC=88=98=20=EC=B5=9C=EC=86=8C=EA=B3=A0=EB=A5=BC=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=EC=9E=90=20=EC=8A=A4=EC=9C=84=EC=B9=98=EB=A1=9C=20?= =?UTF-8?q?=EB=B9=BC=EA=B3=A0=203D=20=EA=B0=B1=EC=8B=A0=EC=9D=84=20?= =?UTF-8?q?=EB=B2=84=ED=8A=BC=EC=9C=BC=EB=A1=9C=20=EB=AA=A8=EC=9D=80?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 횡단배수 최소 계획고가 두 곳에서 강제됐다. 자동설계는 배관 자리 계획고를 시설 여유만큼 들어 올렸고(B05_Profile_Engine_Sections), 화면은 그 아래로 내리는 편집을 차단했다(blocksMinCover). 배수 자리마다 계획고가 끌려 올라가 제어가 어려웠다. 산식(common_util_drainage_pipes.facility_clearance_m)은 그대로 두고 적용 여부만 enforce_pipe_clearance 스위치 하나로 뺐다. 기본은 해제라 자동 전처리가 계획고를 들어 올리지 않고, 편집도 막히지 않는다. 부족분 경고(minCoverWarningText)는 켜고 끔과 무관하게 계속 뜬다. 스위치는 요청 → DB 저장값 → 해제 순으로 정해지며, 계획선 criteria와 B06 재생성 경로도 확정 당시 값을 따라간다. 3D 예상형상은 계획선 편집마다 500ms 디바운스로 따라오던 것을 없앴다. 편집이 쌓이면 상단 [3D 업데이트] 버튼에 대기 점이 붙고, 누를 때 코리도와 측점선을 한 번에 갱신한다. 최초 진입의 자동 그림은 그대로다. --- B05_Profile/B05_Profile_Api_Fetch.ts | 3 ++ B05_Profile/B05_Profile_Engine_Grade.py | 12 +++++ .../B05_Profile_Engine_Grade_Profile.py | 1 + B05_Profile/B05_Profile_Engine_Sections.py | 10 +++- B05_Profile/B05_Profile_Schema.py | 6 +++ B05_Profile/B05_Profile_UI_Corridor.ts | 9 ++-- B05_Profile/B05_Profile_UI_Page.ts | 53 +++++++++++-------- B05_Profile/B05_Profile_UI_Panel.ts | 43 +++++++++++++++ B05_Profile/B05_Profile_UI_Profile_Panel.ts | 7 +++ B05_Profile/B05_Profile_UI_Profile_Render.ts | 5 ++ B05_Profile/B05_Profile_UI_Style.css | 17 ++++++ B06_Section/B06_Section_Router.py | 3 ++ 12 files changed, 140 insertions(+), 29 deletions(-) diff --git a/B05_Profile/B05_Profile_Api_Fetch.ts b/B05_Profile/B05_Profile_Api_Fetch.ts index 677be035..05cd24bd 100644 --- a/B05_Profile/B05_Profile_Api_Fetch.ts +++ b/B05_Profile/B05_Profile_Api_Fetch.ts @@ -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; } diff --git a/B05_Profile/B05_Profile_Engine_Grade.py b/B05_Profile/B05_Profile_Engine_Grade.py index e6eb47a3..cd4e1b6f 100644 --- a/B05_Profile/B05_Profile_Engine_Grade.py +++ b/B05_Profile/B05_Profile_Engine_Grade.py @@ -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), ) diff --git a/B05_Profile/B05_Profile_Engine_Grade_Profile.py b/B05_Profile/B05_Profile_Engine_Grade_Profile.py index 887f5caf..b8db30eb 100644 --- a/B05_Profile/B05_Profile_Engine_Grade_Profile.py +++ b/B05_Profile/B05_Profile_Engine_Grade_Profile.py @@ -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( diff --git a/B05_Profile/B05_Profile_Engine_Sections.py b/B05_Profile/B05_Profile_Engine_Sections.py index 048ef50e..9596f81b 100644 --- a/B05_Profile/B05_Profile_Engine_Sections.py +++ b/B05_Profile/B05_Profile_Engine_Sections.py @@ -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: diff --git a/B05_Profile/B05_Profile_Schema.py b/B05_Profile/B05_Profile_Schema.py index 2aa6e751..2c89dc1a 100644 --- a/B05_Profile/B05_Profile_Schema.py +++ b/B05_Profile/B05_Profile_Schema.py @@ -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]: diff --git a/B05_Profile/B05_Profile_UI_Corridor.ts b/B05_Profile/B05_Profile_UI_Corridor.ts index a1db8ad1..c0ffb7de 100644 --- a/B05_Profile/B05_Profile_UI_Corridor.ts +++ b/B05_Profile/B05_Profile_UI_Corridor.ts @@ -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 { + if (!routeId) return Promise.resolve(); + return ensureCorridor(projectId, routeId, detail, routePoints, designSamples) .then((build) => viewer.setCorridor(build)) .catch(() => viewer.setCorridor(null)); } diff --git a/B05_Profile/B05_Profile_UI_Page.ts b/B05_Profile/B05_Profile_UI_Page.ts index 9c2e30e2..b99820a1 100644 --- a/B05_Profile/B05_Profile_UI_Page.ts +++ b/B05_Profile/B05_Profile_UI_Page.ts @@ -95,26 +95,10 @@ export async function renderB05Route(root: HTMLElement): Promise { // [초기선 복원] 시 그래프의 배관 투영도 지운다(관 정본은 배수유역 초기화가 맡는다). () => 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 { 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 { 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 { 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 { 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 { // 다를 수 있다 — 그릴 때마다 현재 기준으로 맞춘다(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 { // 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 { routePoints, profilePanel.alignmentSamples() ?? undefined, ); + // 방금 정본 그대로 그렸으므로 [3D 업데이트] 대기 표시를 지운다(2026-09-01). + panel.setCorridorPending(false); } } @@ -599,6 +605,7 @@ export async function renderB05Route(root: HTMLElement): Promise { 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(); diff --git a/B05_Profile/B05_Profile_UI_Panel.ts b/B05_Profile/B05_Profile_UI_Panel.ts index 7dfe9199..7e58e2c9 100644 --- a/B05_Profile/B05_Profile_UI_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Panel.ts @@ -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; 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) { @@ -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) { diff --git a/B05_Profile/B05_Profile_UI_Profile_Panel.ts b/B05_Profile/B05_Profile_UI_Profile_Panel.ts index 07489011..a9c1e43d 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Panel.ts @@ -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; diff --git a/B05_Profile/B05_Profile_UI_Profile_Render.ts b/B05_Profile/B05_Profile_UI_Profile_Render.ts index dd03bab8..6f4a2d8d 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Render.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Render.ts @@ -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(라운드 중심). 곡선 샘플로 재면 이웃 틸팅이 라운드 형상만 diff --git a/B05_Profile/B05_Profile_UI_Style.css b/B05_Profile/B05_Profile_UI_Style.css index 665e004c..c27e729a 100644 --- a/B05_Profile/B05_Profile_UI_Style.css +++ b/B05_Profile/B05_Profile_UI_Style.css @@ -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; diff --git a/B06_Section/B06_Section_Router.py b/B06_Section/B06_Section_Router.py index 6e0259ab..0ce22413 100644 --- a/B06_Section/B06_Section_Router.py +++ b/B06_Section/B06_Section_Router.py @@ -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 }