From cf5de5641843b11d90c92a7e47df1184ad439efe Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 16 Oct 2026 11:00:04 +0900 Subject: [PATCH 01/18] =?UTF-8?q?perf(=EC=A0=84=EC=B2=98=EB=A6=AC):=20343-?= =?UTF-8?q?1=20=EA=B0=99=EC=9D=80=20=EC=A7=80=ED=98=95=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=EB=8B=A4=EC=8B=9C=20=EC=98=AC=EB=A6=BC=20=3D=20?= =?UTF-8?q?=EC=A0=84=EC=B2=98=EB=A6=AC=20=EA=B2=B0=EA=B3=BC=20=EC=82=B4?= =?UTF-8?q?=EB=A6=BC=20=C2=B7=20=EB=8B=A4=EC=8B=9C=20=EB=8F=8C=20=EB=95=8C?= =?UTF-8?q?=20=EC=A0=90=EA=B5=B0=20=EC=95=88=20=ED=92=82=20=C2=B7=20?= =?UTF-8?q?=EB=8F=84=EC=97=BD=20=EB=B3=91=ED=95=A9=20=C2=B7=20=EA=B5=AD?= =?UTF-8?q?=EA=B0=80=20GIS=20=C2=B7=20=ED=83=80=EC=9D=BC=20=EA=B8=B0?= =?UTF-8?q?=EC=96=B5=20=EC=97=B4=EC=87=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - B03 재올림 — 지형 파일이 source_identity.json 과 같으면 B04 폴더 · 지표면 DB 행 남기고 단계 표시만 되돌림 - 확인 창 「처음부터 다시」 따로 고름(fresh_start) · 409 응답에 preprocess_kept - B04 다시 돎 — 점 수 · 평균 Z 를 structured_stats.json 에 · xyz 는 쓸 때만 풂 - 도엽 병합 열쇠 = 도엽 번호 목록 · 국가 GIS 열쇠 = 범위 · 좌표계 - 지도 타일 메모리 기억 열쇠에 mtime · 크기 - 시험 test_343_1_preprocess_keep 13건 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL --- B03_FileInput/B03_FileInput_Api_Fetch.ts | 11 +- B03_FileInput/B03_FileInput_Router_Chunks.py | 2 + B03_FileInput/B03_FileInput_Router_Helpers.py | 39 ++- B03_FileInput/B03_FileInput_Schema.py | 4 + B03_FileInput/B03_FileInput_UI_Upload.ts | 23 +- B04_PreProcess/B04_PreProcess_Engine.py | 107 +++++++- .../B04_PreProcess_Engine_MvtHelper.py | 16 +- common_util/common_util_project_reset.py | 32 ++- .../tester/test_343_1_preprocess_keep.py | 246 ++++++++++++++++++ 9 files changed, 449 insertions(+), 31 deletions(-) create mode 100644 resources/tester/test_343_1_preprocess_keep.py diff --git a/B03_FileInput/B03_FileInput_Api_Fetch.ts b/B03_FileInput/B03_FileInput_Api_Fetch.ts index 5cd23d9e8..490b315a6 100644 --- a/B03_FileInput/B03_FileInput_Api_Fetch.ts +++ b/B03_FileInput/B03_FileInput_Api_Fetch.ts @@ -57,11 +57,14 @@ export interface UploadStatusResponse { */ export class ReplaceOutputsConfirmRequired extends Error { readonly targets: string[]; + /** 지형 파일이 그대로라 전처리 결과를 살리는지 — 「처음부터 다시」를 따로 묻는다. */ + readonly preprocessKept: boolean; - constructor(message: string, targets: string[]) { + constructor(message: string, targets: string[], preprocessKept = false) { super(message); this.name = "ReplaceOutputsConfirmRequired"; this.targets = targets; + this.preprocessKept = preprocessKept; } } @@ -70,12 +73,14 @@ async function readJsonOrThrow(response: Response): Promise { message?: string; confirm?: string; targets?: string[]; + preprocess_kept?: boolean; }; if (!response.ok) { if (response.status === 409 && payload.confirm === "replace_outputs") { throw new ReplaceOutputsConfirmRequired( payload.message ?? "이미 만들어 둔 결과가 지워집니다.", payload.targets ?? [], + payload.preprocess_kept === true, ); } throw new Error(payload.message ?? `HTTP ${response.status}`); @@ -115,6 +120,7 @@ export async function createUploadSession( completeUpload = false, lasFree = false, confirmReplace = false, + freshStart = false, ): Promise { const response = await fetch(`${API_BASE_URL}/projects/${projectId}/upload-sessions`, { method: "POST", @@ -128,6 +134,7 @@ export async function createUploadSession( complete_upload: completeUpload, las_free: lasFree, confirm_replace: confirmReplace, + fresh_start: freshStart, }), }); return await readJsonOrThrow(response); @@ -160,6 +167,7 @@ export async function finalizeUploadSession( fingerprint?: string | null, lasFree = false, confirmReplace = false, + freshStart = false, ): Promise { const response = await fetch(`${API_BASE_URL}/projects/${projectId}/finalize`, { method: "POST", @@ -172,6 +180,7 @@ export async function finalizeUploadSession( fingerprint: fingerprint ?? null, las_free: lasFree, confirm_replace: confirmReplace, + fresh_start: freshStart, }), }); return await readJsonOrThrow(response); diff --git a/B03_FileInput/B03_FileInput_Router_Chunks.py b/B03_FileInput/B03_FileInput_Router_Chunks.py index 81cef6b1f..3f7ca619b 100644 --- a/B03_FileInput/B03_FileInput_Router_Chunks.py +++ b/B03_FileInput/B03_FileInput_Router_Chunks.py @@ -113,6 +113,7 @@ async def create_project_upload_session( project_id, payload.las_free, payload.confirm_replace, + payload.fresh_start, ) await connection.commit() except Exception: @@ -309,6 +310,7 @@ async def finalize_project_upload( project_id, payload.las_free, payload.confirm_replace, + payload.fresh_start, ) await connection.commit() except Exception: diff --git a/B03_FileInput/B03_FileInput_Router_Helpers.py b/B03_FileInput/B03_FileInput_Router_Helpers.py index b088a9928..58fe0ec67 100644 --- a/B03_FileInput/B03_FileInput_Router_Helpers.py +++ b/B03_FileInput/B03_FileInput_Router_Helpers.py @@ -29,7 +29,7 @@ from B03_FileInput.B03_FileInput_Schema import ( ) from common_util.common_util_initial_snapshot import clear_designing, discard_initial_snapshot from common_util.common_util_json import atomic_write_json -from common_util.common_util_project_reset import purge_project_outputs +from common_util.common_util_project_reset import PREPROCESS_STAGE_DIR, purge_project_outputs from common_util.common_util_storage import resolve_stored_project_path from common_util.common_util_workflow_state import ( complete_stage, @@ -61,8 +61,10 @@ class OutputsWouldBeDiscarded(Exception): 로 다시 들어온다. 자동 절차는 이 예외를 만들지 않는다 — 부를 때 확인을 켜 준다. """ - def __init__(self, targets: list[str]) -> None: + def __init__(self, targets: list[str], preprocess_kept: bool = False) -> None: self.targets = targets + # 지형 파일이 그대로라 전처리 결과를 살리는지 — 화면이 「처음부터 다시」를 따로 묻는다. + self.preprocess_kept = preprocess_kept super().__init__("설계 산출물이 있어 확인이 필요합니다.") @@ -78,14 +80,17 @@ def _confirm_replace_response(exc: "OutputsWouldBeDiscarded") -> JSONResponse: "아래가 지워지고 **되돌릴 수 없습니다**: " + " · ".join(exc.targets) ), "targets": exc.targets, + "preprocess_kept": exc.preprocess_kept, }, ) -def describe_existing_outputs(project_root: Path) -> list[str]: +def describe_existing_outputs(project_root: Path, keep_preprocess: bool = False) -> list[str]: """새 자료를 받으면 **지워질 것**의 이름을 사람 말로 늘어놓는다. 없으면 빈 목록.""" targets: list[str] = [] for stage, label in _OUTPUT_STAGE_LABELS: + if keep_preprocess and stage == PREPROCESS_STAGE_DIR: + continue stage_root = project_root / stage if not stage_root.is_dir(): continue @@ -201,16 +206,37 @@ async def _already_uploaded( ) +def preprocess_input_unchanged(project_root: Path, terrain_paths: list[Path]) -> bool: + """지형 파일이 마지막 전처리 때와 같은지 — `processed/source_identity.json` 과 대조. + + 같은 지문(크기 + 앞·가운데·끝) 파일은 전송을 건너뛰어 파일이 그대로 남는다 — + 이름·크기·수정시각이 전처리가 적어 둔 값과 같으면 같은 입력이다. + """ + if not terrain_paths: + return False + from B04_PreProcess.B04_PreProcess_Engine import _source_identity + + identity_path = project_root / PREPROCESS_STAGE_DIR / "processed" / "source_identity.json" + try: + stored = json.loads(identity_path.read_text(encoding="utf-8")) + return stored == _source_identity(terrain_paths) + except (OSError, ValueError): + return False + + async def _complete_file_input_if_ready( connection: aiomysql.Connection, project_id: UUID, las_free: bool = False, confirm_replace: bool = True, + fresh_start: bool = False, ) -> int: """새 자료로 갈아 끼운다. `confirm_replace=False` 면 지울 것이 있을 때 멈추고 묻는다. 기본이 `True` 인 까닭 — 자동 절차(체인·스크립트)는 물음에 걸리면 안 된다. 사람이 올리는 갈래(라우터)만 `False` 로 불러 확인을 받는다(2026-09-08 사용자 지시). + 지형 파일이 그대로면 전처리 결과는 살리고 단계 표시만 되돌린다 — + `fresh_start=True`(확인 창의 「처음부터 다시」)면 전처리까지 지운다. """ file_types, point_cloud_input_id, route_csv_input_id = await get_project_input_readiness( connection, project_id @@ -234,14 +260,15 @@ async def _complete_file_input_if_ready( gap_message = merge_gap_error(terrain_paths) if gap_message: raise ValueError(gap_message) + keep_preprocess = not fresh_start and preprocess_input_unchanged(project_root, terrain_paths) # ⚠ 여기서부터는 되돌릴 수 없다 — 아래 셋이 파일까지 지운다. 지울 것이 있으면 먼저 묻는다. if not confirm_replace: - targets = describe_existing_outputs(project_root) + targets = describe_existing_outputs(project_root, keep_preprocess) if targets: - raise OutputsWouldBeDiscarded(targets) + raise OutputsWouldBeDiscarded(targets, keep_preprocess) clear_designing(project_root) discard_initial_snapshot(project_root) - await purge_project_outputs(connection, str(project_id), project_root) + await purge_project_outputs(connection, str(project_id), project_root, keep_preprocess) async with connection.cursor(aiomysql.DictCursor) as cursor: await reset_stages_after_input_change(cursor, str(project_id)) await complete_stage(cursor, str(project_id), 0) diff --git a/B03_FileInput/B03_FileInput_Schema.py b/B03_FileInput/B03_FileInput_Schema.py index f9ab3a634..39c49d9f8 100644 --- a/B03_FileInput/B03_FileInput_Schema.py +++ b/B03_FileInput/B03_FileInput_Schema.py @@ -65,6 +65,8 @@ class ChunkSessionCreateRequest(FileUploadDescriptor): # 이미 있는 설계 산출물을 지워도 좋다는 사람의 대답(2026-09-08). 거짓이면 지울 것이 # 있을 때 409 로 멈추고 무엇이 지워지는지 알린다 — 되돌릴 수 없는 삭제라서다. confirm_replace: bool = False + # 확인 창의 「처음부터 다시」 — 지형 파일이 그대로여도 전처리 결과까지 지운다. + fresh_start: bool = False class ChunkSessionCreateResponse(BaseModel): @@ -109,6 +111,8 @@ class UploadFinalizeRequest(BaseModel): # 이미 있는 설계 산출물을 지워도 좋다는 사람의 대답(2026-09-08). 거짓이면 지울 것이 # 있을 때 409 로 멈추고 무엇이 지워지는지 알린다 — 되돌릴 수 없는 삭제라서다. confirm_replace: bool = False + # 확인 창의 「처음부터 다시」 — 지형 파일이 그대로여도 전처리 결과까지 지운다. + fresh_start: bool = False class UploadStatusResponse(BaseModel): diff --git a/B03_FileInput/B03_FileInput_UI_Upload.ts b/B03_FileInput/B03_FileInput_UI_Upload.ts index a64cfb981..afba2771a 100644 --- a/B03_FileInput/B03_FileInput_UI_Upload.ts +++ b/B03_FileInput/B03_FileInput_UI_Upload.ts @@ -83,9 +83,11 @@ export function confirmReplaceUpload(slotLabel: string, fileName: string): Promi * 되돌릴 수 없는 삭제라 확인 없이는 진행하지 않는다(2026-09-08 사용자 지시). * 자동 절차는 이 자리를 지나지 않는다 — 서버가 확인을 켜고 부른다. */ -async function askReplaceOutputs(run: (confirmReplace: boolean) => Promise): Promise { +async function askReplaceOutputs( + run: (confirmReplace: boolean, freshStart: boolean) => Promise, +): Promise { try { - return await run(false); + return await run(false, false); } catch (error) { if (!(error instanceof ReplaceOutputsConfirmRequired)) throw error; const detail = error.targets.length @@ -99,7 +101,16 @@ async function askReplaceOutputs(run: (confirmReplace: boolean) => Promise 되돌릴 수 없습니다. 계속할까요?`, ); if (!agreed) throw new Error("업로드를 취소했습니다 — 기존 결과는 그대로 있습니다."); - return await run(true); + // 지형 파일이 그대로면 전처리 결과는 살린다 — 「처음부터 다시」는 여기서 따로 고른다. + const freshStart = + error.preprocessKept && + window.confirm( + `같은 지형 파일이라 전처리 결과(지표면)는 살려 다시 씁니다. + +전처리까지 처음부터 다시 할까요? +[확인] 처음부터 다시 · [취소] 전처리 결과 살림`, + ); + return await run(true, freshStart); } } @@ -123,7 +134,7 @@ export async function uploadOneFile( const fingerprint = state.uploadSessionId ? null : await fileFingerprint(file); let session = state.uploadSessionId; if (!session) { - const created = await askReplaceOutputs((confirmReplace) => + const created = await askReplaceOutputs((confirmReplace, freshStart) => createUploadSession( projectId, file, @@ -132,6 +143,7 @@ export async function uploadOneFile( completeUpload, lasFree, confirmReplace, + freshStart, ), ); if (created.already_uploaded) { @@ -184,7 +196,7 @@ export async function uploadOneFile( } } - const response = await askReplaceOutputs((confirmReplace) => + const response = await askReplaceOutputs((confirmReplace, freshStart) => finalizeUploadSession( projectId, session, @@ -193,6 +205,7 @@ export async function uploadOneFile( fingerprint, lasFree, confirmReplace, + freshStart, ), ); localStorage.removeItem(storageKey); diff --git a/B04_PreProcess/B04_PreProcess_Engine.py b/B04_PreProcess/B04_PreProcess_Engine.py index b94ef1c31..8c806ee6b 100644 --- a/B04_PreProcess/B04_PreProcess_Engine.py +++ b/B04_PreProcess/B04_PreProcess_Engine.py @@ -55,6 +55,87 @@ def _source_identity(las_paths: list[Path]) -> dict[str, Any]: return files[0] if len(files) == 1 else {"files": files} +class _LazyXyz: + """structured.npz 의 xyz — 처음 값을 쓸 때만 푼다. 길이는 저장해 둔 점 수로 답한다.""" + + def __init__(self, structured: Any, count: int, array: np.ndarray | None = None) -> None: + self._structured = structured + self._count = count + self._array = array + + def _load(self) -> np.ndarray: + if self._array is None: + self._array = self._structured["xyz"] + return self._array + + def __len__(self) -> int: + return self._count + + def __getitem__(self, key: Any) -> Any: + return self._load()[key] + + def __array__(self, dtype: Any = None, copy: Any = None) -> np.ndarray: + return np.asarray(self._load(), dtype=dtype) + + def __getattr__(self, name: str) -> Any: + return getattr(self._load(), name) + + +class _LazyStructured(dict): + """구조화 결과 — 열쇠마다 처음 쓸 때만 푼다(저장본이 다 맞는 다시 돎은 점군을 안 풂).""" + + def __init__(self, structured: Any, xyz: _LazyXyz) -> None: + super().__init__() + self._structured = structured + self["xyz"] = xyz + + # 옛 dict 와 같은 열쇠만 보인다 — 필터가 열쇠 유무로 갈리는 일이 없게. + _KEYS = ("classification",) + + def __missing__(self, key: str) -> Any: + if key not in self._KEYS: + raise KeyError(key) + value = self._structured[key] + self[key] = value + return value + + def __contains__(self, key: object) -> bool: + return dict.__contains__(self, key) or key in self._KEYS + + +def _structured_stats(structured: Any, structured_path: Path) -> tuple[dict[str, Any], Any]: + """점 수 · 평균 Z 를 `structured_stats.json` 에서 읽는다 — 없거나 낡으면 한 번 풀어 적는다. + + 돌려주는 둘째 값 = 이번에 푼 xyz(없으면 None) — 같은 부름 안에서 다시 풀지 않게. + """ + stats_path = structured_path.parent / "structured_stats.json" + stat = structured_path.stat() + stamp = {"size_bytes": int(stat.st_size), "mtime_ns": int(stat.st_mtime_ns)} + try: + stored = json.loads(stats_path.read_text(encoding="utf-8")) + if stored.get("structured") == stamp: + return stored, None + except (OSError, ValueError, AttributeError): + pass + xyz = structured["xyz"] + count = int(len(xyz)) + stats = { + "structured": stamp, + "point_count": count, + "mean_z": float(np.mean(xyz[:, 2])) if count else None, + } + atomic_write_json(stats_path, stats) + return stats, xyz + + +def _key_matches(key_path: Path, key: Any) -> bool: + """결과 옆 열쇠 파일이 지금 입력과 같은지.""" + try: + return json.loads(key_path.read_text(encoding="utf-8")) == key + except (OSError, ValueError): + return False + + def _relative_to_project(project_root: Path, path: Path) -> str: """프로젝트 루트 기준 posix 상대 경로 문자열.""" return path.relative_to(project_root).as_posix() @@ -179,13 +260,14 @@ def run_surface_analysis( _report(10, "structurize", "구조화 캐시 재사용") logger.info("B04 구조화 캐시 재사용: %s", structured_path.name) with np.load(structured_path) as structured: - xyz = structured["xyz"] + # 점 수 · 평균 Z 는 저장값 — 저장본이 다 맞으면 점군(841 MB 압축)을 풀지 않는다. + stored_stats, loaded_xyz = _structured_stats(structured, structured_path) bounds = structured["bounds"] - total_points = int(len(xyz)) + total_points = int(stored_stats["point_count"]) stats = { "min_z": float(bounds[2, 0]), "max_z": float(bounds[2, 1]), - "mean_z": float(np.mean(xyz[:, 2])) if total_points else None, + "mean_z": stored_stats["mean_z"], } bounds_dict = { "x_min": float(bounds[0, 0]), @@ -193,7 +275,8 @@ def run_surface_analysis( "y_min": float(bounds[1, 0]), "y_max": float(bounds[1, 1]), } - data = {"xyz": xyz, "bounds": bounds, "classification": structured["classification"]} + data = _LazyStructured(structured, _LazyXyz(structured, total_points, loaded_xyz)) + data["bounds"] = bounds # 2. 지면 필터 실행 — mask_{filter}.npy 영구 캐시 우선 재사용 (PLAN A-1) _report(40, "ground_filter", "지면 필터 적용 중") @@ -386,14 +469,21 @@ def download_geodata( # 등고선은 국가 gpkg 추가 시점이 전처리보다 늦을 수 있으므로 개별로 존재 여부를 확인한다. contour_geojson = processed_dir / "등고선_bounds.geojson" + # 열쇠 = 받은 범위 · 좌표계 — 노선이 바뀌어 범위가 달라지면 다시 받는다. + gis_key_path = processed_dir / "gis_vectors.key.json" + gis_key = json.loads( + json.dumps({"epsg": project_epsg, "bounds": bounds_dict_for_download}, default=float) + ) if ( rebuild or not any(processed_dir.glob("*_bounds.geojson")) or not contour_geojson.exists() + or not _key_matches(gis_key_path, gis_key) ): try: step_started = time.monotonic() download_all_gis_vectors(prj_path, bounds_dict_for_download, processed_dir) + atomic_write_json(gis_key_path, gis_key) logger.info( "B04 국가 GIS 벡터 다운로드 완료 (%.1fs)", time.monotonic() - step_started ) @@ -433,9 +523,13 @@ def download_geodata( # 선정에서 빠진 zip은 쓰이지 않으므로 정리한다(다른 지역 잔재 포함). prune_sheets(sheet_store, sheet_grid) - # 확보된 도엽을 레이어별 병합 GeoJSON으로 산출 (기존 산출물 있으면 스킵) + # 확보된 도엽을 레이어별 병합 GeoJSON으로 산출 — 열쇠(도엽 번호 목록)가 같으면 스킵 + merge_key_path = processed_dir / "sheet_merge.key.json" + merge_key = {"sheets": sorted(str(item) for item in sheet_result["available"])} if sheet_result["available"] and ( - rebuild or not any(processed_dir.glob("도엽_*.geojson")) + rebuild + or not any(processed_dir.glob("도엽_*.geojson")) + or not _key_matches(merge_key_path, merge_key) ): from B04_PreProcess.B04_PreProcess_Engine_SheetParser import ( parse_and_merge_sheets, @@ -450,6 +544,7 @@ def download_geodata( {k: v["count"] for k, v in merged.items()}, time.monotonic() - step_started, ) + atomic_write_json(merge_key_path, merge_key) except Exception as exc: logger.warning("B04 수치지형도 도엽 확보 실패: %s", exc) except Exception as exc: diff --git a/B04_PreProcess/B04_PreProcess_Engine_MvtHelper.py b/B04_PreProcess/B04_PreProcess_Engine_MvtHelper.py index c945e8f3a..8e0b09265 100644 --- a/B04_PreProcess/B04_PreProcess_Engine_MvtHelper.py +++ b/B04_PreProcess/B04_PreProcess_Engine_MvtHelper.py @@ -18,7 +18,7 @@ try: except ImportError: mapbox_vector_tile = None -# 메모리 캐시: {project_id}_{layer_name}: GeoDataFrame +# 메모리 캐시: {project_id}_{layer_name}: ((mtime_ns, 크기), GeoDataFrame) _gdf_cache: dict[str, Any] = {} @@ -41,9 +41,13 @@ def get_tile_bbox(xtile: int, ytile: int, zoom: int) -> tuple[float, float, floa def load_geojson_cached(filepath: Path, cache_key: str) -> Any: """GeoJSON 파일을 한 번 읽으면 메모리에 GeoDataFrame으로 캐싱합니다.""" global _gdf_cache - if cache_key not in _gdf_cache: - if not filepath.exists(): - raise FileNotFoundError(f"File not found: {filepath}") + if not filepath.exists(): + raise FileNotFoundError(f"File not found: {filepath}") + # 열쇠에 수정시각·크기 — 전처리를 다시 돌려 파일이 바뀌면 옛 타일을 버린다. + stat = filepath.stat() + stamp = (stat.st_mtime_ns, stat.st_size) + cached = _gdf_cache.get(cache_key) + if cached is None or cached[0] != stamp: if gpd is None: raise ImportError("geopandas가 설치되어 있지 않습니다.") gdf = gpd.read_file(filepath) @@ -51,8 +55,8 @@ def load_geojson_cached(filepath: Path, cache_key: str) -> Any: gdf.set_crs(epsg=4326, inplace=True) if gdf.crs.to_epsg() != 4326: gdf = gdf.to_crs(epsg=4326) - _gdf_cache[cache_key] = gdf - return _gdf_cache[cache_key] + _gdf_cache[cache_key] = (stamp, gdf) + return _gdf_cache[cache_key][1] def generate_mvt_tile( diff --git a/common_util/common_util_project_reset.py b/common_util/common_util_project_reset.py index c8780c718..b4e02fec9 100644 --- a/common_util/common_util_project_reset.py +++ b/common_util/common_util_project_reset.py @@ -41,10 +41,17 @@ _PROJECT_OUTPUT_TABLES = ( ) -def purge_output_folders(project_root: Path) -> list[str]: +# 같은 지형 파일을 다시 올렸을 때 살리는 것 — 전처리 폴더와 그 DB 행. +PREPROCESS_STAGE_DIR = "B04_PreProcess" +_PREPROCESS_TABLES = frozenset({"surface_models", "processed_point_cloud"}) + + +def purge_output_folders(project_root: Path, keep_preprocess: bool = False) -> list[str]: """단계별 산출물 폴더를 비운다. 지운 폴더 이름을 돌려준다.""" removed: list[str] = [] for name in OUTPUT_STAGE_DIRS: + if keep_preprocess and name == PREPROCESS_STAGE_DIR: + continue target = project_root / name if not target.exists(): continue @@ -54,7 +61,9 @@ def purge_output_folders(project_root: Path) -> list[str]: return removed -async def purge_output_records(connection: aiomysql.Connection, project_id: str) -> dict[str, int]: +async def purge_output_records( + connection: aiomysql.Connection, project_id: str, keep_preprocess: bool = False +) -> dict[str, int]: """산출물 DB 레코드를 지운다. 파일만 지우면 화면은 "자료가 있다"고 착각한다. 호출부가 트랜잭션을 관리한다(여기서 커밋하지 않는다). @@ -75,6 +84,8 @@ async def purge_output_records(connection: aiomysql.Connection, project_id: str) ) deleted["route_statistics"] = cursor.rowcount for table in _PROJECT_OUTPUT_TABLES: + if keep_preprocess and table in _PREPROCESS_TABLES: + continue await cursor.execute(f"DELETE FROM {table} WHERE project_id = %s", (project_id,)) deleted[table] = cursor.rowcount await cursor.execute("DELETE FROM routes WHERE project_id = %s", (project_id,)) @@ -83,14 +94,21 @@ async def purge_output_records(connection: aiomysql.Connection, project_id: str) async def purge_project_outputs( - connection: aiomysql.Connection, project_id: str, project_root: Path + connection: aiomysql.Connection, + project_id: str, + project_root: Path, + keep_preprocess: bool = False, ) -> None: - """입력 자료 교체 시 옛 계산 결과(파일 + DB)를 함께 지운다.""" - removed = purge_output_folders(project_root) - deleted = await purge_output_records(connection, project_id) + """입력 자료 교체 시 옛 계산 결과(파일 + DB)를 함께 지운다. + + `keep_preprocess` — 지형 파일이 그대로면 전처리(B04) 결과는 살린다. + """ + removed = purge_output_folders(project_root, keep_preprocess) + deleted = await purge_output_records(connection, project_id, keep_preprocess) logger.info( - "입력 자료 교체 — 옛 산출물 정리: project_id=%s 폴더=%s 레코드=%s", + "입력 자료 교체 — 옛 산출물 정리: project_id=%s 전처리 살림=%s 폴더=%s 레코드=%s", project_id, + keep_preprocess, ",".join(removed) or "없음", {key: value for key, value in deleted.items() if value}, ) diff --git a/resources/tester/test_343_1_preprocess_keep.py b/resources/tester/test_343_1_preprocess_keep.py new file mode 100644 index 000000000..79604d880 --- /dev/null +++ b/resources/tester/test_343_1_preprocess_keep.py @@ -0,0 +1,246 @@ +"""343-1 같은 파일 다시 올림 = 전처리 결과 살림 · 점군 안 풂 · 도엽 병합 · 타일 기억 열쇠. + +서버 함수만 시험 — 실제 재올림은 하지 않음(완료 메일). +""" + +from __future__ import annotations + +import asyncio +import json +import os +from pathlib import Path + +import numpy as np + +from B03_FileInput.B03_FileInput_Router_Helpers import ( + OutputsWouldBeDiscarded, + _confirm_replace_response, + describe_existing_outputs, + preprocess_input_unchanged, +) +from B04_PreProcess.B04_PreProcess_Engine import ( + _key_matches, + _LazyStructured, + _LazyXyz, + _source_identity, + _structured_stats, +) +from B04_PreProcess.B04_PreProcess_Engine_Ground import summarize_masks +from common_util.common_util_project_reset import purge_output_folders, purge_output_records + + +def _project(tmp_path: Path) -> tuple[Path, Path]: + las = tmp_path / "B03_FileInput" / "input" / "a.las" + las.parent.mkdir(parents=True) + las.write_bytes(b"LASF" + b"0" * 100) + processed = tmp_path / "B04_PreProcess" / "processed" + processed.mkdir(parents=True) + (processed / "source_identity.json").write_text( + json.dumps(_source_identity([las])), encoding="utf-8" + ) + return tmp_path, las + + +def test_같은_지형_파일이면_전처리_살림(tmp_path: Path) -> None: + root, las = _project(tmp_path) + assert preprocess_input_unchanged(root, [las]) is True + + +def test_지형_파일이_바뀌면_살리지_않음(tmp_path: Path) -> None: + root, las = _project(tmp_path) + stat = las.stat() + os.utime(las, ns=(stat.st_atime_ns, stat.st_mtime_ns + 5_000_000_000)) + assert preprocess_input_unchanged(root, [las]) is False + assert preprocess_input_unchanged(root, []) is False + + +def test_전처리_기록이_없으면_살리지_않음(tmp_path: Path) -> None: + root, las = _project(tmp_path) + (root / "B04_PreProcess" / "processed" / "source_identity.json").unlink() + assert preprocess_input_unchanged(root, [las]) is False + + +def test_살리면_확인_창_목록에서_전처리_빠짐(tmp_path: Path) -> None: + root, _ = _project(tmp_path) + (root / "B05_Profile").mkdir() + (root / "B05_Profile" / "route.json").write_text("{}", encoding="utf-8") + assert "지표면·전처리 결과" in describe_existing_outputs(root) + kept = describe_existing_outputs(root, keep_preprocess=True) + assert "지표면·전처리 결과" not in kept + assert "노선·종단 설계" in kept + + +def test_확인_응답에_전처리_살림_표시() -> None: + body = json.loads(_confirm_replace_response(OutputsWouldBeDiscarded(["횡단 설계"], True)).body) + assert body["preprocess_kept"] is True + body = json.loads(_confirm_replace_response(OutputsWouldBeDiscarded(["횡단 설계"])).body) + assert body["preprocess_kept"] is False + + +def test_폴더_지우기는_전처리만_남김(tmp_path: Path) -> None: + root, _ = _project(tmp_path) + (root / "B05_Profile").mkdir() + (root / "B05_Profile" / "route.json").write_text("{}", encoding="utf-8") + removed = purge_output_folders(root, keep_preprocess=True) + assert "B04_PreProcess" not in removed + assert (root / "B04_PreProcess" / "processed" / "source_identity.json").is_file() + assert not (root / "B05_Profile" / "route.json").exists() + assert "B04_PreProcess" in purge_output_folders(root) + assert not (root / "B04_PreProcess" / "processed").exists() + + +class _Cursor: + def __init__(self, log: list[str]) -> None: + self.log = log + self.rowcount = 0 + + async def __aenter__(self) -> "_Cursor": + return self + + async def __aexit__(self, *_: object) -> None: + return None + + async def execute(self, sql: str, _params: object = None) -> None: + self.log.append(sql) + + +class _Connection: + def __init__(self) -> None: + self.log: list[str] = [] + + def cursor(self) -> _Cursor: + return _Cursor(self.log) + + +def test_DB_지우기는_지표면_행을_남김() -> None: + connection = _Connection() + asyncio.run(purge_output_records(connection, "p", keep_preprocess=True)) # type: ignore[arg-type] + joined = "\n".join(connection.log) + assert "surface_models" not in joined + assert "processed_point_cloud" not in joined + assert "DELETE FROM routes" in joined + connection = _Connection() + asyncio.run(purge_output_records(connection, "p")) # type: ignore[arg-type] + assert "surface_models" in "\n".join(connection.log) + + +def test_점_수_평균Z_저장값_두번째는_안_풂(tmp_path: Path) -> None: + path = tmp_path / "structured.npz" + xyz = np.array([[0, 0, 1], [1, 1, 3]], dtype=np.float32) + np.savez_compressed(path, xyz=xyz, bounds=np.zeros((3, 2))) + with np.load(path) as structured: + stats, loaded = _structured_stats(structured, path) + assert stats["point_count"] == 2 and stats["mean_z"] == 2.0 + assert loaded is not None + with np.load(path) as structured: + stats, loaded = _structured_stats(structured, path) + assert loaded is None and stats["point_count"] == 2 + # 구조화가 다시 돌면(파일이 바뀌면) 다시 셈 + np.savez_compressed(path, xyz=np.zeros((5, 3), np.float32), bounds=np.zeros((3, 2))) + with np.load(path) as structured: + stats, loaded = _structured_stats(structured, path) + assert stats["point_count"] == 5 and loaded is not None + + +class _NoXyz(dict): + files = ["xyz", "bounds", "classification"] + + def __getitem__(self, key: str) -> object: + if key == "xyz": + raise AssertionError("점군을 풀면 안 됨") + return dict.__getitem__(self, key) + + +def test_저장본이_맞으면_점군을_풀지_않음() -> None: + source = _NoXyz(classification=np.zeros(3, np.uint8), bounds=np.zeros((3, 2))) + data = _LazyStructured(source, _LazyXyz(source, 3)) + summary = summarize_masks(data, {"csf": np.array([True, False, True])}) + assert summary["csf"]["total_point_count"] == 3 + assert "classification" in data and "rgb" not in data + assert len(data["classification"]) == 3 + + +def test_점군은_쓸_때_풂() -> None: + xyz = np.arange(9, dtype=np.float32).reshape(3, 3) + lazy = _LazyXyz({"xyz": xyz}, 3) + assert lazy.shape == (3, 3) and lazy.ndim == 2 + assert np.asarray(lazy, dtype=np.float64)[2, 2] == 8.0 + assert lazy[1, 0] == 3.0 + + +def test_결과_옆_열쇠_대조(tmp_path: Path) -> None: + key_path = tmp_path / "sheet_merge.key.json" + key = {"sheets": ["37612001", "37612002"]} + assert _key_matches(key_path, key) is False + key_path.write_text(json.dumps(key), encoding="utf-8") + assert _key_matches(key_path, key) is True + assert _key_matches(key_path, {"sheets": ["37612001"]}) is False + + +def test_지도_타일_기억은_파일이_바뀌면_다시_읽음(tmp_path: Path) -> None: + from B04_PreProcess.B04_PreProcess_Engine_MvtHelper import load_geojson_cached + + path = tmp_path / "등고선_bounds.geojson" + + def write(count: int) -> None: + features = [ + { + "type": "Feature", + "properties": {"h": index}, + "geometry": {"type": "Point", "coordinates": [127.0 + index * 0.01, 37.0]}, + } + for index in range(count) + ] + path.write_text( + json.dumps({"type": "FeatureCollection", "features": features}), encoding="utf-8" + ) + + write(1) + assert len(load_geojson_cached(path, "t343_contour")) == 1 + assert len(load_geojson_cached(path, "t343_contour")) == 1 + write(3) + stat = path.stat() + os.utime(path, ns=(stat.st_atime_ns, stat.st_mtime_ns + 5_000_000_000)) + assert len(load_geojson_cached(path, "t343_contour")) == 3 + + +def test_다시_돌면_같은_결과_점군_안_풂(tmp_path: Path, monkeypatch) -> None: # type: ignore[no-untyped-def] + """작은 LAS 로 전처리 두 번 — 두 번째는 저장본만 읽고 점군 xyz 를 풀지 않음.""" + import laspy + + import B04_PreProcess.B04_PreProcess_Engine as engine + import B04_PreProcess.B04_PreProcess_Engine_SheetSurface as sheet + + rng = np.random.default_rng(1) + count = 4000 + x = rng.uniform(200000, 200060, count) + y = rng.uniform(500000, 500060, count) + header = laspy.LasHeader(point_format=3, version="1.2") + header.offsets = [200000, 500000, 0] + header.scales = [0.01, 0.01, 0.01] + las_data = laspy.LasData(header) + las_data.x, las_data.y, las_data.z = x, y, 100 + 0.1 * x - 0.1 * 200000 + las_data.classification = np.full(count, 2, np.uint8) + las_path = tmp_path / "B03_FileInput" / "input" / "t.las" + las_path.parent.mkdir(parents=True) + las_data.write(las_path) + + monkeypatch.setattr(engine, "download_geodata", lambda *a, **k: None) + monkeypatch.setattr(sheet, "build_sheet_surface_from_route", lambda *a, **k: []) + first = engine.run_surface_analysis(tmp_path, las_path, source_filters=None, methods=["dtm"]) + + real_lazy_load = engine._LazyXyz._load + calls: list[int] = [] + + def counting_load(self): # type: ignore[no-untyped-def] + calls.append(1) + return real_lazy_load(self) + + monkeypatch.setattr(engine._LazyXyz, "_load", counting_load) + second = engine.run_surface_analysis(tmp_path, las_path, source_filters=None, methods=["dtm"]) + assert first["models"] and calls == [] + assert second["processed"] == first["processed"] + assert second["ground_summary"] == first["ground_summary"] + assert [m["model_file_path"] for m in second["models"]] == [ + m["model_file_path"] for m in first["models"] + ] From 0efc77380eba7f520e5b3ebbb327aeadc96e60e9 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 16 Oct 2026 11:01:28 +0900 Subject: [PATCH 02/18] =?UTF-8?q?perf(=EB=B0=B0=EC=88=98=EC=9C=A0=EC=97=AD?= =?UTF-8?q?):=20342-3=20=EB=B9=A0=EB=A5=B8=20=EA=B8=B8=20=E2=80=94=20?= =?UTF-8?q?=ED=9D=90=EB=A6=84=20=EB=B0=A9=ED=96=A5=20=ED=81=B0=20=EA=B2=A9?= =?UTF-8?q?=EC=9E=90=20=ED=95=9C=20=EB=B2=88=20=C2=B7=20=EB=8F=84=EB=A1=9C?= =?UTF-8?q?=20=EB=8B=BF=EC=9D=8C=20=ED=8C=90=EC=A0=95=20=ED=95=9C=EA=BA=BC?= =?UTF-8?q?=EB=B2=88=EC=97=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 하강 방향장 = 해석 격자 둘레 400 m 큰 격자로 한 번 짓고 회차마다 잘라 씀(crop_descent) · 넘으면 그때 다시 지음 - classify_flow 한 칸씩 걷기 → 포인터 더블링(_reach_by_doubling) · 기록용 출발 셀 수는 최외곽 · 나머지 셀 수로 - 거울 시험 test_342_3_watershed_fast(옛 걷기와 칸마다 같음 · 잘라 씀 안쪽 같음) · 실측 bench_342_watershed - 검증_sub3 실측: 적색 차이 0칸 · 도로 귀속 · 강도 · 관 자리 같음 · 108 → 27 s(붐빈 랩탑 · 340 기준 약 15 s) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XWh5wNNycMkbwMpACz1rte --- .../B04_PreProcess_Engine_Watershed_Expand.py | 96 ++++++++++-- .../B04_PreProcess_Engine_Watershed_Flow.py | 86 ++++------- resources/tester/bench_342_watershed.py | 94 ++++++++++++ resources/tester/test_342_3_watershed_fast.py | 138 ++++++++++++++++++ 4 files changed, 349 insertions(+), 65 deletions(-) create mode 100644 resources/tester/bench_342_watershed.py create mode 100644 resources/tester/test_342_3_watershed_fast.py diff --git a/B04_PreProcess/B04_PreProcess_Engine_Watershed_Expand.py b/B04_PreProcess/B04_PreProcess_Engine_Watershed_Expand.py index 7ef8234a7..324b8ab02 100644 --- a/B04_PreProcess/B04_PreProcess_Engine_Watershed_Expand.py +++ b/B04_PreProcess/B04_PreProcess_Engine_Watershed_Expand.py @@ -10,6 +10,9 @@ ④ **새로 추가된 셀에 적색이 하나도 없으면 종료** (2026-07-31 사용자 지시) 격자 bbox에 닿으면 격자 자체도 셀 정수배로 넓힌다 — 도로 시작점 기준 격자점은 유지된다. + +하강 방향장은 처음에 **넉넉한 큰 격자**로 한 번 짓고, 회차마다 그 격자에서 잘라 쓴다 +(`DESCENT_SPARE_M` · 342-3). 격자가 커질 때마다 다시 짓던 것(8회 · 30 s)을 한 번으로 줄인다. """ from __future__ import annotations @@ -43,6 +46,11 @@ from config.config_system import ( logger = logging.getLogger(__name__) +# 하강 방향장을 지을 때 해석 격자 둘레에 더 두는 여유(m). 확장이 이 안에서 끝나면 방향장을 +# 한 번만 짓는다 — 검증_sub3 실측 확장은 첫 격자에서 남 350 m · 서 200 m 까지 넓어졌다. +# 넘으면 그때 넓힌 격자 둘레로 다시 짓는다(결과는 같고 시간만 듦). +DESCENT_SPARE_M = 400.0 + @dataclass class GridAnalysis: @@ -78,16 +86,23 @@ def analyze_domain( """주어진 격자·해석 영역에 대해 등고선 하강 방향과 도로 도달 색을 한 번 계산한다. **하강 방향장은 해석 영역과 무관하다** — 등고선 기하만으로 정해진다. 그래서 확장 - 회차마다 다시 계산하지 않고, 격자가 커졌을 때만 새로 만들어 넘겨받는다(`descent`). + 회차마다 다시 계산하지 않고, 이 격자를 덮는 큰 방향장(`descent`)을 받으면 잘라 쓴다. 해석 영역은 마지막에 마스크로만 씌운다. """ from B03_FileInput.B03_FileInput_Service_Chain import _log_steps marks = [("시작", time.perf_counter())] - reused = descent is not None and descent.spec == spec - if not reused: + fitted = crop_descent(descent, spec) if descent is not None else None + if fitted is None: descent = build_contour_descent(spec, contour_features, None, elevation_floor_m) - marks.append(("하강 방향장" + ("(재사용)" if reused else "(새로 만듦)"), time.perf_counter())) + else: + descent = fitted + marks.append( + ( + "하강 방향장" + ("(잘라 씀)" if fitted is not None else "(새로 만듦)"), + time.perf_counter(), + ) + ) valid = descent.valid & domain if not valid.any(): return None @@ -128,8 +143,12 @@ def expand_by_red_boundary( # 두면 방향장을 다시 만들지 않고 해석 영역만 넓히며 몇 회차를 돌 수 있다. spec, domain = _pad_spec(spec, domain, band_cells * 2) started_cells = int(domain.sum()) + spare_cells = max(0, int(round(DESCENT_SPARE_M / spec.cell_m))) + source = build_contour_descent( + _pad_spec(spec, domain, spare_cells)[0], contour_features, None, elevation_floor_m + ) analysis = analyze_domain( - spec, domain, contour_features, route_line, upstream_streams, elevation_floor_m + spec, domain, contour_features, route_line, upstream_streams, elevation_floor_m, source ) if analysis is None: return None @@ -160,6 +179,14 @@ def expand_by_red_boundary( if not added_mask.any(): closed = True break + if crop_descent(source, grown_spec) is None: + # 넉넉히 둔 방향장 밖으로 넓어짐 — 넓힌 격자 둘레로 다시 짓는다. + source = build_contour_descent( + _pad_spec(grown_spec, grown_domain, spare_cells)[0], + contour_features, + None, + elevation_floor_m, + ) logger.info( "배수유역: %d회차 확장 — 최외곽 적색 %d셀 주변 %.0fm, 셀 %d개 추가", @@ -175,22 +202,20 @@ def expand_by_red_boundary( route_line, upstream_streams, elevation_floor_m, - # 격자가 그대로면 방향장을 재사용한다 — 등고선 기하가 안 바뀌었으므로 결과는 같다. - descent=analysis.descent if grown_spec == current else None, + descent=source, ) if widened_analysis is None: break analysis = widened_analysis rounds += 1 logger.info( - "[계측] 확장 %d회차 %.1fs · 격자 %d×%d · 셀 %d (+%d) · 방향장 %s", + "[계측] 확장 %d회차 %.1fs · 격자 %d×%d · 셀 %d (+%d)", attempt + 1, time.perf_counter() - _round_started, grown_spec.n_rows, grown_spec.n_cols, int(widened.sum()), int(added_mask.sum()), - "재사용" if grown_spec == current else "새로", ) added_reaches = widened_analysis.flow.reaches_road.reshape( @@ -230,6 +255,59 @@ def _dilate_by(mask: np.ndarray, steps: int) -> np.ndarray: return result +def crop_descent(source: ContourDescent, spec: GridSpec) -> ContourDescent | None: + """큰 방향장에서 `spec` 격자 몫을 잘라 낸다. 덮지 못하거나 셀이 안 맞으면 None. + + 잘린 가장자리 밖을 가리키던 셀은 제자리(싱크)로 둔다 — 해석 영역 밖으로 빠지는 물과 + 같은 판정(미도달)이다. 화살표(방위)는 원래 방향 그대로 둔다. + """ + big = source.spec + if big == spec: + return source + if big.cell_m != spec.cell_m: + return None + row_shift = (big.y_max - spec.y_max) / spec.cell_m + col_shift = (spec.x_min - big.x_min) / spec.cell_m + row0, col0 = int(round(row_shift)), int(round(col_shift)) + if ( + abs(row_shift - row0) > 1e-6 + or abs(col_shift - col0) > 1e-6 + or row0 < 0 + or col0 < 0 + or row0 + spec.n_rows > big.n_rows + or col0 + spec.n_cols > big.n_cols + ): + return None + rows = slice(row0, row0 + spec.n_rows) + cols = slice(col0, col0 + spec.n_cols) + + def window(flat: np.ndarray) -> np.ndarray: + return flat.reshape(big.n_rows, big.n_cols)[rows, cols] + + target = window(source.receiver).astype(np.int64) + target_row = target // big.n_cols - row0 + target_col = target % big.n_cols - col0 + inside = ( + (target_row >= 0) + & (target_row < spec.n_rows) + & (target_col >= 0) + & (target_col < spec.n_cols) + ) + local = np.arange(spec.size, dtype=np.int64).reshape(spec.n_rows, spec.n_cols) + receiver = np.where(inside, target_row * spec.n_cols + target_col, local) + return ContourDescent( + spec=spec, + band_elevation=source.band_elevation[rows, cols].copy(), + valid=source.valid[rows, cols].copy(), + receiver=receiver.reshape(-1).astype(np.int32), + step_length=np.where(inside, window(source.step_length), 0.0) + .reshape(-1) + .astype(np.float32), + azimuth=window(source.azimuth).reshape(-1).copy(), + levels=source.levels, + ) + + def _pad_spec(spec: GridSpec, domain: np.ndarray, cells: int) -> tuple[GridSpec, np.ndarray]: """격자에 사방 여유를 두고 해석 영역 마스크를 그 안으로 옮겨 담는다.""" if cells <= 0: diff --git a/B04_PreProcess/B04_PreProcess_Engine_Watershed_Flow.py b/B04_PreProcess/B04_PreProcess_Engine_Watershed_Flow.py index 216cfaf65..f91655cc7 100644 --- a/B04_PreProcess/B04_PreProcess_Engine_Watershed_Flow.py +++ b/B04_PreProcess/B04_PreProcess_Engine_Watershed_Flow.py @@ -278,8 +278,8 @@ class FlowClassification: direction: np.ndarray # (R*C,) int16 — 32방위 코드(32=제자리, 33=무효) reaches_road: np.ndarray # (R*C,) bool — 물길을 따라가면 도로에 닿는가 analyzed: np.ndarray # (R*C,) bool — 실제로 판정한 셀 - outer_seeds: int # 최외곽에서 출발해 판정한 셀 수 - interior_seeds: int # 최외곽 추적에 안 걸려 따로 출발시킨 내부 셀 수 + outer_seeds: int # 최외곽 셀 수(기록용 · 세류 셀 뺌) + interior_seeds: int # 그 밖 판정 셀 수(기록용 · 세류 셀 뺌) burned: np.ndarray | None = None # (R*C,) bool — 세류망을 따라 흐름을 새긴 셀 @@ -321,6 +321,8 @@ def classify_flow( 다시 분석하지 않는다. ④ 최외곽 추적에 안 걸린 내부 셀을 그다음에 따로 출발시킨다. + 판정 값은 이 차례로 걷는 것과 칸마다 같다 — 계산은 한꺼번에 한다(`_reach_by_doubling`). + `azimuth`를 주면 그 32방위 코드를 그대로 화살표로 쓴다(등고선 하강 방향). 주지 않으면 지표면 기울기에서 뽑는다. **화살표는 실제 수신 셀과 같은 방향이어야 한다** — 어긋나면 화살표로 사슬을 따라가는 눈 검증이 성립하지 않는다. @@ -336,21 +338,13 @@ def classify_flow( direction = descent_azimuth(spec, terrain.elevation, terrain.valid, receiver, burned) else: direction = _azimuth_with_burn(spec, azimuth, receiver, burned) - reaches = np.zeros(spec.size, dtype=bool) - # 0=미방문, 1=경로에 올라 있음, 2=판정 완료 - state = np.zeros(spec.size, dtype=np.int8) - - # ⓪ 세류선과 겹치는 셀을 먼저 적색으로 못박는다. - reaches[stream_cells] = True - state[stream_cells] = 2 + reaches = _reach_by_doubling(receiver, valid, absorbing, stream_cells) + analyzed = valid | stream_cells stream_done = int(stream_cells.sum()) + # 기록용 숫자 — 한꺼번에 판정하므로 「출발」 구분이 없다. 최외곽 셀 수 · 나머지 셀 수만 남긴다. + outer_done = int((outermost_cells(terrain.valid).reshape(-1) & ~stream_cells).sum()) + interior_done = int((valid & ~stream_cells).sum()) - outer_done - outer = np.flatnonzero(outermost_cells(terrain.valid)) - remaining = np.flatnonzero(valid) - outer_done = _walk_from(outer, receiver, valid, absorbing, state, reaches) - interior_done = _walk_from(remaining, receiver, valid, absorbing, state, reaches) - - analyzed = state == 2 logger.info( "배수유역: 흐름 판정 %d셀 (세류 선확정 %d / 최외곽 출발 %d / 내부 보충 %d) — " "도로 도달 %d, 미도달 %d", @@ -391,52 +385,32 @@ def _azimuth_with_burn( return direction -def _walk_from( - starts: np.ndarray, +def _reach_by_doubling( receiver: np.ndarray, valid: np.ndarray, absorbing: np.ndarray, - state: np.ndarray, - reaches: np.ndarray, -) -> int: - """출발 셀에서 물길 사슬을 따라가며 판정하고, 사슬 전체에 같은 색을 적는다. + stream_cells: np.ndarray, +) -> np.ndarray: + """물길 사슬을 끝까지 따라간 종착 셀로 셀마다 도로 도달을 한꺼번에 판정한다. - `absorbing`은 도로 셀과 확정된 세류망 셀 — 여기 닿으면 사슬 전체가 도로 도달이다. - 새로 판정한 셀 수를 돌려준다. + 사슬을 한 칸씩 걷던 판정(최외곽 출발 → 내부 보충 · 이미 판정된 셀의 색 물려받기)과 + **칸마다 같은 값**이다 — 순환이 없으면 어느 셀에서 출발하든 같은 종착에 닿기 때문이다. + 종착 = 도로 · 세류 셀(적색) · 싱크 · 해석 영역 밖으로 나가는 셀(미도달). 세류 셀은 표고가 + 없어도 따라간다. 포인터 더블링(`trace_flow` 와 같은 방식)이라 파이썬 반복이 없다. """ - resolved = 0 - path: list[int] = [] - for start in starts.tolist(): - if state[start] == 2: - continue - path.clear() - node = start - while True: - if state[node] == 2: - verdict = bool(reaches[node]) # 이미 색이 정해진 셀 — 그 색을 물려받는다 - break - if state[node] == 1: # 방어: 채움·평탄해소 후에는 순환이 없어야 한다 - verdict = False - break - state[node] = 1 - path.append(node) - if absorbing[node]: - verdict = True # 도로 또는 세류망에 합류 — 여기서 하류로 빠진다 - break - following = int(receiver[node]) - if following == node: - verdict = False # 싱크에 갇힘 - break - if not valid[following] and state[following] != 2: - # 해석 영역 밖으로 빠짐. 단, 이미 색이 정해진 셀(세류 선확정 등)이면 따라간다. - verdict = False - break - node = following - for visited in path: - reaches[visited] = verdict - state[visited] = 2 - resolved += len(path) - return resolved + index = np.arange(receiver.size, dtype=np.int64) + following = receiver.astype(np.int64) + terminal = absorbing | (following == index) | ~(valid[following] | stream_cells[following]) + jump = np.where(terminal, index, following) + for _ in range(_MAX_DOUBLING_ROUNDS): + next_jump = jump[jump] + if np.array_equal(next_jump, jump): + break + jump = next_jump + # 순환에 갇힌 사슬은 종착에 못 닿는다 — 걷던 판정처럼 미도달. + reaches = absorbing[jump] & terminal[jump] & (valid | stream_cells) + reaches[stream_cells] = True + return reaches # ── 격자 확장 (별도 단계) ─────────────────────────────────────────────────── diff --git a/resources/tester/bench_342_watershed.py b/resources/tester/bench_342_watershed.py new file mode 100644 index 000000000..497903663 --- /dev/null +++ b/resources/tester/bench_342_watershed.py @@ -0,0 +1,94 @@ +"""342-3 · 347-1 배수유역 빠른 길 실측 — 검증_sub3 도엽 · 노선으로 preview_stages 를 돌려 +시간과 결과(적색 · 흐름 방향 · 도로 귀속 · 강도)를 tmp/342/<이름>.npz 에 남기고 기준과 대조한다. + + ./venv/Scripts/python.exe resources/tester/bench_342_watershed.py base # 기준 저장 + ./venv/Scripts/python.exe resources/tester/bench_342_watershed.py new base # 새로 돌려 대조 + +pytest 대상 아님(DB · 프로젝트 파일이 있어야 돎). 저장 안 함 — 프로젝트 파일은 읽기만 +(흐름 방향 저장본 drainage/descent_*.npz 는 347-1 부터 씀). +""" + +import asyncio +import json +import logging +import sys +import time +from pathlib import Path +from uuid import UUID + +import numpy as np + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) +PID = UUID("1f93a515-df0a-4975-a8ba-daaa7672b123") # 검증_sub3 +OUT_DIR = ROOT / "tmp" / "342" + + +async def _prepare(): + from config.config_db import close_db_pool, init_db_pool + + await init_db_pool() + try: + from B04_PreProcess.B04_PreProcess_Router_Watershed import _prepare as prepare + + started = time.perf_counter() + prepared = await prepare(PID) + return prepared, time.perf_counter() - started + finally: + await close_db_pool() + + +def main() -> None: + name = sys.argv[1] + base = sys.argv[2] if len(sys.argv) > 2 else None + OUT_DIR.mkdir(parents=True, exist_ok=True) + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(name)s:%(message)s", + handlers=[logging.FileHandler(OUT_DIR / f"{name}.log", "w", encoding="utf-8")], + ) + prepared, prepare_s = asyncio.run(_prepare()) + from B04_PreProcess.B04_PreProcess_Engine_Watershed_Analyze import preview_stages + + started = time.perf_counter() + pv = preview_stages(prepared["vertices"], prepared["contours"], prepared["streams"]) + preview_s = time.perf_counter() - started + spec = pv.spec + red = pv.flow.reaches_road & pv.flow.analyzed + result = { + "spec": np.array([spec.x_min, spec.y_max, spec.cell_m, spec.n_rows, spec.n_cols]), + "domain": pv.domain, + "red": red, + "direction": pv.flow.direction, + "road_slot": pv.routing.road_slot, + "strength": np.array(pv.strength_profile), + "pipes": np.array([p.chainage_m for p in pv.pipes]), + } + np.savez_compressed(OUT_DIR / f"{name}.npz", **result) + report = { + "prepare_s": round(prepare_s, 2), + "preview_s": round(preview_s, 2), + "total_s": round(prepare_s + preview_s, 2), + "rounds": pv.expand_rounds, + "grid": [spec.n_rows, spec.n_cols], + "red_cells": int(red.sum()), + } + if base: + ref = np.load(OUT_DIR / f"{base}.npz") + same_spec = bool(np.array_equal(ref["spec"], result["spec"])) + report["same_spec"] = same_spec + if same_spec: + for key in ("domain", "red", "direction", "road_slot"): + report[f"diff_{key}"] = int((ref[key] != result[key]).sum()) + else: + report["base_spec"] = ref["spec"].tolist() + report["new_spec"] = result["spec"].tolist() + report["red_cells_base"] = int(ref["red"].sum()) + report["same_strength"] = bool(np.array_equal(ref["strength"], result["strength"])) + report["same_pipes"] = bool(np.array_equal(ref["pipes"], result["pipes"])) + print(json.dumps(report, ensure_ascii=False)) + (OUT_DIR / f"{name}.json").write_text(json.dumps(report, ensure_ascii=False, indent=1), "utf-8") + + +if __name__ == "__main__": + main() diff --git a/resources/tester/test_342_3_watershed_fast.py b/resources/tester/test_342_3_watershed_fast.py new file mode 100644 index 000000000..4f649d687 --- /dev/null +++ b/resources/tester/test_342_3_watershed_fast.py @@ -0,0 +1,138 @@ +"""342-3 배수유역 빠른 길 — 도로 닿음 판정 한꺼번에 · 큰 방향장 잘라 씀. + +- 판정: 옛 한 칸씩 걷기(최외곽 출발 → 내부 보충 · 색 물려받기)와 칸마다 같음(적색 차이 0). +- 잘라 씀: 같은 격자면 그대로 · 셀 어긋남 · 범위 밖은 None · 안쪽은 제 격자로 지은 것과 같음. +실측(검증_sub3 · 적색 차이 0 · 60 → 약 15 s)은 bench_342_watershed.py. +""" + +import math + +import numpy as np + +from B04_PreProcess.B04_PreProcess_Engine_Watershed_Descent import build_contour_descent +from B04_PreProcess.B04_PreProcess_Engine_Watershed_Expand import _pad_spec, crop_descent +from B04_PreProcess.B04_PreProcess_Engine_Watershed_Flow import ( + _reach_by_doubling, + outermost_cells, +) +from B04_PreProcess.B04_PreProcess_Engine_Watershed_Grid import GridSpec + + +def _walk_reference(receiver, valid2d, absorbing, stream): + """옛 classify_flow 판정(한 칸씩 걷기) 그대로.""" + valid = valid2d.reshape(-1) + reaches = np.zeros(receiver.size, bool) + state = np.zeros(receiver.size, np.int8) + reaches[stream] = True + state[stream] = 2 + starts = list(np.flatnonzero(outermost_cells(valid2d))) + list(np.flatnonzero(valid)) + for start in starts: + if state[start] == 2: + continue + path, node = [], start + while True: + if state[node] == 2: + verdict = bool(reaches[node]) + break + if state[node] == 1: + verdict = False + break + state[node] = 1 + path.append(node) + if absorbing[node]: + verdict = True + break + following = int(receiver[node]) + if following == node: + verdict = False + break + if not valid[following] and state[following] != 2: + verdict = False + break + node = following + for visited in path: + reaches[visited] = verdict + state[visited] = 2 + return reaches + + +def _random_case(seed, rows=60, cols=70): + rng = np.random.default_rng(seed) + size = rows * cols + potential = rng.random(size) + index = np.arange(size) + r, c = index // cols, index % cols + receiver = index.copy() + best = potential.copy() + for dr in (-1, 0, 1): + for dc in (-1, 0, 1): + nr, nc = r + dr, c + dc + ok = (nr >= 0) & (nr < rows) & (nc >= 0) & (nc < cols) + n = np.where(ok, nr * cols + nc, index) + better = ok & (potential[n] < best) + best = np.where(better, potential[n], best) + receiver = np.where(better, n, receiver) + valid2d = rng.random((rows, cols)) > 0.15 + road = np.zeros(size, bool) + road[(r == rows // 2) & (c > 5) & (c < cols - 5)] = True + stream = (rng.random(size) > 0.97) & ~road # 표고 없는 세류 셀도 섞임 + # 세류 셀은 물길 다음 셀로 새김 — 순환 하나를 일부러 만든다(걷던 판정 = 미도달). + receiver = receiver.astype(np.int32) + receiver[stream] = np.roll(index, 1)[stream] + receiver[5 * cols + 5], receiver[5 * cols + 6] = 5 * cols + 6, 5 * cols + 5 + absorbing = (road & valid2d.reshape(-1)) | stream + return receiver, valid2d, absorbing, stream + + +def test_reach_matches_walk_cell_by_cell(): + for seed in range(8): + receiver, valid2d, absorbing, stream = _random_case(seed) + expected = _walk_reference(receiver, valid2d, absorbing, stream) + got = _reach_by_doubling(receiver, valid2d.reshape(-1), absorbing, stream) + assert int((got != expected).sum()) == 0, seed + assert expected.sum() > 0 and (~expected).sum() > 0 + + +def _circle_contours(cx, cy, radii_levels): + features = [] + for radius, level in radii_levels: + coords = [ + [cx + radius * math.cos(t), cy + radius * math.sin(t)] + for t in np.linspace(0, 2 * math.pi, 181) + ] + features.append( + { + "geometry": {"type": "LineString", "coordinates": coords}, + "properties": {"elevation": level}, + } + ) + return features + + +def test_crop_descent_matches_own_grid_inside(): + contours = _circle_contours(100.0, -100.0, [(r, 200.0 - r) for r in range(10, 140, 10)]) + spec = GridSpec(x_min=40.0, y_max=-40.0, cell_m=1.0, n_rows=120, n_cols=120) + big_spec, _ = _pad_spec(spec, np.ones((120, 120), bool), 30) + big = build_contour_descent(big_spec, contours) + own = build_contour_descent(spec, contours) + cut = crop_descent(big, spec) + assert cut is not None and cut.spec == spec + inner = np.zeros((120, 120), bool) + inner[20:100, 20:100] = True + flat = inner.reshape(-1) + assert np.array_equal(cut.valid[inner], own.valid[inner]) + assert np.array_equal(cut.receiver[flat], own.receiver[flat]) + assert np.array_equal(cut.azimuth[flat], own.azimuth[flat]) + # 잘린 몫 밖을 가리키는 셀은 없다(제자리로 둠). + assert cut.receiver.min() >= 0 and cut.receiver.max() < spec.size + assert crop_descent(big, big_spec) is big + + +def test_crop_descent_refuses_misaligned_or_outside(): + contours = _circle_contours(100.0, -100.0, [(r, 200.0 - r) for r in range(10, 140, 10)]) + spec = GridSpec(x_min=40.0, y_max=-40.0, cell_m=1.0, n_rows=50, n_cols=50) + big = build_contour_descent(spec, contours) + shifted = GridSpec(x_min=40.5, y_max=-40.0, cell_m=1.0, n_rows=20, n_cols=20) + outside = GridSpec(x_min=30.0, y_max=-40.0, cell_m=1.0, n_rows=20, n_cols=20) + assert crop_descent(big, shifted) is None + assert crop_descent(big, outside) is None From a22b72fb105af7ca42bf106a346bfc6b3f83a25b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 16 Oct 2026 11:01:38 +0900 Subject: [PATCH 03/18] =?UTF-8?q?fix(B05=20=EC=A7=80=EB=B0=98=EA=B3=A0):?= =?UTF-8?q?=20342-4=20=EC=A7=80=EB=B0=98=EA=B3=A0=20=EA=B0=92=20=EA=B0=88?= =?UTF-8?q?=EB=A6=BC=20=EB=91=98=20=EB=A7=9E=EC=B6=A4=20=E2=80=94=20?= =?UTF-8?q?=EB=85=B8=EC=84=A0=20=EA=BC=AD=EC=A7=93=EC=A0=90=20z=20=C2=B7?= =?UTF-8?q?=20=EC=A2=85=EB=8B=A8=20=EB=AF=B8=EB=A6=AC=EB=B3=B4=EA=B8=B0=20?= =?UTF-8?q?=3D=20=EA=B3=B5=EC=9A=A9=201=20m=20=EC=8C=8D=EC=84=A0=ED=98=95?= =?UTF-8?q?=20=ED=91=9C=EB=B3=B8=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 노선 꼭짓점 z: 한 칸 값 · 빈칸 0.0 대신 build_surface_sampler · 빈칸 · 밖은 이웃 정점 누가거리 보간 · 모두 밖이면 멈춤 - 종단 미리보기 · [확인] 균형 목표: 2 m 비용면 대신 1 m 표본기(추천 점수 비용면은 그대로) - 시험 test_342_4_ground_unify.py Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013QyNXY2TUKXCFMwAca2APG --- B05_Profile/B05_Profile_Engine_AsPlanned.py | 42 ++++++------ ...B05_Profile_Engine_RouteInitial_Profile.py | 30 ++++++--- .../B05_Profile_Router_RouteInitial.py | 12 +++- resources/tester/test_342_4_ground_unify.py | 66 +++++++++++++++++++ 4 files changed, 116 insertions(+), 34 deletions(-) create mode 100644 resources/tester/test_342_4_ground_unify.py diff --git a/B05_Profile/B05_Profile_Engine_AsPlanned.py b/B05_Profile/B05_Profile_Engine_AsPlanned.py index d176d08cb..2f54aa5dd 100644 --- a/B05_Profile/B05_Profile_Engine_AsPlanned.py +++ b/B05_Profile/B05_Profile_Engine_AsPlanned.py @@ -32,28 +32,26 @@ logger = logging.getLogger(__name__) _MODELS_SUBDIR = "B04_PreProcess/models" -def _elevation_lookup( - project_root: Path, filter_key: str, method: str, smooth: bool -) -> tuple[np.ndarray, np.ndarray, np.ndarray]: - """지표면 격자(x, y, z) — 노선 정점의 표고를 뜨는 데 쓴다.""" - from B05_Profile.B05_Profile_Engine_Solver import _load_dtm_grid, _sample_surface_on_grid +def _vertex_z( + project_root: Path, + filter_key: str, + method: str, + smooth: bool, + vertices: list[tuple[float, float]], +) -> list[float]: + """노선 정점 표고 — 측점 생성과 같은 공용 표본기(1 m 쌍선형 · 네 칸 유효). - models_dir = Path(project_root) / _MODELS_SUBDIR - x_coords, y_coords, dtm_z, _valid = _load_dtm_grid(models_dir, filter_key, smooth) - surface_z = _sample_surface_on_grid( - models_dir, filter_key, method, smooth, x_coords, y_coords, dtm_z - ) - return x_coords, y_coords, surface_z + 빈칸 · 격자 밖 정점은 유효한 이웃 정점 값을 누가거리로 보간(0.0 을 흘리지 않음) · + 유효한 정점이 없으면 ValueError.""" + from common_util.common_util_surface_sampler import build_surface_sampler - -def _z_at( - x_coords: np.ndarray, y_coords: np.ndarray, z_grid: np.ndarray, x: float, y: float -) -> float: - """격자에서 가장 가까운 칸의 표고. 격자 밖이면 가장자리 값.""" - col = int(np.clip(np.searchsorted(x_coords, x), 0, len(x_coords) - 1)) - row = int(np.clip(np.searchsorted(y_coords, y), 0, len(y_coords) - 1)) - value = float(z_grid[row, col]) - return value if math.isfinite(value) else 0.0 + sampler = build_surface_sampler(Path(project_root) / _MODELS_SUBDIR, filter_key, method, smooth) + xy = np.asarray(vertices, dtype=np.float64) + z, valid = sampler.sample_xy(xy) + if not valid.any(): + raise ValueError("계획노선 정점이 모두 지표면 밖입니다.") + chain = np.concatenate([[0.0], np.cumsum(np.hypot(*np.diff(xy, axis=0).T))]) + return np.interp(chain, chain[valid], z[valid]).tolist() def solve_as_planned( @@ -67,8 +65,8 @@ def solve_as_planned( """계획노선 정점을 그대로 노선으로 삼는다. 반환 꼴은 솔버와 같다.""" if len(vertices) < 2: raise ValueError("계획노선 정점이 2개 미만입니다.") - x_coords, y_coords, z_grid = _elevation_lookup(project_root, filter_key, method, smooth) - polyline = [[float(x), float(y), _z_at(x_coords, y_coords, z_grid, x, y)] for x, y in vertices] + heights = _vertex_z(project_root, filter_key, method, smooth, vertices) + polyline = [[float(x), float(y), z] for (x, y), z in zip(vertices, heights)] max_uphill = float(options.get("max_uphill_grade") or 0.26) max_downhill = float(options.get("max_downhill_grade") or 0.26) diff --git a/B05_Profile/B05_Profile_Engine_RouteInitial_Profile.py b/B05_Profile/B05_Profile_Engine_RouteInitial_Profile.py index de8e9fc8f..095e63c8b 100644 --- a/B05_Profile/B05_Profile_Engine_RouteInitial_Profile.py +++ b/B05_Profile/B05_Profile_Engine_RouteInitial_Profile.py @@ -45,6 +45,7 @@ from config.config_system_design import ( if TYPE_CHECKING: from B05_Profile.B05_Profile_Engine_RouteInitial_Search import Terrain + from common_util.common_util_surface_sampler import SurfaceElevationSampler STATION_M = 20.0 # 측점 간격 — 추천 비교값(`..._Metrics.STATION_M`)과 같음 TAIL_MERGE_M = 5.0 # 종점과 이보다 가까운 정규 측점은 뺌(짧은 끝 구간의 기울기 튐 방지) @@ -64,9 +65,25 @@ PIPE_SNAP_M = 5.0 # 관 자리가 노선에서 이 안이면 그 노선의 관 GRADE_EPS = 1e-6 +def _sample( + terrain: Terrain | SurfaceElevationSampler, xy: np.ndarray +) -> tuple[np.ndarray, np.ndarray]: + """(N, 2) 점의 (표고, 유효) — 표본기면 공용 1 m 쌍선형(종단 미리보기 · [확인]) · + `Terrain` 이면 추천 비용면(2 m · 빈칸 평균 표고 · 유효는 가까운 칸 — 추천 점수 전용).""" + if hasattr(terrain, "sample_xy"): + return terrain.sample_xy(xy) + lookup = xy[:, ::-1] + grid = (terrain.y, terrain.x) + z = RegularGridInterpolator(grid, terrain.z, bounds_error=False, fill_value=np.nan)(lookup) + valid = RegularGridInterpolator( + grid, terrain.valid.astype(np.float64), method="nearest", bounds_error=False, fill_value=0 + )(lookup) + return z, valid > 0.5 + + def route_sections( vertices: list[tuple[float, float]], - terrain: Terrain, + terrain: Terrain | SurfaceElevationSampler, design_curves: list[dict[str, Any]] | None = None, ) -> tuple[np.ndarray, np.ndarray, list[list[dict[str, Any]]], list[str], list[dict[str, Any]]]: """측점 누가거리 · 중심 지반고 · 측점별 지반 횡단 샘플 · 단면유형(상단측 절토) · 곡선부 확폭. @@ -88,14 +105,9 @@ def route_sections( center = np.column_stack([np.interp(at, chain, xy[:, 0]), np.interp(at, chain, xy[:, 1])]) offsets = np.arange(-CROSS_WIDE_M, CROSS_WIDE_M + 1e-9, CROSS_STEP_M) points = center[:, None, :] + left[:, None, :] * offsets[None, :, None] - lookup = points.reshape(-1, 2)[:, ::-1] - grid = (terrain.y, terrain.x) - z = RegularGridInterpolator(grid, terrain.z, bounds_error=False, fill_value=np.nan)(lookup) - valid = RegularGridInterpolator( - grid, terrain.valid.astype(np.float64), method="nearest", bounds_error=False, fill_value=0 - )(lookup) + z, valid = _sample(terrain, points.reshape(-1, 2)) z = z.reshape(len(at), len(offsets)) - ok = (valid.reshape(z.shape) > 0.5) & np.isfinite(z) + ok = valid.reshape(z.shape) & np.isfinite(z) middle = int(np.argmin(np.abs(offsets))) ground = np.where(ok[:, middle], z[:, middle], np.nan) if np.isnan(ground).all(): @@ -405,7 +417,7 @@ def balanced_profile( def route_profile( vertices: list[tuple[float, float]], - terrain: Terrain, + terrain: Terrain | SurfaceElevationSampler, *, max_grade: float, design_speed: int, diff --git a/B05_Profile/B05_Profile_Router_RouteInitial.py b/B05_Profile/B05_Profile_Router_RouteInitial.py index 5bb6f494e..53091ec56 100644 --- a/B05_Profile/B05_Profile_Router_RouteInitial.py +++ b/B05_Profile/B05_Profile_Router_RouteInitial.py @@ -284,12 +284,13 @@ async def balanced_profile_of( `terrain` 은 편집 창 종단 띠만 바꿈(PLAN 133-1) — None = 지금 설계 지형([확인] 체인). """ from B05_Profile.B05_Profile_Engine_Grade import legal_grade_limit_pct - from B05_Profile.B05_Profile_Engine_RouteInitial import load_terrain_and_streams from B05_Profile.B05_Profile_Engine_RouteInitial_Profile import route_profile + from B05_Profile.B05_Profile_Engine_Solver import _MODELS_SUBDIR from common_util.common_util_surface_confirmation import ( analysis_terrain_params, get_surface_confirmation_params, ) + from common_util.common_util_surface_sampler import build_surface_sampler grade_class, design_speed, terrain_type = await replan._road_settings(project_id) max_grade = legal_grade_limit_pct(grade_class, terrain_type, False, design_speed) / 100.0 @@ -300,8 +301,13 @@ async def balanced_profile_of( selection = await get_surface_confirmation_params(connection, str(project_id)) selection = analysis_terrain_params(selection, terrain) try: - surface, _streams = await asyncio.to_thread( - load_terrain_and_streams, project_root, selection + # 측점 생성과 같은 1 m 쌍선형 표본(추천 점수의 2 m 비용면 아님) — 띠 값 = [확인] 뒤 값 + surface = await asyncio.to_thread( + build_surface_sampler, + project_root / _MODELS_SUBDIR, + str(selection.get("source_filter")), + str(selection.get("method") or "dtm"), + bool(selection.get("smooth")), ) profile = await asyncio.to_thread( functools.partial( diff --git a/resources/tester/test_342_4_ground_unify.py b/resources/tester/test_342_4_ground_unify.py new file mode 100644 index 000000000..49cce81e0 --- /dev/null +++ b/resources/tester/test_342_4_ground_unify.py @@ -0,0 +1,66 @@ +"""342-4 지반고 값 갈림 맞춤 — 노선 꼭짓점 z · 종단 미리보기가 공용 1 m 표본기와 같은 값. + + ./venv/Scripts/python.exe -m pytest resources/tester/test_342_4_ground_unify.py -q +""" + +from pathlib import Path + +import numpy as np +import pytest + +from B05_Profile.B05_Profile_Engine_AsPlanned import solve_as_planned +from B05_Profile.B05_Profile_Engine_RouteInitial_Profile import route_sections +from common_util.common_util_surface_sampler import DtmGridSampler, build_surface_sampler + + +def _grid(tmp_path: Path) -> Path: + """1 m 남짓 고르지 않은 축 · 기울어진 면 · 가운데 빈칸 한 덩이.""" + x = np.cumsum(np.r_[0.0, np.full(120, 1.0) + 0.03 * np.sin(np.arange(120))]) + y = np.cumsum(np.r_[0.0, np.full(80, 1.0) + 0.02 * np.cos(np.arange(80))]) + xx, yy = np.meshgrid(x, y) + z = 100.0 + 0.1 * xx + 0.05 * yy + np.sin(xx / 7.0) + valid = np.ones(z.shape, dtype=bool) + valid[38:42, 58:62] = False + models = tmp_path / "B04_PreProcess" / "models" + models.mkdir(parents=True) + np.savez(models / "dtm_classification.npz", x=x, y=y, z=z, valid_mask=valid) + return models + + +def test_꼭짓점_z_는_공용_표본기_값(tmp_path: Path) -> None: + models = _grid(tmp_path) + vertices = [(5.3, 10.7), (40.2, 30.9), (60.0, 40.0), (100.4, 60.2)] # 셋째 = 빈칸 + result = solve_as_planned(tmp_path, "classification", False, vertices, {}) + z = [p[2] for p in result["polyline"]] + expect, valid = build_surface_sampler(models, "classification", "dtm", False).sample_xy( + np.asarray(vertices) + ) + assert valid.tolist() == [True, True, False, True] + assert np.allclose([z[0], z[1], z[3]], expect[[0, 1, 3]], atol=1e-9) + # 빈칸은 0.0 이 아니라 이웃 정점 사이 누가거리 보간 + chain = np.concatenate([[0.0], np.cumsum(np.hypot(*np.diff(vertices, axis=0).T))]) + assert z[2] == pytest.approx(np.interp(chain[2], chain[[1, 3]], expect[[1, 3]])) + assert min(z) > 50.0 + + +def test_꼭짓점_모두_밖이면_멈춤(tmp_path: Path) -> None: + _grid(tmp_path) + with pytest.raises(ValueError): + solve_as_planned(tmp_path, "classification", False, [(-50.0, -50.0), (-20.0, -40.0)], {}) + + +def test_종단_미리보기_지반은_1m_쌍선형(tmp_path: Path) -> None: + models = _grid(tmp_path) + sampler = DtmGridSampler.from_npz(models / "dtm_classification.npz") + vertices = [(10.0, 40.0), (110.0, 40.0)] # 가운데 빈칸을 지남 + at, ground, sections, _modes, _curves = route_sections(vertices, sampler) + for chain, row in zip(at, sections): + cx = 10.0 + chain + offsets = np.array([s["offset_m"] for s in row]) + z, valid = sampler.sample_xy(np.column_stack([np.full(offsets.size, cx), 40.0 + offsets])) + assert [s["valid"] for s in row] == valid.tolist() + got = np.array([np.nan if s["elevation_m"] is None else s["elevation_m"] for s in row]) + assert np.allclose(got[valid], z[valid], atol=1e-9) + assert all(s["elevation_m"] is None for s, ok in zip(row, valid) if not ok) + # 중심 빈칸 측점의 지반고는 이웃 측점 보간 — 0.0 · 평균 표고로 안 메움 + assert np.isfinite(ground).all() and ground.min() > 50.0 From 8eb5ba54506514432e7d06ad4f21dfd56b2a023b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 16 Oct 2026 11:02:18 +0900 Subject: [PATCH 04/18] =?UTF-8?q?refactor(=EC=A2=85=EB=8B=A8=EB=B3=B4?= =?UTF-8?q?=EA=B8=B0):=20345-1=20design=5Fview=5Fprofile=20=EC=AA=BC?= =?UTF-8?q?=EA=B0=AC=20=E2=80=94=20=EB=85=B8=EC=84=A0=20=C2=B7=20=EC=A2=85?= =?UTF-8?q?=EB=8B=A8=EB=A9=B4=20=EA=B7=B8=EB=A6=AC=EA=B8=B0(=5Fsections)?= =?UTF-8?q?=20=C2=B7=203D=20=EB=AA=AB(=5F3d)=20=C2=B7=20=EC=83=81=ED=83=9C?= =?UTF-8?q?=20=ED=95=9C=20=EB=B2=8C(ProfileViewState)=20=C2=B7=20=EB=8F=99?= =?UTF-8?q?=EC=9E=91=20=EA=B7=B8=EB=8C=80=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - design_view_profile.ts 700 → 517줄 · _sections 179 · _3d 114 - 시험: 관련 51벌 370 통과(이전과 같은 3 실패 = 205_1 · 더하기 글 · 손 안 댄 곳) · typecheck 통과 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VmC34fsxxbweEG3RUjs3Mn --- A00_Common/design/design_view_profile.ts | 309 ++++-------------- A00_Common/design/design_view_profile_3d.ts | 114 +++++++ .../design/design_view_profile_sections.ts | 179 ++++++++++ 3 files changed, 355 insertions(+), 247 deletions(-) create mode 100644 A00_Common/design/design_view_profile_3d.ts create mode 100644 A00_Common/design/design_view_profile_sections.ts diff --git a/A00_Common/design/design_view_profile.ts b/A00_Common/design/design_view_profile.ts index 3bb3c6acb..232e1817d 100644 --- a/A00_Common/design/design_view_profile.ts +++ b/A00_Common/design/design_view_profile.ts @@ -5,21 +5,15 @@ * * 여기서는 종단 보기 몫만 건다 — 3D · 좌측 종단 칸 재료 · 구조물 다리 · 종단도 알림 · 저장 몫. * (옛 `B05_Profile_UI_Page.ts` 의 본체다. 버튼 동작은 `_Page_Actions`, 최신 노선 캐시는 - * `_Page_Latest`, 3D 측점 바는 `_Page_Stations`, 숨김 · 보임은 `_Page_Life` 가 맡는다.) + * `_Page_Latest`, 3D 측점 바는 `_Page_Stations`, 숨김 · 보임은 `_Page_Life` 가 맡는다. + * 노선 · 종단면 그리기는 `design_view_profile_sections`, 3D 몫은 `design_view_profile_3d`.) * ========================================================================== */ import { showToast } from "@ui/ui_template_elements"; import { purgeOtherProjects } from "../b_asset_cache"; import { createProgressCircle } from "@ui/ui_template_progress"; -import { - fetchConfirmedSurface, - type SurfaceConfirmedResponse, -} from "../../B04_PreProcess/B04_PreProcess_Api_Fetch"; -import { - clearRouteLatestCache, - updateContourInterval, - type RouteLatestResponse, -} from "../../B05_Profile/B05_Profile_Api_Fetch"; +import { fetchConfirmedSurface } from "../../B04_PreProcess/B04_PreProcess_Api_Fetch"; +import { clearRouteLatestCache } from "../../B05_Profile/B05_Profile_Api_Fetch"; import { createRoutePanel } from "../../B05_Profile/B05_Profile_UI_Panel"; import { createRouteProfilePanel } from "../../B05_Profile/B05_Profile_UI_Profile_Panel"; import { leaveForDashboard } from "../b_missing_data_guard"; @@ -51,28 +45,18 @@ import { rememberRockBoundaryDefault, rememberStandardDefaults, } from "../../B06_Section/B06_Section_UI_Standard_Panel"; +import { fetchSectionContext } from "../../B06_Section/B06_Section_Api_Fetch"; import { - fetchSectionContext, - type SectionDetailResponse, -} from "../../B06_Section/B06_Section_Api_Fetch"; -import { loadSectionDetail } from "../../B06_Section/B06_Section_Section_Store"; -import { - migrateLegacyStations, onStaleStructureTypes, structureAnchorM, } from "../../B05_Profile/B05_Profile_Api_Structures"; import { pipeToFacilityItem } from "../../B05_Profile/B05_Profile_UI_Structures_Panel_Types"; -import { - loadCorridorIfFresh, - refreshCorridor, - saveCorridorIfDirty, -} from "../../B05_Profile/B05_Profile_UI_Corridor"; +import { saveCorridorIfDirty } from "../../B05_Profile/B05_Profile_UI_Corridor"; import { profileSaveParts, type PageActionContext, } from "../../B05_Profile/B05_Profile_UI_Page_Actions"; -import { createLatestSource, panelValuesOf } from "../../B05_Profile/B05_Profile_UI_Page_Latest"; -import { stationLinesOf } from "../../B05_Profile/B05_Profile_UI_Page_Stations"; +import { createLatestSource } from "../../B05_Profile/B05_Profile_UI_Page_Latest"; import { setPipeCrossOpener, setPipeSectionsSource } from "./design_structure_pipe_calc"; import type { RouteProfilePanelCallbacks } from "../../B05_Profile/B05_Profile_UI_Profile_Panel_Types"; import "../../B05_Profile/B05_Profile_UI_Style.css"; @@ -85,14 +69,11 @@ import { DEFAULT_ROAD_WIDTHS, fetchRoadWidths, loadUphillOverrides, - restorePoints, saveUphillOverrides, - toBounds, toGradeClass, } from "../../B05_Profile/B05_Profile_UI_Page_Helpers"; - -/** 구 구조물 측점 이관을 이미 시도한 노선 — 탭 수명 동안 유지한다. */ -const migratedRoutes = new Set(); +import { createProfileSections, type ProfileViewState } from "./design_view_profile_sections"; +import { createProfile3d } from "./design_view_profile_3d"; type PipeReport = Parameters>[0]; @@ -133,7 +114,7 @@ export async function mountProfileView( * 관도 없는 자리면 그 측점선 · 3D 띠(횡단에서 고른 일반 측점 · 188-1) — 구조물 고름은 선 없음(189-1). */ const selectGraphAt = (id: string | null, chainageM: number | null): void => { const structurePick = !!readSelection(activeProjectId)?.structureId; - const sections = structurePick ? [] : (currentSectionDetail?.cross_sections ?? []); + const sections = structurePick ? [] : (state.detail?.cross_sections ?? []); const target = graphTargetAt(id, chainageM, lastPipes ?? [], sections); if ("pipeChainageM" in target) return profilePanel.setSelectedStructure(pipeIdAt(target.pipeChainageM, activeProjectId)); @@ -199,19 +180,19 @@ export async function mountProfileView( }, ); - /** 확정 지표면 — 모델 id 와 3D 로딩에 필요한 범위만 들고 있는다(2026-09-06 호출 정리). */ - let confirmedSurface: SurfaceConfirmedResponse | null = null; - let latest: RouteLatestResponse | null = null; - let roadWidths = DEFAULT_ROAD_WIDTHS; - let currentSectionDetail: SectionDetailResponse | null = null; + const state: ProfileViewState = { + confirmedSurface: null, + latest: null, + roadWidths: DEFAULT_ROAD_WIDTHS, + detail: null, + corridorVisible: true, + routeReady: false, + restoring: true, + }; // 배관 관 길이 · 흙막이 개수(54-1) — 종단 보기도 같은 횡단 자료로(B05 = B06) - setPipeSectionsSource(() => currentSectionDetail?.cross_sections ?? null); + setPipeSectionsSource(() => state.detail?.cross_sections ?? null); // 좌측 단 수를 바꾸면 횡단 보기를 숨긴 채 세워 같은 단 수 제어로 씀(54-27) setPipeCrossOpener(() => shared.ensureView("cross")); - /** [예상형상] 표시 상태 — 측점 바를 계획고 위로 올릴지 판단한다(패널 기본 ON). */ - let corridorVisible = true; - let routeReady = false; - let restoring = true; // 선택 동기화 재진입 가드(3D↔그래프↔사이드바 상호 갱신의 무한 재귀 차단). let selectionSyncing = false; @@ -239,8 +220,7 @@ export async function mountProfileView( markStation: (chainageM) => profilePanel.drainage.markStation(chainageM), selectStructure3D: (chainageM) => viewer.structurePick.selectAtChainage(chainageM), regularChainage: (stationId) => - currentSectionDetail?.cross_sections.find((entry) => entry.station_id === stationId) - ?.chainage_m, + state.detail?.cross_sections.find((entry) => entry.station_id === stationId)?.chainage_m, remember: (chainageM) => rememberPick(chainageM, null), }); @@ -253,7 +233,7 @@ export async function mountProfileView( let oldSnapped = false; /** 옛 소수 측점(관 · 구조물)을 정수 m 로 — 관 · 구조물 · 계획고 편집이 다 실린 뒤 한 번(272-4). */ function snapOld(): void { - if (oldSnapped || restoring || !lastPipes) return; + if (oldSnapped || state.restoring || !lastPipes) return; oldSnapped = true; panel.structures.snapOld(); } @@ -269,6 +249,24 @@ export async function mountProfileView( const latestSource = createLatestSource(activeProjectId); const loadLatest = latestSource.load; + const sections = createProfileSections({ + projectId: activeProjectId, + state, + viewer, + panel: () => panel, + profilePanel: () => profilePanel, + bridge: () => bridge, + }); + const { restorePanel, renderLatest, restoreSections } = sections; + const view3d = createProfile3d({ + projectId: activeProjectId, + state, + viewer, + host, + latestSource, + profilePanel: () => profilePanel, + sections: () => sections, + }); const panel = createRoutePanel( { @@ -280,44 +278,12 @@ export async function mountProfileView( // 측점 눈금 간격은 좌측 패널이 쥔 값을 그대로 넘긴다 — 모달이 따로 굳히지 않는다. stationIntervalM: panel.values().stationInterval ?? undefined, // 바탕 등고선 — 확정 지표면이 있으면 모달이 LAS 등고선을 쓴다(계획서 0-9 ⑥). - surfaceModelId: confirmedSurface?.model_id ?? null, - contourIntervalM: latest?.surface_params.contour_interval_m, - smooth: latest?.surface_params.smooth, + surfaceModelId: state.confirmedSurface?.model_id ?? null, + contourIntervalM: state.latest?.surface_params.contour_interval_m, + smooth: state.latest?.surface_params.smooth, }), - onContourApply: (interval) => applyContours(interval), - onSurfaceVisible: viewer.setSurfaceVisible, - onCorridorVisible: (visible) => { - corridorVisible = visible; - viewer.setCorridorVisible(visible); - // 측점 바 기준면이 바뀐다(예상형상 위 ↔ 원지반 위) — 다시 그린다. - if (currentSectionDetail) renderStationLines(currentSectionDetail); - }, - onContoursVisible: viewer.setContoursVisible, - onAxesVisible: viewer.setAxesVisible, - onStationLinesVisible: viewer.setStationLinesVisible, - onStationLabelsVisible: viewer.setStationLabelsVisible, - onSurfaceGrayscale: viewer.setSurfaceGrayscale, - onView: viewer.setView, - onResetView: () => viewer.setView("top"), - onProjection: viewer.setProjection, - // [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); - // 만든 것을 **바로 올려 둔다**(2026-09-06) — 다음 진입이 네트워크 없이 선다. - void saveCorridorIfDirty(activeProjectId, latest.route.id); - }, + onContourApply: (interval) => view3d.applyContours(interval), + ...view3d.viewCallbacks, onMovePoint: viewer.beginMoveSelected, onDeletePoint: viewer.markers.deleteSelected, onRadiusChange: (radius) => viewer.markers.updateSelected({ radius_m: radius }), @@ -362,11 +328,11 @@ export async function mountProfileView( /** 입력·마커 변경 시 측점 라인만 다시 그린다. 지형 구분·등급·기울기 세부 입력은 종단 그래프의 * 위반 판정 기준까지 **즉시** 갈아 끼운다(2026-08-19 사용자 지시 6). */ function markStale(): void { - if (restoring || !routeReady) return; + if (state.restoring || !state.routeReady) return; profilePanel.setGradeLimit(panel.gradeLimitPct()); // 횡단배수 최소고 강제 체크는 재계산 없이 편집 가드에만 즉시 반영된다(2026-09-01). profilePanel.setEnforceMinCover(panel.values().enforcePipeClearance); - if (currentSectionDetail) renderStationLines(currentSectionDetail); + sections.redrawStationLines(); } viewer.markers.onChange(markStale); @@ -377,31 +343,13 @@ export async function mountProfileView( }); viewer.root.append(panel.viewControls); - function restorePanel(next: RouteLatestResponse): void { - panel.restore(panelValuesOf(next)); - viewer.markers.setPoints(restorePoints(next)); - } - - function renderStationLines(detail: SectionDetailResponse): void { - const lines = stationLinesOf({ - detail, - irregular: bridge.irregularStations(), - routePoints: latest?.route_points ?? [], - liveSamples: profilePanel.alignmentSamples(), - corridorVisible, - }); - viewer.renderStationLines(lines, roadWidths[panel.values().gradeClass] / 2); - } - /** 구조물·계곡 통과 시설을 사이드 목록·그래프·3D에 맞추는 다리(정본 저장까지 맡는다). */ const bridge = createStructuresBridge({ projectId: activeProjectId, panel: () => panel, profilePanel: () => profilePanel, - renderStationLines: () => { - if (currentSectionDetail) renderStationLines(currentSectionDetail); - }, - isRestoring: () => restoring, + renderStationLines: () => sections.redrawStationLines(), + isRestoring: () => state.restoring, }); // 종단도 · 3D 강조 = 고름 한 칸에서 파생 · 이 보기가 보일 때만(횡단 보기는 제 규칙 · 233-1 · 233-2) const profilePick = bindProfilePick( @@ -411,129 +359,18 @@ export async function mountProfileView( { irregular: () => bridge.irregularStations(), pipes: () => lastPipes ?? [], - sections: () => currentSectionDetail?.cross_sections ?? [], + sections: () => state.detail?.cross_sections ?? [], }, ); // 3D 노선 우클릭 → 구조물 배치 메뉴(그래프·배수유역도와 동일, 2026-08-19). - bridge.mountViewerMenu(viewer, () => latest?.route_points ?? []); - - function renderSections(detail: SectionDetailResponse, routeId?: number): void { - currentSectionDetail = detail; - profilePanel.render(detail, panel.values().stationInterval ?? undefined, routeId); - // 저장된 계획선의 정책은 만들 당시 기준이라 지금 설정(임도 종류·설계속도·지형)과 - // 다를 수 있다 — 그릴 때마다 현재 기준으로 맞춘다(2026-08-19). - profilePanel.setGradeLimit(panel.gradeLimitPct()); - profilePanel.setEnforceMinCover(panel.values().enforcePipeClearance); - profilePanel.setStationDisplay(panel.stationDisplayOffset()); - profilePanel.setIrregularStations(bridge.irregularStations()); - renderStationLines(detail); - // 예상형상 코리도 — 저장본이 지금 정본과 맞을 때만 올린다. 낡았으면 [3D 업데이트] 대기 - // 표시만 켠다(2026-09-06 실측: 받아놓고 버리는 데다 다시 만드느라 전환이 16.7초까지 갔다). - const routePoints = latest?.route_points ?? []; - if (routePoints.length > 1) { - void loadCorridorIfFresh( - viewer, - activeProjectId, - routeId ?? latest?.route?.id, - detail, - routePoints, - profilePanel.alignmentSamples() ?? undefined, - ).then((fresh) => panel.setCorridorPending(!fresh)); - } - } - - async function restoreSections(routeId: number): Promise { - let detail: SectionDetailResponse; - profilePanel.setLoading("종단면 자료를 불러오는 중…"); - try { - // 공유 캐시 — 횡단 보기가 이미 받아 뒀으면 같은 객체를 재사용해 두 보기가 같은 값을 본다. - detail = await loadSectionDetail(activeProjectId, routeId); - } catch { - // 종횡단 데이터 자체가 없는 경우(생성 실패·최초 진입)는 빈 안내로 둔다. - currentSectionDetail = null; - profilePanel.setLoading(null); - profilePanel.clear(); - viewer.renderStationLines([], 0); - return; - } - profilePanel.setLoading(null); - try { - renderSections(detail, routeId); - // 구 확정분에 남은 비정규 측점(자유 텍스트)을 구조물 정본으로 1회 이관한다 — 멱등이지만 - // 탭 수명 동안 노선마다 한 번만 시도한다(2026-08-17 · 09-06 실측). - const legacy = detail.longitudinal.stations - .filter((station) => station.kind === "irregular") - .map((station) => ({ - chainage_m: station.chainage_m, - structure: station.structure ?? "", - })); - const migrateKey = `${activeProjectId}:${routeId}`; - if (legacy.length && !migratedRoutes.has(migrateKey)) { - migratedRoutes.add(migrateKey); - void migrateLegacyStations(activeProjectId, legacy) - .then((result) => { - if (result.migrated > 0) { - showToast(`구 구조물 측점 ${result.migrated}건을 구조물 목록으로 옮겼습니다.`); - return bridge.refreshFromServer().then(() => undefined); - } - return undefined; - }) - .catch(() => { - /* 이관 실패는 치명적이지 않다 — 다음 진입에서 다시 시도된다. */ - }); - } - } catch (error) { - // 렌더 실패를 "데이터 없음"으로 감추면 원인을 알 수 없게 된다 — 실패 사실을 드러낸다. - console.error("B05 종단면도 렌더 실패", error); - showToast( - error instanceof Error - ? `종단면도 표시 실패: ${error.message}` - : "종단면도 표시에 실패했습니다.", - "error", - ); - } - } - - function renderLatest(next: RouteLatestResponse): void { - latest = next; - routeReady = Boolean(next.route && next.route_points.length > 1); - profilePanel.setRoutePolyline(next.route_points ?? []); - if (next.route) { - const stored = next.route.algorithm_params ?? {}; - viewer.markers.renderRoute( - next.route_points, - (stored.curve_warning_segments as Array<{ - polyline_start_index: number; - polyline_end_index: number; - }>) ?? [], - ); - } - } - - async function applyContours(interval: number): Promise { - // 화면 전체를 잠그지 않는다 — 진행 표시는 [재적용] 버튼이 맡는다(CLAUDE.md 5장). - try { - await viewer.reloadContours(interval); - await updateContourInterval(activeProjectId, interval); - // 세션 캐시에도 반영해 다음 진입 시 옛 등고선 간격으로 복원되지 않게 한다. - const cached = latestSource.read(); - if (cached) { - latestSource.write({ - ...cached, - surface_params: { ...cached.surface_params, contour_interval_m: interval }, - }); - } - } catch (error) { - showToast(error instanceof Error ? error.message : "등고선 조회에 실패했습니다.", "error"); - } - } + bridge.mountViewerMenu(viewer, () => state.latest?.route_points ?? []); // [경로 계산] · 저장 몫은 따로 뗀 모듈이 맡는다(2026-09-02). const actionContext: PageActionContext = { projectId: activeProjectId, - latest: () => latest, - confirmedSurface: () => confirmedSurface, - routeReady: () => routeReady, + latest: () => state.latest, + confirmedSurface: () => state.confirmedSurface, + routeReady: () => state.routeReady, viewer: () => viewer, panel: () => panel, profilePanel: () => profilePanel, @@ -551,13 +388,13 @@ export async function mountProfileView( }); // 탭이 다시 보일 때 정의 판이 바뀌었으면 종류 · 패널 다시 읽음(322-0) - const reloadIfShown = (): void => void (host.visible() && !restoring && bridge.load()); + const reloadIfShown = (): void => void (host.visible() && !state.restoring && bridge.load()); host.onDispose(onStaleStructureTypes(reloadIfShown)); // 한 페이지 두 보기 — 숨김 · 보임 · 떠남(계획서 24-3a · 24-3b). bindProfileViewLife(host, { projectId: activeProjectId, viewer, - ready: () => !restoring, + ready: () => !state.restoring, // 다시 보이면 횡단 보기가 한 벌(폼 · 종단도)에 실어 둔 값을 종단 보기 값으로 되돌린다. reload: async () => { await bridge.load(); @@ -577,7 +414,7 @@ export async function mountProfileView( }, // 보기를 떠나는 자리 = 코리도 영구저장 시점(2026-08-23 사용자 확정) — 이동은 막지 않는다. onHide: () => { - if (latest?.route?.id) void saveCorridorIfDirty(activeProjectId, latest.route.id); + if (state.latest?.route?.id) void saveCorridorIfDirty(activeProjectId, state.latest.route.id); }, }); @@ -609,7 +446,7 @@ export async function mountProfileView( fetchRoadWidths(activeProjectId), ]); if (host.disposed()) return; - roadWidths = configuredRoadWidths; + state.roadWidths = configuredRoadWidths; panel.restore({ stationInterval: sectionContext.defaults.station_interval_m, crossSampleInterval: sectionContext.defaults.cross_sample_interval_m, @@ -633,8 +470,8 @@ export async function mountProfileView( // ② 확정 지표면 — 모델 id·범위를 한 번에 받는다. const confirmed = await fetchConfirmedSurface(activeProjectId); - confirmedSurface = confirmed.model_id === null ? null : confirmed; - if (!confirmedSurface) { + state.confirmedSurface = confirmed.model_id === null ? null : confirmed; + if (!state.confirmedSurface) { // 새 자료가 올라와 옛 결과가 지워진 상태 — 여기서 보여 줄 게 없다. leaveForDashboard(); return; @@ -668,33 +505,11 @@ export async function mountProfileView( showToast(error instanceof Error ? error.message : "화면을 불러오지 못했습니다.", "error"); } finally { progress.remove(); - restoring = false; + state.restoring = false; snapOld(); restorePick(); // 관·구조물 목록 중 늦게 오는 쪽이 있어 여기서 한 번 더. } // ④ 3D 지형 — 화면을 잡지 않고 뒤에서 올린다. 실패해도 나머지는 그대로 쓴다. - void (async () => { - const surface = confirmedSurface; - const current = latest as RouteLatestResponse | null; // 닫힘 안에서 채워져 흐름 분석이 못 봄 - if (!surface || !current || host.disposed()) return; - try { - // 범위는 ②에서 받아 둔 확정 응답에 이미 들어 있다 — 다시 부르지 않는다. - if (!surface.bounds) throw new Error("지표면 범위 정보를 찾을 수 없습니다."); - await viewer.loadSurface( - activeProjectId, - surface.model_id as number, - current.surface_params.method, - current.surface_params.smooth, - current.surface_params.contour_interval_m, - toBounds(surface.bounds), - ); - if (host.disposed()) return; // 올리는 사이 페이지를 떠났다. - renderLatest(current); // 지형이 올라온 뒤에야 마커·측점선이 지형 위에 놓인다. - if (currentSectionDetail) renderStationLines(currentSectionDetail); - showToast("3D 지형 준비 완료", "success"); - } catch (error) { - showToast(error instanceof Error ? error.message : "3D 지형을 불러오지 못했습니다.", "error"); - } - })(); + void view3d.loadTerrain(); } diff --git a/A00_Common/design/design_view_profile_3d.ts b/A00_Common/design/design_view_profile_3d.ts new file mode 100644 index 000000000..16a7dda32 --- /dev/null +++ b/A00_Common/design/design_view_profile_3d.ts @@ -0,0 +1,114 @@ +/* ============================================================================= + * design_view_profile_3d.ts + * 종단 보기의 **3D 몫** — 보기 조작 줄 콜백 · [3D 업데이트] · 등고선 다시 적용 · 3D 지형 올리기. + * `design_view_profile.ts` 가 700줄을 넘어 뗀 조각(345-1). 상태는 본체가 쥔 `ProfileViewState`. + * ========================================================================== */ + +import { showToast } from "@ui/ui_template_elements"; +import { updateContourInterval } from "../../B05_Profile/B05_Profile_Api_Fetch"; +import { refreshCorridor, saveCorridorIfDirty } from "../../B05_Profile/B05_Profile_UI_Corridor"; +import type { createLatestSource } from "../../B05_Profile/B05_Profile_UI_Page_Latest"; +import { toBounds } from "../../B05_Profile/B05_Profile_UI_Page_Helpers"; +import type { ViewControlCallbacks } from "../../B05_Profile/B05_Profile_UI_Panel_View"; +import type { createRouteProfilePanel } from "../../B05_Profile/B05_Profile_UI_Profile_Panel"; +import type { RouteViewer } from "../../B05_Profile/B05_Profile_UI_Viewer"; +import type { DesignPageHost } from "./design_page"; +import type { createProfileSections, ProfileViewState } from "./design_view_profile_sections"; + +export interface Profile3dDeps { + projectId: string; + state: ProfileViewState; + viewer: RouteViewer; + host: DesignPageHost; + latestSource: ReturnType; + profilePanel: () => ReturnType; + sections: () => ReturnType; +} + +export function createProfile3d(deps: Profile3dDeps) { + const { projectId, state, viewer } = deps; + + /** 보기 조작 줄(ISO · 켬끔 · [3D 업데이트])이 부르는 것 — 좌측 패널 콜백에 섞어 넘긴다. */ + const viewCallbacks: ViewControlCallbacks = { + onSurfaceVisible: viewer.setSurfaceVisible, + onCorridorVisible: (visible) => { + state.corridorVisible = visible; + viewer.setCorridorVisible(visible); + // 측점 바 기준면이 바뀐다(예상형상 위 ↔ 원지반 위) — 다시 그린다. + deps.sections().redrawStationLines(); + }, + onContoursVisible: viewer.setContoursVisible, + onAxesVisible: viewer.setAxesVisible, + onStationLinesVisible: viewer.setStationLinesVisible, + onStationLabelsVisible: viewer.setStationLabelsVisible, + onSurfaceGrayscale: viewer.setSurfaceGrayscale, + onView: viewer.setView, + onResetView: () => viewer.setView("top"), + onProjection: viewer.setProjection, + // [3D 업데이트](2026-09-01) — 밀린 계획선 편집을 예상형상·측점선에 한 번에 반영한다. + onCorridorRefresh: async () => { + const detail = state.detail; + const latest = state.latest; + if (!detail || !latest?.route?.id) return; + const routePoints = latest.route_points ?? []; + if (routePoints.length <= 1) return; + await refreshCorridor( + viewer, + projectId, + latest.route.id, + detail, + routePoints, + deps.profilePanel().alignmentSamples() ?? undefined, + ); + // 측점 바·라벨·램프도 계획고를 따라 움직여야 한다(2026-08-23 지적 ③). + deps.sections().renderStationLines(detail); + // 만든 것을 **바로 올려 둔다**(2026-09-06) — 다음 진입이 네트워크 없이 선다. + void saveCorridorIfDirty(projectId, latest.route.id); + }, + }; + + async function applyContours(interval: number): Promise { + // 화면 전체를 잠그지 않는다 — 진행 표시는 [재적용] 버튼이 맡는다(CLAUDE.md 5장). + try { + await viewer.reloadContours(interval); + await updateContourInterval(projectId, interval); + // 세션 캐시에도 반영해 다음 진입 시 옛 등고선 간격으로 복원되지 않게 한다. + const cached = deps.latestSource.read(); + if (cached) { + deps.latestSource.write({ + ...cached, + surface_params: { ...cached.surface_params, contour_interval_m: interval }, + }); + } + } catch (error) { + showToast(error instanceof Error ? error.message : "등고선 조회에 실패했습니다.", "error"); + } + } + + /** ④ 3D 지형 — 화면을 잡지 않고 뒤에서 올린다. 실패해도 나머지는 그대로 쓴다. */ + async function loadTerrain(): Promise { + const surface = state.confirmedSurface; + const current = state.latest; + if (!surface || !current || deps.host.disposed()) return; + try { + // 범위는 ②에서 받아 둔 확정 응답에 이미 들어 있다 — 다시 부르지 않는다. + if (!surface.bounds) throw new Error("지표면 범위 정보를 찾을 수 없습니다."); + await viewer.loadSurface( + projectId, + surface.model_id as number, + current.surface_params.method, + current.surface_params.smooth, + current.surface_params.contour_interval_m, + toBounds(surface.bounds), + ); + if (deps.host.disposed()) return; // 올리는 사이 페이지를 떠났다. + deps.sections().renderLatest(current); // 지형이 올라온 뒤에야 마커·측점선이 지형 위에 놓인다. + deps.sections().redrawStationLines(); + showToast("3D 지형 준비 완료", "success"); + } catch (error) { + showToast(error instanceof Error ? error.message : "3D 지형을 불러오지 못했습니다.", "error"); + } + } + + return { viewCallbacks, applyContours, loadTerrain }; +} diff --git a/A00_Common/design/design_view_profile_sections.ts b/A00_Common/design/design_view_profile_sections.ts new file mode 100644 index 000000000..bd0e8118b --- /dev/null +++ b/A00_Common/design/design_view_profile_sections.ts @@ -0,0 +1,179 @@ +/* ============================================================================= + * design_view_profile_sections.ts + * 종단 보기의 **노선 · 종단면 그리기** — 최신 노선 · 측점선 · 종단면 자료(불러오기 · 그리기 · + * 옛 측점 이관). `design_view_profile.ts` 가 700줄을 넘어 뗀 조각(345-1). 상태는 본체가 쥔 + * `ProfileViewState` 한 벌을 같이 본다. + * ========================================================================== */ + +import { showToast } from "@ui/ui_template_elements"; +import type { SurfaceConfirmedResponse } from "../../B04_PreProcess/B04_PreProcess_Api_Fetch"; +import type { RouteLatestResponse } from "../../B05_Profile/B05_Profile_Api_Fetch"; +import type { RoutePanel } from "../../B05_Profile/B05_Profile_UI_Panel"; +import type { createRouteProfilePanel } from "../../B05_Profile/B05_Profile_UI_Profile_Panel"; +import type { createStructuresBridge } from "../../B05_Profile/B05_Profile_UI_Page_Structures"; +import type { RouteViewer } from "../../B05_Profile/B05_Profile_UI_Viewer"; +import type { SectionDetailResponse } from "../../B06_Section/B06_Section_Api_Fetch"; +import { loadSectionDetail } from "../../B06_Section/B06_Section_Section_Store"; +import { migrateLegacyStations } from "../../B05_Profile/B05_Profile_Api_Structures"; +import { loadCorridorIfFresh } from "../../B05_Profile/B05_Profile_UI_Corridor"; +import { stationLinesOf } from "../../B05_Profile/B05_Profile_UI_Page_Stations"; +import { restorePoints, type RoadWidths } from "../../B05_Profile/B05_Profile_UI_Page_Helpers"; +import { panelValuesOf } from "../../B05_Profile/B05_Profile_UI_Page_Latest"; + +/** 종단 보기 본체 · 조각이 함께 보는 값 한 벌. */ +export interface ProfileViewState { + /** 확정 지표면 — 모델 id 와 3D 로딩에 필요한 범위만 들고 있는다(2026-09-06 호출 정리). */ + confirmedSurface: SurfaceConfirmedResponse | null; + latest: RouteLatestResponse | null; + roadWidths: RoadWidths; + detail: SectionDetailResponse | null; + /** [예상형상] 표시 상태 — 측점 바를 계획고 위로 올릴지 판단한다(패널 기본 ON). */ + corridorVisible: boolean; + routeReady: boolean; + restoring: boolean; +} + +export interface ProfileSectionsDeps { + projectId: string; + state: ProfileViewState; + viewer: RouteViewer; + panel: () => RoutePanel; + profilePanel: () => ReturnType; + bridge: () => ReturnType; +} + +/** 구 구조물 측점 이관을 이미 시도한 노선 — 탭 수명 동안 유지한다. */ +const migratedRoutes = new Set(); + +export function createProfileSections(deps: ProfileSectionsDeps) { + const { projectId, state, viewer } = deps; + + function restorePanel(next: RouteLatestResponse): void { + deps.panel().restore(panelValuesOf(next)); + viewer.markers.setPoints(restorePoints(next)); + } + + function renderStationLines(detail: SectionDetailResponse): void { + const lines = stationLinesOf({ + detail, + irregular: deps.bridge().irregularStations(), + routePoints: state.latest?.route_points ?? [], + liveSamples: deps.profilePanel().alignmentSamples(), + corridorVisible: state.corridorVisible, + }); + viewer.renderStationLines(lines, state.roadWidths[deps.panel().values().gradeClass] / 2); + } + + /** 지금 종단면 자료로 측점선만 다시 그린다(자료가 없으면 그대로). */ + function redrawStationLines(): void { + if (state.detail) renderStationLines(state.detail); + } + + function renderSections(detail: SectionDetailResponse, routeId?: number): void { + const panel = deps.panel(); + const profilePanel = deps.profilePanel(); + state.detail = detail; + profilePanel.render(detail, panel.values().stationInterval ?? undefined, routeId); + // 저장된 계획선의 정책은 만들 당시 기준이라 지금 설정(임도 종류·설계속도·지형)과 + // 다를 수 있다 — 그릴 때마다 현재 기준으로 맞춘다(2026-08-19). + profilePanel.setGradeLimit(panel.gradeLimitPct()); + profilePanel.setEnforceMinCover(panel.values().enforcePipeClearance); + profilePanel.setStationDisplay(panel.stationDisplayOffset()); + profilePanel.setIrregularStations(deps.bridge().irregularStations()); + renderStationLines(detail); + // 예상형상 코리도 — 저장본이 지금 정본과 맞을 때만 올린다. 낡았으면 [3D 업데이트] 대기 + // 표시만 켠다(2026-09-06 실측: 받아놓고 버리는 데다 다시 만드느라 전환이 16.7초까지 갔다). + const routePoints = state.latest?.route_points ?? []; + if (routePoints.length > 1) { + void loadCorridorIfFresh( + viewer, + projectId, + routeId ?? state.latest?.route?.id, + detail, + routePoints, + profilePanel.alignmentSamples() ?? undefined, + ).then((fresh) => panel.setCorridorPending(!fresh)); + } + } + + async function restoreSections(routeId: number): Promise { + const profilePanel = deps.profilePanel(); + let detail: SectionDetailResponse; + profilePanel.setLoading("종단면 자료를 불러오는 중…"); + try { + // 공유 캐시 — 횡단 보기가 이미 받아 뒀으면 같은 객체를 재사용해 두 보기가 같은 값을 본다. + detail = await loadSectionDetail(projectId, routeId); + } catch { + // 종횡단 데이터 자체가 없는 경우(생성 실패·최초 진입)는 빈 안내로 둔다. + state.detail = null; + profilePanel.setLoading(null); + profilePanel.clear(); + viewer.renderStationLines([], 0); + return; + } + profilePanel.setLoading(null); + try { + renderSections(detail, routeId); + // 구 확정분에 남은 비정규 측점(자유 텍스트)을 구조물 정본으로 1회 이관한다 — 멱등이지만 + // 탭 수명 동안 노선마다 한 번만 시도한다(2026-08-17 · 09-06 실측). + const legacy = detail.longitudinal.stations + .filter((station) => station.kind === "irregular") + .map((station) => ({ + chainage_m: station.chainage_m, + structure: station.structure ?? "", + })); + const migrateKey = `${projectId}:${routeId}`; + if (legacy.length && !migratedRoutes.has(migrateKey)) { + migratedRoutes.add(migrateKey); + void migrateLegacyStations(projectId, legacy) + .then((result) => { + if (result.migrated > 0) { + showToast(`구 구조물 측점 ${result.migrated}건을 구조물 목록으로 옮겼습니다.`); + return deps + .bridge() + .refreshFromServer() + .then(() => undefined); + } + return undefined; + }) + .catch(() => { + /* 이관 실패는 치명적이지 않다 — 다음 진입에서 다시 시도된다. */ + }); + } + } catch (error) { + // 렌더 실패를 "데이터 없음"으로 감추면 원인을 알 수 없게 된다 — 실패 사실을 드러낸다. + console.error("B05 종단면도 렌더 실패", error); + showToast( + error instanceof Error + ? `종단면도 표시 실패: ${error.message}` + : "종단면도 표시에 실패했습니다.", + "error", + ); + } + } + + function renderLatest(next: RouteLatestResponse): void { + state.latest = next; + state.routeReady = Boolean(next.route && next.route_points.length > 1); + deps.profilePanel().setRoutePolyline(next.route_points ?? []); + if (next.route) { + const stored = next.route.algorithm_params ?? {}; + viewer.markers.renderRoute( + next.route_points, + (stored.curve_warning_segments as Array<{ + polyline_start_index: number; + polyline_end_index: number; + }>) ?? [], + ); + } + } + + return { + restorePanel, + renderStationLines, + redrawStationLines, + renderSections, + restoreSections, + renderLatest, + }; +} From b8ed0828746e6108356df48ea40a205a4ee11b87 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 16 Oct 2026 11:03:37 +0900 Subject: [PATCH 05/18] =?UTF-8?q?fix(=EB=B0=B0=EC=88=98=EC=9C=A0=EC=97=AD)?= =?UTF-8?q?:=20342-3=20=EA=B4=80=EA=B2=BD=20=C2=B7=20=EC=8B=9C=EC=84=A4=20?= =?UTF-8?q?=EC=9E=90=EB=8F=99=20=EC=A7=80=EC=A0=95=20=EB=81=94=20=E2=80=94?= =?UTF-8?q?=20=EC=83=88=20=EA=B4=80=20=3D=20=EB=A7=88=EC=8A=A4=ED=84=B0=20?= =?UTF-8?q?=EA=B8=B0=EB=B3=B8=20=C2=B7=20=EC=B6=94=EC=B2=9C=EC=9D=80=20?= =?UTF-8?q?=EC=9D=91=EB=8B=B5=20=EC=A0=9C=EC=95=88=EB=A7=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - _apply_recommendations 지움(시설 BOX암거 · 세월교 바꿈 · 관경 넣기) · 새 관은 _apply_new_defaults 가 구조물정의 배관 기본(1000) - 응답 recommended_facility · recommended_diameter_mm 그대로(제안) - 시험 test_b05_drainage_recommend 고쳐 씀(추천 얹지 않음 · 옛 관 그대로 · 마스터 기본 1000) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XWh5wNNycMkbwMpACz1rte --- .../B04_PreProcess_Router_Basins.py | 40 +------ .../tester/test_b05_drainage_recommend.py | 103 +++++++----------- 2 files changed, 44 insertions(+), 99 deletions(-) diff --git a/B04_PreProcess/B04_PreProcess_Router_Basins.py b/B04_PreProcess/B04_PreProcess_Router_Basins.py index e84ff0220..e6de81797 100644 --- a/B04_PreProcess/B04_PreProcess_Router_Basins.py +++ b/B04_PreProcess/B04_PreProcess_Router_Basins.py @@ -30,7 +30,6 @@ from common_util.common_util_auth import get_optional_session from common_util.common_util_drainage_context import DrainageContext, load_drainage_context from common_util.common_util_drainage_detail import DrainageDetail, build_detail from common_util.common_util_drainage_pipes import ( - PIPE_FACILITY_PIPE, PIPE_SOURCE_USER, PipePoint, carry_facility_attributes, @@ -84,8 +83,8 @@ def _build( # 계산기는 chainage만 다뤄 시설 종류·구간·옵션이 사라진다 — 원본에서 되붙인다 # (2026-08-17 계곡 통과 시설: 배관/BOX암거/물넘이/세월교). carried = carry_facility_attributes(rebuilt, points or []) - # 되붙인 뒤에 추천을 얹는다 — 순서가 뒤바뀌면 승계된 사용자 값이 추천에 덮인다. - _apply_recommendations(detail, carried) + # 관경 · 시설 자동 지정 없음 — 10-16 사용자 지시로 끔 · 제안(recommended_*)만 응답에 실음. + # 새 관은 아래 등록부 기본값(구조물정의 배관 관경)을 받는다. flows = {round(basin.chainage_m, 2): basin.design_flow_m3s for basin in detail.basins} _apply_new_defaults([point for point, new in zip(carried, fresh) if new], flows) return detail, carried @@ -105,7 +104,7 @@ def _float(value: Any) -> float: def _apply_new_defaults( points: list[PipePoint], flows: dict[float, float | None] | None = None ) -> None: - """새로 놓인 관에만 등록부 기본값을 **빈 키에** 채움(PLAN 46-7 · 추천 뒤 — 추천 관경이 이김). + """새로 놓인 관에만 등록부 기본값을 **빈 키에** 채움(PLAN 46-7). 시설이 생길 때 기본값을 칸에 실제 값으로 넣어 두고 사용자가 고침 — 이미 있던 관의 빈 칸은 그대로 (옛 프로젝트 빈칸 = 미확정 유지 · 사용자 결정 2026-10-05). @@ -176,39 +175,6 @@ def _retag(pipes: list[StructureCandidate], points: list[PipePoint]) -> None: pipe.reason = source or PIPE_SOURCE_USER -def _apply_recommendations(detail: DrainageDetail, points: list[PipePoint]) -> None: - """유역별 추천(시설 종류·관경)을 관 지점 옵션의 **빈칸에만** 채운다. - - "초기 전처리 계산에서 해당값으로 설정"(2026-08-17 사용자 지시) — 자동 배치된 관이 - 유역 유량에 맞는 규격을 처음부터 갖고 있어야 폼을 열지 않아도 하류가 값을 받는다. - - 덮어쓰지 않는 두 경우: ① 사용자가 직접 놓거나 옮긴 관(`source == "user"`) ② - 그 옵션 키가 이미 있는 관. 설계자가 고른 값을 재계산이 되돌리면 안 되기 때문이다. - """ - by_chainage = {round(basin.chainage_m, 2): basin for basin in detail.basins} - for point in points: - if point.source == PIPE_SOURCE_USER: - continue - basin = by_chainage.get(round(point.chainage_m, 2)) - if basin is None: - continue - options = dict(point.options or {}) - # 시설 종류는 아직 손대지 않은 기본 배관일 때만 추천으로 바꾼다. - if ( - point.facility == PIPE_FACILITY_PIPE - and basin.recommended_facility != PIPE_FACILITY_PIPE - ): - point.facility = basin.recommended_facility - if ( - point.facility == PIPE_FACILITY_PIPE - and basin.recommended_diameter_mm is not None - and "pipe_diameter_mm" not in options - ): - options["pipe_diameter_mm"] = basin.recommended_diameter_mm - if options: - point.options = options - - def _payload( project_id: UUID, context: DrainageContext, diff --git a/resources/tester/test_b05_drainage_recommend.py b/resources/tester/test_b05_drainage_recommend.py index 6a3183a89..f145898a0 100644 --- a/resources/tester/test_b05_drainage_recommend.py +++ b/resources/tester/test_b05_drainage_recommend.py @@ -1,4 +1,4 @@ -"""배수 추천 구조물·관경과 자동 배치 주입 (PLAN 2026-08-17 「4」). +"""배수 추천 구조물·관경 (PLAN 2026-08-17 「4」) — 제안만 · 자동 지정 없음(342-3 · 10-16 사용자 지시). 추천 근거는 **유량(유효직경)뿐**이다 — 계곡 횡단경사·하천 차수는 지형 계산이 필요해 이번 범위에서 뺐고 화면이 "현장 확인"으로 안내한다(사용자 확정). @@ -8,9 +8,12 @@ D > 2,000㎜ 세월교·물넘이 검토 """ +from pathlib import Path +from types import SimpleNamespace + import pytest -from B04_PreProcess.B04_PreProcess_Router_Basins import _apply_recommendations +from B04_PreProcess import B04_PreProcess_Router_Basins as basins from B05_Profile.B05_Profile_Structures_Schema import load_structure_types from common_util.common_util_drainage_detail import ( DrainageDetail, @@ -22,9 +25,9 @@ from common_util.common_util_drainage_pipes import ( PIPE_FACILITY_FORD_BRIDGE, PIPE_FACILITY_PIPE, PIPE_SOURCE_STREAM, - PIPE_SOURCE_USER, PipePoint, ) +from common_util.common_util_route_geometry import StructureCandidate from config.config_system import DRAINAGE_RECOMMEND_DIAMETERS_MM @@ -58,13 +61,7 @@ def test_recommended_sizes_match_registry_choices(): assert list(DRAINAGE_RECOMMEND_DIAMETERS_MM) == choices -# ── 자동 배치 주입 ───────────────────────────────────────────────────────── - - -def _detail(*basins: WatershedBasin) -> DrainageDetail: - detail = DrainageDetail.__new__(DrainageDetail) - detail.basins = list(basins) - return detail +# ── 자동 지정 끔 — 제안은 응답에만 ───────────────────────────────────────────── def _basin(chainage: float, facility: str, diameter: int | None) -> WatershedBasin: @@ -78,58 +75,40 @@ def _basin(chainage: float, facility: str, diameter: int | None) -> WatershedBas ) -def test_auto_pipe_gets_recommended_diameter(): - """자동 배치 관은 폼을 열지 않아도 유역 유량에 맞는 관경을 갖는다.""" +def _run_build(monkeypatch, basin: WatershedBasin, points: list[PipePoint] | None): + detail = DrainageDetail( + pipes=[StructureCandidate(chainage_m=100.0, x=0.0, y=0.0, reason=PIPE_SOURCE_STREAM)], + basins=[basin], + ) + monkeypatch.setattr(basins, "build_detail", lambda *args: detail) + monkeypatch.setattr(basins, "drainage_dir", lambda path: Path(".")) + _, carried = basins._build("x", SimpleNamespace(vertices=[]), points) + return carried + + +def _master_pipe_diameter(): + pipe = {item.type_id: item for item in load_structure_types()}["pipe"] + return {option.key: option for option in pipe.options}["pipe_diameter_mm"].default + + +@pytest.mark.parametrize( + "facility, diameter", + [(PIPE_FACILITY_PIPE, 1500), (PIPE_FACILITY_BOX, None), (PIPE_FACILITY_FORD_BRIDGE, None)], +) +def test_new_pipe_gets_master_default_not_recommendation(monkeypatch, facility, diameter): + """새로 놓인 관 = 배관 · 마스터 기본 관경 — 추천(관경 · BOX암거 · 세월교)은 안 얹는다.""" + carried = _run_build(monkeypatch, _basin(100.0, facility, diameter), None) + assert carried[0].facility == PIPE_FACILITY_PIPE + assert carried[0].options["pipe_diameter_mm"] == _master_pipe_diameter() + + +def test_existing_auto_pipe_is_untouched(monkeypatch): + """이미 놓인 자동 관의 빈 관경 · 시설은 추천으로 안 채운다(옛 값 그대로).""" points = [PipePoint(chainage_m=100.0, source=PIPE_SOURCE_STREAM)] - _apply_recommendations(_detail(_basin(100.0, PIPE_FACILITY_PIPE, 1200)), points) - assert points[0].options == {"pipe_diameter_mm": 1200} - assert points[0].facility == PIPE_FACILITY_PIPE + carried = _run_build(monkeypatch, _basin(100.0, PIPE_FACILITY_BOX, None), points) + assert carried[0].facility == PIPE_FACILITY_PIPE + assert carried[0].options is None -def test_auto_point_switches_facility_to_box(): - """추천이 BOX암거면 시설 종류까지 바꾼다(관경은 붙이지 않는다).""" - points = [PipePoint(chainage_m=100.0, source=PIPE_SOURCE_STREAM)] - _apply_recommendations(_detail(_basin(100.0, PIPE_FACILITY_BOX, None)), points) - assert points[0].facility == PIPE_FACILITY_BOX - assert points[0].options is None - - -def test_user_placed_point_is_never_overwritten(): - """사용자가 직접 놓거나 옮긴 관은 재계산이 건드리지 않는다.""" - points = [PipePoint(chainage_m=100.0, source=PIPE_SOURCE_USER)] - _apply_recommendations(_detail(_basin(100.0, PIPE_FACILITY_BOX, None)), points) - assert points[0].facility == PIPE_FACILITY_PIPE - assert points[0].options is None - - -def test_existing_option_is_kept(): - """설계자가 고른 관경을 추천이 되돌리면 안 된다 — 빈칸만 채운다.""" - points = [ - PipePoint( - chainage_m=100.0, - source=PIPE_SOURCE_STREAM, - options={"pipe_diameter_mm": 800}, - ) - ] - _apply_recommendations(_detail(_basin(100.0, PIPE_FACILITY_PIPE, 1500)), points) - assert points[0].options == {"pipe_diameter_mm": 800} - - -def test_already_chosen_facility_is_kept(): - """이미 다른 시설로 바꿔 둔 지점은 추천이 되돌리지 않는다.""" - points = [ - PipePoint( - chainage_m=100.0, - source=PIPE_SOURCE_STREAM, - facility=PIPE_FACILITY_FORD_BRIDGE, - ) - ] - _apply_recommendations(_detail(_basin(100.0, PIPE_FACILITY_BOX, None)), points) - assert points[0].facility == PIPE_FACILITY_FORD_BRIDGE - - -def test_point_without_basin_is_untouched(): - """담당 유역이 없는 관(유역 미산출)은 그대로 둔다.""" - points = [PipePoint(chainage_m=500.0, source=PIPE_SOURCE_STREAM)] - _apply_recommendations(_detail(_basin(100.0, PIPE_FACILITY_PIPE, 1200)), points) - assert points[0].options is None +def test_master_pipe_default_is_1000(): + assert float(_master_pipe_diameter()) == 1000 From 5025313b29e7d7beb971d2fc066b70a8733677b5 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 16 Oct 2026 11:03:45 +0900 Subject: [PATCH 06/18] =?UTF-8?q?perf(342-2):=20=EB=85=B8=EC=84=A0=20[?= =?UTF-8?q?=ED=99=95=EC=9D=B8]=20=EC=B2=B4=EC=9D=B8=20=EC=A2=85=ED=9A=A1?= =?UTF-8?q?=EB=8B=A8=20=EC=83=9D=EC=84=B1=20=ED=95=9C=20=EB=B2=88=20=C2=B7?= =?UTF-8?q?=20=EC=9E=90=EB=8F=99=20=EC=B2=B4=EC=9D=B8=20=EC=9E=AC=EA=B3=84?= =?UTF-8?q?=EC=82=B0=20=ED=95=9C=20=EB=B2=88=20=C2=B7=203D=20=EA=B8=B8=20?= =?UTF-8?q?=EB=AA=A8=ED=98=95=20=EC=84=9C=EB=B2=84=20=EC=A7=93=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 노선 [확인] 체인: solve 안 첫 생성을 건너뛰고 관 정한 뒤 한 번만(건너뛴 생성의 옵션을 넘겨 입력 같음 · 배수유역 실패 때도 종횡단 세움) - 자동 체인: 서버 재계산 · 3D 짓기를 [확정] 끝 한 번씩(재계산 → 짓기 차례) - 3D 길 모형: B06 [저장] · [확정] 끝에 서버 Node 가 지음 · 브라우저 올림(PUT) 길 없앰 · 브라우저는 해시 맞으면 받기만 · 어긋나면 미리보기만 - 시험 test_342_2_chain_once · corridor router 시험 고쳐 씀 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD --- B03_FileInput/B03_FileInput_Service_Chain.py | 70 ++--- B05_Profile/B05_Profile_Router.py | 102 ++++--- B05_Profile/B05_Profile_Router_Corridor.py | 46 +--- B05_Profile/B05_Profile_UI_Corridor.ts | 76 +---- B06_Section/B06_Section_Router.py | 20 +- B06_Section/B06_Section_Router_Confirm.py | 17 ++ resources/tester/test_342_2_chain_once.py | 276 +++++++++++++++++++ resources/tester/test_b05_corridor_router.py | 43 +-- resources/tester/test_user_values_survive.py | 5 +- 9 files changed, 442 insertions(+), 213 deletions(-) create mode 100644 resources/tester/test_342_2_chain_once.py diff --git a/B03_FileInput/B03_FileInput_Service_Chain.py b/B03_FileInput/B03_FileInput_Service_Chain.py index 90791b126..a2f589bea 100644 --- a/B03_FileInput/B03_FileInput_Service_Chain.py +++ b/B03_FileInput/B03_FileInput_Service_Chain.py @@ -18,6 +18,7 @@ WF1(지표면 분석·자동 확정)이 끝나면 사용자가 화면에 없어 import asyncio import logging import time +from dataclasses import asdict from pathlib import Path from typing import Any from uuid import UUID @@ -65,7 +66,11 @@ def _planned_route_points_in_project_crs( async def _prepare_drainage_pipes_and_reprofile( - project_id: UUID, route_id: int, *, refresh: bool = False + project_id: UUID, + route_id: int, + *, + refresh: bool = False, + stored_options: dict[str, Any] | None = None, ) -> bool: """배수유역 분석 → 기본 관 지점 확정 저장 → 배관 정착 계획선으로 종횡단 재생성. @@ -75,12 +80,13 @@ async def _prepare_drainage_pipes_and_reprofile( 들어왔을 때 배수유역도도 비어 있게 된다 — 여기서 미리 계산해 영구저장소에 남긴다. 실패해도 예외를 던지지 않는다 — 관 없이 만든 계획선이라도 남는 편이 낫다. + + `stored_options` = 노선 [확인] 체인이 건너뛴 첫 생성의 옵션(342-2) — 반폭 · 재생성 옵션. """ from B04_PreProcess.B04_PreProcess_Router_Basins import put_pipe_points from B04_PreProcess.B04_PreProcess_Router_Watershed import get_primary_region from B06_Section.B06_Section_Repository import get_latest_section_options - from B06_Section.B06_Section_Router import regenerate_sections - from B06_Section.B06_Section_Schema import SectionRegenerateRequest + from B06_Section.B06_Section_Router import regenerate_route_sections from config.config_db import get_db_pool from config.config_system import SECTION_CROSS_HALF_WIDTH_M @@ -113,14 +119,16 @@ async def _prepare_drainage_pipes_and_reprofile( marks.append(("관 지점 확정", time.perf_counter())) # 3) 저장된 관 자리를 물려 계획선을 다시 산출한다. 반폭은 이미 쓰던 값을 유지한다. - pool = get_db_pool() - async with pool.acquire() as connection: - stored_options = await get_latest_section_options(connection, project_id) + seeded = stored_options is not None + if not seeded: + pool = get_db_pool() + async with pool.acquire() as connection: + stored_options = await get_latest_section_options(connection, project_id) half_width = float( (stored_options or {}).get("cross_half_width_m") or SECTION_CROSS_HALF_WIDTH_M ) - regenerated = await regenerate_sections( - project_id, route_id, SectionRegenerateRequest(cross_half_width_m=half_width) + regenerated = await regenerate_route_sections( + project_id, route_id, half_width, stored_options=stored_options if seeded else None ) if isinstance(regenerated, JSONResponse): logger.error( @@ -319,26 +327,8 @@ async def run_auto_design_chain( route_id, ) - # 3D 예상형상(코리도) — 브라우저가 쓰는 TS 빌더를 서버에서 한 번 돌려 영구저장한다 - # (2026-09-04 사용자 확정). 이걸 빼면 사용자가 B05에 처음 들어간 그 순간 브라우저가 - # 만들어 첫 진입이 느리다. 실패는 비치명적 — 저장본이 없으면 브라우저가 예전처럼 만든다. - try: - from B05_Profile.B05_Profile_Corridor_Prebuild import prebuild_corridor - - await prebuild_corridor(project_id, route_id, project_root) - except Exception: # noqa: BLE001 — 코리도 실패가 체인을 막지는 않는다 - logger.exception("코리도 사전 생성 실패: project_id=%s", project_id) - - # 구조물 폐회로 면적 + 유토곡선 — 화면이 쓰는 TS 를 서버에서 한 번 돌려 정본에 - # 얹는다(2026-09-06). 이걸 빼면 B06 을 안 연 프로젝트의 수량이 구조물을 모르는 - # 표준값으로 남고 유토곡선은 아예 없다. 초기값 스냅샷 **앞**이라 되돌릴 - # 기준선에도 보정이 담긴다. - try: - from B06_Section.B06_Section_Server_Calc_Prebuild import recompute_server_side - - await recompute_server_side(project_id, route_id) - except Exception: # noqa: BLE001 — 재계산 실패가 체인을 막지는 않는다 - logger.exception("횡단 서버 재계산 실패: project_id=%s", project_id) + # 서버 재계산(구조물 면적 · 유토곡선)과 3D 길 모형 짓기는 [확정](confirm_sections) 끝에서 + # 한 번씩 — 재계산 → 짓기 차례 · 초기값 스냅샷 **앞**이라 기준선에도 담긴다(342-2). # 초기값 스냅샷 — 여기가 [초기화]가 되돌릴 기준선이다(CLAUDE.md 5장). # 체인 규약대로 실패는 비치명적이다: 계산 결과는 그대로 두고 실패 마커만 남겨 @@ -426,13 +416,19 @@ async def run_redesign_chain( """ from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path from B05_Profile.B05_Profile_Repository import get_latest_route - from B05_Profile.B05_Profile_Router import confirm_latest_route, solve_route + from B05_Profile.B05_Profile_Router import ( + _section_options, + confirm_latest_route, + solve_route_record, + ) from B05_Profile.B05_Profile_Schema import RouteSolveRequest from B06_Section.B06_Section_Repository import ( get_cross_section_designs, + get_latest_section_options, get_longitudinal_section, ) from B06_Section.B06_Section_Repository_Bulk import merge_cross_section_designs + from B06_Section.B06_Section_Router import regenerate_route_sections from B06_Section.B06_Section_Router_Confirm import _rowless_station_chainages, confirm_sections from B06_Section.B06_Section_Router_Design import CARRY_INPUT_KEYS, compute_default_designs from B06_Section.B06_Section_Schema import SectionConfirmRequest @@ -476,6 +472,7 @@ async def run_redesign_chain( state = await get_workflow_state(cursor, str(project_id)) old_longitudinal = await get_longitudinal_section(connection, project_id, old_route_id) old_designs = await get_cross_section_designs(connection, old_route_id) + old_section_options = await get_latest_section_options(connection, project_id) stage2 = next( (s for s in (state or {}).get("stages", []) if int(s.get("stage_no", -1)) == 2), None ) @@ -528,7 +525,8 @@ async def run_redesign_chain( start_elevation_offset_m=params.get("start_elevation_offset_m"), end_elevation_offset_m=params.get("end_elevation_offset_m"), ) - solve_result: Any = await solve_route(project_id, request) + # 종횡단은 관을 정한 뒤 한 번만 만든다(342-2) — 여기서 만들면 아래 4c 가 통째로 덮는 헛일. + solve_result: Any = await solve_route_record(project_id, request, generate_sections=False) if isinstance(solve_result, JSONResponse): # 사유까지 남긴다 — 상태 코드만으로는 무엇이 막았는지 못 짚는다(2026-09-06 # 노선 편집 [확인]이 조용히 400 으로 끊겨 종횡단만 옛 노선에 남았음). @@ -571,7 +569,17 @@ async def run_redesign_chain( marks.append(("B05 확정", time.perf_counter())) # 노선이 바뀌었으므로 배수유역·관 지점도 새 노선 기준으로 다시 만든다 — 옛 관 자리로 # 계획선을 앉히면 전부 어긋나고, 저장분은 노선 변경을 스스로 알지 못한다. - await _prepare_drainage_pipes_and_reprofile(project_id, new_route_id, refresh=True) + # 건너뛴 첫 생성이 남겼을 옵션(요청 → 옛 저장 → 기본) — 재생성이 그것을 읽어 결과가 같다. + seed = asdict(_section_options(request, old_section_options)) + seed.pop("extra_stations", None) + piped = await _prepare_drainage_pipes_and_reprofile( + project_id, new_route_id, refresh=True, stored_options=seed + ) + if not piped: + # 관 없이라도 종횡단은 세운다 — 새 노선엔 아직 종횡단이 없다(첫 생성을 건너뜀). + await regenerate_route_sections( + project_id, new_route_id, float(seed["cross_half_width_m"]), stored_options=seed + ) marks.append(("배수·관·측점 재생성", time.perf_counter())) diff --git a/B05_Profile/B05_Profile_Router.py b/B05_Profile/B05_Profile_Router.py index 1f0576dca..589a5c862 100644 --- a/B05_Profile/B05_Profile_Router.py +++ b/B05_Profile/B05_Profile_Router.py @@ -131,6 +131,17 @@ async def solve_route( project_id: UUID, request: RouteSolveRequest ) -> RouteSolveResponse | JSONResponse: """경로 탐색을 실행하고 결과를 GeoJSON 저장 + DB 기록한다.""" + return await solve_route_record(project_id, request) + + +async def solve_route_record( + project_id: UUID, request: RouteSolveRequest, *, generate_sections: bool = True +) -> RouteSolveResponse | JSONResponse: + """`solve_route` 본체 — `generate_sections=False` 면 노선 행만 남기고 종횡단은 안 만든다. + + 노선 [확인] 체인은 관을 정한 뒤 종횡단을 한 번만 만든다(342-2) — 여기서 만들면 관 없는 + 종횡단을 지었다가 곧바로 통째로 덮는 헛일(1.5~2 s)이 된다. + """ pool = get_db_pool() try: params = { @@ -269,55 +280,56 @@ async def solve_route( longitudinal_length_m: float | None = None cross_section_count: int | None = None grade_summary: dict[str, Any] | None = None - try: - crs_epsg = await get_surface_crs_epsg( - connection, project_id, request.surface_model_id - ) - stored_options = await get_latest_section_options(connection, project_id) - stored_grade_options = await get_latest_grade_options(connection, project_id) - sections = await asyncio.to_thread( - run_section_generation, - project_root, - design["route_data_path"], - request.filter_key, - request.method, - request.smooth, - options=_section_options(request, stored_options), - grade_options=_grade_options(request, stored_grade_options), - # B05 폼이 계획선 입력의 authoritative 소스이므로 요청 값을 그대로 - # 저장한다(사용자가 값을 지우면 저장분도 지워져 법정 기본값으로 복귀). - grade_overrides=request.grade_options(), - crs=f"EPSG:{crs_epsg}" if crs_epsg is not None else None, - ) - await connection.begin() + if generate_sections: try: - await delete_sections_for_route(connection, route_id) - await create_longitudinal_section( - connection, - project_id=project_id, - route_id=route_id, - data=sections["longitudinal"]["data"], - longitudinal_file_path=sections["longitudinal"]["file_path"], + crs_epsg = await get_surface_crs_epsg( + connection, project_id, request.surface_model_id ) - await insert_cross_sections( - connection, - project_id=project_id, - route_id=route_id, - sections=sections["cross_sections"], + stored_options = await get_latest_section_options(connection, project_id) + stored_grade_options = await get_latest_grade_options(connection, project_id) + sections = await asyncio.to_thread( + run_section_generation, + project_root, + design["route_data_path"], + request.filter_key, + request.method, + request.smooth, + options=_section_options(request, stored_options), + grade_options=_grade_options(request, stored_grade_options), + # B05 폼이 계획선 입력의 authoritative 소스이므로 요청 값을 그대로 + # 저장한다(사용자가 값을 지우면 저장분도 지워져 법정 기본값으로 복귀). + grade_overrides=request.grade_options(), + crs=f"EPSG:{crs_epsg}" if crs_epsg is not None else None, ) - await connection.commit() + await connection.begin() + try: + await delete_sections_for_route(connection, route_id) + await create_longitudinal_section( + connection, + project_id=project_id, + route_id=route_id, + data=sections["longitudinal"]["data"], + longitudinal_file_path=sections["longitudinal"]["file_path"], + ) + await insert_cross_sections( + connection, + project_id=project_id, + route_id=route_id, + sections=sections["cross_sections"], + ) + await connection.commit() + except Exception: + await connection.rollback() + raise + longitudinal_length_m = sections["longitudinal"]["data"]["length_m"] + cross_section_count = len(sections["cross_sections"]) + grade_summary = sections["longitudinal"]["data"].get("grade_summary") except Exception: - await connection.rollback() - raise - longitudinal_length_m = sections["longitudinal"]["data"]["length_m"] - cross_section_count = len(sections["cross_sections"]) - grade_summary = sections["longitudinal"]["data"].get("grade_summary") - except Exception: - logger.exception( - "B05 종횡단 생성 실패 (경로는 저장됨): project_id=%s route_id=%s", - project_id, - route_id, - ) + logger.exception( + "B05 종횡단 생성 실패 (경로는 저장됨): project_id=%s route_id=%s", + project_id, + route_id, + ) return RouteSolveResponse( project_id=str(project_id), diff --git a/B05_Profile/B05_Profile_Router_Corridor.py b/B05_Profile/B05_Profile_Router_Corridor.py index 97d461372..b662451d1 100644 --- a/B05_Profile/B05_Profile_Router_Corridor.py +++ b/B05_Profile/B05_Profile_Router_Corridor.py @@ -1,23 +1,21 @@ """B05 3D 예상형상(코리도) 서피스 영구저장 라우터. -코리도 메쉬는 브라우저(TS 단일 구현)가 종횡단 정본으로 빌드한 **파생 데이터**다. -여기서는 파일 보관·조회만 한다 — 기하 계산 없음(2026-08-23 계획). -저장 흐름: 초기 빌드 직후 1회 + 임시저장·종단/횡단 페이지 이동 시 갱신. +코리도 메쉬는 종횡단 정본에서 나온 **파생 데이터**다. 짓기는 서버 Node 가 같은 TS 로 한다 +(`B05_Profile_Corridor_Prebuild` — 자동 체인 · 노선 [확인] 체인 · B06 [저장] · [확정] 끝 · 342-2). +여기서는 조회만 한다 — 브라우저 올림(PUT)은 없앰(서버 검산 없이 받아 적던 규칙 어김). 검증(버전 해시)은 프론트가 종횡단 정본과 대조한다. """ import asyncio -import json import logging import re from pathlib import Path from uuid import UUID -from fastapi import APIRouter, Body, Query +from fastapi import APIRouter, Query from fastapi.responses import JSONResponse, Response from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path -from common_util.common_util_json import atomic_write_json from common_util.common_util_storage import resolve_stored_project_path from config.config_db import get_db_pool @@ -25,9 +23,6 @@ logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/projects", tags=["B05 Corridor"]) -# 코리도 JSON 상한 — 리본 좌표(base64 Float32) 기준 넉넉히. 초과는 비정상 요청. -MAX_CORRIDOR_BYTES = 64 * 1024 * 1024 - def corridor_path(project_root: Path, route_id: int) -> Path: """코리도 저장본 자리 — 사전 생성(체인)·초기값 복원도 같은 자리를 쓴다.""" @@ -126,36 +121,3 @@ async def get_corridor( return JSONResponse( status_code=500, content={"status": "error", "message": "코리도 조회에 실패했습니다."} ) - - -@router.put("/{project_id}/routes/{route_id}/corridor", response_model=None) -async def put_corridor(project_id: UUID, route_id: int, payload: dict = Body(...)) -> JSONResponse: - """코리도 파일 저장(원자적 덮어쓰기). 내용 검증은 최소 — 파생 데이터 보관용.""" - if not isinstance(payload, dict) or "ribbons" not in payload: - return JSONResponse( - status_code=422, - content={"status": "error", "message": "코리도 형식이 올바르지 않습니다."}, - ) - try: - encoded = json.dumps(payload, ensure_ascii=False, separators=(",", ":")) - if len(encoded.encode("utf-8")) > MAX_CORRIDOR_BYTES: - return JSONResponse( - status_code=413, - content={"status": "error", "message": "코리도 데이터가 허용 크기를 넘습니다."}, - ) - project_root = await _resolve_project_root(project_id) - if project_root is None: - return JSONResponse( - status_code=404, content={"status": "error", "message": "프로젝트가 없습니다."} - ) - path = corridor_path(project_root, route_id) - await asyncio.to_thread(path.parent.mkdir, parents=True, exist_ok=True) - await asyncio.to_thread(atomic_write_json, path, payload) - return JSONResponse(content={"status": "ok"}) - except (OSError, TypeError, ValueError) as exc: - logger.warning( - "B05 코리도 저장 실패: project_id=%s route_id=%s error=%s", project_id, route_id, exc - ) - return JSONResponse( - status_code=500, content={"status": "error", "message": "코리도 저장에 실패했습니다."} - ) diff --git a/B05_Profile/B05_Profile_UI_Corridor.ts b/B05_Profile/B05_Profile_UI_Corridor.ts index c13401a46..7bf2963c5 100644 --- a/B05_Profile/B05_Profile_UI_Corridor.ts +++ b/B05_Profile/B05_Profile_UI_Corridor.ts @@ -1,14 +1,13 @@ /* ============================================================================= * B05_Profile_UI_Corridor.ts - * 코리도(3D 예상형상) 오케스트레이션 — 로드·빌드·저장·캐시. + * 코리도(3D 예상형상) 오케스트레이션 — 받기·미리보기 짓기·캐시. * - * 흐름(2026-08-23 사용자 확정): - * 1) 초기: 종횡단 정본 로드 시 브라우저가 정밀 빌드 → 표시. 저장본이 없던 - * 경우에만 즉시 영구저장(PUT). - * 2) 재접근: 저장본 GET → 버전 해시가 현 정본과 일치하면 그대로 표시, - * 불일치·404면 재빌드(dirty 표시). - * 3) 수정: 재빌드는 메모리에서만 — 임시저장·종단/횡단 페이지 이동 시 - * saveCorridorIfDirty()로 영구저장. + * 흐름(342-2 · 사용자 확정 10-16): + * 1) 짓기 = 서버 Node(`B05_Profile_Corridor_Prebuild`) — 자동 체인 · 노선 [확인] 체인 · + * B06 [저장] · [확정] 끝. 브라우저는 올리지 않는다(PUT 없음). + * 2) 받기: 저장본 GET → 버전 해시가 현 정본과 맞으면 그대로 표시. + * 3) 어긋나면(저장 안 한 초안 — 벽 · 구조물) [3D 업데이트] 때만 브라우저가 미리보기로 + * 지음 — 메모리에만 · 올리지 않음. * 버전 해시 = 종횡단 정본의 설계 입력 요약(FNV-1a) — 측점·계획고·설계선이 * 바뀌면 달라진다. 구조물 서피스 훅(CorridorStructureHook)은 예약만(범위 제외). * ========================================================================== */ @@ -27,7 +26,6 @@ import { corridorHash, ENVELOPE_VERSION, deserialize, - serialize, type CorridorEnvelope, } from "./B05_Profile_UI_Corridor_Envelope"; @@ -40,7 +38,6 @@ export interface CorridorStructureHook { interface CacheEntry { hash: string; build: CorridorBuildResult; - dirty: boolean; } const cache = new Map(); @@ -116,26 +113,9 @@ async function fetchStored( } } -async function putStored( - projectId: string, - routeId: number, - envelope: CorridorEnvelope, -): Promise { - try { - const response = await requestCorridor(`/projects/${projectId}/routes/${routeId}/corridor`, { - method: "PUT", - body: JSON.stringify(envelope), - }); - return response.ok; - } catch { - return false; - } -} - /** * 코리도 준비 — 캐시/저장본/빌드 순으로 확보해 반환. - * 반환된 build는 뷰어 setCorridor가 심 보정으로 제자리 수정할 수 있다(직렬화는 - * saveCorridorIfDirty 시점의 최신 상태를 담는다). + * 반환된 build는 뷰어 setCorridor가 심 보정으로 제자리 수정할 수 있다. */ /** 종단 계획선 샘플(라이브 편집 반영분) — buildCorridor designSamples와 같은 꼴. */ export type ProfileSamples = Array<{ @@ -176,7 +156,7 @@ export async function ensureCorridor( if (lookup.kind === "envelope" && lookup.envelope.hash === hash) { try { const build = deserialize(lookup.envelope); - cache.set(key, { hash, build, dirty: false }); + cache.set(key, { hash, build }); markSource("stored", hash); return build; } catch { @@ -186,10 +166,6 @@ export async function ensureCorridor( // 저장본이 낡았고 다시 만들지 않기로 했으면 여기서 끝낸다 — 캐시도 건드리지 않아 // 화면에 이미 서 있는 예상형상이 그대로 남는다([3D 업데이트] 대기 표시는 부르는 쪽 몫). if (!rebuild) return null; - // 저장본이 아예 없는 경우(missing)와 있는데 낡은 경우(stale)를 가른다 — 아래 저장 규칙이 - // 갈린다. `stale` 도 파일은 있으므로 즉시 PUT 하지 않고 [저장]·페이지 이동 때 올린다. - const hasStored = lookup.kind !== "missing"; - // 종단 계획선 샘플을 함께 넘겨 측점 사이가 종단곡선을 따라 부드럽게 이어지게 한다. // 라이브 편집분(alignment.samples)이 있으면 그걸 쓴다 — 정본 design_profiles는 // 편집 전 값이라 전환점·종단 보정이 낡은 계획고를 따라간다(2026-08-23 지적 ③). @@ -202,40 +178,14 @@ export async function ensureCorridor( cache.delete(key); return null; } + // 미리보기 — 메모리에만(올리지 않음 · 서버가 [저장]·[확정] 때 정본으로 지음) markSource("built", hash); - if (!hasStored) { - // 최초 생성 — 계획 확정 흐름대로 즉시 영구저장(실패해도 표시는 진행). - cache.set(key, { hash, build, dirty: false }); - void putStored(projectId, routeId, serialize(build, hash)).then((ok) => { - if (!ok) markDirty(projectId, routeId); - }); - } else { - // 정본이 저장본보다 새것 — 임시저장·페이지 이동 때 저장한다(사용자 확정 흐름). - cache.set(key, { hash, build, dirty: true }); - } + cache.set(key, { hash, build }); return build; } -function markDirty(projectId: string, routeId: number): void { - const entry = cache.get(keyOf(projectId, routeId)); - if (entry) entry.dirty = true; -} - -/** 임시저장·B05↔B06 페이지 이동 훅 — 미저장 변경분이 있을 때만 PUT. */ -export async function saveCorridorIfDirty(projectId: string, routeId: number): Promise { - const entry = cache.get(keyOf(projectId, routeId)); - if (!entry || !entry.dirty) return; - const envelope = serialize(entry.build, entry.hash); - const ok = await putStored(projectId, routeId, envelope); - if (!ok) return; - entry.dirty = false; - // 방금 올린 것을 **보관함에도** 담는다(2026-09-06) — 그러지 않으면 다음 진입이 열쇠가 - // 바뀐 주소로 한 번 더 받아 온다. 담아 두면 네트워크 없이 선다. - const url = `${corridorUrlPrefix(projectId, routeId)}?hash=${encodeURIComponent(entry.hash)}`; - const bytes = new TextEncoder().encode(JSON.stringify(envelope)).buffer as ArrayBuffer; - await purgeAssetsWithPrefix(projectId, corridorUrlPrefix(projectId, routeId)); - await writeCachedBytes(projectId, url, bytes); -} +/** 옛 올림 자리 — 3D 길 모형은 서버가 [저장]·[확정] 때 지음(342-2). 부르는 곳이 지워지면 함께 지움. */ +export async function saveCorridorIfDirty(_projectId: string, _routeId: number): Promise {} /** ③ 사면이 읽을 측점 단면을 얹는다(115-1 · 저장본에는 안 담긴다). */ function withSections( diff --git a/B06_Section/B06_Section_Router.py b/B06_Section/B06_Section_Router.py index f80a2a120..2f43d05ca 100644 --- a/B06_Section/B06_Section_Router.py +++ b/B06_Section/B06_Section_Router.py @@ -379,6 +379,21 @@ async def regenerate_sections( project_id: UUID, route_id: int, request: SectionRegenerateRequest ) -> SectionDetailResponse | JSONResponse: """표시 옵션의 횡단 반폭으로 종횡단을 재생성해 저장하고 상세를 반환한다.""" + return await regenerate_route_sections(project_id, route_id, request.cross_half_width_m) + + +async def regenerate_route_sections( + project_id: UUID, + route_id: int, + cross_half_width_m: float, + *, + stored_options: dict[str, Any] | None = None, +) -> SectionDetailResponse | JSONResponse: + """`regenerate_sections` 본체 — `stored_options` 를 주면 DB 최신 종단 옵션 대신 그것을 쓴다. + + 노선 [확인] 체인은 새 노선의 첫 생성을 건너뛰므로(342-2) DB 최신 종단이 아직 옛 노선 것이다 — + 건너뛴 첫 생성이 남겼을 옵션을 체인이 넘겨 결과를 지금과 같게 둔다. + """ pool = get_db_pool() try: async with pool.acquire() as connection: @@ -390,7 +405,8 @@ async def regenerate_sections( ) surface_params = await get_surface_confirmation_params(connection, str(project_id)) stored_path = await get_project_storage_relative_path(connection, project_id) - stored_options = await get_latest_section_options(connection, project_id) + if stored_options is None: + stored_options = await get_latest_section_options(connection, project_id) async with connection.cursor(aiomysql.DictCursor) as cursor: workflow = await get_workflow_state(cursor, str(project_id)) route_stage = next( @@ -411,7 +427,7 @@ async def regenerate_sections( options=_regeneration_options( stored_options, stage_params, - request.cross_half_width_m, + cross_half_width_m, ), # 계획선까지 함께 재계산·저장 — 없으면 계획 횡단도선·유토곡선이 사라진다. grade_options=_regeneration_grade_options(stage_params), diff --git a/B06_Section/B06_Section_Router_Confirm.py b/B06_Section/B06_Section_Router_Confirm.py index db16f52ab..3a23ceddf 100644 --- a/B06_Section/B06_Section_Router_Confirm.py +++ b/B06_Section/B06_Section_Router_Confirm.py @@ -186,6 +186,19 @@ async def _recompute_stored_designs(project_id: UUID, route_id: int) -> None: ) +async def _prebuild_corridor(project_id: UUID, route_id: int, project_root: Path) -> None: + """3D 길 모형을 서버 Node 가 지어 남긴다(342-2) — 정본이 다시 선 [저장] · [확정] 끝. + + 브라우저는 열쇠(해시)가 맞으면 받기만 한다. 실패는 비치명적 — 저장은 그대로 남는다. + """ + try: + from B05_Profile.B05_Profile_Corridor_Prebuild import prebuild_corridor + + await prebuild_corridor(project_id, route_id, project_root) + except Exception: + logger.exception("3D 길 모형 서버 짓기 실패 (저장은 유지): route_id=%s", route_id) + + async def _stale_route_response( connection: aiomysql.Connection, project_id: UUID, route_id: int ) -> JSONResponse | None: @@ -276,6 +289,7 @@ async def save_sections( raise # 편집이 들어간 **뒤** 서버가 정본을 다시 낸다 — 바뀐 벽·측점이 면적·유토곡선에 실린다. await _recompute_stored_designs(project_id, route_id) + await _prebuild_corridor(project_id, route_id, project_root) return SectionConfirmResponse( project_id=str(project_id), route_id=route_id, confirmed=False ) @@ -401,6 +415,9 @@ async def confirm_sections( route_id, ) marks.append(("측구 방향 B05 역반영", time.perf_counter())) + # 역반영까지 끝난 정본으로 3D 길 모형(자동 · 노선 [확인] 체인도 이 끝을 지남) + await _prebuild_corridor(project_id, route_id, project_root) + marks.append(("3D 길 모형 서버 짓기", time.perf_counter())) _log_steps("B06 확정", marks) return SectionConfirmResponse(project_id=str(project_id), route_id=route_id) except Exception: diff --git a/resources/tester/test_342_2_chain_once.py b/resources/tester/test_342_2_chain_once.py new file mode 100644 index 000000000..2a98d131d --- /dev/null +++ b/resources/tester/test_342_2_chain_once.py @@ -0,0 +1,276 @@ +# -*- coding: utf-8 -*- +"""342-2 노선 [확인] 체인 — 종횡단 생성 한 번 · 자동 체인 재계산 한 번 · 3D 는 [저장]·[확정] 끝. + +① 노선 [확인] 체인(`run_redesign_chain`)이 종횡단을 **한 번만** 만들고, 그 한 번이 옛 흐름의 + 마지막 생성(4c `regenerate_sections`)과 **같은 입력**(옵션 · 반폭 · 계획선 기준)을 받는다. + 옛 흐름은 solve 안 첫 생성이 남긴 옵션을 4c 가 읽었다 — 그 옵션을 체인이 넘겨 같게 둠. +② 배수유역이 실패해도 종횡단은 선다(첫 생성을 건너뛰었으므로 체인이 한 번 세움). +③ 자동 체인은 서버 재계산 · 3D 짓기를 따로 부르지 않음 — [확정](confirm_sections) 끝 한 번씩 · + 재계산 → 짓기 차례 · [저장](save_sections)도 끝에 짓기. + +DB · 엔진은 가짜로 바꿔 체인의 차례와 생성 입력만 잰다. +""" + +from __future__ import annotations + +import asyncio +import re +import sys +from dataclasses import asdict +from pathlib import Path +from types import SimpleNamespace +from uuid import uuid4 + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +import B03_FileInput.B03_FileInput_Repository as b03_repo # noqa: E402 +import B03_FileInput.B03_FileInput_Service_Chain as chain # noqa: E402 +import B04_PreProcess.B04_PreProcess_Router_Basins as basins # noqa: E402 +import B04_PreProcess.B04_PreProcess_Router_Watershed as watershed # noqa: E402 +import B05_Profile.B05_Profile_Repository as b05_repo # noqa: E402 +import B05_Profile.B05_Profile_Router as b05_router # noqa: E402 +import B06_Section.B06_Section_Repository as b06_repo # noqa: E402 +import B06_Section.B06_Section_Repository_Bulk as b06_bulk # noqa: E402 +import B06_Section.B06_Section_Router as b06_router # noqa: E402 +import B06_Section.B06_Section_Router_Confirm as b06_confirm # noqa: E402 +import B06_Section.B06_Section_Router_Design as b06_design # noqa: E402 +import common_util.common_util_initial_snapshot as snapshot # noqa: E402 +import common_util.common_util_storage as storage # noqa: E402 +import common_util.common_util_workflow_state as workflow # noqa: E402 +import config.config_db as config_db # noqa: E402 +from B05_Profile.B05_Profile_Schema import RouteSolveRequest # noqa: E402 +from fastapi.responses import JSONResponse # noqa: E402 + +# stage 2 params(요청 쪽) · 옛 노선 종단 옵션(DB 쪽)이 **엇갈리게** — 옛 흐름 순서를 못 지키면 값이 갈린다 +PARAMS = { + "filter_key": "csf", + "method": "dtm", + "smooth": False, + "points": {"bp": {"x": 0.0, "y": 0.0}, "ep": {"x": 100.0, "y": 0.0}, "cp": []}, + "options": {"grade_class": "trunk", "terrain_type": "normal"}, + "cross_half_width_m": 20.0, + "cross_sample_interval_m": 0.5, + "station_interval_m": None, + "long_sample_interval_m": None, +} +OLD_STORED = { + "station_interval_m": 25.0, + "cross_half_width_m": 30.0, + "cross_sample_interval_m": 1.0, + "long_sample_interval_m": 2.0, +} + + +class _Cursor: + async def __aenter__(self): + return self + + async def __aexit__(self, *exc): + return False + + async def execute(self, *args, **kwargs): + return None + + async def fetchone(self): + return None + + +class _Connection: + def cursor(self, *args): + return _Cursor() + + async def begin(self): + return None + + async def commit(self): + return None + + async def rollback(self): + return None + + +class _Acquire: + async def __aenter__(self): + return _Connection() + + async def __aexit__(self, *exc): + return False + + +class _Pool: + def acquire(self): + return _Acquire() + + +def _async(value): + async def fake(*args, **kwargs): + return value + + return fake + + +@pytest.fixture +def world(monkeypatch, tmp_path): + calls: dict = {"generate": [], "order": [], "latest_options": 0} + + async def solve(project_id, request, *, generate_sections=True): + calls["order"].append("solve") + calls["solve_generates"] = generate_sections + return SimpleNamespace(route_id=6, total_length_m=100.0) + + async def primary_region(project_id, refresh=False): + calls["order"].append("drainage") + return calls.get("region", {}) + + async def latest_options(connection, project_id): + calls["latest_options"] += 1 + return OLD_STORED + + def generate(project_root, route_data_path, filter_key, method, smooth, **kwargs): + calls["order"].append("generate") + calls["generate"].append(kwargs) + data = {"length_m": 100.0, "options": {}} + return { + "longitudinal": {"data": data, "file_path": "long.json"}, + "cross_sections": [], + "result": {"longitudinal": {}, "cross_sections": []}, + } + + async def confirm(project_id, route_id, request=None, mark_stage_complete=True): + calls["order"].append("confirm_sections") + return SimpleNamespace(route_id=route_id) + + state = {"stages": [{"stage_no": 2, "params": PARAMS}]} + monkeypatch.setattr(config_db, "get_db_pool", lambda: _Pool()) + monkeypatch.setattr(b05_repo, "get_latest_route", _async({"id": 5})) + monkeypatch.setattr(b03_repo, "get_project_storage_relative_path", _async("p")) + monkeypatch.setattr(storage, "resolve_stored_project_path", lambda path: str(tmp_path)) + monkeypatch.setattr(snapshot, "mark_designing", lambda root: None) + monkeypatch.setattr(snapshot, "clear_designing", lambda root: None) + monkeypatch.setattr(snapshot, "discard_initial_snapshot", lambda root: False) + monkeypatch.setattr(workflow, "get_workflow_state", _async(state)) + monkeypatch.setattr( + b06_repo, "get_longitudinal_section", _async({"data": {}, "longitudinal_file_path": "x"}) + ) + monkeypatch.setattr(b06_repo, "get_cross_section_designs", _async([])) + monkeypatch.setattr(b06_repo, "get_latest_section_options", latest_options) + monkeypatch.setattr(b05_router, "solve_route_record", solve) + monkeypatch.setattr(b05_router, "confirm_latest_route", _async({})) + monkeypatch.setattr(watershed, "get_primary_region", primary_region) + monkeypatch.setattr(basins, "put_pipe_points", _async({"saved_count": 2})) + monkeypatch.setattr(b06_confirm, "_rowless_station_chainages", lambda *a: []) + monkeypatch.setattr(b06_confirm, "confirm_sections", confirm) + monkeypatch.setattr(b06_design, "compute_default_designs", lambda *a: []) + monkeypatch.setattr(b06_bulk, "merge_cross_section_designs", _async(0)) + # 재생성 본체(B06_Section_Router) — DB 는 가짜 · 생성 입력만 잰다 + monkeypatch.setattr(b06_router, "get_db_pool", lambda: _Pool()) + monkeypatch.setattr( + b06_router, + "get_route_generation_source", + _async({"route_data_path": "route.geojson", "crs_epsg": 5186}), + ) + monkeypatch.setattr( + b06_router, + "get_surface_confirmation_params", + _async({"source_filter": "csf", "method": "dtm", "smooth": False}), + ) + monkeypatch.setattr(b06_router, "get_project_storage_relative_path", _async("p")) + monkeypatch.setattr(b06_router, "get_latest_section_options", latest_options) + monkeypatch.setattr(b06_router, "get_workflow_state", _async(state)) + monkeypatch.setattr(b06_router, "resolve_stored_project_path", lambda path: str(tmp_path)) + monkeypatch.setattr(b06_router, "run_section_generation", generate) + monkeypatch.setattr(b06_router, "delete_sections_for_route", _async(None)) + monkeypatch.setattr(b06_router, "create_longitudinal_section", _async(None)) + monkeypatch.setattr(b06_router, "insert_cross_sections", _async(None)) + monkeypatch.setattr(b06_router, "attach_saved_shapes", lambda *a: None) + return calls + + +def _old_flow_options(): + """옛 흐름 — solve 첫 생성 옵션(요청 → 옛 저장 → 기본)을 4c 가 DB 에서 읽어 다시 고른 값.""" + request = RouteSolveRequest( + filter_key="csf", + method="dtm", + smooth=False, + algorithm="as_planned", + bp=PARAMS["points"]["bp"], + ep=PARAMS["points"]["ep"], + cross_half_width_m=PARAMS["cross_half_width_m"], + cross_sample_interval_m=PARAMS["cross_sample_interval_m"], + ) + first = b05_router._section_options(request, OLD_STORED) + stored_by_first = {**asdict(first), "extra_stations": []} + return b06_router._regeneration_options( + stored_by_first, PARAMS, float(stored_by_first["cross_half_width_m"]) + ) + + +def test_종횡단은_한_번_관_뒤에(world): + failure = asyncio.run(chain.run_redesign_chain(uuid4(), 1, {})) + assert failure is None + assert world["solve_generates"] is False + assert len(world["generate"]) == 1 + order = world["order"] + assert order.index("solve") < order.index("drainage") < order.index("generate") + assert order.index("generate") < order.index("confirm_sections") + + +def test_한_번이_옛_마지막_생성과_같은_입력(world): + asyncio.run(chain.run_redesign_chain(uuid4(), 1, {})) + got = world["generate"][0] + expected = _old_flow_options() + assert got["options"] == expected + # 옛 노선 저장 반폭(30)이 아니라 요청 반폭(20) — 첫 생성이 남기던 값 + assert got["options"].cross_half_width_m == 20.0 + assert got["options"].cross_sample_interval_m == 0.5 + assert got["options"].station_interval_m == 25.0 + assert got["grade_options"] == b06_router._regeneration_grade_options(PARAMS) + assert "grade_overrides" not in got # 옛 4c 도 안 넘김 — 저장본 grade_overrides 빈 값 그대로 + # 생성 쪽은 DB 최신 종단(=옛 노선)을 다시 읽지 않음 — 체인이 넘긴 옵션 + assert world["latest_options"] == 1 # 체인이 옛 노선 옵션을 읽은 한 번뿐 + + +def test_배수유역이_실패해도_종횡단은_선다(world): + world["region"] = JSONResponse(status_code=500, content={}) + failure = asyncio.run(chain.run_redesign_chain(uuid4(), 1, {})) + assert failure is None + assert len(world["generate"]) == 1 + assert world["generate"][0]["options"] == _old_flow_options() + + +def _body(source: str, name: str) -> str: + start = source.index(f"async def {name}(") + following = re.search(r"\n(async def|def|@router)", source[start + 10 :]) + return source[start : start + 10 + following.start()] if following else source[start:] + + +def test_자동_체인은_재계산_짓기를_따로_안_부름(): + source = (ROOT / "B03_FileInput" / "B03_FileInput_Service_Chain.py").read_text("utf-8") + auto = _body(source, "run_auto_design_chain") + assert "recompute_server_side" not in auto + assert "prebuild_corridor" not in auto + assert "confirm_sections(" in auto + + +def test_확정_저장_끝에서_재계산_뒤_짓기(): + source = (ROOT / "B06_Section" / "B06_Section_Router_Confirm.py").read_text("utf-8") + for name in ("confirm_sections", "save_sections"): + body = _body(source, name) + assert body.count("_recompute_stored_designs(") == 1, name + assert body.count("_prebuild_corridor(") == 1, name + assert body.index("_recompute_stored_designs(") < body.index("_prebuild_corridor("), name + confirm = _body(source, "confirm_sections") + # 측구 방향 역반영(종단 파일 고침)까지 끝난 뒤 짓기 + assert confirm.index("_merge_uphill_overrides_into_longitudinal") < confirm.index( + "_prebuild_corridor(" + ) + + +def test_브라우저는_3D_를_올리지_않음(): + source = (ROOT / "B05_Profile" / "B05_Profile_UI_Corridor.ts").read_text("utf-8") + assert '"PUT"' not in source + assert "putStored" not in source diff --git a/resources/tester/test_b05_corridor_router.py b/resources/tester/test_b05_corridor_router.py index 4914c3848..72bc88649 100644 --- a/resources/tester/test_b05_corridor_router.py +++ b/resources/tester/test_b05_corridor_router.py @@ -1,6 +1,6 @@ """B05 코리도(예상형상) 저장 라우터 검증. -파생 데이터 보관 계약 — 경로 규칙, PUT 검증(형식·크기), 파일 왕복. +파생 데이터 보관 계약 — 경로 규칙 · 브라우저 올림(PUT) 없음(342-2 서버가 지음) · 파일 왕복. DB 의존(_resolve_project_root)은 스텁으로 대체해 파일 로직만 검증한다. """ @@ -24,19 +24,16 @@ def test_corridor_path_layout(tmp_path): assert path == tmp_path / "B05_Profile" / "corridor" / "corridor_0007.json" -def test_put_rejects_wrong_shape(tmp_path, monkeypatch): - """ribbons 없는 본문은 422 — 파생 데이터라도 최소 형식은 지킨다.""" - - async def fake_root(_project_id): - return tmp_path - - monkeypatch.setattr(corridor, "_resolve_project_root", fake_root) - response = asyncio.run(corridor.put_corridor(uuid4(), 1, {"nope": True})) - assert response.status_code == 422 +def test_put_removed(): + """브라우저 올림 길 없음 — 3D 길 모형은 서버 Node 가 [저장]·[확정]·체인 끝에 지음(342-2).""" + assert not hasattr(corridor, "put_corridor") + methods = {(route.path, method) for route in corridor.router.routes for method in route.methods} + assert all(method != "PUT" for _, method in methods) -def test_put_then_get_roundtrip(tmp_path, monkeypatch): - """PUT 저장본을 GET이 그대로 돌려준다(원자 기록·경로 일치).""" +def test_stored_then_get_roundtrip(tmp_path, monkeypatch): + """서버가 둔 저장본을 GET이 그대로 돌려준다(경로 일치).""" + from common_util.common_util_json import atomic_write_json async def fake_root(_project_id): return tmp_path @@ -48,14 +45,15 @@ def test_put_then_get_roundtrip(tmp_path, monkeypatch): "ribbons": [{"kind": "cut", "side": "left", "colCount": 2, "chainages": [0, 2]}], "outline": {"chainages": [0, 2], "left": [[0, 1]], "right": [[0, -1]]}, } - put_response = asyncio.run(corridor.put_corridor(uuid4(), 3, payload)) - assert put_response.status_code == 200 - stored = tmp_path / "B05_Profile" / "corridor" / "corridor_0003.json" - assert stored.is_file() + stored = corridor.corridor_path(tmp_path, 3) + stored.parent.mkdir(parents=True) + atomic_write_json(stored, payload) get_response = asyncio.run(corridor.get_corridor(uuid4(), 3)) assert get_response.status_code == 200 assert json.loads(get_response.body) == payload + stale = asyncio.run(corridor.get_corridor(uuid4(), 3, hash="cafe")) + assert json.loads(stale.body) == {"status": "stale", "hash": "deadbeef"} def test_get_missing_returns_404(tmp_path, monkeypatch): @@ -67,16 +65,3 @@ def test_get_missing_returns_404(tmp_path, monkeypatch): monkeypatch.setattr(corridor, "_resolve_project_root", fake_root) response = asyncio.run(corridor.get_corridor(uuid4(), 99)) assert response.status_code == 404 - - -def test_put_rejects_oversize(tmp_path, monkeypatch): - """크기 상한 초과는 413 — 비정상 요청 차단.""" - - async def fake_root(_project_id): - return tmp_path - - monkeypatch.setattr(corridor, "MAX_CORRIDOR_BYTES", 64) - monkeypatch.setattr(corridor, "_resolve_project_root", fake_root) - payload = {"version": 1, "hash": "x", "ribbons": [{"blob": "y" * 200}], "outline": {}} - response = asyncio.run(corridor.put_corridor(uuid4(), 1, payload)) - assert response.status_code == 413 diff --git a/resources/tester/test_user_values_survive.py b/resources/tester/test_user_values_survive.py index 36a7abf5d..04ef0fefe 100644 --- a/resources/tester/test_user_values_survive.py +++ b/resources/tester/test_user_values_survive.py @@ -13,6 +13,7 @@ """ import math +import re import sys from pathlib import Path @@ -189,7 +190,9 @@ def test_이월은_측점_재생성_뒤에_한다(): source = (PROJECT_ROOT / "B03_FileInput" / "B03_FileInput_Service_Chain.py").read_text( encoding="utf-8" ) - rebuild = source.index("_prepare_drainage_pipes_and_reprofile(project_id, new_route_id") + rebuild = re.search( + r"_prepare_drainage_pipes_and_reprofile\(\s*project_id, new_route_id", source + ).start() carry = source.index("merge_cross_section_designs(") assert carry > rebuild, "이월이 측점 재생성보다 앞섰음 — 덮어써서 사라진다" From a2c512b30ca3d03476da3dded74385a6bc777810 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 16 Oct 2026 11:04:19 +0900 Subject: [PATCH 07/18] =?UTF-8?q?chore(B05=20=EC=A0=95=EB=A6=AC):=20342-4?= =?UTF-8?q?=20B05=5FProfile/Old=2027=ED=8C=8C=EC=9D=BC=20=EC=A7=80?= =?UTF-8?q?=EC=9B=80=20=C2=B7=20=EB=85=B8=EC=84=A0=20=EA=B1=B0=EC=9A=B8=20?= =?UTF-8?q?=EC=8B=9C=ED=97=98=20resources/tester=20=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Old/ 부르는 곳 0(주석만) · tsconfig exclude 줄 정리 · typecheck 통과 - test_route_polyline_browser_mirror.py — tmp/tests 에서 옮김 · 빌드 자리 = pytest 임시 폴더 · 10 통과 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013QyNXY2TUKXCFMwAca2APG --- B05_Profile/Old/B05_Profile_UI_Corridor.ts | 292 -------- .../Old/B05_Profile_UI_Corridor_Build.ts | 635 ---------------- .../B05_Profile_UI_Corridor_Build_Types.ts | 102 --- .../Old/B05_Profile_UI_Corridor_Carve.ts | 494 ------------- .../Old/B05_Profile_UI_Corridor_Carve_Wing.ts | 256 ------- .../Old/B05_Profile_UI_Corridor_Clip.ts | 468 ------------ .../Old/B05_Profile_UI_Corridor_Cut.ts | 620 ---------------- .../Old/B05_Profile_UI_Corridor_Deform.ts | 692 ------------------ ...05_Profile_UI_Corridor_Deform_Breakline.ts | 612 ---------------- ...05_Profile_UI_Corridor_Deform_Structure.ts | 430 ----------- .../B05_Profile_UI_Corridor_Deform_Terrain.ts | 317 -------- .../Old/B05_Profile_UI_Corridor_Envelope.ts | 331 --------- .../Old/B05_Profile_UI_Corridor_Legacy.ts | 60 -- .../Old/B05_Profile_UI_Corridor_Mesh.ts | 538 -------------- .../Old/B05_Profile_UI_Corridor_Modes.ts | 207 ------ .../Old/B05_Profile_UI_Corridor_Plan.ts | 629 ---------------- .../B05_Profile_UI_Corridor_Plan_Footprint.ts | 348 --------- .../Old/B05_Profile_UI_Corridor_Region.ts | 404 ---------- .../Old/B05_Profile_UI_Corridor_Skirt.ts | 223 ------ .../Old/B05_Profile_UI_Corridor_Station.ts | 607 --------------- .../Old/B05_Profile_UI_Corridor_Tin.ts | 375 ---------- .../Old/B05_Profile_UI_Corridor_Tin_Design.ts | 349 --------- .../Old/B05_Profile_UI_Corridor_Union.ts | 34 - B05_Profile/Old/B05_Profile_UI_Panel_View.ts | 143 ---- B05_Profile/Old/B05_Profile_UI_RouteCalc.ts | 192 ----- .../Old/B05_Profile_UI_Viewer_Corridor.ts | 317 -------- .../Old/B05_Profile_UI_Viewer_Debug.ts | 55 -- .../test_route_polyline_browser_mirror.py | 243 ++++++ tsconfig.json | 2 +- 29 files changed, 244 insertions(+), 9731 deletions(-) delete mode 100644 B05_Profile/Old/B05_Profile_UI_Corridor.ts delete mode 100644 B05_Profile/Old/B05_Profile_UI_Corridor_Build.ts delete mode 100644 B05_Profile/Old/B05_Profile_UI_Corridor_Build_Types.ts delete mode 100644 B05_Profile/Old/B05_Profile_UI_Corridor_Carve.ts delete mode 100644 B05_Profile/Old/B05_Profile_UI_Corridor_Carve_Wing.ts delete mode 100644 B05_Profile/Old/B05_Profile_UI_Corridor_Clip.ts delete mode 100644 B05_Profile/Old/B05_Profile_UI_Corridor_Cut.ts delete mode 100644 B05_Profile/Old/B05_Profile_UI_Corridor_Deform.ts delete mode 100644 B05_Profile/Old/B05_Profile_UI_Corridor_Deform_Breakline.ts delete mode 100644 B05_Profile/Old/B05_Profile_UI_Corridor_Deform_Structure.ts delete mode 100644 B05_Profile/Old/B05_Profile_UI_Corridor_Deform_Terrain.ts delete mode 100644 B05_Profile/Old/B05_Profile_UI_Corridor_Envelope.ts delete mode 100644 B05_Profile/Old/B05_Profile_UI_Corridor_Legacy.ts delete mode 100644 B05_Profile/Old/B05_Profile_UI_Corridor_Mesh.ts delete mode 100644 B05_Profile/Old/B05_Profile_UI_Corridor_Modes.ts delete mode 100644 B05_Profile/Old/B05_Profile_UI_Corridor_Plan.ts delete mode 100644 B05_Profile/Old/B05_Profile_UI_Corridor_Plan_Footprint.ts delete mode 100644 B05_Profile/Old/B05_Profile_UI_Corridor_Region.ts delete mode 100644 B05_Profile/Old/B05_Profile_UI_Corridor_Skirt.ts delete mode 100644 B05_Profile/Old/B05_Profile_UI_Corridor_Station.ts delete mode 100644 B05_Profile/Old/B05_Profile_UI_Corridor_Tin.ts delete mode 100644 B05_Profile/Old/B05_Profile_UI_Corridor_Tin_Design.ts delete mode 100644 B05_Profile/Old/B05_Profile_UI_Corridor_Union.ts delete mode 100644 B05_Profile/Old/B05_Profile_UI_Panel_View.ts delete mode 100644 B05_Profile/Old/B05_Profile_UI_RouteCalc.ts delete mode 100644 B05_Profile/Old/B05_Profile_UI_Viewer_Corridor.ts delete mode 100644 B05_Profile/Old/B05_Profile_UI_Viewer_Debug.ts create mode 100644 resources/tester/test_route_polyline_browser_mirror.py diff --git a/B05_Profile/Old/B05_Profile_UI_Corridor.ts b/B05_Profile/Old/B05_Profile_UI_Corridor.ts deleted file mode 100644 index 0f4545232..000000000 --- a/B05_Profile/Old/B05_Profile_UI_Corridor.ts +++ /dev/null @@ -1,292 +0,0 @@ -/* ============================================================================= - * B05_Profile_UI_Corridor.ts - * 코리도(3D 예상형상) 오케스트레이션 — 로드·빌드·저장·캐시. - * - * 흐름(2026-08-23 사용자 확정): - * 1) 초기: 종횡단 정본 로드 시 브라우저가 정밀 빌드 → 표시. 저장본이 없던 - * 경우에만 즉시 영구저장(PUT). - * 2) 재접근: 저장본 GET → 버전 해시가 현 정본과 일치하면 그대로 표시, - * 불일치·404면 재빌드(dirty 표시). - * 3) 수정: 재빌드는 메모리에서만 — 임시저장·종단/횡단 페이지 이동 시 - * saveCorridorIfDirty()로 영구저장. - * 버전 해시 = 종횡단 정본의 설계 입력 요약(FNV-1a) — 측점·계획고·설계선이 - * 바뀌면 달라진다. 구조물 서피스 훅(CorridorStructureHook)은 예약만(범위 제외). - * ========================================================================== */ - -import { API_BASE_URL, API_TIMEOUT_MS } from "@config/config_frontend"; -import { - purgeAssetsWithPrefix, - readCachedBytes, - writeCachedBytes, -} from "../A00_Common/b_asset_cache"; -import type { SectionDetailResponse } from "../B06_Section/B06_Section_Api_Fetch"; -import type { RoutePoint } from "./B05_Profile_Api_Fetch"; -import { buildCorridor, type CorridorBuildResult } from "./B05_Profile_UI_Corridor_Build"; -import { - corridorHash, - ENVELOPE_VERSION, - deserialize, - serialize, - type CorridorEnvelope, -} from "./B05_Profile_UI_Corridor_Envelope"; -import { rememberLegacyInputs } from "./B05_Profile_UI_Corridor_Legacy"; - -/** 구조물별 서피스 대체/삽입 훅 — 별도 지침 후 구현 예정(2026-08-23 범위 제외). */ -export interface CorridorStructureHook { - /** 구조물이 점유한 측점 구간 [start, end] (chainage m) — 이 구간 리본을 대체한다. */ - applies: (chainageStart: number, chainageEnd: number) => boolean; -} - -interface CacheEntry { - hash: string; - build: CorridorBuildResult; - dirty: boolean; -} - -const cache = new Map(); - -function keyOf(projectId: string, routeId: number): string { - return `${projectId}:${routeId}`; -} - -async function requestCorridor(path: string, init: RequestInit): Promise { - const controller = new AbortController(); - const timeoutId = window.setTimeout(() => controller.abort(), API_TIMEOUT_MS); - try { - return await fetch(`${API_BASE_URL}${path}`, { - ...init, - credentials: "include", - headers: { "Content-Type": "application/json", ...(init.headers ?? {}) }, - signal: controller.signal, - }); - } finally { - window.clearTimeout(timeoutId); - } -} - -/** 저장본 조회 결과 — 파일을 받았거나, 열쇠가 어긋나 안 받았거나, 아예 없거나. */ -type StoredLookup = - { kind: "envelope"; envelope: CorridorEnvelope } | { kind: "stale" } | { kind: "missing" }; - -/** - * 저장본을 가져온다. **열쇠(hash)를 함께 보내면 서버가 먼저 맞춰 본다** — 어긋나면 - * 파일 대신 수십 바이트짜리 `stale` 만 온다(2026-09-06). 그 전에는 18.6MB 를 다 받은 뒤 - * 해시가 다르다고 버렸다. - */ -/** 이 노선 코리도의 보관 주소 앞머리 — 열쇠만 다른 옛 보관본을 치울 때 쓴다. */ -function corridorUrlPrefix(projectId: string, routeId: number): string { - return `${API_BASE_URL}/projects/${projectId}/routes/${routeId}/corridor`; -} - -async function fetchStored( - projectId: string, - routeId: number, - hash: string, -): Promise { - const url = `${corridorUrlPrefix(projectId, routeId)}?hash=${encodeURIComponent(hash)}`; - try { - // **브라우저 보관함을 먼저 본다**(2026-09-06) — 저장본이 최신이면 열쇠가 맞아 매번 - // 17MB 를 받았고, 새로고침 한 번이면 메모리 캐시가 날아가 또 받았다. 주소에 열쇠가 - // 박혀 있으므로 정본이 바뀌면 저절로 다른 주소가 되어 옛 보관본을 안 쓴다. - const cached = await readCachedBytes(projectId, url); - if (cached) { - const payload = JSON.parse(new TextDecoder().decode(cached)) as CorridorEnvelope; - if (payload?.version === ENVELOPE_VERSION && Array.isArray(payload.ribbons)) { - return { kind: "envelope", envelope: payload }; - } - } - const response = await requestCorridor( - `/projects/${projectId}/routes/${routeId}/corridor?hash=${encodeURIComponent(hash)}`, - { method: "GET" }, - ); - if (!response.ok) return { kind: "missing" }; - const text = await response.text(); - const payload = JSON.parse(text) as CorridorEnvelope & { status?: string }; - if (payload?.status === "stale") return { kind: "stale" }; - if (payload && payload.version === ENVELOPE_VERSION && Array.isArray(payload.ribbons)) { - // 담기 전에 열쇠만 다른 옛 보관본을 치운다 — 17MB 짜리가 쌓이지 않게. - void purgeAssetsWithPrefix(projectId, corridorUrlPrefix(projectId, routeId)).then(() => - writeCachedBytes(projectId, url, new TextEncoder().encode(text).buffer as ArrayBuffer), - ); - return { kind: "envelope", envelope: payload }; - } - return { kind: "missing" }; - } catch { - return { kind: "missing" }; // 저장본 조회 실패는 빌드로 폴백 — 표시를 막지 않는다. - } -} - -async function putStored( - projectId: string, - routeId: number, - envelope: CorridorEnvelope, -): Promise { - try { - const response = await requestCorridor(`/projects/${projectId}/routes/${routeId}/corridor`, { - method: "PUT", - body: JSON.stringify(envelope), - }); - return response.ok; - } catch { - return false; - } -} - -/** - * 코리도 준비 — 캐시/저장본/빌드 순으로 확보해 반환. - * 반환된 build는 뷰어 setCorridor가 심 보정으로 제자리 수정할 수 있다(직렬화는 - * saveCorridorIfDirty 시점의 최신 상태를 담는다). - */ -/** 종단 계획선 샘플(라이브 편집 반영분) — buildCorridor designSamples와 같은 꼴. */ -export type ProfileSamples = Array<{ - chainage_m: number; - elevation_m: number; - ground_elevation_m?: number; -}>; - -/** 마지막 코리도가 **어디서 왔는지** — 저장본을 그대로 썼나, 브라우저가 다시 만들었나. - * 서버 사전 생성(2026-09-04)이 실제로 먹었는지 화면 밖에서 수치로 확인하는 창구다 - * (`__corridorScene`·`__corridorSkirt`와 같은 디버그 훅). */ -declare global { - interface Window { - __corridorSource?: { source: "stored" | "built"; hash: string; at: number }; - } -} - -function markSource(source: "stored" | "built", hash: string): void { - window.__corridorSource = { source, hash, at: Date.now() }; -} - -export async function ensureCorridor( - projectId: string, - routeId: number, - detail: SectionDetailResponse, - routePoints: RoutePoint[], - designSamples?: ProfileSamples, - /** 저장본이 낡았을 때 **브라우저가 다시 만들지** 여부. 진입 경로는 false 로 부른다 — - * 3D 는 [3D 업데이트]로만 도는 수동 조작인데 진입만 자동으로 남아 있었다(2026-09-06). */ - rebuild = true, -): Promise { - const key = keyOf(projectId, routeId); - const hash = corridorHash(detail, routePoints); - const cached = cache.get(key); - if (cached && cached.hash === hash) return cached.build; - - const lookup = await fetchStored(projectId, routeId, hash); - if (lookup.kind === "envelope" && lookup.envelope.hash === hash) { - try { - const build = deserialize(lookup.envelope); - cache.set(key, { hash, build, dirty: false }); - markSource("stored", hash); - return build; - } catch { - // 손상 저장본 — 빌드로 폴백. - } - } - // 저장본이 낡았고 다시 만들지 않기로 했으면 여기서 끝낸다 — 캐시도 건드리지 않아 - // 화면에 이미 서 있는 예상형상이 그대로 남는다([3D 업데이트] 대기 표시는 부르는 쪽 몫). - if (!rebuild) return null; - // 저장본이 아예 없는 경우(missing)와 있는데 낡은 경우(stale)를 가른다 — 아래 저장 규칙이 - // 갈린다. `stale` 도 파일은 있으므로 즉시 PUT 하지 않고 [저장]·페이지 이동 때 올린다. - const hasStored = lookup.kind !== "missing"; - - // 종단 계획선 샘플을 함께 넘겨 측점 사이가 종단곡선을 따라 부드럽게 이어지게 한다. - // 라이브 편집분(alignment.samples)이 있으면 그걸 쓴다 — 정본 design_profiles는 - // 편집 전 값이라 전환점·종단 보정이 낡은 계획고를 따라간다(2026-08-23 지적 ③). - const build = buildCorridor( - detail.cross_sections, - routePoints, - designSamples ?? detail.longitudinal.design_profiles?.[0]?.samples, - ); - if (!build) { - cache.delete(key); - return null; - } - markSource("built", hash); - if (!hasStored) { - // 최초 생성 — 계획 확정 흐름대로 즉시 영구저장(실패해도 표시는 진행). - cache.set(key, { hash, build, dirty: false }); - void putStored(projectId, routeId, serialize(build, hash)).then((ok) => { - if (!ok) markDirty(projectId, routeId); - }); - } else { - // 정본이 저장본보다 새것 — 임시저장·페이지 이동 때 저장한다(사용자 확정 흐름). - cache.set(key, { hash, build, dirty: true }); - } - return build; -} - -function markDirty(projectId: string, routeId: number): void { - const entry = cache.get(keyOf(projectId, routeId)); - if (entry) entry.dirty = true; -} - -/** 임시저장·B05↔B06 페이지 이동 훅 — 미저장 변경분이 있을 때만 PUT. */ -export async function saveCorridorIfDirty(projectId: string, routeId: number): Promise { - const entry = cache.get(keyOf(projectId, routeId)); - if (!entry || !entry.dirty) return; - const envelope = serialize(entry.build, entry.hash); - const ok = await putStored(projectId, routeId, envelope); - if (!ok) return; - entry.dirty = false; - // 방금 올린 것을 **보관함에도** 담는다(2026-09-06) — 그러지 않으면 다음 진입이 열쇠가 - // 바뀐 주소로 한 번 더 받아 온다. 담아 두면 네트워크 없이 선다. - const url = `${corridorUrlPrefix(projectId, routeId)}?hash=${encodeURIComponent(entry.hash)}`; - const bytes = new TextEncoder().encode(JSON.stringify(envelope)).buffer as ArrayBuffer; - await purgeAssetsWithPrefix(projectId, corridorUrlPrefix(projectId, routeId)); - await writeCachedBytes(projectId, url, bytes); -} - -/** 새 방식 지형 변형이 읽을 측점 단면을 얹는다(115-1 · 저장본에는 안 담긴다). - * 옛 방식 A(115-4) 입력도 함께 묶는다 — 단추를 켤 때 즉석 빌드용(메모리만). */ -function withSections( - build: CorridorBuildResult | null, - detail: SectionDetailResponse, - routePoints: RoutePoint[], - designSamples?: ProfileSamples, -): CorridorBuildResult | null { - if (build) { - build.sections = detail.cross_sections; - rememberLegacyInputs(build, { detail, routePoints, designSamples }); - } - return build; -} - -/** Page 훅 — 현재 종횡단 정본 그대로 코리도를 확보해 뷰어에 반영(실패 시 제거). - * 끝나는 시점을 [3D 업데이트] 버튼이 기다려야 해 Promise를 돌려준다(2026-09-01). */ -export function refreshCorridor( - viewer: { setCorridor: (build: CorridorBuildResult | null) => void }, - projectId: string, - routeId: number | undefined, - detail: SectionDetailResponse, - routePoints: RoutePoint[], - designSamples?: ProfileSamples, - rebuild = true, -): Promise { - if (!routeId) return Promise.resolve(); - return ensureCorridor(projectId, routeId, detail, routePoints, designSamples, rebuild) - .then((build) => viewer.setCorridor(withSections(build, detail, routePoints, designSamples))) - .catch(() => viewer.setCorridor(null)); -} - -/** - * 진입 전용 — 저장본이 **그대로 맞을 때만** 3D 에 올린다. 낡았으면 받지도 만들지도 않고 - * `false` 를 돌려주므로, 부르는 쪽이 [3D 업데이트] 대기 표시를 켜면 된다(2026-09-06). - * 돌아온 값이 곧 「지금 화면의 3D 가 최신인가」다. - */ -export function loadCorridorIfFresh( - viewer: { setCorridor: (build: CorridorBuildResult | null) => void }, - projectId: string, - routeId: number | undefined, - detail: SectionDetailResponse, - routePoints: RoutePoint[], - designSamples?: ProfileSamples, -): Promise { - if (!routeId) return Promise.resolve(false); - return ensureCorridor(projectId, routeId, detail, routePoints, designSamples, false) - .then((build) => { - if (build) viewer.setCorridor(withSections(build, detail, routePoints, designSamples)); - return build !== null; - }) - .catch(() => false); -} diff --git a/B05_Profile/Old/B05_Profile_UI_Corridor_Build.ts b/B05_Profile/Old/B05_Profile_UI_Corridor_Build.ts deleted file mode 100644 index 5b8082bc3..000000000 --- a/B05_Profile/Old/B05_Profile_UI_Corridor_Build.ts +++ /dev/null @@ -1,635 +0,0 @@ -/* ============================================================================= - * B05_Profile_UI_Corridor_Build.ts - * 계획노선 코리도(3D 예상형상) **순수 기하 빌더** — Three.js 의존 없음. - * - * 입력: 종횡단 상세(SectionDetailResponse)의 측점별 설계선(design_line)과 - * 측점 프레임(center, left_xy), 노선 폴리라인(route_points). - * 출력: 종류별(차도/노견/측구/절토비탈/성토비탈) 리본 정점 격자(모델 좌표)와 - * 좌우 catch line 외곽(원지반 클리핑 경계). - * - * 원칙(2026-08-23 계획): 계산식 재구현 금지 — 백엔드가 내려준 design_line을 - * offset 경계로 잘라 분류만 한다. 측점 사이는 노선 폴리라인을 따라 중간 - * 프레임을 삽입하고 단면을 파라메트릭 보간해 곡선 구간이 각지지 않게 한다. - * ========================================================================== */ - -import type { RoutePoint } from "./B05_Profile_Api_Fetch"; -import type { CrossSection } from "../B06_Section/B06_Section_Api_Fetch"; -import { - classifyStation, - mixedGround, - PIECE_COLS, - pieceKey, - resample, - trimSlopeToGround, - type CorridorKind, - type CorridorSide, - type OffsetPoint, - type StationPieces, - type XY, -} from "./B05_Profile_UI_Corridor_Station"; - -export type { CorridorKind, CorridorSide } from "./B05_Profile_UI_Corridor_Station"; -import { - bracketControls, - buildPieceControls, - buildPolylineSampler, - buildCrossingFinder, - buildProfileSampler, - lerpPoints, - slerpLeft, - type PieceControl, -} from "./B05_Profile_UI_Corridor_Frames"; -import { buildCorridorStructures, type RouteFrame } from "./B05_Profile_UI_Corridor_Structures"; -export type { CorridorStructure, StructureFrame } from "./B05_Profile_UI_Corridor_Structures"; -import type { - CorridorBuildResult, - CorridorCap, - CorridorRibbon, -} from "./B05_Profile_UI_Corridor_Build_Types"; -// 결과 자료형은 `_Build_Types` 에 있다(2026-09-02 분리) — 호출부 경로 유지를 위해 재수출. -export type { - CorridorBuildResult, - CorridorCap, - CorridorCutWall, - CorridorRibbon, -} from "./B05_Profile_UI_Corridor_Build_Types"; -import { buildStructurePatchRibbons } from "./B05_Profile_UI_Corridor_Carve"; -import { structureSilhouettes } from "./B05_Profile_UI_Corridor_Station_Structure"; -import { buildPlanCurves } from "./B05_Profile_UI_Corridor_Plan"; -import { maskFillByPlanCurves } from "./B05_Profile_UI_Corridor_Cut"; -export type { PlanCurve } from "./B05_Profile_UI_Corridor_Plan"; - -/** 리본 하나 — rows[i]는 프레임 i의 단면 점(colCount개, 모델 x/y/z 평탄 배열). */ -/** - * 구조물 구간 **성토 패치 리본** 적용 여부(2026-08-27 사용자). B06 횡단이 내는 - * 변형 성토선을 3D 포켓에 되메운다 — 다단이면 `성토 > 구조물 > 성토`라 성토선이 - * 하나가 아니므로 **구간 1줄 = 리본 1장**으로 낸다(`_Carve.ts` `fillRuns`). - * - * 절취(`FILL_CUT_APPLY`)와 별개다: 패치는 잘린 자리를 채우기만 하고, 절취·측벽· - * 경계 트림(0-2~0-6)은 그대로 둔다. 패치 리본은 절취 대상이 아니다 - * (`_Cut.ts` `ribbon.patch` 제외). - */ -const FILL_PATCH_APPLY: boolean = true; - -/** 종방향 세분 간격(m) — 곡선 각짐 방지(2026-08-23 사용자: 노선 폴리라인 따라 세분). - * 평면 궤적을 스플라인으로 잇게 되어(2026-08-23) 0.1m까지 쪼갤 이유가 없어졌다 — - * 0.2m면 곡선이 충분히 매끄럽고 데이터·연산은 절반이다(사용자 제안). */ -const SUBDIVIDE_STEP_M = 0.2; - -/** - * 투영 커브 **안쪽 블록**의 종방향 걸음(m) — 2026-08-26 사용자 B안. - * 지금은 바깥과 같은 0.2m라 경계 행만 새로 끼워 넣는 셈이다. 더 촘촘한 A안(0.1m)으로 - * 바꾸려면 이 값만 내리면 된다(사용자: "보고 판단, A로 바꿀 수도 있다"). - */ -const STRUCTURE_SPAN_STEP_M = SUBDIVIDE_STEP_M; - -/** - * 투영 커브 안쪽 **성토면 도려내기** 적용 여부(2026-08-26 사용자). 끄면 커브만 얹고 - * 서피스는 그대로 둔다. 절토면은 대상이 아니다(사용자: 성토면만). - */ -const FILL_CUT_APPLY: boolean = true; - -/** - * 구조물 점유 구간의 시작·끝 누가거리 — 여기를 **행 경계로 강제**해야 탑뷰 투영 - * 커브가 격자 모서리 위에 앉는다. 값은 `_Corridor_Plan.ts`의 비탈 커브 구간과 같은 - * 출처(`structureSilhouettes`의 전/후 몫)를 쓴다 — 두 곳이 어긋나면 커브 끝이 다시 - * 셀 중간에 뜬다. - */ -interface StructureSpanRange { - side: CorridorSide; - from: number; - to: number; -} - -function structureSpanBounds(crossSections: ReadonlyArray): StructureSpanRange[] { - const spans: StructureSpanRange[] = []; - for (const section of crossSections) { - for (const silhouette of structureSilhouettes(section)) { - spans.push({ - side: silhouette.side, - from: section.chainage_m - silhouette.span.beforeM, - to: section.chainage_m + silhouette.span.afterM, - }); - } - } - return spans; -} - -/** - * 한 측점 구간(s0~s1)의 행 자리(0~1). 등간격 걸음에 **구조물 구간 경계**를 끼워 - * 넣고 정렬·중복 제거한다. 비-마지막 구간은 t=1을 빼는 현행 규칙을 그대로 지킨다 - * (다음 구간의 t=0과 겹치면 두께 0 행이 생긴다). - */ -function rowFractions( - startChainageM: number, - spanM: number, - steps: number, - includeEnd: boolean, - spans: ReadonlyArray, -): number[] { - const ts: number[] = []; - for (let k = 0; k <= (includeEnd ? steps : steps - 1); k += 1) ts.push(k / steps); - const endChainageM = startChainageM + spanM; - const insideStep = Math.max(0.01, STRUCTURE_SPAN_STEP_M); - const put = (chainageM: number): void => { - const t = (chainageM - startChainageM) / spanM; - if (t > 1e-9 && t < 1 - 1e-9) ts.push(t); - }; - for (const { from, to } of spans) { - if (to <= startChainageM || from >= endChainageM) continue; - put(from); - put(to); - // 안쪽 블록만 자기 걸음으로 다시 나눈다. 바깥과 같은 걸음(B안)이면 아래 중복 - // 제거가 전부 걸러 내 정점이 늘지 않는다 — 경계 행만 새로 생긴다. - if (insideStep < SUBDIVIDE_STEP_M - 1e-9) { - for (let at = from + insideStep; at < to - 1e-9; at += insideStep) put(at); - } - } - ts.sort((a, b) => a - b); - // 1mm보다 가까운 행은 합친다 — 겹친 행은 넓이 0 삼각형만 만든다. - const epsilon = spanM > 1e-9 ? 0.001 / spanM : 1; - return ts.filter((t, index) => index === 0 || t - ts[index - 1] > epsilon); -} - -/** 노선 폴리라인 누적거리 파라미터화 — chainage로 XY를 보간한다. */ -/** 종단 보정량 — 도로부(차도·노견·측구)는 계획고에 붙어 있으므로 전량 가산한다. - * 비탈은 상대 좌표라 행 앵커를 통해 보정이 실리고, 끝은 지반 트림이 정한다. */ -function applyProfileShift(points: OffsetPoint[], shift: number): void { - points.forEach((point) => (point.elevation_m += shift)); -} - -/** - * 코리도 빌드 — 측점별 분류 후 노선 폴리라인을 따라 세분·보간해 종류별 리본과 - * 클리핑 외곽선을 만든다. design 있는 측점이 2개 미만이면 null. - */ -export function buildCorridor( - crossSections: CrossSection[], - routePoints: RoutePoint[], - /** - * 종단 계획선 샘플 — 두 가지에 쓴다. - * ① 측점 사이를 직선이 아닌 **계획선 그대로**(종단곡선 포함) 따라가게 한다. - * ② 계획고와 지반고가 만나는 자리 = **절토↔성토 전환점**을 찾는다(2026-08-23 - * 사용자 확정): 그 지점이 측구·절토의 종점이고, 거기서부터 성토로 넘어간다. - */ - designSamples?: Array<{ - chainage_m: number; - elevation_m: number; - ground_elevation_m?: number; - }>, - subdivideStepM: number = SUBDIVIDE_STEP_M, - /** - * 옛 방식 A(115-4 · 08-25 `6e87dd6cd` 「실루엣 갈아 끼우기」) — 배수관 세트 측 비탈을 - * 실루엣으로 갈아 끼우고 스케치 · 자르기 · 패치 없이 리본 · 외곽 · 마구리 · 솔리드만 낸다. - */ - options: { legacy?: boolean } = {}, -): CorridorBuildResult | null { - const legacy = options.legacy === true; - const stations = crossSections - .map((section) => classifyStation(section, { silhouette: legacy })) - .filter((s): s is StationPieces => s !== null) - .sort((a, b) => a.chainage_m - b.chainage_m); - if (stations.length < 2) return null; - - const sampler = buildPolylineSampler(routePoints); - // 종단 계획선 보간기 — 없으면 측점 간 직선(구 동작)으로 떨어진다. - const profileZ = buildProfileSampler(designSamples); - // 절토↔성토 전환점 탐색기 — 측구·절토의 종점을 이 자리로 잡는다. - const crossingAt = buildCrossingFinder(designSamples); - // 등장하는 (kind,side) 전체 — 리본 목록 확정. - const keys = new Set(); - stations.forEach((s) => s.pieces.forEach((_v, key) => keys.add(key))); - - const rows: Array<{ - chainage_m: number; - center: XY; - left: XY; - sections: Map; - outerLeft: OffsetPoint; - outerRight: OffsetPoint; - /** 그 행 지반선 — 성토 리본에 원지반 표고를 함께 실어 절단면 측벽의 밑선으로 쓴다. */ - ground: ((offset: number) => number) | null; - }> = []; - - const toModel = (row: (typeof rows)[number], point: OffsetPoint): [number, number, number] => [ - row.center.x + row.left.x * point.offset_m, - row.center.y + row.left.y * point.offset_m, - point.elevation_m, - ]; - - /** - * 조각별 종방향 제어점(2026-08-24 사용자: 구조물 구간 끝을 스무스하게). - * · 노견은 **계획고 기준 상대** 단면 — 행 계획고에 얹어 종단을 그대로 탄다. - * · 절·성토는 **앵커(도로부 바깥 끝) 기준 상대** 단면 — 아래 공통 블록이 얹는다. - */ - const designZ = (chainageM: number): number => profileZ?.(chainageM) ?? 0; - const controlsByKey = new Map(); - (["left", "right"] as const).forEach((side) => { - const shoulderKey = pieceKey("shoulder", side); - const shoulder = buildPieceControls( - stations, - side, - (station) => { - const piece = station.pieces.get(shoulderKey); - if (!piece) return undefined; - const base = designZ(station.chainage_m); - return piece.map((point) => ({ - offset_m: point.offset_m, - elevation_m: point.elevation_m - base, - })); - }, - // 노견 확장(2026-08-25 ①)은 wallSpans 없이도 구간 내내 유지돼야 한다. - (station) => station.shoulderSpans?.[side] ?? station.wallSpans?.[side], - ); - if (shoulder) controlsByKey.set(shoulderKey, shoulder); - (["cut", "fill"] as const).forEach((kind) => { - const slopeKey = pieceKey(kind, side); - const slope = buildPieceControls(stations, side, (station) => - station.slopeRelative.get(slopeKey), - ); - if (slope) controlsByKey.set(slopeKey, slope); - }); - }); - - // 구조물 솔리드·절단 경계를 행 루프보다 먼저 만든다 — 행마다 경계로 비탈을 트림한다(2026-08-25). - // 배수관 세트 구조물(기슭막이·집수정·배관) — B06 횡단과 같은 계산을 그대로 얹는다. - // 스윕 프레임은 코리도 행과 같은 규약(평면 스플라인 + 좌향 slerp)을 쓴다. - const frameAt = (at: number): RouteFrame | null => { - const first = stations[0]; - const last = stations[stations.length - 1]; - const clamped = Math.min(Math.max(at, first.chainage_m), last.chainage_m); - let index = 0; - while (index < stations.length - 2 && stations[index + 1].chainage_m < clamped) index += 1; - const a = stations[index]; - const b = stations[index + 1]; - const span = b.chainage_m - a.chainage_m; - const t = span <= 1e-9 ? 0 : (clamped - a.chainage_m) / span; - const xy = sampler?.(clamped) ?? { - x: a.center.x + (b.center.x - a.center.x) * t, - y: a.center.y + (b.center.y - a.center.y) * t, - }; - const left = slerpLeft(a.left, b.left, t); - return { - cx: xy.x, - cy: xy.y, - leftX: left.x, - leftY: left.y, - designZ: profileZ?.(clamped) ?? null, - }; - }; - const structures = buildCorridorStructures(crossSections, frameAt); - // 탑뷰 투영 커브가 격자 모서리에 앉도록 구조물 구간 경계를 행으로 끼워 넣는다. - // 옛 방식 A 는 그날처럼 등간격 행만 쓴다. - const spanBounds = legacy ? [] : structureSpanBounds(crossSections); - - for (let i = 0; i < stations.length - 1; i += 1) { - const s0 = stations[i]; - const s1 = stations[i + 1]; - const span = s1.chainage_m - s0.chainage_m; - if (span <= 1e-6) continue; - // 하한은 0으로 나누는 것만 막는다 — 0.5m로 잡아 두는 바람에 0.1m 지정이 통째로 - // 깎여 종단이 계단처럼 보였다(2026-08-23 사용자 지적, 저장본 702행으로 확인). - const steps = Math.max(1, Math.ceil(span / Math.max(0.01, subdivideStepM))); - const last = i === stations.length - 2; - // 이 구간의 절토↔성토 전환 위치(0~1). 못 찾으면 중간으로 둔다(구 동작). - const crossing = crossingAt?.(s0.chainage_m, s1.chainage_m) ?? null; - const tSwitch = - crossing === null ? 0.5 : Math.min(0.98, Math.max(0.02, (crossing - s0.chainage_m) / span)); - for (const t of rowFractions(s0.chainage_m, span, steps, last, spanBounds)) { - const chainage = s0.chainage_m + span * t; - const center = - t === 0 - ? s0.center - : t === 1 - ? s1.center - : (sampler?.(chainage) ?? { - x: s0.center.x + (s1.center.x - s0.center.x) * t, - y: s0.center.y + (s1.center.y - s0.center.y) * t, - }); - const left = slerpLeft(s0.left, s1.left, t); - // 종단 보정 — 측점 사이를 직선으로 이으면 종단곡선이 각진다. 계획선 실제 - // 표고와 직선 보간값의 차이만큼 도로부(차도·노견·측구)를 통째로 올린다. - // 비탈은 상대 좌표라 행 앵커로 보정이 실리고, 끝은 지반 트림이 정한다. - const shift = profileZ - ? (profileZ(chainage) ?? 0) - - ((profileZ(s0.chainage_m) ?? 0) + - ((profileZ(s1.chainage_m) ?? 0) - (profileZ(s0.chainage_m) ?? 0)) * t) - : 0; - const sections = new Map(); - /** 이 행에서 기슭막이 구간이라 지반 트림을 생략할 비탈 키. */ - const fixedSlopeKeys = new Set(); - /** 구조물 측점 단면을 그대로 쓴 조각 → 그 측점의 누가거리(종단 보정 기준). */ - const fixedSource = new Map(); - /** 제어점 보간으로 이미 계획고에 얹은 조각 — 종단 보정을 두 번 하지 않게 표시. */ - const controlPlaced = new Set(); - keys.forEach((key) => { - const [kind, side] = key.split(":") as [CorridorKind, CorridorSide]; - const hasA = s0.pieces.has(key); - const hasB = s1.pieces.has(key); - // 양쪽 다 없으면 그 구간에는 조각 자체가 없다 — 리본을 끊어 없는 자리에 - // 서피스가 생기지 않게 한다(2026-08-23 사용자 지적: 측구 없는 구간). - if (!hasA && !hasB) return; - // 측구는 한쪽에만 있으면 **전환점(계획선-지반선 교차, 없으면 중간)에서 - // 끊는다**(2026-08-23 사용자 확정). 횡단배수 연결 자동 계산이 생기기 - // 전까지의 표현 규칙이다. - let points: OffsetPoint[]; - if (kind === "ditch") { - // 측구는 **도로 끝에 얹는다** — 도로부는 한 몸이므로 위치를 따로 보간하지 - // 않는다(2026-08-23 사용자 지시). 한쪽 측점에만 있으면 그 절반 구간만 - // 그리되, 자리는 항상 그 프레임의 노견 끝을 따라간다. - // 측구 종점 = 절토↔성토 전환점(계획선이 지면선을 통과하는 자리). - if (hasA !== hasB && (hasA ? t > tSwitch : t < tSwitch)) return; - const relA = s0.ditchRelative.get(key); - const relB = s1.ditchRelative.get(key); - const relative = relA && relB ? lerpPoints(relA, relB, t) : (relA ?? relB); - if (!relative) return; - const edge0 = side === "right" ? s0.roadEdge.right : s0.roadEdge.left; - const edge1 = side === "right" ? s1.roadEdge.right : s1.roadEdge.left; - const edge = { - offset_m: edge0.offset_m + (edge1.offset_m - edge0.offset_m) * t, - elevation_m: edge0.elevation_m + (edge1.elevation_m - edge0.elevation_m) * t, - }; - points = relative.map((point) => ({ - offset_m: edge.offset_m + point.offset_m, - elevation_m: edge.elevation_m + point.elevation_m, - })); - } else if (kind === "cut" || kind === "fill") { - // 절·성토도 **제어점 보간**이다(2026-08-24 사용자: 확장된 노폭 끝을 스무스하게). - // 상대 좌표라 아래 공통 블록이 행 앵커(측구 끝/노견 끝)에 얹는다. 구조물 - // 구간 안(같은 구간의 두 경계 사이)은 단면이 고정되고 지반 트림을 건너뛴다 — - // 사면이 구조물 상단에서 끝나기 때문. 구간 밖은 이웃 측점까지 테이퍼. - const controls = controlsByKey.get(key); - if (controls) { - const { a, b, t: tc } = bracketControls(controls, chainage); - points = - a === b - ? a.points.map((point) => ({ ...point })) - : lerpPoints(a.points, b.points, tc); - // 구조물 구간 안(같은 구간의 두 경계 사이)이든 경계와 이웃 측점 사이 - // 테이퍼든, 구조물 단면이 섞인 행은 지반 트림을 건너뛴다 — 트림이 성토부선 - // (구조물이 만든 긴 앞치마)을 지반 교차점까지 잘라내 8.1m 절벽이 남았다 - // (2026-08-24 실측 2.23 → 9.88m). - if (a.spanId !== null || b.spanId !== null) fixedSlopeKeys.add(key); - } else { - const relA = s0.slopeRelative.get(key); - const relB = s1.slopeRelative.get(key); - const relative = - relA && relB - ? lerpPoints(relA, relB, t) - : (relA ?? relB)?.map((point) => ({ ...point })); - if (!relative || relative.length < 2) return; - points = relative; - } - } else { - // 노견은 **제어점 보간**으로 그린다(2026-08-24 사용자: 스무스 연결). - // 구조물 구간 안은 두 제어점이 같은 단면이라 그대로 고정되고, 구간 밖은 - // 경계 ↔ 이웃 측점 사이에서 테이퍼가 생긴다. 계획고는 아래에서 얹는다. - const controls = kind === "shoulder" ? controlsByKey.get(key) : undefined; - if (controls) { - const { a, b, t: tc } = bracketControls(controls, chainage); - const relative = - a === b - ? a.points.map((point) => ({ ...point })) - : lerpPoints(a.points, b.points, tc); - const base = designZ(chainage); - points = relative.map((point) => ({ - offset_m: point.offset_m, - elevation_m: point.elevation_m + base, - })); - controlPlaced.add(key); - } else { - const pa = s0.pieces.get(key); - const pb = s1.pieces.get(key); - if (!pa || !pb) return; - points = lerpPoints(pa, pb, t); - } - } - // 종단 보정 — 보간 조각은 직선 보간 대비 **곡선 차이만**(shift) 얹으면 되지만, - // 구조물 측점 단면을 그대로 쓴 조각은 그 측점 표고에 얼어붙어 있으므로 - // **원 측점과의 계획고 차이 전량**을 얹어야 한다. 안 그러면 구조물 구간에서 - // 노견만 종단을 안 타고 양 끝에 단이 생긴다(2026-08-24 사용자 지적, 실측 - // 0.49~0.61m 단 / 종단 기울기 10.1%). - const source = fixedSource.get(key); - const rowShift = controlPlaced.has(key) - ? 0 // 제어점 보간은 상대 단면을 행 계획고에 직접 얹는다 — 추가 보정 불필요. - : source !== undefined && profileZ - ? (profileZ(chainage) ?? 0) - (profileZ(source) ?? 0) - : shift; - if (rowShift !== 0 && kind !== "cut" && kind !== "fill") { - applyProfileShift(points, rowShift); - } - sections.set(key, points); - }); - // 노견 안쪽 끝은 차도 끝과 **한 점**이어야 한다 — 노견은 제어점 보간, 차도는 - // 측점 보간이라 테이퍼 구간에서 최대 0.023m 벌어졌다(2026-08-24 실측). 차도에 맞춘다. - const roadTop = sections.get(pieceKey("carriageway", "center")); - if (roadTop && roadTop.length >= 2) { - const ends = { left: roadTop[roadTop.length - 1], right: roadTop[0] }; - (["left", "right"] as const).forEach((side) => { - const piece = sections.get(pieceKey("shoulder", side)); - if (!piece || piece.length < 2) return; - const inner = side === "left" ? piece[0] : piece[piece.length - 1]; - inner.offset_m = ends[side].offset_m; - inner.elevation_m = ends[side].elevation_m; - }); - } - // 비탈(상대 좌표)을 그 행의 도로부 바깥 끝(측구가 있으면 측구 끝, 없으면 노견 - // 끝) 앵커에 얹고, 행 지반선과 직접 교차시켜 종결한다(2026-08-23 완전 분리). - const groundRow = mixedGround(s0.ground, s1.ground, t); - (["left", "right"] as const).forEach((side) => { - const ditchPoints = sections.get(pieceKey("ditch", side)); - const edge0 = side === "right" ? s0.roadEdge.right : s0.roadEdge.left; - const edge1 = side === "right" ? s1.roadEdge.right : s1.roadEdge.left; - // 비탈 앵커 = 도로부 바깥 끝. 측구가 있으면 측구 끝, 없으면 **노견 조각의 - // 바깥 끝**(구조물 구간에서 넓어진 노폭이 그대로 반영된다 — 2026-08-23). - const shoulderPoints = sections.get(pieceKey("shoulder", side)); - const shoulderOuter = shoulderPoints - ? side === "right" - ? shoulderPoints[0] - : shoulderPoints[shoulderPoints.length - 1] - : null; - const anchor = ditchPoints - ? side === "right" - ? ditchPoints[0] - : ditchPoints[ditchPoints.length - 1] - : (shoulderOuter ?? { - offset_m: edge0.offset_m + (edge1.offset_m - edge0.offset_m) * t, - // 도로부는 종단 보정을 전량 받는다 — 그 값을 얹어야 노면과 높이가 맞는다. - elevation_m: edge0.elevation_m + (edge1.elevation_m - edge0.elevation_m) * t + shift, - }); - (["cut", "fill"] as const).forEach((kind) => { - const slopeKey = pieceKey(kind, side); - const slope = sections.get(slopeKey); - if (!slope || slope.length < 2) return; - // 상대 좌표 → 앵커 절대 좌표(경사 형상 보존, 위치만 도로부를 따라간다). - slope.forEach((point) => { - point.offset_m += anchor.offset_m; - point.elevation_m += anchor.elevation_m; - }); - // 기슭막이 구간 — 사면은 벽 이음선 상단에서 끝난다(지반 트림 금지). - if (fixedSlopeKeys.has(slopeKey)) return; - if (!groundRow) return; - // 절토는 지반 위로 만나는 곳까지, 성토는 아래로 만나는 곳까지 — 지반이 - // 반대편이면 이 행에 그 종류는 없다(조각 드롭 → 리본 자연 종료 = 분리). - const inner2outer = side === "right" ? [...slope].reverse() : slope; - const trimmed = trimSlopeToGround(inner2outer, kind, groundRow); - if (!trimmed) { - sections.delete(slopeKey); - return; - } - const rescaled = resample(trimmed, PIECE_COLS[kind]); - if (side === "right") rescaled.reverse(); - sections.set(slopeKey, rescaled); - }); - }); - // 클리핑 외곽 = 이 행의 실제 점유 끝(비탈 → 측구 → 노견 순) — 리본과 클리핑 - // 구멍을 행 단위로 일치시켜 서피스 상호 침범을 없앤다(2026-08-23). - const outerOf = (side: "left" | "right"): OffsetPoint => { - for (const kind of ["cut", "fill", "ditch"] as const) { - const piece = sections.get(pieceKey(kind, side)); - if (piece && piece.length >= 2) { - return side === "right" ? piece[0] : piece[piece.length - 1]; - } - } - const edge0 = side === "right" ? s0.roadEdge.right : s0.roadEdge.left; - const edge1 = side === "right" ? s1.roadEdge.right : s1.roadEdge.left; - return { - offset_m: edge0.offset_m + (edge1.offset_m - edge0.offset_m) * t, - elevation_m: edge0.elevation_m + (edge1.elevation_m - edge0.elevation_m) * t, - }; - }; - rows.push({ - chainage_m: chainage, - center, - left, - sections, - outerLeft: outerOf("left"), - outerRight: outerOf("right"), - ground: groundRow, - }); - } - } - if (rows.length < 2) return null; - - const caps: CorridorCap[] = []; - /** - * 측구가 끊기는 자리의 마감벽 — 측구 단면을 **도로 가장자리 높이까지** 세로로 - * 막는다(2026-08-23 사용자 지적: 지금은 공중에서 끊겨 보인다). 위쪽 모서리를 - * 도로 쪽 끝점 높이로 두면 도로 옆면에 붙은 벽으로 마감된다. - */ - const pushDitchCap = (row: (typeof rows)[number], key: string, side: CorridorSide): void => { - const points = row.sections.get(key); - if (!points || points.length < 2) return; - const roadSide = side === "right" ? points[points.length - 1] : points[0]; - caps.push({ - points: points.map((point) => { - const [x, y] = toModel(row, point); - return [x, y, roadSide.elevation_m, point.elevation_m] as [number, number, number, number]; - }), - }); - }; - - const ribbons: CorridorRibbon[] = []; - keys.forEach((key) => { - const [kind, side] = key.split(":") as [CorridorKind, CorridorSide]; - const colCount = PIECE_COLS[kind]; - // 조각이 있는 **연속 구간**마다 리본을 따로 만든다 — 없는 구간을 폭 0으로 - // 이어 붙이면 측구가 없는 자리에도 서피스·윤곽선이 남는다(2026-08-23 수정). - let start = -1; - const flush = (endExclusive: number): void => { - if (start < 0 || endExclusive - start < 2) { - start = -1; - return; - } - const chainages: number[] = []; - const positions = new Float64Array((endExclusive - start) * colCount * 3); - // 성토 리본만 원지반 표고를 같이 싣는다 — 잘린 자리 측벽의 밑선(2026-08-27 사용자). - const groundZ = kind === "fill" ? new Float64Array((endExclusive - start) * colCount) : null; - let cursor = 0; - let groundCursor = 0; - for (let row = start; row < endExclusive; row += 1) { - chainages.push(rows[row].chainage_m); - rows[row].sections.get(key)!.forEach((point) => { - const [x, y, z] = toModel(rows[row], point); - positions[cursor] = x; - positions[cursor + 1] = y; - positions[cursor + 2] = z; - cursor += 3; - if (groundZ) { - // 지반선을 못 읽는 행은 서피스 표고를 그대로 둔다 — 높이 0짜리 벽이 된다. - groundZ[groundCursor] = rows[row].ground?.(point.offset_m) ?? z; - groundCursor += 1; - } - }); - } - ribbons.push({ kind, side, colCount, chainages, positions, ...(groundZ ? { groundZ } : {}) }); - // 측구 리본이 노선 중간에서 시작·종료하면 그 자리를 벽으로 막는다. - if (kind === "ditch") { - if (start > 0) pushDitchCap(rows[start], key, side); - if (endExclusive < rows.length) pushDitchCap(rows[endExclusive - 1], key, side); - } - start = -1; - }; - for (let row = 0; row < rows.length; row += 1) { - if (rows[row].sections.has(key)) { - if (start < 0) start = row; - } else { - flush(row); - } - } - flush(rows.length); - }); - - const outline: CorridorBuildResult["outline"] = { chainages: [], left: [], right: [] }; - rows.forEach((row) => { - outline.chainages.push(row.chainage_m); - const [lx, ly] = toModel(row, row.outerLeft); - const [rx, ry] = toModel(row, row.outerRight); - outline.left.push([lx, ly]); - outline.right.push([rx, ry]); - }); - - // 시·종점 마구리 — 코리도가 끊기는 자리를 설계선↔지반선으로 봉인해 속이 안 보이게 한다. - [rows[0], rows[rows.length - 1]].forEach((row, index) => { - const station = index === 0 ? stations[0] : stations[stations.length - 1]; - const points = station.capSection.map(([offset, zDesign, zGround]) => { - const [x, y] = toModel(row, { offset_m: offset, elevation_m: zDesign }); - return [x, y, zDesign, zGround] as [number, number, number, number]; - }); - if (points.length >= 2) caps.push({ points }); - }); - if (legacy) return { ribbons, outline, caps, structures }; - // 탑뷰 투영 커브(2026-08-26 사용자) — 세트별 평면(구조물 상단 +5m)에 눕힌 - // 구조물·비탈 커브. 원본 비탈선은 코리도 바깥 윤곽(outline)을 그대로 읽는다. - // 리본을 함께 넘겨 파란 스케치 커브를 절·성토면에 얹은 빨간 투영선도 낸다. - // - // **자르기보다 먼저** 내야 한다 — 성토면을 먼저 도려내면 투영선이 z를 읽을 면이 - // 없어져 커브가 통째로 사라진다(2026-08-26). - const planCurves = buildPlanCurves(crossSections, structures, frameAt, outline, ribbons); - - // 커브 안쪽 성토면은 **셀 마스크**로 지운다 — 행을 잘라 내면 결국 노선 기준이 - // 되어 비스듬한 커브에서 어긋난다(2026-08-26 사용자 지적, `_Cut.ts` 머리말). - // 잘린 자리 윤곽(`cut-merged`)은 새 데이텀에 얹어 기존 커브 **뒤에 덧붙인다**. - const cut = FILL_CUT_APPLY ? maskFillByPlanCurves(ribbons, planCurves) : null; - - // 성토 패치는 **투영 커브·절취를 다 낸 뒤** 붙인다 — 먼저 넣으면 투영선이 패치 - // 표면에서 z를 읽어 절취 경계가 달라지고, 절취가 패치를 다시 갉는다. - const patchRibbons = FILL_PATCH_APPLY - ? buildStructurePatchRibbons(crossSections, structures, frameAt, profileZ ?? null, planCurves) - : []; - // 패치도 같은 커브로 한 번 더 훑는다 — 단 **반대 규칙**이다(`patchClip` 표식이 붙은 - // 세월교 패치만: 절취 영역 **안쪽만** 남긴다). 지우는 면과 채우는 면이 같은 경계를 - // 공유해야 삐져나오지도 벌어지지도 않는다(2026-08-27 사용자). 병합 커브·측벽은 - // 여기서 안 나온다 — 기본 성토면 몫이다. - const clippedPatches = - FILL_CUT_APPLY && patchRibbons.length - ? maskFillByPlanCurves(patchRibbons, planCurves).ribbons - : patchRibbons; - return { - ribbons: [...(cut ? cut.ribbons : ribbons), ...clippedPatches], - outline, - caps, - structures, - planCurves: cut ? [...planCurves, ...cut.curves] : planCurves, - cutWalls: cut ? cut.walls : [], - }; -} diff --git a/B05_Profile/Old/B05_Profile_UI_Corridor_Build_Types.ts b/B05_Profile/Old/B05_Profile_UI_Corridor_Build_Types.ts deleted file mode 100644 index 9dcf752d7..000000000 --- a/B05_Profile/Old/B05_Profile_UI_Corridor_Build_Types.ts +++ /dev/null @@ -1,102 +0,0 @@ -/* ============================================================================= - * B05_Profile_UI_Corridor_Build_Types.ts - * 코리도 빌더가 내는 **결과 자료형** — 리본·절취 측벽·캡·빌드 결과. - * - * `B05_Profile_UI_Corridor_Build` 에서 떼어냈다(700줄 제한, 2026-09-02). 계산은 전혀 - * 없고 모양과 그 뜻(주석)만 있다. 빌더가 그대로 다시 내보내므로 기존 import 경로 - * (`... from "./B05_Profile_UI_Corridor_Build"`)는 전부 유효하다. - * ========================================================================== */ - -import type { CrossSection } from "../B06_Section/B06_Section_Api_Fetch"; -import type { CorridorKind, CorridorSide } from "./B05_Profile_UI_Corridor_Station"; -import type { CorridorStructure } from "./B05_Profile_UI_Corridor_Structures"; -import type { PlanCurve } from "./B05_Profile_UI_Corridor_Plan"; - -export interface CorridorRibbon { - kind: CorridorKind; - side: CorridorSide; - colCount: number; - chainages: number[]; - /** - * 구조물 **패치 리본**(2026-08-25) — 투영커브로 잘린 자리에 다시 그리는 변형 성토면. - * 절단 대상에서 빠진다(패치까지 자르면 구멍이 도로 뚫린다). - */ - patch?: boolean; - /** - * 패치를 **절취 영역 안쪽으로 클립**한다(2026-08-27 사용자: 삐져나오지도 벌어지지도 - * 않게). `maskFillByPlanCurves`가 기본 성토면을 지우는 규칙의 **정확한 반대**를 건다 — - * 영역 밖 셀은 지우고, 걸친 셀은 안쪽 조각만 남긴다. 두 면이 같은 경계를 공유하니 - * 겹침도 구멍도 안 생긴다. **세월교 전용** — 구조물별로 로직이 다르다. - */ - patchClip?: boolean; - /** - * rowCount × colCount × 3 (모델 좌표). - * - * **Float64**다 — 모델 좌표는 EPSG 절대값(십만 m대)이라 Float32에 담으면 해상도가 - * 0.14m까지 떨어져, 0.2m로 세분한 점들이 양자화 계단을 타며 최대 35°짜리 지그재그가 - * 생겼다(2026-08-23 실측). 화면에 올릴 때 원점을 빼면서 Float32로 줄인다. - */ - positions: Float64Array; - /** - * 셀 마스크 — `(rowCount-1) × (colCount-1)`, 1이면 **그 셀은 안 그린다** - * (2026-08-26 투영 커브 안쪽 지우기). 정점은 그대로 두고 삼각형만 뺀다. - */ - cellMask?: Uint8Array; - /** - * 축 고정 커브(BOX암거)가 **걸친 셀**을 커브에서 잘라 되살린 조각 삼각형 - * (모델 좌표, 삼각형당 9). 격자로는 못 담는 비스듬한 경계를 여기로 메운다 - * (2026-08-26 사용자: 이원화하여 관리). - */ - trimTris?: Float64Array; - /** `trimTris`와 짝인 UV(삼각형당 6) — 빗금이 실척으로 이어지게 한다. */ - trimUvs?: Float32Array; - /** - * 격자점별 **원지반 표고**(rowCount × colCount) — 성토 리본에만 싣는다. - * 잘린 자리 측벽을 성토면에서 원지반까지 세우는 밑선이다(2026-08-27 사용자). - */ - groundZ?: Float64Array; -} - -/** - * 잘린 자리 **측벽 서피스**(2026-08-27 사용자) — 리본 메시에 섞지 않고 구조물처럼 - * 따로 그린다. 삼각형 수프(정점당 3, 삼각형당 9). 빗금이 없어 UV는 담지 않는다. - */ -export interface CorridorCutWall { - side: "left" | "right"; - positions: Float64Array; - /** 패치 바깥 끝 스커트인가(2026-08-27) — 뷰어가 지형 색인으로 뒤늦게 붙이므로, - * 다시 붙일 때 옛 것을 걷어내려면 표식이 있어야 한다(`_Corridor_Skirt.ts`). */ - patchSkirt?: boolean; - /** - * 정점별 UV(정점당 2) — **빗금 있는 성토 서피스**로 그릴 때만 싣는다. - * 절취 측벽은 일반 서피스(빗금 없음)이고, 패치 스커트는 성토면의 연장이라 빗금을 - * 넣는다(2026-08-27 사용자: "변형 성토면 하단 끝단부 서피스는 메시 형태"). - */ - uvs?: Float32Array; -} - -/** 시·종점 마구리(캡) — 설계선과 지반선 사이를 세로로 봉인하는 스트립(모델 좌표). */ -export interface CorridorCap { - /** [x, y, z설계, z지반] × N — offset 오름차순. */ - points: Array<[number, number, number, number]>; -} - -export interface CorridorBuildResult { - ribbons: CorridorRibbon[]; - /** 클리핑 경계 — 프레임별 좌/우 최외곽(catch) XY(모델). 좌우 같은 길이. */ - outline: { chainages: number[]; left: Array<[number, number]>; right: Array<[number, number]> }; - /** 노선 시·종점 단면 봉인(2026-08-23 품질 개선). 캡이 없으면 빈 배열. */ - caps: CorridorCap[]; - /** 배수관 세트 구조물(기슭막이·집수정·배관) 솔리드(2026-08-23). 없으면 빈 배열. */ - structures: CorridorStructure[]; - /** - * 구조물 세트별 **탑뷰 투영 커브**(2026-08-26 사용자) — 세트 구조물 상단 5m - * 평면에 눕힌 닫힌 고리들. 구조물·비탈(변형/원본)을 유입·유출로 나눠 담는다. - * 저장 코리도엔 없을 수 있다. - */ - planCurves?: PlanCurve[]; - /** 절취 측벽 서피스(2026-08-27) — 저장본에도 담는다. 없으면 빈 배열. */ - cutWalls?: CorridorCutWall[]; - /** 측점 단면 — 새 방식 지형 변형이 비탈 단면을 읽는다(115-1). 메모리만 · 저장본에 안 담는다. */ - sections?: CrossSection[]; -} diff --git a/B05_Profile/Old/B05_Profile_UI_Corridor_Carve.ts b/B05_Profile/Old/B05_Profile_UI_Corridor_Carve.ts deleted file mode 100644 index 359f937c5..000000000 --- a/B05_Profile/Old/B05_Profile_UI_Corridor_Carve.ts +++ /dev/null @@ -1,494 +0,0 @@ -/* ============================================================================= - * B05_Profile_UI_Corridor_Carve.ts - * 구조물이 성토·절토면에서 도려낼 **탑뷰 투영 풋프린트**와, 잘린 자리에 다시 그리는 - * **패치 리본**(변형 성토면)을 낸다. - * - * 절단은 **순수 평면 투영**이다(2026-08-25 사용자 재확정: "말 그대로 투영이어야 함 — - * 노선을 따라 컷을 하면 다를 수밖에 없음"). 노선 파라미터(누가거리 구간)로 자르지 - * 않고, 각 코리도 행의 횡단선(실좌표 XY 직선)을 구조물·패치의 **실좌표 풋프린트 - * 사각형들과 실제로 교차**시켜 그 행에서 덮인 편거리 끝을 얻는다. 그래서 - * · 날개벽처럼 배관 구간 뒤에서 넓어지는 부재는 절단 경계도 그 벌어진 윤곽을 - * 그대로 따라간다 — 하나의 박스가 아니다(2026-08-25 사용자 ②). - * · 곡선 구간에서도 절단 경계 = 실제 탑뷰 투영이다. - * 원지반에는 쓰지 않는다(사용자: 투영 및 삭제는 원지반 제외). - * - * **탑뷰 투영 커브**(구조물 상단 5m 평면)는 여기 없다 — `_Corridor_Plan.ts`로 - * 옮겼다(2026-08-26 사용자 재작성 지시). - * - * 풋프린트 구성: - * · 구조물 솔리드 — 링과 링 사이의 평면 사각형(실좌표). - * · 변형 성토선(실루엣) 패치가 덮을 영역 — 점유 구간을 노선 프레임으로 1m씩 걸어 - * [노견 → 실루엣 바깥 끝] 사각형(실좌표). 성토부 하단(지반 접점)까지 포함이라 - * 실루엣 구간 안 행은 사실상 통째로 지워진다(2026-08-25 사용자 ④). - * ========================================================================== */ - -import type { CrossSection } from "../B06_Section/B06_Section_Api_Fetch"; -import type { CorridorStructure, RouteFrame } from "./B05_Profile_UI_Corridor_Structures"; -import { structureSilhouettes } from "./B05_Profile_UI_Corridor_Station_Structure"; -import { - cutInnerTo, - PIECE_COLS, - resample, - trimSlopeToGround, - type CorridorKind, - type OffsetPoint, -} from "./B05_Profile_UI_Corridor_Station"; -import type { CorridorRibbon } from "./B05_Profile_UI_Corridor_Build"; -import { buildGroundAt, type PlanCurve } from "./B05_Profile_UI_Corridor_Plan"; -import { groundInterpolator } from "../B06_Section/B06_Section_UI_Cross_Culvert_Solve"; -import { - clipRunBelow, - extendRunTo, - NEAR_LIMIT_M, - PATCH_WING_MARGIN_M, - prependRunTo, - wingRangeAt, - wingSpanOf, - wingSpanOnRoute, -} from "./B05_Profile_UI_Corridor_Carve_Wing"; -import { unionQuadOf } from "./B05_Profile_UI_Corridor_Union"; -import type { PXY, UnionQuad as FootprintQuad } from "./B05_Profile_UI_Corridor_Union"; - -/** 풋프린트가 노견보다 이만큼은 밖으로 나가야 자를 값어치가 있다(m). */ -const MIN_REACH_M = 0.05; - -/** 패치 줄이 "지반에서 끝나는 줄"로 볼 허용 낙차(m) — 소유 측점 바깥 끝에서 잰다. - * 스커트 게이트(`_Corridor_Skirt.ts` PATCH_TOE_TOLERANCE_M)와 같은 값·같은 이유다. */ -const PATCH_TRIM_TOLERANCE_M = 0.5; - -/** 링 폴리곤의 편거리 양 끝 — 그 링이 평면에서 차지하는 폭. */ -export function ringEdges( - polygon: ReadonlyArray, -): [number, number] | null { - if (!polygon.length) return null; - let min = Infinity; - let max = -Infinity; - for (const [offset] of polygon) { - if (offset < min) min = offset; - if (offset > max) max = offset; - } - return max - min > 1e-6 ? [min, max] : null; -} - -/** 구조물·패치의 탑뷰 투영 풋프린트 — 행 횡단선과 교차시켜 절단 범위를 판정한다. */ -export class CarveFootprint { - private quads: FootprintQuad[] = []; - /** 스트립(연속 사각형 띠) 원본 — 진단·확장용으로 남겨 둔다. */ - private strips: Array> = []; - - get isEmpty(): boolean { - return this.quads.length === 0; - } - - /** - * 스트립 하나 추가 — `edges[i]`는 그 자리의 좌·우 가장자리 두 점이다. - * 이웃한 두 가장자리가 사각형 한 칸이 되고, 전체는 하나의 띠다. - */ - addStrip(edges: ReadonlyArray<[PXY, PXY]>): void { - if (edges.length < 2) return; - for (let i = 0; i < edges.length - 1; i += 1) { - const near = edges[i]; - const far = edges[i + 1]; - this.quads.push(unionQuadOf(near[0], near[1], far[1], far[0])); - } - this.strips.push(edges.map((edge) => [edge[0], edge[1]])); - } - - /** - * 행 횡단선(원점 c, 좌향 단위벡터 u — 편거리 o의 자리 = c + u·o)이 풋프린트와 - * 겹치는 **그 측의 가장 바깥 편거리**. 안 겹치면 null. - */ - outerAt( - cx: number, - cy: number, - ux: number, - uy: number, - side: "left" | "right", - innerOffset: number, - /** 그 행 비탈 조각의 바깥 끝 편거리 — 조각 너머에서 시작하는 겹침은 무관하다. */ - outerLimit: number, - ): number | null { - const sign = side === "left" ? 1 : -1; - let outer: number | null = null; - for (const quad of this.quads) { - // 거친 걸러내기 — 행 원점에서 너무 먼 사각형은 볼 필요 없다. - if ( - quad.minX > cx + NEAR_LIMIT_M || - quad.maxX < cx - NEAR_LIMIT_M || - quad.minY > cy + NEAR_LIMIT_M || - quad.maxY < cy - NEAR_LIMIT_M - ) { - continue; - } - // 사각형 변과 직선의 교차 — 볼록 사각형이라 교차 o들의 [min,max]가 곧 겹친 구간. - let hitMin = Infinity; - let hitMax = -Infinity; - const points = quad.points; - for (let i = 0; i < 4; i += 1) { - const a = points[i]; - const b = points[(i + 1) % 4]; - const ex = b.x - a.x; - const ey = b.y - a.y; - const denominator = ux * ey - uy * ex; - if (Math.abs(denominator) < 1e-12) continue; - const dx = a.x - cx; - const dy = a.y - cy; - const t = (ux * dy - uy * dx) / denominator; - if (t < -1e-9 || t > 1 + 1e-9) continue; - const o = Math.abs(ux) > Math.abs(uy) ? (a.x + ex * t - cx) / ux : (a.y + ey * t - cy) / uy; - if (o < hitMin) hitMin = o; - if (o > hitMax) hitMax = o; - } - if (hitMin > hitMax) continue; - // 그 측(노견 바깥)에 걸친 구간만 — 반대측 구조물은 무관하다. - const far = sign > 0 ? hitMax : hitMin; - const near = sign > 0 ? hitMin : hitMax; - if ((far - innerOffset) * sign <= MIN_REACH_M) continue; - // 조각 바깥 끝 너머에서 **시작**하는 겹침은 이 비탈과 무관하다(횡단선이 무한 - // 직선이라 멀리 굽은 노선의 다른 구조물이 얻어걸린다 — 2026-08-25 프로브에서 - // 84.3m 행이 275.7m 구조물에 걸려 -52m까지 잘려 나갔다). - if ((near - outerLimit) * sign > MIN_REACH_M) continue; - if (outer === null || (far - outer) * sign > 0) outer = far; - } - return outer; - } -} - -/** 구조물 솔리드의 링 사이 평면 사각형을 풋프린트에 담는다(`kinds` 필터 선택). */ -function addStructureQuads( - footprint: CarveFootprint, - structures: ReadonlyArray, - kinds?: ReadonlySet, -): void { - for (const structure of structures) { - if (structure.kind === "pipe") continue; - if (kinds && !kinds.has(structure.kind)) continue; - const rings = structure.rings; - if (!rings || rings.length < 2) continue; - const perRing = structure.polygons; - const shared = structure.polygon; - const edges = rings.map((frame, index) => { - const edge = ringEdges(perRing?.[index] ?? shared ?? []); - if (!edge) return null; - return edge.map((offset) => ({ - x: frame.cx + frame.leftX * offset, - y: frame.cy + frame.leftY * offset, - })) as [PXY, PXY]; - }); - // 링이 끊긴 자리(폴리곤 없음)에서 스트립을 나눈다 — 이어 붙이면 없는 자리까지 덮는다. - let run: Array<[PXY, PXY]> = []; - for (const edge of edges) { - if (edge) { - run.push(edge); - } else { - footprint.addStrip(run); - run = []; - } - } - footprint.addStrip(run); - } -} - -/** - * 벽·날개벽(kind="revet")만의 풋프린트 — **패치 리본을 가르는** 부재들이다. - * 구체·바닥판(kind="basin")은 패치 아래에 눕는 부재라 여기서 빼야 한다(BOX 상판 - * 위 성토선 패치가 통째로 지워진다). - */ -export function buildRevetFootprint(structures: ReadonlyArray): CarveFootprint { - const footprint = new CarveFootprint(); - addStructureQuads(footprint, structures, new Set(["revet"])); - return footprint; -} - -/** - * 행 가장자리 쌍(안쪽·바깥쪽, null = 끊김)을 **닫힌 외곽 고리**로 잇는다 — 안쪽을 - * 앞으로, 바깥쪽을 뒤로 이어 닫는다. 내부 칸막이는 안 생긴다. - */ -export function assembleStripLoops( - rows: ReadonlyArray<[[number, number, number], [number, number, number]] | null>, -): Array> { - const loops: Array> = []; - let run: Array<[[number, number, number], [number, number, number]]> = []; - const flush = (): void => { - if (run.length >= 2) { - const loop: Array<[number, number, number]> = run.map((edge) => edge[0]); - for (let i = run.length - 1; i >= 0; i -= 1) loop.push(run[i][1]); - loop.push(loop[0]); - loops.push(loop); - } - run = []; - }; - for (const edge of rows) { - if (edge) run.push(edge); - else flush(); - } - flush(); - return loops; -} - -/** - * 구조물 **패치 리본**(2026-08-25 사용자 ④) — 구조물 때문에 기본 성토선에서 변형된 - * 성토선은 투영으로 잘린 자리에 **다시 그려야 하는 대상**이다. B06 횡단 실루엣을 - * 점유 구간 전 길이에 걸쳐 노선 프레임에 얹은 별도 리본으로 만든다. 기본 성토면은 - * 건드리지 않는다(강제 변형 로직은 꺼져 있다) — 패치가 잘린 구멍을 채운다. - * - * 벽·날개벽(kind="revet")이 패치 영역을 가로지르면 행마다 그 풋프린트만큼 안쪽을 - * 잘라내고, 한 행이 통째로 덮이면 리본을 거기서 끊는다 — **분리된 서피스는 별개 - * 리본**이 된다(2026-08-25 사용자). - */ -export function buildStructurePatchRibbons( - crossSections: ReadonlyArray, - structures: ReadonlyArray, - frameAt: (chainageM: number) => RouteFrame | null, - profileZ: ((chainageM: number) => number | null) | null, - planCurves: ReadonlyArray = [], -): CorridorRibbon[] { - const ribbons: CorridorRibbon[] = []; - const revets = buildRevetFootprint(structures); - // 행별 지반선 — 패치를 행마다 지반 교차선에서 끝내는 데 쓴다(투영 커브 - // `slope-deformed`와 같은 출처·같은 보간이라 빨간 교차선과 정확히 맞는다). - const groundOfChainage = buildGroundAt(crossSections); - for (const section of crossSections) { - const design = section.design; - /** - * BOX암거는 행 **가로 방향을 기준 측점 좌향으로 고정**한다(2026-08-27 사용자: - * "박스암거는 축 방향과 동일하게 놓여야 한다"). 행 자리는 노선 위 그대로 두고 - * 방향만 고정하는 것 — 투영 커브(`_Corridor_Plan.ts` `frameOf`)와 같은 규칙이라 - * 지우는 영역과 채우는 영역이 같은 방향으로 선다. 세월교·배수관은 노선 프레임. - */ - const boxAxis = section.box ? section.frame.left_xy : null; - /** - * 날개 몫 스윕·트림은 **세월교 전용**이다(2026-08-27 사용자: "구조물별로 구분되어야 - * 함 — 세월교, 박스암거, 기슭막이"). 날개벽은 세월교와 BOX암거 두 곳에서 쓰지만 - * 평면 스케치를 내는 로직이 서로 다르고(`wingAxis` 유무), 기슭막이(배수관 세트)는 - * 날개 사다리꼴 자체가 없다. BOX·기슭막이는 종전 경로를 그대로 탄다. - */ - const isFord = section.ford != null; - const isBox = section.box != null; - const frameOf = (chainageM: number): RouteFrame | null => { - const frame = frameAt(chainageM); - if (!frame || !boxAxis) return frame; - return { ...frame, leftX: boxAxis[0], leftY: boxAxis[1] }; - }; - const ownFrame = frameOf(section.chainage_m) ?? { - cx: section.center_x, - cy: section.center_y, - leftX: section.frame.left_xy[0], - leftY: section.frame.left_xy[1], - designZ: null, - }; - for (const silhouette of structureSilhouettes(section)) { - // 노견 확장점 안쪽(수평 구간)은 노견 조각이 가져갔다 — 패치는 그 바깥부터. - // 단 그 측에 측구가 있으면 노견을 안 넓혔으므로 스트립도 하지 않는다 — 하면 - // 패치가 노견에서 떨어져 뜬다(2026-08-25 사용자 보고). - const ditch = design?.ditch; - const ditchEnabled = design?.ditch_enabled ?? (ditch != null && ditch.type !== "none"); - const ditchOnSide = ditchEnabled && design?.ditch_side === silhouette.side; - const sign = silhouette.side === "left" ? 1 : -1; - // B06이 낸 성토 구간 배열을 그대로 쓴다 — **구간 1줄 = 리본 1장**이라 다단에서도 - // 줄이 안 섞이고 역주행으로 접히지도 않는다(2026-08-27 사용자). 배열이 없으면 - // 옛 동작 한 줄: 패치 전용 점(세월교 성토부선)이 있으면 그걸 쓴다 — 벽 전면·유로를 - // 리본으로 덮으면 관 구멍·L자 통로가 막힌다(2026-08-25 사용자 ②). - // 배열이 **비어 있으면** 그 측엔 성토 구간이 없다는 뜻이라 리본도 없다 — - // 옛 한 줄로 되돌리면 축퇴 실루엣(같은 점 2개)이 폭 0 리본을 만든다. - const runs: OffsetPoint[][] = silhouette.fillRuns ?? [ - silhouette.patchPoints ?? silhouette.points, - ]; - for (let source of runs) { - // 노견에서 시작하는 줄만 확장점으로 다듬는다 — 다단 성토 구간처럼 이미 바깥에서 - // 시작하는 줄에 확장점을 앞에 붙이면 노견에 없는 판이 하나 생긴다(2026-08-27). - const widen = silhouette.widenTo; - if ( - widen && - !silhouette.patchPoints && - !ditchOnSide && - source.length > 0 && - (source[0].offset_m - widen.offset_m) * sign <= 1e-9 - ) { - source = source.filter((point) => (point.offset_m - widen.offset_m) * sign > -1e-9); - if (source.length && Math.abs(source[0].offset_m - widen.offset_m) > 1e-9) { - source = [widen, ...source]; - } - } - if (source.length < 2) continue; - // 이 줄이 소유 측점에서 지반에 닿는가 — 닿는 줄만 행별 지반 트림 대상이다. - // 벽 뒷면·구체 상단에서 끝나는 줄을 트림하면 8.1m 절벽이 되살아난다(2026-08-24). - const stationGround = groundInterpolator(section.samples); - const runOuter = source[source.length - 1]; - const runReachesGround = - stationGround !== null && - Math.abs(runOuter.elevation_m - stationGround(runOuter.offset_m)) <= - PATCH_TRIM_TOLERANCE_M; - // "auto" = 실루엣 가운데 표고가 지반보다 높으면 성토, 낮으면 절토. - let kind: CorridorKind = silhouette.kind === "auto" ? "fill" : silhouette.kind; - if (silhouette.kind === "auto") { - const groundAt = groundInterpolator(section.samples); - const mid = silhouette.points[Math.floor(silhouette.points.length / 2)]; - if (groundAt && mid.elevation_m < groundAt(mid.offset_m) - 0.05) kind = "cut"; - } - const colCount = PIECE_COLS[kind]; - const spanFrom = section.chainage_m - silhouette.span.beforeM; - const spanTo = section.chainage_m + silhouette.span.afterM; - // 날개 사다리꼴 몫만큼 더 뻗는다(2026-08-27 사용자 ②) — 지우는 영역과 맞춘다. - // 세월교는 **노선 실투영**으로 잰다 — 직선 전방 투영은 곡선 + 먼 편거리에서 - // 3m 어긋나 고리 앞부분이 범위 밖으로 밀려났다(2026-08-27 A안). BOX암거는 - // 종전 직선 투영 그대로 — 로직이 분리돼 있고 이미 채워져 있다. - const wing = isFord - ? wingSpanOnRoute(planCurves, section.chainage_m, silhouette.side, frameOf) - : wingSpanOf(planCurves, section.chainage_m, silhouette.side, ownFrame); - // 여유는 **구조물 점유 구간 기준** 5m다(측점 기준이 아니다 — 세월교 우측은 - // 점유 구간이 이미 측점에서 6.1m 앞까지 가 있다). 그 안에서 날개벽 끝점 - // 횡단선까지 스윕한다(2026-08-27 사용자). - const from = Math.max( - spanFrom - PATCH_WING_MARGIN_M, - Math.min(spanFrom, section.chainage_m - wing.beforeM), - ); - const to = Math.min( - spanTo + PATCH_WING_MARGIN_M, - Math.max(spanTo, section.chainage_m + wing.afterM), - ); - if (!(to - from > 1e-6)) continue; - const rowChainages: number[] = []; - for (let at = from; at < to; at += 1) rowChainages.push(at); - rowChainages.push(to); - const baseZ = profileZ?.(section.chainage_m) ?? null; - - // 행 버퍼 — 벽·날개벽에 통째로 덮인 행에서 끊어 별개 리본으로 내보낸다. - let buffer: Array<{ chainage: number; frame: RouteFrame; points: typeof source }> = []; - const flush = (): void => { - if (buffer.length >= 2) { - const positions = new Float64Array(buffer.length * colCount * 3); - buffer.forEach((row, index) => { - const shift = - baseZ != null && row.frame.designZ != null ? row.frame.designZ - baseZ : 0; - resample(row.points, colCount).forEach((point, col) => { - const i = (index * colCount + col) * 3; - positions[i] = row.frame.cx + row.frame.leftX * point.offset_m; - positions[i + 1] = row.frame.cy + row.frame.leftY * point.offset_m; - positions[i + 2] = point.elevation_m + shift; - }); - }); - ribbons.push({ - kind, - side: silhouette.side, - colCount, - chainages: buffer.map((row) => row.chainage), - positions, - patch: true, - // 절취 영역 안쪽으로 자르는 규칙은 세월교에만 걸려 있었다 — BOX암거 패치는 - // 한 번도 안 잘려 절취 영역 밖으로 넘쳤다(2026-09-02 실측: BOX 패치 리본 - // 두 벌에 셀 마스크가 없었다). BOX도 `wing-box` 커브로 절취 영역을 내므로 - // 같은 규칙을 태운다. 기슭막이(배수관 세트)는 날개 사다리꼴 자체가 없어 제외. - ...(isFord || isBox ? { patchClip: true } : {}), - }); - } - buffer = []; - }; - - for (const at of rowChainages) { - const frame = frameOf(at); - if (!frame) { - flush(); - continue; - } - // 그 행이 주황 날개 스케치와 만나는 구간(안쪽·바깥쪽 끝) — 세월교 전용. - const wingRange = isFord - ? wingRangeAt(planCurves, section.chainage_m, silhouette.side, frame) - : null; - // 여기서 행을 끊지 않는다 — 스윕 범위가 이미 **날개벽 끝점 횡단선**까지로 - // 트림돼 있다(`wingSpanOnRoute` + `PATCH_WING_MARGIN_M`). 고리와 안 만나는 - // 중간 행까지 끊으면 구조물 구간과 날개 사이가 토막 난다(2026-08-27 사용자: - // "노선방향 스윕은 좋은데 조금 부족한 구간이 있다"). - let points = source; - // 벽 트림은 **옛 한 줄** 전용이다. 날개 몫으로 늘린 행도 트림하지 않는다 — - // **날개벽 평면 스케치까지 변형 성토면에 포함**하는 게 맞다(2026-08-27 사용자): - // 벽 솔리드가 그 위에 불투명하게 앉으므로 덮여도 안 보이고, 트림을 걸면 - // 날개 사다리꼴이 통째로 비어 구멍이 남는다(실측 덮임 0~36%). - // 옛 한 줄 전용 근거: 한 줄은 구조물을 가로질러 그어져 벽이 덮은 - // 안쪽을 잘라내야 했다. B06 구간 배열은 이미 벽 **사이**에 정의돼 있어 겹치는 - // 건 아랫단 벽 뒷면 0.15m 남짓뿐이고 그것도 불투명한 벽 솔리드 안에 묻힌다. - // 반대로 트림을 걸면 바깥 끝 하나로 자르는 규칙이라 단 사이 구간이 아랫단 벽 - // 바깥까지 통째로 잘려 사라진다(2026-08-27 실측: 0.86m 구간이 벽 행에서 전멸). - if (!revets.isEmpty && !silhouette.fillRuns) { - const outerCov = revets.outerAt( - frame.cx, - frame.cy, - frame.leftX, - frame.leftY, - silhouette.side, - points[0].offset_m, - points[points.length - 1].offset_m, - ); - if (outerCov !== null) { - const cut = cutInnerTo(points, outerCov, silhouette.side); - if (!cut) { - // 이 행은 벽·날개벽이 통째로 덮는다 — 리본을 끊는다(분리 서피스). - flush(); - continue; - } - points = cut; - } - } - // 행별 지반 절단·연장(2026-08-27 사용자) — 지반에 닿는 줄은 그 행 지반선과 - // **직접 교차시켜 끝낸다**(빨간 교차선). 소유 측점 단면 한 장을 복사한 것이라 - // · 지반이 올라오면 교차점까지 **자르고**(교차선 밖은 스커트가 수직 마감), - // · 지반이 내려가면 마지막 구배 그대로 **연장**해 원본 성토 끝선까지 간다. - // 이 지반 핀이 있어야 바깥 끝이 원지반에 물리고 스커트도 선다(2026-08-27 사용자). - if (runReachesGround) { - const ground = groundOfChainage(at); - if (ground) { - const shift = baseZ != null && frame.designZ != null ? frame.designZ - baseZ : 0; - const placed = points.map((point) => ({ - offset_m: point.offset_m, - elevation_m: point.elevation_m + shift, - })); - const trimmed = trimSlopeToGround(placed, kind, ground); - // null(시작부터 지반 반대편)이면 그대로 둔다 — 행을 죽이면 구멍이 된다. - if (trimmed && trimmed.length >= 2) { - // flush가 shift를 다시 얹으므로 여기서는 되돌려 저장한다. - points = trimmed.map((point) => ({ - offset_m: point.offset_m, - elevation_m: point.elevation_m - shift, - })); - } - } - } - // 주황 **날개 평면 스케치(`wing-box`)** 몫은 그 위에 **덧붙인다**(2026-08-27 - // 사용자: "스윕을 노선 기준으로 더 넓히고 주황 스케치로 트림" — 지우는 게 - // 아니라 그 자리에 변형 성토면이 **있어야** 한다). 지우는 쪽이 같은 고리로 - // 성토면을 도려내므로(`_Corridor_Cut.ts` `CUT_SOURCES`) 채우는 쪽이 거기까지 - // 못 가면 구멍이 남는다(실측: 세월교 149.7 날개 구멍 66~98%). - // 지반 핀보다 스케치가 더 바깥일 때만 늘린다 — 안쪽이면 지반 핀을 그대로 둔다. - if (wingRange) { - // 바깥 — 스케치 경계까지 설계 성토 물매로 덧이음. - if ((wingRange.outer - points[points.length - 1].offset_m) * sign > 1e-6) { - const extended = extendRunTo(points, wingRange.outer); - if (extended) points = extended; - } - // 안쪽 — 노견(확장점)보다 안쪽으로는 안 간다. 노면을 덮으면 안 된다. - const roadLimit = - silhouette.widenTo?.offset_m ?? silhouette.points[0]?.offset_m ?? points[0].offset_m; - const innerTarget = - (wingRange.inner - roadLimit) * sign < 0 ? roadLimit : wingRange.inner; - if ((points[0].offset_m - innerTarget) * sign > 1e-6) { - const prepended = prependRunTo(points, innerTarget); - if (prepended) points = prepended; - } - } - // 바닥판 상단 Z를 넘는 점은 지운다(2026-08-27 사용자 ①). - if (silhouette.floorTopZ != null) { - const shift = baseZ != null && frame.designZ != null ? frame.designZ - baseZ : 0; - points = clipRunBelow(points, silhouette.floorTopZ, shift); - if (points.length < 2) { - flush(); - continue; - } - } - buffer.push({ chainage: at, frame, points }); - } - flush(); - } - } - } - return ribbons; -} diff --git a/B05_Profile/Old/B05_Profile_UI_Corridor_Carve_Wing.ts b/B05_Profile/Old/B05_Profile_UI_Corridor_Carve_Wing.ts deleted file mode 100644 index 534cf72b1..000000000 --- a/B05_Profile/Old/B05_Profile_UI_Corridor_Carve_Wing.ts +++ /dev/null @@ -1,256 +0,0 @@ -/* ============================================================================= - * B05_Profile_UI_Corridor_Carve_Wing.ts - * 패치 리본(변형 성토면)의 **날개 몫** 도구 — 스윕 범위 산출과 줄 자르기·늘리기. - * `_Corridor_Carve.ts`에서 잘라 냈다(700줄 제한, 2026-08-27). - * - * 지우는 쪽은 날개 사다리꼴(`wing-box` 고리)을 영역으로 도려내는데 - * (`_Corridor_Cut.ts` `CUT_SOURCES`) 채우는 쪽은 노선 프레임 사각격자 리본이라 - * 그대로는 그 부채꼴을 못 덮는다. 여기 도구들이 그 간극을 메운다. - * · 종방향 — `wingSpanOf`(직선 투영, BOX암거용) / `wingSpanOnRoute`(노선 실투영, 세월교용) - * · 횡방향 — `wingRangeAt`(행 횡단선 × 고리 교점의 안쪽·바깥쪽 끝) - * · 줄 손질 — `extendRunTo` / `prependRunTo` / `clipRunBelow` - * - * 「주황 바깥선 트림」(버전 76~78) 도구는 **뺐다**(2026-09-02) — 그 판이 사용자 지시로 - * 원복된 뒤 다시 배선되지 않아 참조가 0이었다. 이력은 `_Corridor.ts` 버전 주석에 남는다. - * ========================================================================== */ - -import type { RouteFrame } from "./B05_Profile_UI_Corridor_Structures"; -import type { OffsetPoint } from "./B05_Profile_UI_Corridor_Station"; -import type { PlanCurve } from "./B05_Profile_UI_Corridor_Plan"; - -/** 행 횡단선에서 풋프린트를 볼 최대 반경(m) — 이보다 먼 사각형은 후보에서 뺀다. - * 횡단선은 무한 직선이라 헤어핀 건너편 구조물까지 얻어걸린다 — 반경으로 거른다. */ -export const NEAR_LIMIT_M = 30; - -/** - * 날개 사다리꼴(`wing-box`)이 **노선 방향으로 얼마나 더 뻗는가**(2026-08-27 사용자 ②). - * - * 원본 성토면을 지우는 영역은 `스트립 ∪ 날개 사다리꼴`인데(`_Corridor_Cut.ts` - * `CUT_SOURCES`) 되메우는 패치는 스트립만 덮어 날개 옆이 구멍으로 남았다. 지우는 - * 쪽과 같은 커브를 읽어 그 종방향 몫만큼 패치 행 범위를 넓힌다. - * - * 커브 점을 그 측점 프레임의 **전방 벡터**에 투영해 누가거리 몫을 잰다 — 구간이 수 m라 - * 노선 곡률은 무시한다(`_Corridor_Plan.ts`가 노견 편거리를 구간 내내 쓰는 것과 같은 이유). - */ -export function wingSpanOf( - curves: ReadonlyArray, - chainageM: number, - side: "left" | "right", - frame: RouteFrame, -): { beforeM: number; afterM: number } { - // 전방 = 좌향 벡터를 90° 돌린 것. 부호는 누가거리 증가 방향과 맞춘다. - const forwardX = -frame.leftY; - const forwardY = frame.leftX; - let before = 0; - let after = 0; - for (const curve of curves) { - if (curve.source !== "wing-box" || curve.side !== side) continue; - if (Math.abs(curve.setChainageM - chainageM) > 1e-6) continue; - for (const loop of curve.loops) { - for (const [x, y] of loop) { - const s = (x - frame.cx) * forwardX + (y - frame.cy) * forwardY; - if (s < 0) before = Math.max(before, -s); - else after = Math.max(after, s); - } - } - } - return { beforeM: before, afterM: after }; -} - -/** 날개 고리를 노선에 실투영할 때 훑는 반경(m)과 간격(m) — 행 간격 1m보다 촘촘하다. */ -const WING_SPAN_SEARCH_M = 30; -const WING_SPAN_STEP_M = 0.25; - -/** - * 날개 몫으로 **더 스윕해 두는 여유**(m, 2026-08-27 사용자: "현재 기준보다 5m 양쪽으로 - * 늘려서 서피스를 만들고 날개벽 끝점 횡단선으로 트림"). 트림선이 이 안에 들어오므로 - * 화면 결과는 트림선까지다 — 여유는 트림선이 행 사이에 떨어져도 끊기지 않게 한다. - */ -export const PATCH_WING_MARGIN_M = 5; - -/** - * 날개 사다리꼴의 **노선 실투영** 종방향 범위(2026-08-27 사용자 A안). `wingSpanOf`는 - * 측점 프레임의 **직선** 전방 벡터에 투영해 곡률을 무시하는데, 세월교 날개는 편거리 - * -7.85m까지 나가 있어 그 근사가 3m 어긋난다(실측: 우측 고리 140.55~143.88 인데 - * 패치는 143.6부터 시작 — 고리 앞부분이 통째로 범위 밖이라 98% 구멍). - * - * 후보 누가거리를 실제 `frameAt`로 훑어 각 점의 **최근접 측점**을 찾는다. - * - * 재는 대상은 **고리 전체 점**이다. 날개벽 선 두 끝만 쓰면 좌측이 143.2~157.3 → - * 144.4~155.9로 **줄어든다**(2026-08-27 사용자: "좌측 스윕 더 가야 함") — 날개 - * 사다리꼴은 벽 끝점보다 성토 끝선 쪽으로 더 뻗기 때문이다(우측 실측: 벽 끝 142.12, - * 고리 끝 140.55). 트림은 구조물 점유 구간 ±`PATCH_WING_MARGIN_M`이 맡는다. - * BOX암거는 이 경로를 쓰지 않는다 — 로직이 분리돼 있고 이미 채워져 있다. - */ -export function wingSpanOnRoute( - curves: ReadonlyArray, - chainageM: number, - side: "left" | "right", - frameAt: (chainageM: number) => RouteFrame | null, -): { beforeM: number; afterM: number } { - const points: Array<[number, number]> = []; - for (const curve of curves) { - if (curve.source !== "wing-box" || curve.side !== side) continue; - if (Math.abs(curve.setChainageM - chainageM) > 1e-6) continue; - for (const loop of curve.loops) for (const [x, y] of loop) points.push([x, y]); - } - if (!points.length) return { beforeM: 0, afterM: 0 }; - const candidates = routeCandidates(chainageM, frameAt); - if (!candidates.length) return { beforeM: 0, afterM: 0 }; - let before = 0; - let after = 0; - for (const [x, y] of points) { - const s = projectChainage(candidates, x, y) - chainageM; - if (s < 0) before = Math.max(before, -s); - else after = Math.max(after, s); - } - return { beforeM: before, afterM: after }; -} - -/** 측점 주변 후보 자리 — 고리 점을 노선에 실투영할 때 쓴다. */ -function routeCandidates( - chainageM: number, - frameAt: (chainageM: number) => RouteFrame | null, -): Array<{ at: number; cx: number; cy: number }> { - const candidates: Array<{ at: number; cx: number; cy: number }> = []; - for ( - let at = chainageM - WING_SPAN_SEARCH_M; - at <= chainageM + WING_SPAN_SEARCH_M; - at += WING_SPAN_STEP_M - ) { - const frame = frameAt(at); - if (frame) candidates.push({ at, cx: frame.cx, cy: frame.cy }); - } - return candidates; -} - -/** 점 하나의 최근접 누가거리. */ -function projectChainage( - candidates: ReadonlyArray<{ at: number; cx: number; cy: number }>, - x: number, - y: number, -): number { - let bestAt = candidates[0].at; - let bestDistance = Infinity; - for (const candidate of candidates) { - const distance = (x - candidate.cx) ** 2 + (y - candidate.cy) ** 2; - if (distance < bestDistance) { - bestDistance = distance; - bestAt = candidate.at; - } - } - return bestAt; -} - -/** - * 그 행 횡단선이 **주황 날개 평면 스케치**(`wing-box` 고리)와 만나는 **안쪽·바깥쪽 - * 끝 편거리**(2026-08-27 사용자: "스윕 범위를 날개 평면 스케치까지 지정"). 안 만나면 null. - * - * 바깥만으로는 부족하다 — 세월교 좌측 고리는 편거리 +2.64부터인데 패치는 +4.15에서 - * 시작한다(실측). 고리 몸통 1.5m가 패치 **안쪽**이라 안쪽으로도 늘려야 덮인다. - * - * 지우는 쪽이 그 고리 영역을 통째로 도려내므로(`_Corridor_Cut.ts` `CUT_SOURCES`) - * 채우는 쪽도 같은 고리까지 뻗어야 구멍이 안 남는다. `wingSpanOf`가 **종방향** 몫을 - * 재는 것과 짝이 되는 **가로방향** 몫이다. - */ -export function wingRangeAt( - curves: ReadonlyArray, - chainageM: number, - side: "left" | "right", - frame: RouteFrame, -): { inner: number; outer: number } | null { - const sign = side === "left" ? 1 : -1; - let inner: number | null = null; - let outer: number | null = null; - for (const curve of curves) { - if (curve.source !== "wing-box" || curve.side !== side) continue; - if (Math.abs(curve.setChainageM - chainageM) > 1e-6) continue; - for (const loop of curve.loops) { - for (let i = 0; i < loop.length - 1; i += 1) { - const [ax, ay] = loop[i]; - const [bx, by] = loop[i + 1]; - const ex = bx - ax; - const ey = by - ay; - const denominator = frame.leftX * ey - frame.leftY * ex; - if (Math.abs(denominator) < 1e-12) continue; - const t = (frame.leftX * (ay - frame.cy) - frame.leftY * (ax - frame.cx)) / denominator; - if (t < -1e-9 || t > 1 + 1e-9) continue; - const o = - Math.abs(frame.leftX) > Math.abs(frame.leftY) - ? (ax + ex * t - frame.cx) / frame.leftX - : (ay + ey * t - frame.cy) / frame.leftY; - // 헤어핀 건너편 고리가 무한 직선에 얻어걸리는 걸 막는다(`outerAt`과 같은 이유). - if (Math.abs(o) > NEAR_LIMIT_M) continue; - if (o * sign <= 0) continue; - if (outer === null || (o - outer) * sign > 0) outer = o; - if (inner === null || (o - inner) * sign < 0) inner = o; - } - } - } - return outer === null || inner === null ? null : { inner, outer }; -} - -/** 첫 구배 그대로 `targetOffset`까지 **안쪽으로** 늘린 줄 — 못 늘리면 null. - * 벽 뒤로 성토면을 잇는 것이라 벽 솔리드에 묻힌다(벽 전면·유로는 안 담는다 — - * 2026-08-25 사용자 ②의 "관 구멍·L자 통로를 막지 마라"는 그대로 지킨다). */ -export function prependRunTo(run: OffsetPoint[], targetOffset: number): OffsetPoint[] | null { - if (run.length < 2) return null; - const head = run[0]; - const next = run[1]; - const step = next.offset_m - head.offset_m; - if (Math.abs(step) < 1e-9) return null; - const slope = (next.elevation_m - head.elevation_m) / step; - return [ - { - offset_m: targetOffset, - elevation_m: head.elevation_m + slope * (targetOffset - head.offset_m), - }, - ...run, - ]; -} - -/** - * **세월교 바닥판 상단 Z를 넘는 점을 지운다**(2026-08-27 사용자 ①). 안쪽으로 늘린 - * 성토 물매가 유로 위로 솟아 바닥 상단과 안 만나는 자리로 튀는 걸 막는다. - * - * 줄은 안쪽→바깥 순이고 성토면은 바깥으로 내려가므로 천장 초과분은 **앞머리**다. - * 지우기만 하면 경계가 격자에 걸려 계단이 되므로, 천장을 지나는 마디에 **교차점을 - * 새로 꽂는다**. 비교는 종단 shift를 얹은 **실제 표고**로 한다 — 바닥판은 측점에 - * 고정된 구조물이라 shift와 함께 움직이지 않는다. - */ -export function clipRunBelow(run: OffsetPoint[], ceilingZ: number, shift: number): OffsetPoint[] { - const above = (point: OffsetPoint): boolean => point.elevation_m + shift > ceilingZ + 1e-9; - let start = 0; - while (start < run.length && above(run[start])) start += 1; - if (start === 0) return run; - if (start >= run.length) return []; - const outside = run[start - 1]; - const inside = run[start]; - const kept = run.slice(start); - const drop = outside.elevation_m - inside.elevation_m; - if (Math.abs(drop) > 1e-9) { - const ratio = (outside.elevation_m + shift - ceilingZ) / drop; - const offset = outside.offset_m + (inside.offset_m - outside.offset_m) * ratio; - if (Math.abs(offset - inside.offset_m) > 1e-6) { - kept.unshift({ offset_m: offset, elevation_m: ceilingZ - shift }); - } - } - return kept; -} - -/** 마지막 구배(= 설계 성토 물매) 그대로 `targetOffset`까지 늘린 줄 — 못 늘리면 null. */ -export function extendRunTo(run: OffsetPoint[], targetOffset: number): OffsetPoint[] | null { - if (run.length < 2) return null; - const tail = run[run.length - 1]; - const before = run[run.length - 2]; - const step = tail.offset_m - before.offset_m; - if (Math.abs(step) < 1e-9) return null; - const slope = (tail.elevation_m - before.elevation_m) / step; - return [ - ...run, - { - offset_m: targetOffset, - elevation_m: tail.elevation_m + slope * (targetOffset - tail.offset_m), - }, - ]; -} diff --git a/B05_Profile/Old/B05_Profile_UI_Corridor_Clip.ts b/B05_Profile/Old/B05_Profile_UI_Corridor_Clip.ts deleted file mode 100644 index 4038a8790..000000000 --- a/B05_Profile/Old/B05_Profile_UI_Corridor_Clip.ts +++ /dev/null @@ -1,468 +0,0 @@ -/* ============================================================================= - * B05_Profile_UI_Corridor_Clip.ts - * 서피스에서 **평면 영역**(`_Corridor_Region.ts`)이 덮는 자리를 경계 재절단으로 - * 도려낸다. 두 군데에 쓴다. - * · 지형 ← 코리도 스트립(절·성토 점유)만. 리본 절단은 Build의 파라메트릭 트림으로 옮겼다(2026-08-25) - * - * 방식(2026-08-23 사용자 확정 — 정밀): 영역 경계를 씬 수평면(x,z)에 투영한 선분으로 - * 삼각형을 판정하고, - * - 완전 내부 삼각형은 제거, - * - 경계에 걸친 삼각형은 경계 선분으로 실제 절단·재삼각분할 후 내부 조각만 버린다. - * 원본 geometry는 건드리지 않고 클리핑본을 새로 만든다 — [예상형상] 토글 OFF 시 - * 원본 완전체가 다시 보여야 하기 때문(두 벌 유지·스왑). - * - * 절단면(**밀어내기 서피스**)은 따로 만들지 않는다 — 잘린 자리에 구조물 솔리드가 - * 그대로 들어앉아 빈틈이 메워진다(2026-08-25 사용자 확정 절차 ④~⑥). 절단 경계가 - * 곧 구조물 외곽이라 각은 지지만 서피스를 뭉개지 않는다. - * - * 정점 속성(색·UV 등)은 절단 시 선형 보간해 유지한다 — 리본은 UV로 빗금을 반복하므로 - * UV가 끊기면 절단 자리에서 해칭이 튄다. - * ========================================================================== */ - -import * as THREE from "three"; -import type { ModelBounds } from "./B05_Profile_UI_Markers"; -import type { CorridorBuildResult } from "./B05_Profile_UI_Corridor_Build"; -import type { TerrainBandSplit } from "./B05_Profile_UI_Corridor_Split"; -import type { P2, PlanRegion, RegionSegment } from "./B05_Profile_UI_Corridor_Region"; -import { - CorridorStrip, - PlanLoopRegion, - SubtractedRegion, - pointInTri2, - segmentsIntersect, -} from "./B05_Profile_UI_Corridor_Region"; - -export { CorridorStrip }; - -/** 보간해서 이어 갈 정점 속성 한 종류. */ -interface AttrSlot { - name: string; - itemSize: number; - source: THREE.BufferAttribute; - values: ArrayLike; - extra: number[]; -} - -/** 절단 대상 삼각형 — 씬 좌표 정점 3개와 정점 속성(슬롯 순서). */ -interface Tri { - px: Float64Array; // [x0,y0,z0, x1,y1,z1, x2,y2,z2] - attrs: Float64Array[]; // 슬롯마다 [v0…, v1…, v2…] -} - -/** 절단 대상에서 빼는 속성 — 위치는 따로 다루고, 법선은 원본 것만 물려받는다. */ -const SKIPPED_ATTRIBUTES = new Set(["position", "normal"]); - -/** - * 여러 영역을 하나처럼 본다 — 어느 하나에라도 들면 "안"이다. - * `sceneY`를 주면 그 표고를 덮는 영역만 센다(구조물 밑을 지나는 면은 안 지운다). - */ -function insideAny(regions: ReadonlyArray, p: P2, sceneY?: number): boolean { - return regions.some((region) => region.contains(p, sceneY)); -} - -function segmentsNearAny( - regions: ReadonlyArray, - minX: number, - maxX: number, - minZ: number, - maxZ: number, -): RegionSegment[] { - if (regions.length === 1) return regions[0].segmentsNear(minX, maxX, minZ, maxZ); - const result: RegionSegment[] = []; - for (const region of regions) result.push(...region.segmentsNear(minX, maxX, minZ, maxZ)); - return result; -} - -/** 삼각형을 선분의 무한직선으로 절단해 소삼각형 목록으로 — 실교차 시에만 호출. */ -function splitTriByLine(tri: Tri, p: P2, q: P2): Tri[] { - const dir = { x: q.x - p.x, z: q.z - p.z }; - const dist = (x: number, z: number): number => dir.x * (z - p.z) - dir.z * (x - p.x); - const d = [dist(tri.px[0], tri.px[2]), dist(tri.px[3], tri.px[5]), dist(tri.px[6], tri.px[8])]; - const pos: number[] = []; - const neg: number[] = []; - d.forEach((value, i) => (value >= 0 ? pos.push(i) : neg.push(i))); - if (pos.length === 0 || neg.length === 0) return [tri]; - - /** 정점 하나의 값 묶음 — 위치 3개 + 슬롯별 값. */ - interface Vertex { - p: number[]; - a: number[][]; - } - const vertexOf = (i: number): Vertex => ({ - p: [tri.px[i * 3], tri.px[i * 3 + 1], tri.px[i * 3 + 2]], - a: tri.attrs.map((values, slot) => { - const size = values.length / 3; - void slot; - return Array.from({ length: size }, (_unused, c) => values[i * size + c]); - }), - }); - const lerpVertex = (i: number, j: number): Vertex => { - const t = d[i] / (d[i] - d[j]); - return { - p: [0, 1, 2].map( - (axis) => tri.px[i * 3 + axis] + (tri.px[j * 3 + axis] - tri.px[i * 3 + axis]) * t, - ), - a: tri.attrs.map((values) => { - const size = values.length / 3; - return Array.from( - { length: size }, - (_unused, c) => values[i * size + c] + (values[j * size + c] - values[i * size + c]) * t, - ); - }), - }; - }; - const makeTri = (a: Vertex, b: Vertex, c: Vertex): Tri => ({ - px: Float64Array.from([...a.p, ...b.p, ...c.p]), - attrs: a.a.map((_unused, slot) => - Float64Array.from([...a.a[slot], ...b.a[slot], ...c.a[slot]]), - ), - }); - - // 한쪽 1개 / 반대쪽 2개 — 교차점 2개로 삼각형 3개. - const lone = pos.length === 1 ? pos[0] : neg[0]; - const pair = pos.length === 1 ? neg : pos; - const i0 = lerpVertex(lone, pair[0]); - const i1 = lerpVertex(lone, pair[1]); - return [ - makeTri(vertexOf(lone), i0, i1), - makeTri(vertexOf(pair[0]), vertexOf(pair[1]), i0), - makeTri(vertexOf(pair[1]), i1, i0), - ]; -} - -function triCentroid(tri: Tri): P2 { - return { - x: (tri.px[0] + tri.px[3] + tri.px[6]) / 3, - z: (tri.px[2] + tri.px[5] + tri.px[8]) / 3, - }; -} - -/** 삼각형 무게중심의 씬 Y — 구조물 상단과 견주어 덮이는지 판정한다. */ -function triCentroidY(tri: Tri): number { - return (tri.px[1] + tri.px[4] + tri.px[7]) / 3; -} - -/** 세그먼트가 삼각형과 실제로 교차하는가(끝점 포함) — 과절단 방지 가드. */ -function segmentTouchesTri(tri: Tri, p: P2, q: P2): boolean { - const a: P2 = { x: tri.px[0], z: tri.px[2] }; - const b: P2 = { x: tri.px[3], z: tri.px[5] }; - const c: P2 = { x: tri.px[6], z: tri.px[8] }; - if (pointInTri2(p, a, b, c) || pointInTri2(q, a, b, c)) return true; - return ( - segmentsIntersect(p, q, a, b) || segmentsIntersect(p, q, b, c) || segmentsIntersect(p, q, c, a) - ); -} - -/** 보간해 이어 갈 속성 슬롯 목록 — 위치·법선을 뺀 나머지 전부. */ -function attributeSlots(geometry: THREE.BufferGeometry): AttrSlot[] { - const slots: AttrSlot[] = []; - for (const name of Object.keys(geometry.attributes)) { - if (SKIPPED_ATTRIBUTES.has(name)) continue; - const attribute = geometry.getAttribute(name) as THREE.BufferAttribute | undefined; - if (!attribute) continue; - // 배열은 count*itemSize보다 길 수 있다(로더가 버퍼를 공유·패딩하는 경우) — 정확히 - // 쓰는 구간만 잘라 쓴다. 통째로 복사하면 대상 배열을 넘어 터진다 - // (2026-08-23 "offset is out of bounds"로 클리핑 전체가 죽어 있던 원인). - const values = ( - attribute.array as unknown as { subarray(a: number, b: number): ArrayLike } - ).subarray(0, attribute.count * attribute.itemSize); - slots.push({ name, itemSize: attribute.itemSize, source: attribute, values, extra: [] }); - } - return slots; -} - -/** - * BufferGeometry 하나를 영역들로 재절단한다. - * 반환: 영역 밖 조각만 남긴 geometry (변화 없으면 null). - */ -export function clipGeometry( - geometry: THREE.BufferGeometry, - regions: ReadonlyArray, -): THREE.BufferGeometry | null { - if (!regions.length) return null; - const position = geometry.getAttribute("position") as THREE.BufferAttribute | undefined; - if (!position) return null; - const index = geometry.getIndex(); - const triCount = index ? index.count / 3 : position.count / 3; - - // 성능(2026-08-23 프리즈 개선): 원본 정점 버퍼는 그대로 두고 **인덱스만** 새로 만든다. - // 영역은 서피스 전체에서 가느다란 띠라 삼각형 대부분이 그대로 남는다 — 그 다수를 - // 정점 복사 없이 정수 3개 push로 넘기면 편집 반복에도 멎지 않는다. 잘린 조각만 - // 새 정점으로 뒤에 덧붙인다. - const px = (position.array as Float32Array).subarray(0, position.count * 3); - const slots = attributeSlots(geometry); - const keptIndices: number[] = []; - const extraPositions: number[] = []; - const baseCount = position.count; - let changed = false; - - const regionMinX = Math.min(...regions.map((region) => region.minX)); - const regionMaxX = Math.max(...regions.map((region) => region.maxX)); - const regionMinZ = Math.min(...regions.map((region) => region.minZ)); - const regionMaxZ = Math.max(...regions.map((region) => region.maxZ)); - - const readTri = (ia: number, ib: number, ic: number): Tri => ({ - px: Float64Array.from([ - px[ia * 3], - px[ia * 3 + 1], - px[ia * 3 + 2], - px[ib * 3], - px[ib * 3 + 1], - px[ib * 3 + 2], - px[ic * 3], - px[ic * 3 + 1], - px[ic * 3 + 2], - ]), - attrs: slots.map((slot) => - Float64Array.from( - [ia, ib, ic].flatMap((v) => - Array.from({ length: slot.itemSize }, (_unused, c) => slot.values[v * slot.itemSize + c]), - ), - ), - ), - }); - /** 잘린 조각 — 새 정점으로 덧붙이고 그 인덱스를 쓴다. */ - const emitFragment = (tri: Tri): void => { - for (let v = 0; v < 3; v += 1) { - keptIndices.push(baseCount + extraPositions.length / 3); - extraPositions.push(tri.px[v * 3], tri.px[v * 3 + 1], tri.px[v * 3 + 2]); - slots.forEach((slot, s) => { - for (let c = 0; c < slot.itemSize; c += 1) { - slot.extra.push(tri.attrs[s][v * slot.itemSize + c]); - } - }); - } - }; - - for (let t = 0; t < triCount; t += 1) { - const ia = index ? index.getX(t * 3) : t * 3; - const ib = index ? index.getX(t * 3 + 1) : t * 3 + 1; - const ic = index ? index.getX(t * 3 + 2) : t * 3 + 2; - const ax = px[ia * 3]; - const az = px[ia * 3 + 2]; - const bx = px[ib * 3]; - const bz = px[ib * 3 + 2]; - const ccx = px[ic * 3]; - const ccz = px[ic * 3 + 2]; - const minX = Math.min(ax, bx, ccx); - const maxX = Math.max(ax, bx, ccx); - const minZ = Math.min(az, bz, ccz); - const maxZ = Math.max(az, bz, ccz); - // 영역 전체 AABB 밖 — 그대로 유지(정점 복사 없이 인덱스만). - if (maxX < regionMinX || minX > regionMaxX || maxZ < regionMinZ || minZ > regionMaxZ) { - keptIndices.push(ia, ib, ic); - continue; - } - const near = segmentsNearAny(regions, minX, maxX, minZ, maxZ); - if (near.length === 0) { - // 경계와 무관 — 전부 안이면 버리고 아니면 유지(경계 선분이 안 닿는 - // 삼각형은 안/밖 어느 한쪽에 통째로 있다). - const cy = (px[ia * 3 + 1] + px[ib * 3 + 1] + px[ic * 3 + 1]) / 3; - if (insideAny(regions, { x: (ax + bx + ccx) / 3, z: (az + bz + ccz) / 3 }, cy)) { - changed = true; - continue; - } - keptIndices.push(ia, ib, ic); - continue; - } - // 경계 근접 — 실교차 세그먼트로 순차 절단 후 무게중심 판정. - const tri = readTri(ia, ib, ic); - let fragments: Tri[] = [tri]; - for (const segment of near) { - const next: Tri[] = []; - for (const fragment of fragments) { - if (segmentTouchesTri(fragment, segment.p, segment.q)) { - next.push(...splitTriByLine(fragment, segment.p, segment.q)); - } else { - next.push(fragment); - } - } - fragments = next; - } - const kept = fragments.filter( - (fragment) => !insideAny(regions, triCentroid(fragment), triCentroidY(fragment)), - ); - if (kept.length === fragments.length) { - keptIndices.push(ia, ib, ic); // 절단은 됐지만 전부 밖 — 원본 인덱스 유지. - } else { - changed = true; - kept.forEach(emitFragment); - } - } - - if (!changed) return null; - const clipped = new THREE.BufferGeometry(); - const positions = new Float32Array(baseCount * 3 + extraPositions.length); - positions.set(px, 0); - positions.set(extraPositions, baseCount * 3); - clipped.setAttribute("position", new THREE.BufferAttribute(positions, 3)); - for (const slot of slots) { - // 정점색은 정규화 Uint8일 수 있다(GLB 관례) — **원본 배열 종류 그대로** 담고 - // normalized 플래그까지 물려받아야 색이 그대로 산다. Float32로 옮겨 담으면 - // 0~255 raw가 그대로 들어가 색이 날아간다(2026-08-23 화면 검증). - const source = slot.source.array as unknown as { - constructor: new (length: number) => { set(a: ArrayLike, o?: number): void }; - }; - const merged = new source.constructor(baseCount * slot.itemSize + slot.extra.length); - merged.set(slot.values, 0); - // 정수 배열이면 보간값 소수부가 잘린다 — 반올림해 담는다. - merged.set( - slot.source.normalized ? slot.extra.map((value) => Math.round(value)) : slot.extra, - baseCount * slot.itemSize, - ); - clipped.setAttribute( - slot.name, - new THREE.BufferAttribute( - merged as unknown as THREE.TypedArray, - slot.itemSize, - slot.source.normalized, - ), - ); - } - clipped.setIndex(keptIndices); - // 법선은 원본이 가진 것만 물려받는다. 원본에 법선이 없으면(GLB가 안 실어 보내는 - // 경우) **우리도 만들지 않는다** — 직접 계산하면 감기 방향이 원본과 달라져 지형이 - // 새까맣게 보였다(2026-08-23 화면 검증). 없을 때의 음영은 THREE가 알아서 만든다. - const normal = geometry.getAttribute("normal") as THREE.BufferAttribute | undefined; - if (normal && normal.count >= baseCount) { - const normals = new Float32Array(baseCount * 3 + extraPositions.length); - normals.set((normal.array as Float32Array).subarray(0, baseCount * 3), 0); - for (let i = 0; i < extraPositions.length; i += 3) { - normals[baseCount * 3 + i] = 0; - normals[baseCount * 3 + i + 1] = 1; - normals[baseCount * 3 + i + 2] = 0; - } - clipped.setAttribute("normal", new THREE.BufferAttribute(normals, 3)); - } - return clipped; -} - -/** 포인트클라우드(meshfree) — 영역 안 점만 걷어낸다. */ -function clipPoints( - geometry: THREE.BufferGeometry, - regions: ReadonlyArray, -): THREE.BufferGeometry { - const position = geometry.getAttribute("position") as THREE.BufferAttribute; - const keep: number[] = []; - for (let i = 0; i < position.count; i += 1) { - if (!insideAny(regions, { x: position.getX(i), z: position.getZ(i) })) keep.push(i); - } - const positions = new Float32Array(keep.length * 3); - keep.forEach((src, dst) => { - positions[dst * 3] = position.getX(src); - positions[dst * 3 + 1] = position.getY(src); - positions[dst * 3 + 2] = position.getZ(src); - }); - const clipped = new THREE.BufferGeometry(); - clipped.setAttribute("position", new THREE.BufferAttribute(positions, 3)); - const color = geometry.getAttribute("color") as THREE.BufferAttribute | undefined; - if (color) { - const size = color.itemSize; - const colors = new Float32Array(keep.length * size); - keep.forEach((src, dst) => { - for (let c = 0; c < size; c += 1) { - colors[dst * size + c] = (color.array as ArrayLike)[src * size + c]; - } - }); - clipped.setAttribute("color", new THREE.BufferAttribute(colors, size, color.normalized)); - } - return clipped; -} - -/** - * 구조물 안착 자리에서 **원지반을 되살린다** — 그 자리 지형을 도려내지 않고 **원본 - * 지형 메시 그대로** 남긴다(2026-08-27 사용자: "원지반 오리지널 데이터에서 가져오라니까", - * "회색 서피스는 있는데 원지반과 같은 메시가 아니잖아. 너가 그냥 만든 거잖아"). - * - * 되살릴 영역 = **`cut-merged`** — 성토면에서 **실제로 도려낸 셀**을 되짚어 낸 윤곽이다 - * (`_Corridor_Cut.ts` `traceMaskLoops`). 지우는 쪽이 남긴 자국을 그대로 쓰므로, 구조물 - * 풋프린트로 영역을 다시 조립할 필요가 없다. - * - * 종전에는 `원본 성토 스케치 − (구조물 ∪ 변형 성토 스케치)`로 영역을 **다시 만들었다**. - * 지우는 쪽(성토 셀 마스크)과 되살리는 쪽(구조물 고리)이 서로 다른 커브를 써 경계가 - * 어긋났고, 날개벽이 패널 두께 슬리버로 남아 그 자리만 성토면도 지형도 없는 검은 줄이 - * 됐다. BOX암거에서는 변형 성토선이 원본과 collinear가 되어 영역이 통째로 공집합이 됐다. - * - * `cut-merged`가 없으면(절취를 안 했으면) 되살릴 것도 없다 — 스트립 그대로. - */ -function withStructureHoles( - strip: CorridorStrip, - build: CorridorBuildResult, - bounds: ModelBounds, -): PlanRegion { - const loops: Array> = []; - for (const curve of build.planCurves ?? []) { - if (curve.source !== "cut-merged") continue; - for (const loop of curve.loops) { - if (loop.length >= 4) loops.push(loop.map(([x, y]) => [x, y] as [number, number])); - } - } - if (!loops.length) return strip; - const expose = new PlanLoopRegion(loops, bounds); - if (expose.isEmpty) return strip; - // 검증용 요약(화면 밖 수치 확인) — `__corridorClip`과 같은 용도. 고리 수만으로는 - // 되살릴 영역이 실제로 남았는지 알 수 없어(BOX에서 공집합인데도 정상값이 찍혔다) - // 영역을 격자로 찍은 실적을 함께 담는다. 코리도 빌드당 한 번이라 비용은 무시할 만하다. - const SAMPLES = 80; - let inside = 0; - for (let i = 0; i < SAMPLES; i += 1) { - const x = expose.minX + ((expose.maxX - expose.minX) * (i + 0.5)) / SAMPLES; - for (let j = 0; j < SAMPLES; j += 1) { - const z = expose.minZ + ((expose.maxZ - expose.minZ) * (j + 0.5)) / SAMPLES; - if (expose.contains({ x, z })) inside += 1; - } - } - (window as unknown as { __corridorHoles?: unknown }).__corridorHoles = { - /** 되살릴 영역 고리 수 = 실제로 도려낸 자리 윤곽(`cut-merged`) 개수. */ - exposeLoops: loops.length, - exposeCells: inside, - exposeRatio: Number((inside / (SAMPLES * SAMPLES)).toFixed(4)), - }; - return new SubtractedRegion(strip, [expose]); -} - -/** - * 코리도 스트립으로 도려낸 지형을 만든다 — 원본은 불변. - * - * **구조물 투영커브는 여기 쓰지 않는다**(2026-08-25 사용자: 투영 및 삭제는 원지반 제외). - * 원지반은 코리도 점유분만 걷어내고, 구조물이 덮는 자리는 성토·절토 리본에서만 도려낸다. - * - * 분할본(TerrainBandSplit)을 받으면 **노선 주변(near)만** 재트림하고 원거리(far)는 - * 처음 만든 그대로 다시 쓴다(2026-08-23 사용자 제안). 편집마다 지형 전체를 - * 훑던 비용이 밴드 안쪽으로 줄어든다. - */ -export function clipTerrain( - split: TerrainBandSplit, - build: CorridorBuildResult, - bounds: ModelBounds, -): THREE.Object3D { - const regions: PlanRegion[] = [ - withStructureHoles(new CorridorStrip(build, bounds), build, bounds), - ]; - const root = new THREE.Group(); - root.name = "terrain-clipped"; - // geometry·material 소유권 표시 — 분할본이 들고 있는 것은 여기서 해제하면 안 된다. - // 이 그룹이 새로 만든 것(userData.owned)만 disposeClippedTerrain이 정리한다. - split.near.forEach((part) => { - if (part.points) { - const points = new THREE.Points( - clipPoints(part.geometry, regions), - part.material as THREE.Material, - ); - points.userData.owned = true; - root.add(points); - return; - } - const clipped = clipGeometry(part.geometry, regions); - const mesh = new THREE.Mesh(clipped ?? part.geometry, part.material); - mesh.userData.owned = clipped !== null; - root.add(mesh); - }); - // 원거리 조각은 코리도와 무관하다 — 그대로 얹는다(재트림·재생성 없음). - split.far.forEach((part) => { - const mesh = new THREE.Mesh(part.geometry, part.material); - mesh.userData.owned = false; - root.add(mesh); - }); - return root; -} diff --git a/B05_Profile/Old/B05_Profile_UI_Corridor_Cut.ts b/B05_Profile/Old/B05_Profile_UI_Corridor_Cut.ts deleted file mode 100644 index 42b90c524..000000000 --- a/B05_Profile/Old/B05_Profile_UI_Corridor_Cut.ts +++ /dev/null @@ -1,620 +0,0 @@ -/* ============================================================================= - * B05_Profile_UI_Corridor_Cut.ts - * 탑뷰 투영 커브(빨간 선) **안쪽 성토면 지우기** — 2026-08-26 사용자. - * - * 방식: 정점은 **하나도 건드리지 않는다**. 격자 셀(사각형) 중 **중심이 커브 안에 - * 드는 셀만 마스크**해 그리기 단계에서 삼각형을 빼면 그 자리가 비워진다. - * - * 왜 이렇게(2026-08-26 사용자 지적): 리본은 `행 × 열` 직사각 격자라 한 행 안에 - * 들쭉날쭉한 구멍을 담지 못한다. 그래서 종전처럼 행을 잘라 내는 방식은 결국 - * **행 경계 = 노선 기준**으로 떨어져, BOX암거처럼 커브가 노선과 비스듬한 자리에서 - * 파란선 바깥까지 잘려 나갔다(실측: 좌측 커브 바깥 변은 200.37~201.91인데 구멍은 - * 200.18~202.11). 셀 마스크는 그 한계가 없다 — 리샘플·조각 이어붙이기도 필요 없다. - * - * **선택 영역은 하나다**(2026-08-26 사용자): 지울 자리는 결국 상단 평면 스케치가 - * 덮는 XY 영역이고, 투영선은 그 XY를 그대로 복사한 것(Z만 서피스에서 읽음)이다. - * 그래서 구조물 종류를 가리지 않고 **한 가지 방법**으로 자른다 — 종전에 노선 흐름 - * 커브와 축 고정 커브(BOX)를 나눠 다루려 했으나, 영역이 같은데 방법만 갈라 두면 - * 한쪽은 계단, 다른 쪽은 지그재그가 됐다. - * - * 클리핑은 **셀 하나를 직선 하나로** 자른다. 셀은 폭 6cm·길이 0.2m라 그 안에서 커브는 - * 사실상 직선이고, 볼록 사각형을 직선으로 자르면 결과도 볼록이라 부채꼴 삼각분할이 - * 항상 옳다. 다각형 대 다각형 클리핑을 시도했다가 되살릴 면적의 40%를 잃고 톱니가 - * 났던 전례가 있다(2026-08-26 실측) — 교점 두 개로 직선을 세우는 지금 방식이 그 함정을 - * 피한다. 자를 직선을 못 세운 셀은 **안쪽 면적 비율**로 가른다(2026-08-27 사용자): - * 절반 이상 잠겼으면 지우고 아니면 남긴다. 종전처럼 통째로 남기면 귀퉁이가 경계에 딱 - * 붙어 안팎 판정을 못 받은 셀들이 경계에 미세 메쉬로 줄줄이 남았다. - * - * 잘린 자리는 비워 둔다 — 구조물 솔리드가 들어앉아 메운다(2026-08-25 사용자 확정). - * 절토면·도로부·원지반은 건드리지 않는다(사용자: 성토면만). - * ========================================================================== */ - -import type { CorridorCutWall, CorridorRibbon } from "./B05_Profile_UI_Corridor_Build"; -import type { PlanCurve } from "./B05_Profile_UI_Corridor_Plan"; - -/** 커브 다각형 하나 — XY와 경계 상자(빠른 배제용). */ -interface CurveArea { - points: Array<[number, number]>; - minX: number; - maxX: number; - minY: number; - maxY: number; -} - -/** 광선 투사 내부 판정(XY). */ -function inside(area: CurveArea, x: number, y: number): boolean { - if (x < area.minX || x > area.maxX || y < area.minY || y > area.maxY) return false; - const points = area.points; - let hit = false; - for (let i = 0, j = points.length - 1; i < points.length; j = i, i += 1) { - const [xi, yi] = points[i]; - const [xj, yj] = points[j]; - if (yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) hit = !hit; - } - return hit; -} - -/** 셀 네 귀퉁이 — 링 순서(row,col) → (row,col+1) → (row+1,col+1) → (row+1,col). */ -interface Corner { - p: [number, number, number]; - /** 셀 안 상대좌표 — a: 열 방향(0~1), b: 행 방향(0~1). UV 보간에 쓴다. */ - a: number; - b: number; -} - -function areaOf(loop: ReadonlyArray<[number, number, number]>): CurveArea { - const points = loop.map((point) => [point[0], point[1]] as [number, number]); - let minX = Infinity; - let maxX = -Infinity; - let minY = Infinity; - let maxY = -Infinity; - for (const [x, y] of points) { - minX = Math.min(minX, x); - maxX = Math.max(maxX, x); - minY = Math.min(minY, y); - maxY = Math.max(maxY, y); - } - return { points, minX, maxX, minY, maxY }; -} - -/** 셀 둘레와 커브 경계의 교점(둘레 진행 순서). */ -function ringCrossings(areas: ReadonlyArray, ring: ReadonlyArray): Corner[] { - const hits: Corner[] = []; - for (let i = 0; i < ring.length; i += 1) { - const from = ring[i]; - const to = ring[(i + 1) % ring.length]; - const rx = to.p[0] - from.p[0]; - const ry = to.p[1] - from.p[1]; - const ts: number[] = []; - for (const area of areas) { - const points = area.points; - for (let k = 0, j = points.length - 1; k < points.length; j = k, k += 1) { - const sx = points[k][0] - points[j][0]; - const sy = points[k][1] - points[j][1]; - const denominator = rx * sy - ry * sx; - if (Math.abs(denominator) < 1e-12) continue; - const dx = points[j][0] - from.p[0]; - const dy = points[j][1] - from.p[1]; - const t = (dx * sy - dy * sx) / denominator; - const u = (dx * ry - dy * rx) / denominator; - if (t >= -1e-9 && t <= 1 + 1e-9 && u >= 0 && u <= 1) ts.push(Math.min(1, Math.max(0, t))); - } - } - ts.sort((p, q) => p - q); - for (const t of ts) { - const last = hits[hits.length - 1]; - const point: Corner = { - p: [from.p[0] + rx * t, from.p[1] + ry * t, from.p[2] + (to.p[2] - from.p[2]) * t], - a: from.a + (to.a - from.a) * t, - b: from.b + (to.b - from.b) * t, - }; - // 같은 자리 중복(모서리 통과) 제거. - if (last && Math.hypot(last.p[0] - point.p[0], last.p[1] - point.p[1]) < 1e-6) continue; - hits.push(point); - } - } - return hits; -} - -/** 셀 클리핑 결과 — 남길 바깥 조각과, 실제로 잘린 **교점 직선**(측벽이 설 자리). */ -interface CellClip { - kept: Corner[]; - chord: [Corner, Corner]; -} - -/** - * 셀을 교점 두 개가 만드는 **직선 하나로** 자르고, 커브 **바깥** 쪽 조각을 돌려준다. - * 교점이 2개가 아니면 null — 부르는 쪽이 셀을 통째로 남긴다. - */ -function clipCellByChord( - areas: ReadonlyArray, - ring: ReadonlyArray, - /** 남길 쪽 — 기본은 커브 **밖**(기본 성토면). 패치는 **안**을 남긴다. */ - keep: "outside" | "inside" = "outside", -): CellClip | null { - const hits = ringCrossings(areas, ring); - // 교점은 보통 2개다. 귀퉁이를 스치거나 커브 마디가 셀 안에서 꺾이면 더 나오는데, - // 그때도 **처음과 마지막**을 이으면 셀 안 경계와 거의 같다(셀이 0.2m × 6cm라 - // 그 안에서 커브는 사실상 직선). 2개일 때만 자르면 3분의 2가 통째로 남아 경계가 - // 다시 지그재그가 됐다(2026-08-26 실측: 걸친 셀 1,230개 중 816개가 미절단). - if (hits.length < 2) return null; - const h0 = hits[0]; - const h1 = hits[hits.length - 1]; - const lx = h1.p[0] - h0.p[0]; - const ly = h1.p[1] - h0.p[1]; - if (Math.hypot(lx, ly) < 1e-9) return null; - /** 직선 왼쪽/오른쪽 부호. */ - const sideOf = (x: number, y: number): number => lx * (y - h0.p[1]) - ly * (x - h0.p[0]); - // 바깥 쪽 부호 — 커브 밖인 귀퉁이로 정한다. - let keepSign = 0; - for (const corner of ring) { - if (areas.some((area) => inside(area, corner.p[0], corner.p[1]))) continue; - const value = sideOf(corner.p[0], corner.p[1]); - if (Math.abs(value) > 1e-9) { - keepSign = Math.sign(value); - break; - } - } - if (!keepSign) return null; - if (keep === "inside") keepSign = -keepSign; - // 볼록 사각형을 직선으로 자른다 — 결과도 볼록이라 부채꼴 삼각분할이 항상 옳다. - const kept: Corner[] = []; - for (let i = 0; i < ring.length; i += 1) { - const from = ring[i]; - const to = ring[(i + 1) % ring.length]; - const sFrom = sideOf(from.p[0], from.p[1]) * keepSign; - const sTo = sideOf(to.p[0], to.p[1]) * keepSign; - if (sFrom >= -1e-9) kept.push(from); - if ((sFrom > 0 && sTo < 0) || (sFrom < 0 && sTo > 0)) { - const t = sFrom / (sFrom - sTo); - kept.push({ - p: [ - from.p[0] + (to.p[0] - from.p[0]) * t, - from.p[1] + (to.p[1] - from.p[1]) * t, - from.p[2] + (to.p[2] - from.p[2]) * t, - ], - a: from.a + (to.a - from.a) * t, - b: from.b + (to.b - from.b) * t, - }); - } - } - return kept.length >= 3 ? { kept, chord: [h0, h1] } : null; -} - -/** - * 셀 안쪽 면적 비율 — 4×4 격자 표본으로 어림한다(2026-08-27). - * - * 클리핑이 실패한 셀을 가르는 데만 쓴다. 실패 셀은 거의 전부 **귀퉁이가 커브 경계에 - * 딱 붙어** `inCount === 4` 판정을 못 받았을 뿐 속은 통째로 안쪽이다(실측: 실패 - * 627개 중 622개가 비율 1.00). - */ -function coverage(areas: ReadonlyArray, ring: ReadonlyArray): number { - const STEPS = 4; - let hits = 0; - for (let i = 0; i < STEPS; i += 1) { - const a = (i + 0.5) / STEPS; - const topX = ring[0].p[0] + (ring[1].p[0] - ring[0].p[0]) * a; - const topY = ring[0].p[1] + (ring[1].p[1] - ring[0].p[1]) * a; - const bottomX = ring[3].p[0] + (ring[2].p[0] - ring[3].p[0]) * a; - const bottomY = ring[3].p[1] + (ring[2].p[1] - ring[3].p[1]) * a; - for (let j = 0; j < STEPS; j += 1) { - const b = (j + 0.5) / STEPS; - const x = topX + (bottomX - topX) * b; - const y = topY + (bottomY - topY) * b; - if (areas.some((area) => inside(area, x, y))) hits += 1; - } - } - return hits / (STEPS * STEPS); -} - -/** 절취 영역이 되는 커브 — 비탈 투영선과 **날개벽 사다리꼴**의 합집합(2026-08-27 사용자). */ -const CUT_SOURCES: ReadonlyArray = ["slope-projected", "wing-box"]; - -/** 병합 커브를 올릴 **새 데이텀** — 그 세트 평면 대비 이만큼 위(2026-08-27 사용자). */ -const MERGED_LIFT_M = 3; - -/** 세트 평면 — 병합 커브를 어느 높이·어느 유입유출로 낼지 정한다. */ -interface SetPlane { - chainage: number; - planZ: number; - role: PlanCurve["role"]; -} - -/** - * 마스크된 셀 덩어리의 **격자 윤곽**을 고리로 추적한다 — 실제로 잘리는 자리다. - * 셀 하나가 6cm × 0.2m라 계단이 그 크기를 넘지 않는다. - */ -function traceMaskLoops( - rows: number, - cols: number, - mask: Uint8Array, -): Array> { - const on = (row: number, col: number): boolean => - row >= 0 && col >= 0 && row < rows - 1 && col < cols - 1 && mask[row * (cols - 1) + col] === 1; - const key = (row: number, col: number): number => row * cols + col; - const edges = new Map>(); - const push = (fromRow: number, fromCol: number, toRow: number, toCol: number): void => { - const list = edges.get(key(fromRow, fromCol)) ?? []; - list.push([toRow, toCol]); - edges.set(key(fromRow, fromCol), list); - }; - // 마스크된 셀에서 **이웃이 안 마스크된 쪽 모서리**만 담는다 — 링 방향을 맞춰 담아야 - // 이어 붙일 때 한 방향으로 돈다. - for (let row = 0; row < rows - 1; row += 1) { - for (let col = 0; col < cols - 1; col += 1) { - if (!on(row, col)) continue; - if (!on(row - 1, col)) push(row, col, row, col + 1); - if (!on(row, col + 1)) push(row, col + 1, row + 1, col + 1); - if (!on(row + 1, col)) push(row + 1, col + 1, row + 1, col); - if (!on(row, col - 1)) push(row + 1, col, row, col); - } - } - /** - * 갈림목에서 **가장 오른쪽으로 도는 변**을 고른다 — 변은 마스크된 셀을 왼쪽에 두고 - * 담기므로(반시계), 오른손 규칙을 지켜야 두 덩어리가 맞닿은 자리에서 서로 다른 - * 고리로 갈린다. 아무 변이나 집으면(`pop()`) walk가 남의 고리로 새 나가 막다른 - * 길에 빠지고, 그 **열린 사슬이 고리로 담긴다** — 그 위에 point-in-polygon을 걸면 - * 판정이 무너진다(2026-08-27 실측: 도려낸 셀의 18%가 되살릴 영역에서 빠졌다). - */ - const pickTurn = ( - from: [number, number], - at: [number, number], - options: ReadonlyArray<[number, number]>, - ): number => { - const inRow = at[0] - from[0]; - const inCol = at[1] - from[1]; - let best = 0; - let bestScore = -1; - options.forEach((to, index) => { - const outRow = to[0] - at[0]; - const outCol = to[1] - at[1]; - // 외적(왼쪽 양수) → 오른쪽 회전일수록 작다. 내적으로 직진/후진을 가른다. - const cross = inCol * outRow - inRow * outCol; - const dot = inCol * outCol + inRow * outRow; - // 우회전(3) > 직진(2) > 좌회전(1) > 되돌아가기(0). - const score = cross < 0 ? 3 : dot > 0 ? 2 : cross > 0 ? 1 : 0; - if (score > bestScore) { - bestScore = score; - best = index; - } - }); - return best; - }; - const loops: Array> = []; - for (const start of [...edges.keys()]) { - const list = edges.get(start); - while (list?.length) { - const head: [number, number] = [Math.floor(start / cols), start % cols]; - const loop: Array<[number, number]> = [head]; - let previous = head; - let current = list.pop() as [number, number]; - let closed = false; - for (let guard = 0; guard < 100000; guard += 1) { - loop.push(current); - if (current[0] === head[0] && current[1] === head[1]) { - closed = true; - break; - } - const next = edges.get(key(current[0], current[1])); - if (!next?.length) break; - const pick = next.length === 1 ? 0 : pickTurn(previous, current, next); - previous = current; - current = next.splice(pick, 1)[0]; - } - // **닫힌 고리만** 쓴다 — 열린 사슬은 영역 판정에 쓸 수 없다. 위 규칙대로 돌면 - // 정상 마스크에서는 언제나 닫힌다(안 닫히면 그 덩어리는 다음 시작점에서 다시 잡힌다). - if (closed && loop.length >= 4) loops.push(loop); - } - } - return loops; -} - -/** - * 실좌표에서 **정말 일직선인** 점만 버린다 — 직선 구간이 점 수천 개가 되지 않게. - * - * 격자 인덱스로 재면 안 된다. 코리도 격자는 노선을 따라 **휘어 있어** 인덱스로 직선인 - * 구간이 실좌표에서는 곡선이다. 인덱스 기준으로 지우면 폴리곤이 실제 마스크 **안쪽으로 - * 코너를 잘라먹고**, 그 밖으로 삐져나온 셀은 되살릴 영역에서 빠져 구멍이 된다 - * (2026-08-27 실측: 도려낸 셀의 18%, 전부 편거리가 큰 바깥 끝 열 40~46. - * 사용자: "유출구에 일부 성토부에 터짐 있음"). - */ -const COLLINEAR_TOLERANCE_M = 0.001; - -function dropCollinear( - loop: ReadonlyArray<[number, number, number]>, -): Array<[number, number, number]> { - // 추적 고리는 머리 점이 꼬리에 한 번 더 실려 온다(닫힘 표시). **순환으로 다듬기 전에 - // 뗀다** — 그대로 두면 머리 점의 이웃이 자기 자신이 되어 `away = 0`으로 지워지고, 그 - // 모서리 양옆의 일직선 점들까지 함께 빠져 **시작 모서리가 대각선으로 잘려 나갔다** - // (2026-09-02 실측: 고리마다 첫 행 쐐기 63셀이 되살릴 영역에서 빠져 구멍이 됐다). - const closed = - loop.length > 1 && - loop[0][0] === loop[loop.length - 1][0] && - loop[0][1] === loop[loop.length - 1][1]; - const ring = closed ? loop.slice(0, -1) : loop; - if (ring.length < 3) return [...loop]; - const kept: Array<[number, number, number]> = []; - for (let i = 0; i < ring.length; i += 1) { - // 앞점은 **마지막으로 남긴 점**이다 — 바로 앞 원점으로 재면 완만한 곡선에서 점마다 - // 1mm 미만이라 전부 빠지고, 남은 긴 현이 곡선 안쪽으로 2cm 파고든다(2026-09-02 실측: - // 직선부 노견 열의 셀 중심이 현 위에 놓였다). `_Region.ts` `simplifyLoop`과 같은 규칙. - const prev = kept[kept.length - 1] ?? ring[ring.length - 1]; - const at = ring[i]; - const next = ring[(i + 1) % ring.length]; - const dx = next[0] - prev[0]; - const dy = next[1] - prev[1]; - const span = Math.hypot(dx, dy); - if (span > 1e-9) { - // prev→next 선분에서 잰 수직거리 — 이만큼도 안 벗어나면 없어도 같은 선이다. - const away = Math.abs(dx * (at[1] - prev[1]) - dy * (at[0] - prev[0])) / span; - if (away < COLLINEAR_TOLERANCE_M) continue; - } - kept.push(at); - } - // 닫힘 표시는 그대로 돌려준다 — 스케치가 `Line`으로 그려져 꼬리 점이 있어야 닫힌다. - if (closed && kept.length) kept.push(kept[0]); - return kept; -} - -/** 측벽을 원지반 **아래로 더 내리는 여유**(m) — 지반 보간 오차만큼 밑이 뜨는 걸 막는다. - * 지형은 코리도 자리에서 이미 잘려 있어 더 내려도 가려진다(2026-08-27 사용자). */ -const SKIRT_MARGIN_M = 1; - -/** - * 잘린 자리 **측벽**(2026-08-27 사용자) — **평면 스케치에서 바로** 세운다. - * - * 벽이 설 자리는 Plan이 `cut-wall` 커브로 넘겨준다(스트립 양 끝 횡단선 + 날개 - * 사다리꼴의 [끝선→날개→끝선]). 노견선·성토 끝선 변은 애초에 빠져 있다. - * 셀을 훑지 않으므로 커브 마디 하나당 판 하나다 — 종전에는 셀마다 한 장씩 나와 - * 세월교 한 세트에 96~111조각(길이 5~20cm)이 깔렸다(2026-08-27 실측). - * - * 표고만 성토 리본에서 읽는다 — 행 하나는 XY상 직선이라 점을 그 선분에 투영해 - * 열 위치를 얻고, 서피스 z와 원지반 z를 같은 자리에서 보간한다. - */ -function sampleRibbon( - ribbon: CorridorRibbon, - x: number, - y: number, -): { top: number; bottom: number } | null { - const ground = ribbon.groundZ; - if (!ground) return null; - const cols = ribbon.colCount; - const rows = ribbon.chainages.length; - let best: { distance: number; row: number; t: number } | null = null; - for (let row = 0; row < rows; row += 1) { - const a = row * cols * 3; - const b = (row * cols + cols - 1) * 3; - const dx = ribbon.positions[b] - ribbon.positions[a]; - const dy = ribbon.positions[b + 1] - ribbon.positions[a + 1]; - const lengthSq = dx * dx + dy * dy; - const t = - lengthSq > 1e-12 - ? Math.min( - 1, - Math.max( - 0, - ((x - ribbon.positions[a]) * dx + (y - ribbon.positions[a + 1]) * dy) / lengthSq, - ), - ) - : 0; - const distance = Math.hypot( - x - (ribbon.positions[a] + dx * t), - y - (ribbon.positions[a + 1] + dy * t), - ); - if (!best || distance < best.distance) best = { distance, row, t }; - } - if (!best) return null; - const span = best.t * (cols - 1); - const col = Math.min(cols - 2, Math.floor(span)); - const f = span - col; - const i = (best.row * cols + col) * 3; - const j = i + 3; - const g = best.row * cols + col; - return { - top: ribbon.positions[i + 2] + (ribbon.positions[j + 2] - ribbon.positions[i + 2]) * f, - bottom: ground[g] + (ground[g + 1] - ground[g]) * f - SKIRT_MARGIN_M, - }; -} - -/** `cut-wall` 커브를 성토면↔원지반 사이 판으로 세운다. */ -function buildWall( - ribbon: CorridorRibbon, - lines: ReadonlyArray>, -): Float64Array | null { - const tris: number[] = []; - for (const line of lines) { - for (let i = 0; i < line.length - 1; i += 1) { - const [x1, y1] = line[i]; - const [x2, y2] = line[i + 1]; - if (Math.hypot(x2 - x1, y2 - y1) < 1e-6) continue; - const a = sampleRibbon(ribbon, x1, y1); - const b = sampleRibbon(ribbon, x2, y2); - if (!a || !b) continue; - if (a.top - a.bottom < 1e-6 && b.top - b.bottom < 1e-6) continue; - tris.push(x1, y1, a.top, x2, y2, b.top, x2, y2, b.bottom); - tris.push(x1, y1, a.top, x2, y2, b.bottom, x1, y1, a.bottom); - } - } - return tris.length ? new Float64Array(tris) : null; -} - -/** - * 성토 리본에 **셀 마스크**(+ 축 고정 커브가 걸친 셀의 조각 삼각형)를 달아 돌려준다. - * 원본 정점은 안 건드린다. 아무것도 안 걸리면 리본을 그대로 통과시킨다. - * - * 절취 영역은 비탈 투영선 ∪ 날개벽 사다리꼴이고, 실제로 잘린 자리를 **병합 커브** - * (`cut-merged`)로 함께 돌려준다 — 기존 커브는 그대로 두고 새 데이텀(+3m)에 얹는다. - */ -export function maskFillByPlanCurves( - ribbons: ReadonlyArray, - curves: ReadonlyArray, -): { ribbons: CorridorRibbon[]; curves: PlanCurve[]; walls: CorridorCutWall[] } { - const areasBySide = new Map(); - const planesBySide = new Map(); - for (const curve of curves) { - if (!CUT_SOURCES.includes(curve.source)) continue; - const bucket = areasBySide.get(curve.side) ?? []; - curve.loops.forEach((loop) => { - if (loop.length >= 4) bucket.push(areaOf(loop)); - }); - areasBySide.set(curve.side, bucket); - const planes = planesBySide.get(curve.side) ?? []; - if (!planes.some((plane) => plane.chainage === curve.setChainageM)) { - planes.push({ chainage: curve.setChainageM, planZ: curve.planZ, role: curve.role }); - } - planesBySide.set(curve.side, planes); - } - - const merged: PlanCurve[] = []; - const walls: CorridorCutWall[] = []; - // 측벽이 설 자리 — Plan이 커브로 넘겨준다(셀을 훑지 않는다). - const wallLinesBySide = new Map>>(); - for (const curve of curves) { - if (curve.source !== "cut-wall") continue; - const bucket = wallLinesBySide.get(curve.side) ?? []; - curve.loops.forEach((line) => { - if (line.length >= 2) bucket.push(line); - }); - wallLinesBySide.set(curve.side, bucket); - } - const cut = ribbons.map((ribbon) => { - const areas = areasBySide.get(ribbon.side); - // 패치는 `patchClip`이 붙은 것만 다룬다 — **절취 영역 안쪽만 남기는 반대 규칙**이다 - // (2026-08-27 사용자). 표식 없는 패치(BOX암거·기슭막이)는 종전대로 손대지 않는다. - const patchClip = ribbon.patch === true && ribbon.patchClip === true; - if (ribbon.kind !== "fill" || (ribbon.patch && !patchClip) || !areas?.length) return ribbon; - const rows = ribbon.chainages.length; - const cols = ribbon.colCount; - if (rows < 2 || cols < 2) return ribbon; - const mask = new Uint8Array((rows - 1) * (cols - 1)); - let masked = 0; - const tris: number[] = []; - const uvs: number[] = []; - const startChainage = ribbon.chainages[0]; - const point = (row: number, col: number): [number, number, number] => { - const i = (row * cols + col) * 3; - return [ribbon.positions[i], ribbon.positions[i + 1], ribbon.positions[i + 2]]; - }; - const uvOf = (row: number, corner: Corner): [number, number] => { - const chainage = - ribbon.chainages[row] + (ribbon.chainages[row + 1] - ribbon.chainages[row]) * corner.b; - return [(chainage - startChainage) / 2, corner.a / Math.max(1, cols - 1)]; - }; - for (let row = 0; row < rows - 1; row += 1) { - for (let col = 0; col < cols - 1; col += 1) { - const ring: Corner[] = [ - { p: point(row, col), a: col, b: 0 }, - { p: point(row, col + 1), a: col + 1, b: 0 }, - { p: point(row + 1, col + 1), a: col + 1, b: 1 }, - { p: point(row + 1, col), a: col, b: 1 }, - ]; - // 귀퉁이가 하나도 안쪽이 아니면 그대로 둔다. - const inCount = ring.filter((corner) => - areas.some((area) => inside(area, corner.p[0], corner.p[1])), - ).length; - if (patchClip) { - // 반대 규칙 — 영역 **안**이 남고 **밖**이 지워진다. - if (inCount === 4) continue; - if (inCount === 0) { - mask[row * (cols - 1) + col] = 1; - masked += 1; - continue; - } - const piece = clipCellByChord(areas, ring, "inside"); - if (!piece) { - if (coverage(areas, ring) < 0.5) { - mask[row * (cols - 1) + col] = 1; - masked += 1; - } - continue; - } - mask[row * (cols - 1) + col] = 1; - masked += 1; - for (let k = 1; k < piece.kept.length - 1; k += 1) { - for (const corner of [piece.kept[0], piece.kept[k], piece.kept[k + 1]]) { - tris.push(corner.p[0], corner.p[1], corner.p[2]); - const [u, v] = uvOf(row, corner); - uvs.push(u, v); - } - } - continue; - } - if (inCount === 0) continue; - if (inCount === 4) { - mask[row * (cols - 1) + col] = 1; - masked += 1; - continue; - } - const clip = clipCellByChord(areas, ring); - // 절단 실패는 **안쪽 면적 비율**로 가른다(2026-08-27 사용자: 면적 필터). - // 절반 이상 잠긴 셀은 지운다 — 종전처럼 통째로 남기면 경계에 미세 메쉬가 - // 줄줄이 남았다(실측: 좌 313·우 314셀, 4.50·2.17㎡). - if (!clip) { - if (coverage(areas, ring) >= 0.5) { - mask[row * (cols - 1) + col] = 1; - masked += 1; - } - continue; - } - mask[row * (cols - 1) + col] = 1; - masked += 1; - const kept = clip.kept; - for (let k = 1; k < kept.length - 1; k += 1) { - for (const corner of [kept[0], kept[k], kept[k + 1]]) { - tris.push(corner.p[0], corner.p[1], corner.p[2]); - const [u, v] = uvOf(row, corner); - uvs.push(u, v); - } - } - } - } - if (!masked) return ribbon; - // 잘린 자리 윤곽을 **새 데이텀**에 얹어 따로 낸다 — 어디가 잘리는지 눈으로 본다. - // 성토 리본은 좌·우뿐이라 커브의 측 표기와 그대로 맞는다. - const side: PlanCurve["side"] = ribbon.side === "right" ? "right" : "left"; - const planes = planesBySide.get(ribbon.side) ?? []; - if (patchClip) { - return { - ...ribbon, - cellMask: mask, - ...(tris.length - ? { trimTris: new Float64Array(tris), trimUvs: new Float32Array(uvs) } - : {}), - }; - } - traceMaskLoops(rows, cols, mask).forEach((raw) => { - if (raw.length < 4 || !planes.length) return; - const meanRow = raw.reduce((sum, [row]) => sum + row, 0) / raw.length; - const chainage = ribbon.chainages[Math.min(rows - 1, Math.round(meanRow))]; - const plane = planes.reduce((best, item) => - Math.abs(item.chainage - chainage) < Math.abs(best.chainage - chainage) ? item : best, - ); - const z = plane.planZ + MERGED_LIFT_M; - // **실좌표로 먼저 옮기고** 다듬는다 — 격자 인덱스로 다듬으면 휜 구간에서 코너를 - // 잘라먹어 그 밖 셀이 되살릴 영역에서 빠진다. - const loop = dropCollinear( - raw.map(([row, col]) => { - const [x, y] = point(row, col); - return [x, y, z] as [number, number, number]; - }), - ); - if (loop.length < 4) return; - merged.push({ - setChainageM: plane.chainage, - source: "cut-merged", - role: plane.role, - side, - planZ: z, - loops: [loop], - }); - }); - const wall = buildWall(ribbon, wallLinesBySide.get(ribbon.side) ?? []); - if (wall) walls.push({ side, positions: wall }); - return { - ...ribbon, - cellMask: mask, - ...(tris.length ? { trimTris: new Float64Array(tris), trimUvs: new Float32Array(uvs) } : {}), - }; - }); - return { ribbons: cut, curves: merged, walls }; -} diff --git a/B05_Profile/Old/B05_Profile_UI_Corridor_Deform.ts b/B05_Profile/Old/B05_Profile_UI_Corridor_Deform.ts deleted file mode 100644 index 63cf7b044..000000000 --- a/B05_Profile/Old/B05_Profile_UI_Corridor_Deform.ts +++ /dev/null @@ -1,692 +0,0 @@ -/* ============================================================================= - * B05_Profile_UI_Corridor_Deform.ts - * 3D 예상형상 **새 방식** — 지형 메시 정점의 높이(z)만 설계면으로 옮기는 변형 코어 - * (계획서 115-1 · 검토 `docs/raw/요구_1008_4/검토_B05_3D_메시변형.md` 3절). - * - * 순수 기하 — Three.js 없음. 지형 높이는 부르는 쪽이 `groundAt`(모델 좌표)으로 준다. - * - * 규칙: - * · 행 = 코리도 빌드의 노면부 리본(차도 · 노견 · 측구) 행. 노면부 바깥 끝 = 앵커. - * · 정점 → (행 · 편거리) 역투영 — 0.2 m 행 중심 격자 색인 + 이웃 행 걷기. - * · 노면부 바깥에서 `z' = clamp(지형 z, 성토선 z, 절토선 z)`. 선 = 앵커 + 측점 사이 - * 보간 단면(제어점 · `_Frames.ts`) · 단면 밖은 마지막 구배 연장. - * · 성토 · 절토는 앵커에서 이어진 자리(첫 지반 교차점 안)에만 — 비탈 끝 밖은 원지반. - * 못 찾으면 단면 끝까지만(115-5 P0-1) · 높이 변화는 단면 높이 + 1 m 까지. - * · 성토 비탈 끝은 종단으로 잇는다(115-7 smoothFillCatches) — 튐 · 못 닫힘 보간 · 원뿔 넓히기 · - * 바뀐 행은 비탈 끝 밖 2 m 덧댐. - * · 조각은 앞뒤 측점 중 하나라도 가진 행에만(`_Build.ts` 와 같은 규칙). - * · XY 는 움직이지 않는다. - * ========================================================================== */ - -import type { CrossSection } from "../B06_Section/B06_Section_Api_Fetch"; -import type { CorridorBuildResult } from "./B05_Profile_UI_Corridor_Build_Types"; -import { - classifyStation, - pieceKey, - type OffsetPoint, - type StationPieces, -} from "./B05_Profile_UI_Corridor_Station"; -import { - bracketControls, - buildPieceControls, - lerpPoints, - type PieceControl, -} from "./B05_Profile_UI_Corridor_Frames"; - -export type DeformSide = "left" | "right"; -export type DeformKind = "cut" | "fill"; - -/** 비탈 끝 찾기 한계(m) · 걸음(m) — 밴드 여유(80 m) 안쪽. */ -export const MAX_REACH_M = 60; -const SCAN_STEP_M = 0.25; -/** 높이 변화 안전 상한 = 단면 높이 + 이 값(m) — 폭주 구덩이 막음(115-5 P0-1 ④). */ -const SAFETY_M = 1; -/** 꺾임 브레이크라인으로 세울 단면 꺾임각(도). */ -const BEND_DEG = 8; -/** 이 안쪽 정점은 앵커 높이 그대로(노면부 끝 경계 정점). */ -const BOUNDARY_EPS_M = 0.01; -/** 역투영 되짚기 허용 오차(m) — 넘으면 곡선 안쪽 모호 자리로 보고 손대지 않는다. */ -const LOCATE_TOLERANCE_M = 0.5; -/** 행 중심 격자 칸(m) · 씨앗 행 간격(행 수). */ -const GRID_M = 4; -const SEED_EVERY = 5; -const WALK_LIMIT = 400; - -// ponytail: 성토 비탈 끝 종단 이음(115-7) 고정 값 — 이웃 ±3 m 중앙값 · 튐 = max(2 m, 25 %) · -// 원뿔 1 m/m · 비탈 끝 밖 덧댐 2 m · 이을 짧은 틈 3 m. 지형마다 안 맞으면 여기서 조절. -const FILL_WINDOW_M = 3; -const FILL_JUMP_M = 2; -const FILL_JUMP_RATIO = 0.25; -const FILL_CONE = 1; -const FILL_SKIRT_M = 2; -const FILL_BRIDGE_M = 3; - -const SIDES: readonly DeformSide[] = ["left", "right"]; -const KINDS: readonly DeformKind[] = ["fill", "cut"]; - -/** 노면부 바깥 끝 앵커 한 행 — 모델 좌표 [x, y, z]. */ -export interface DeformRow { - chainage_m: number; - left: [number, number, number]; - right: [number, number, number]; -} - -/** 비탈 단면(앵커 기준) — 바깥거리 s ≥ 0 오름차순 [s0, dz0, s1, dz1, …]. */ -export type DeformProfile = Float64Array; - -/** 한 행 · 한 측의 단면 출처 — 구조물 구간 고정 단면이면 연장 · 반대 종류를 막는다. */ -export interface RowProfiles { - fill: DeformProfile | null; - cut: DeformProfile | null; - /** 단면 끝 너머로 늘리지 않는다(BOX 「본체 밖 원지반」). */ - noExtend?: boolean; -} - -/** 행 · 측마다 단면을 내주는 자리 — 측점 단면 + 구조물 구간 단면(115-2). */ -export type ProfileSource = (chainageM: number, side: DeformSide) => RowProfiles; - -/** - * 구조물 구간 단면(115-2) — 소유 측점 실루엣의 상부 포락선(앵커 기준 상대 · 측점 단면과 - * 같은 꼴 · 같은 열 수). 구간(전/후 몫) 안은 이 단면 그대로, 밖은 이웃 측점까지 테이퍼. - */ -export interface StructureSlope { - chainage_m: number; - side: DeformSide; - kind: DeformKind; - span: { beforeM: number; afterM: number }; - points: OffsetPoint[]; - /** 실루엣이 구조물에서 끝난다(BOX) — 구간 안은 단면 끝 너머를 원지반으로 둔다. */ - endsAtStructure: boolean; -} - -/** 성토 금지 고리(115-2 · 날개 안쪽 유로 · 에이프런) — 안쪽 정점은 원지반(또는 천장) 위로 못 올린다. */ -export interface DeformLimit { - /** 모델 좌표 닫힌 고리. */ - loop: Array<[number, number]>; - /** 바닥판 상단 등 — 원지반이 이보다 낮으면 여기까지는 올린다. null 이면 원지반. */ - ceiling: number | null; -} - -/** 정점 하나의 역투영 결과. */ -export interface DeformLocation { - row: number; - t: number; - side: DeformSide; - /** 노면부 바깥 끝에서 잰 바깥거리(m) — 음수면 노면부 안. */ - outward: number; -} - -/** 변형 결과 — 높이와 자리(0 원지반 · 1 성토 · 2 절토). 색은 자리로 칠한다. */ -export interface DeformPoint { - z: number; - zone: 0 | 1 | 2; -} - -export interface DeformField { - rows: DeformRow[]; - locate: (x: number, y: number) => DeformLocation | null; - /** 변형된 z — 대상이 아니면 받은 z 그대로. */ - deformZ: (x: number, y: number, z: number) => number; - deformAt: (x: number, y: number, z: number) => DeformPoint; - /** 행 · 측(0 좌 · 1 우) · 종류(0 성토 · 1 절토)의 비탈 끝 바깥거리(m) — 0 이면 비탈 없음. */ - catchAt: (row: number, side: number, kind: number) => number; - /** 비탈 끝 안 단면 꺾임 — [열 번호, 바깥거리] (브레이크라인). */ - bendsAt: (row: number, side: number, kind: number) => ReadonlyArray<[number, number]>; - /** 행 앵커에서 바깥으로 s 만큼 간 평면 자리(모델 좌표). */ - pointAt: (row: number, side: number, s: number) => [number, number]; -} - -/** 단면 높이 — s 가 끝을 넘으면 마지막 구배로 늘린다(`noExtend`면 null). */ -export function profileZ(profile: DeformProfile, s: number, noExtend = false): number | null { - const count = profile.length / 2; - if (count < 1) return null; - if (s <= profile[0]) return profile[1]; - for (let i = 1; i < count; i += 1) { - const s1 = profile[i * 2]; - if (s <= s1) { - const s0 = profile[i * 2 - 2]; - const span = s1 - s0; - const t = span <= 1e-9 ? 1 : (s - s0) / span; - return profile[i * 2 - 1] + (profile[i * 2 + 1] - profile[i * 2 - 1]) * t; - } - } - if (noExtend) return null; - // 마지막 구배 — 끝에서 거꾸로 짚어 길이가 있는 마디를 쓴다(`_Station.ts` trimSlopeToGround 연장과 같은 규칙). - const last = count - 1; - for (let i = last - 1; i >= 0; i -= 1) { - const run = profile[last * 2] - profile[i * 2]; - if (run > 1e-3) { - const grade = (profile[last * 2 + 1] - profile[i * 2 + 1]) / run; - return profile[last * 2 + 1] + grade * (s - profile[last * 2]); - } - } - return profile[last * 2 + 1]; -} - -/** 상대 단면 점(편거리 · 표고, 도로측 원점) → 바깥거리 오름차순 단면. */ -export function outwardProfile( - points: ReadonlyArray, - side: DeformSide, -): DeformProfile | null { - if (points.length < 2) return null; - const sign = side === "left" ? 1 : -1; - const ordered = side === "left" ? points : [...points].reverse(); - const origin = ordered[0]; - const profile = new Float64Array(ordered.length * 2); - ordered.forEach((point, i) => { - profile[i * 2] = Math.max(0, (point.offset_m - origin.offset_m) * sign); - profile[i * 2 + 1] = point.elevation_m - origin.elevation_m; - }); - return profile; -} - -/** 빌드 노면부 리본에서 행마다 좌우 바깥 끝(측구 > 노견 > 차도)을 모은다. */ -export function roadRows(build: CorridorBuildResult): DeformRow[] { - const RANK: Record = { carriageway: 1, shoulder: 2, ditch: 3 }; - const byKey = new Map< - number, - { - chainage: number; - left?: DeformRow["left"]; - lr: number; - right?: DeformRow["right"]; - rr: number; - } - >(); - for (const ribbon of build.ribbons) { - const rank = RANK[ribbon.kind]; - if (!rank || ribbon.patch) continue; - const cols = ribbon.colCount; - ribbon.chainages.forEach((chainage, row) => { - const key = Math.round(chainage * 1000); - const entry = byKey.get(key) ?? { chainage, lr: 0, rr: 0 }; - const at = (col: number): [number, number, number] => { - const i = (row * cols + col) * 3; - return [ribbon.positions[i], ribbon.positions[i + 1], ribbon.positions[i + 2]]; - }; - if (ribbon.side !== "right" && rank > entry.lr) { - entry.left = at(cols - 1); - entry.lr = rank; - } - if (ribbon.side !== "left" && rank > entry.rr) { - entry.right = at(0); - entry.rr = rank; - } - byKey.set(key, entry); - }); - } - return [...byKey.entries()] - .sort((a, b) => a[0] - b[0]) - .flatMap(([, entry]) => - entry.left && entry.right - ? [{ chainage_m: entry.chainage, left: entry.left, right: entry.right }] - : [], - ); -} - -/** - * 측점 비탈 단면으로 내는 출처 — 측점 사이는 제어점 보간(`_Frames.ts`). 구조물 단면을 주면 - * 그 측점의 그 측 · 그 종류는 구조물 단면으로 갈아 끼우고 전/후 몫을 고정 구간으로 세운다 - * (구간 끝 → 이웃 측점 테이퍼). 고정 구간 안은 실루엣 종류만 남긴다. - */ -export function stationProfileSource( - sections: ReadonlyArray, - slopes: ReadonlyArray = [], -): ProfileSource { - const stations = sections - .map(classifyStation) - .filter((s): s is StationPieces => s !== null) - .sort((a, b) => a.chainage_m - b.chainage_m); - const slopeOf = (station: StationPieces, side: DeformSide, kind: DeformKind) => - slopes.find( - (slope) => - slope.side === side && - slope.kind === kind && - Math.abs(slope.chainage_m - station.chainage_m) < 1e-6, - ); - const controls = new Map(); - SIDES.forEach((side) => - KINDS.forEach((kind) => { - const key = pieceKey(kind, side); - controls.set( - key, - buildPieceControls( - stations, - side, - (station) => slopeOf(station, side, kind)?.points ?? station.slopeRelative.get(key), - (station) => slopeOf(station, side, kind)?.span, - ), - ); - }), - ); - const pick = (station: StationPieces, side: DeformSide, kind: DeformKind) => - slopeOf(station, side, kind)?.points ?? station.slopeRelative.get(pieceKey(kind, side)); - /** 행을 감싼 앞뒤 측점 — 범위 밖은 끝 측점. */ - const around = (chainageM: number): [StationPieces, StationPieces] => { - let i = 0; - while (i < stations.length - 2 && stations[i + 1].chainage_m < chainageM) i += 1; - const next = stations[Math.min(i + 1, stations.length - 1)]; - if (chainageM <= stations[i].chainage_m) return [stations[i], stations[i]]; - return chainageM >= next.chainage_m ? [next, next] : [stations[i], next]; - }; - const at = (side: DeformSide, kind: DeformKind, chainageM: number): DeformProfile | null => { - if (!stations.length) return null; - // 앞뒤 측점 둘 다 그 조각이 없으면 이 행에는 없다(`_Build.ts` 규칙 · 115-5 P0-1 ②). - const [s0, s1] = around(chainageM); - const own0 = pick(s0, side, kind); - const own1 = pick(s1, side, kind); - if (!own0 && !own1) return null; - const list = controls.get(pieceKey(kind, side)); - // 그 측 그 종류를 가진 측점이 하나뿐 — 그 단면 그대로. - if (!list) return outwardProfile((own0 ?? own1) as OffsetPoint[], side); - const { a, b, t } = bracketControls(list, chainageM); - const points = a === b ? a.points : lerpPoints(a.points, b.points, t); - return outwardProfile(points, side); - }; - return (chainageM, side) => { - const fill = at(side, "fill", chainageM); - const cut = at(side, "cut", chainageM); - const fixed = slopes.find( - (slope) => - slope.side === side && - chainageM >= slope.chainage_m - slope.span.beforeM - 1e-9 && - chainageM <= slope.chainage_m + slope.span.afterM + 1e-9, - ); - if (!fixed) return { fill, cut }; - return { - fill: fixed.kind === "fill" ? fill : null, - cut: fixed.kind === "cut" ? cut : null, - noExtend: fixed.endsAtStructure, - }; - }; -} - -/** - * 성토 비탈 끝 종단 이음(115-7) — 한 측 성토 열. 행마다 따로 찾은 비탈 끝이 이웃과 튀면(골짜기 · - * 계단 DTM · 못 닫힘) 두 행 혼합이 행 사이에 수직 벽(지느러미 · 빗살 구덩이)을 세운다. - * 튐 · 못 닫힘 행 → 이웃 좋은 행 보간 → 원뿔 넓히기(이웃 차 1 m/m 안). 측점 행은 날것(못 닫힘 빼고). - * 비탈 끝 0 행은 구간을 끊는다 — 단면은 있는데 앵커에서 막힌 3 m 안 틈만 잇는다. - * `open` = 못 닫힘 · `station` = 측점 행 · `shaped` = 그 행에 성토 단면 있음(모두 0/1). - */ -export function smoothFillCatches( - chainages: ArrayLike, - raw: ArrayLike, - open: ArrayLike, - station: ArrayLike, - shaped: ArrayLike, -): Float64Array { - const n = raw.length; - const out = Float64Array.from(raw); - const on = new Uint8Array(n); - const bridged = new Uint8Array(n); - for (let r = 0; r < n; r += 1) on[r] = raw[r] > 0 ? 1 : 0; - for (let r = 1; r < n; r += 1) { - if (on[r] || !on[r - 1]) continue; - let e = r; - while (e < n && !on[e] && shaped[e]) e += 1; - if (e < n && on[e] && chainages[e - 1] - chainages[r] <= FILL_BRIDGE_M) { - for (let k = r; k < e; k += 1) on[k] = bridged[k] = 1; - } - r = e; - } - for (let start = 0; start < n;) { - if (!on[start]) { - start += 1; - continue; - } - let end = start; - while (end + 1 < n && on[end + 1]) end += 1; - const trusted = (k: number) => !open[k] && !bridged[k]; - const good = new Uint8Array(end - start + 1); - let lo = start; - let hi = start; - for (let r = start; r <= end; r += 1) { - if (station[r] && trusted(r)) { - good[r - start] = 1; - continue; - } - while (chainages[lo] < chainages[r] - FILL_WINDOW_M) lo += 1; - while (hi < end && chainages[hi + 1] <= chainages[r] + FILL_WINDOW_M) hi += 1; - const near: number[] = []; - for (let k = lo; k <= hi; k += 1) if (trusted(k)) near.push(raw[k]); - if (!near.length) { - good[r - start] = trusted(r) ? 1 : 0; - continue; - } - near.sort((a, b) => a - b); - const median = near[near.length >> 1]; - const jump = Math.max(FILL_JUMP_M, median * FILL_JUMP_RATIO); - good[r - start] = trusted(r) && Math.abs(raw[r] - median) <= jump ? 1 : 0; - } - // 튐 · 못 닫힘 — 앞뒤 좋은 행을 노선 길이로 보간(한쪽뿐이면 그 값 · 없으면 날것). - let prev = -1; - for (let r = start; r <= end; r += 1) { - if (good[r - start]) { - prev = r; - continue; - } - let next = r + 1; - while (next <= end && !good[next - start]) next += 1; - if (prev >= 0 && next <= end) { - const t = (chainages[r] - chainages[prev]) / (chainages[next] - chainages[prev] || 1); - out[r] = raw[prev] + (raw[next] - raw[prev]) * t; - } else if (prev >= 0) out[r] = raw[prev]; - else if (next <= end) out[r] = raw[next]; - } - // 원뿔 넓히기 — c'_r = max_j(c_j − 1·|s_r − s_j|) 를 앞뒤 두 번 훑어 낸다. 늘린 몫은 측점 닻 - // (측점 값 + 1 m/m)까지만 — 먼 큰 값이 측점까지 번지지 않게. 보간 값보다 줄이지는 않는다. - const base = out.slice(start, end + 1); - const cap = new Float64Array(end - start + 1).fill(Infinity); - for (let r = start + 1; r <= end; r += 1) { - out[r] = Math.max(out[r], out[r - 1] - FILL_CONE * (chainages[r] - chainages[r - 1])); - } - for (let r = end - 1; r >= start; r -= 1) { - out[r] = Math.max(out[r], out[r + 1] - FILL_CONE * (chainages[r + 1] - chainages[r])); - } - for (const step of [1, -1]) { - let anchor = -1; - for (let r = step > 0 ? start : end; r >= start && r <= end; r += step) { - if (station[r] && trusted(r)) anchor = r; - if (anchor < 0) continue; - const reach = raw[anchor] + FILL_CONE * Math.abs(chainages[r] - chainages[anchor]); - cap[r - start] = Math.min(cap[r - start], reach); - } - } - for (let r = start; r <= end; r += 1) { - out[r] = Math.max(base[r - start], Math.min(out[r], cap[r - start])); - } - start = end + 1; - } - return out; -} - -/** 점이 고리 안인가(광선 투사). */ -function insideLoop(loop: ReadonlyArray<[number, number]>, x: number, y: number): boolean { - let hit = false; - for (let i = 0, j = loop.length - 1; i < loop.length; j = i, i += 1) { - const [xi, yi] = loop[i]; - const [xj, yj] = loop[j]; - if (yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) hit = !hit; - } - return hit; -} - -/** - * 변형 장 만들기. 노면부 행이 2개 미만이면 null. - * `groundAt` = 원지반 높이(모델 좌표) — 비탈 끝(첫 지반 교차점) 찾기에만 쓴다. - */ -export function buildDeformField( - build: CorridorBuildResult, - source: ProfileSource, - groundAt: (x: number, y: number) => number | null, - limits: ReadonlyArray = [], -): DeformField | null { - const rows = roadRows(build); - const n = rows.length; - if (n < 2) return null; - const mx = new Float64Array(n); - const my = new Float64Array(n); - const ux = new Float64Array(n); - const uy = new Float64Array(n); - const hw = new Float64Array(n); - /** [행 × 측 × 종류] 단면 · 비탈 끝 거리. 측 = 0 좌 · 1 우, 종류 = 0 성토 · 1 절토. */ - const profiles: Array = new Array(n * 4).fill(null); - const catches = new Float64Array(n * 4); - const heights = new Float64Array(n * 4); - const bends: Array> = new Array(n * 4); - const noExtend = new Uint8Array(n * 2); - /** 비탈 끝을 단면 끝까지 못 찾음(못 닫힘). */ - const open = new Uint8Array(n * 4); - let reachMax = 0; - rows.forEach((row, r) => { - const dx = row.left[0] - row.right[0]; - const dy = row.left[1] - row.right[1]; - const width = Math.hypot(dx, dy) || 1e-9; - mx[r] = (row.left[0] + row.right[0]) / 2; - my[r] = (row.left[1] + row.right[1]) / 2; - ux[r] = dx / width; - uy[r] = dy / width; - hw[r] = width / 2; - SIDES.forEach((side, si) => { - const anchor = side === "left" ? row.left : row.right; - const sign = side === "left" ? 1 : -1; - const shapes = source(row.chainage_m, side); - noExtend[r * 2 + si] = shapes.noExtend ? 1 : 0; - KINDS.forEach((kind, ki) => { - const profile = shapes[kind]; - const slot = r * 4 + si * 2 + ki; - profiles[slot] = profile; - bends[slot] = []; - if (!profile) return; - const count = profile.length / 2; - for (let i = 0; i < count; i += 1) { - heights[slot] = Math.max(heights[slot], Math.abs(profile[i * 2 + 1])); - } - // 비탈 끝 = 앵커에서 바깥으로 걸어 지반과 처음 만나는 자리. 성토는 지반이 선 위로, - // 절토는 아래로 넘어가는 곳. 앵커에서 이미 반대편이면 그 종류는 이 행에 없다(0). - const beyond = (s: number): number | null => { - const line = profileZ(profile, s, shapes.noExtend); - if (line === null) return null; - const ground = groundAt(anchor[0] + ux[r] * sign * s, anchor[1] + uy[r] * sign * s); - if (ground === null) return null; - const gap = anchor[2] + line - ground; - return kind === "fill" ? gap : -gap; - }; - let previous = beyond(0); - let reach = 0; - if (previous !== null && previous > 0) { - let found = false; - for (let s = SCAN_STEP_M; s <= MAX_REACH_M + 1e-9; s += SCAN_STEP_M) { - const now = beyond(s); - if (now === null) { - reach = s - SCAN_STEP_M; - found = true; - break; - } - if (now <= 0) { - reach = s - SCAN_STEP_M + (SCAN_STEP_M * previous) / (previous - now); - found = true; - break; - } - previous = now; - } - // 못 찾으면 단면 끝까지만 — 마지막 구배로 60 m 까지 깎던 폭주 구덩이(115-5 P0-1 ①). - const end = profile[(count - 1) * 2]; - if (!found) reach = end; - // 못 닫힘(115-7) — 단면 끝(측점 = B06 비탈 끝) 너머 튐 폭보다 멀리서야 닫힌 성토. 연장 구배를 - // 안전 상한이 눌러 원지반 위 띠(지느러미)가 되던 행 — 이웃 좋은 행으로 보간한다. - const slack = Math.max(FILL_JUMP_M, end * FILL_JUMP_RATIO); - if (!found || (kind === "fill" && reach > end + slack)) open[slot] = 1; - } - catches[slot] = reach; - }); - }); - }); - - // 성토 비탈 끝 종단 이음(115-7) — 짧아진 행 · 못 닫힘 행은 비탈 끝 밖을 덧댄다. - const chainages = rows.map((row) => row.chainage_m); - const station = new Uint8Array(n); - for (const section of build.sections ?? []) { - let r = 0; - while (r + 1 < n && chainages[r + 1] <= section.chainage_m) r += 1; - if (r + 1 < n && chainages[r + 1] - section.chainage_m < section.chainage_m - chainages[r]) { - r += 1; - } - if (Math.abs(chainages[r] - section.chainage_m) < 0.15) station[r] = 1; - } - const skirt = new Uint8Array(n * 4); - for (let si = 0; si < 2; si += 1) { - const pick = (of: (slot: number) => T) => rows.map((_, r) => of(r * 4 + si * 2)); - const raw = pick((slot) => catches[slot]); - const final = smoothFillCatches( - chainages, - raw, - pick((slot) => open[slot]), - station, - pick((slot) => (profiles[slot] ? 1 : 0)), - ); - final.forEach((c, r) => { - const slot = r * 4 + si * 2; - catches[slot] = c; - if (c > 0 && (open[slot] || c < raw[r] - 0.01 || raw[r] <= 0)) skirt[slot] = 1; - }); - } - for (let slot = 0; slot < n * 4; slot += 1) { - const reach = catches[slot]; - reachMax = Math.max(reachMax, reach + (skirt[slot] ? FILL_SKIRT_M : 0)); - const profile = profiles[slot]; - if (!profile) continue; - const count = profile.length / 2; - // 단면 꺾임(암/토사 · 소단) — 비탈 끝 안쪽만. - for (let i = 1; i < count - 1; i += 1) { - const s = profile[i * 2]; - if (s >= reach) break; - const a0 = Math.atan2(profile[i * 2 + 1] - profile[i * 2 - 1], s - profile[i * 2 - 2]); - const a1 = Math.atan2(profile[i * 2 + 3] - profile[i * 2 + 1], profile[i * 2 + 2] - s); - if (Math.abs(a1 - a0) * (180 / Math.PI) > BEND_DEG) bends[slot].push([i, s]); - } - } - - // 행 중심 격자 — 칸마다 가장 가까운 씨앗 행. 정점은 그 행에서 이웃으로 걸어 자리를 잡는다. - let maxHalf = 0; - hw.forEach((value) => (maxHalf = Math.max(maxHalf, value))); - const radius = maxHalf + reachMax + GRID_M; - let minX = Infinity; - let minY = Infinity; - let maxX = -Infinity; - let maxY = -Infinity; - for (let r = 0; r < n; r += 1) { - minX = Math.min(minX, mx[r]); - maxX = Math.max(maxX, mx[r]); - minY = Math.min(minY, my[r]); - maxY = Math.max(maxY, my[r]); - } - minX -= radius; - minY -= radius; - const cols = Math.max(1, Math.ceil((maxX + radius - minX) / GRID_M)); - const gridRows = Math.max(1, Math.ceil((maxY + radius - minY) / GRID_M)); - const seed = new Int32Array(cols * gridRows).fill(-1); - const seedDistance = new Float64Array(cols * gridRows).fill(Infinity); - const span = Math.ceil(radius / GRID_M); - const seeds: number[] = []; - for (let r = 0; r < n; r += SEED_EVERY) seeds.push(r); - if (seeds[seeds.length - 1] !== n - 1) seeds.push(n - 1); - for (const r of seeds) { - const c0 = Math.floor((mx[r] - minX) / GRID_M); - const g0 = Math.floor((my[r] - minY) / GRID_M); - for (let g = Math.max(0, g0 - span); g <= Math.min(gridRows - 1, g0 + span); g += 1) { - for (let c = Math.max(0, c0 - span); c <= Math.min(cols - 1, c0 + span); c += 1) { - const d = Math.hypot(minX + (c + 0.5) * GRID_M - mx[r], minY + (g + 0.5) * GRID_M - my[r]); - if (d > radius || d >= seedDistance[g * cols + c]) continue; - seedDistance[g * cols + c] = d; - seed[g * cols + c] = r; - } - } - } - - const along = (r: number, x: number, y: number): number => - (x - mx[r]) * uy[r] - (y - my[r]) * ux[r]; - const lateral = (r: number, x: number, y: number): number => - (x - mx[r]) * ux[r] + (y - my[r]) * uy[r]; - - // ponytail: 칸의 가장 가까운 씨앗 하나로 걷는다 — 헤어핀 두 가지가 한 칸에 겹치면 칸 중심에 - // 가까운 쪽(대개 편거리 작은 쪽)을 탄다. 어긋나면 씨앗 둘을 견주는 쪽으로 넓힌다. - function locate(x: number, y: number): DeformLocation | null { - const c = Math.floor((x - minX) / GRID_M); - const g = Math.floor((y - minY) / GRID_M); - if (c < 0 || g < 0 || c >= cols || g >= gridRows) return null; - let k = seed[g * cols + c]; - if (k < 0) return null; - let steps = 0; - while (k > 0 && along(k, x, y) < 0 && steps < WALK_LIMIT) { - k -= 1; - steps += 1; - } - while (k + 1 < n && along(k + 1, x, y) >= 0 && steps < WALK_LIMIT) { - k += 1; - steps += 1; - } - if (k + 1 >= n) return null; - const a0 = along(k, x, y); - const a1 = along(k + 1, x, y); - if (a0 < 0 || a1 >= 0) return null; - const t = a0 / (a0 - a1); - const o = lateral(k, x, y) * (1 - t) + lateral(k + 1, x, y) * t; - // 되짚기 — 보간 행 위 그 편거리 자리가 정점과 같아야 한다(곡선 안쪽 너머는 버린다). - const cx = mx[k] + (mx[k + 1] - mx[k]) * t; - const cy = my[k] + (my[k + 1] - my[k]) * t; - const vx = ux[k] + (ux[k + 1] - ux[k]) * t; - const vy = uy[k] + (uy[k + 1] - uy[k]) * t; - const norm = Math.hypot(vx, vy) || 1; - if (Math.hypot(cx + (vx / norm) * o - x, cy + (vy / norm) * o - y) > LOCATE_TOLERANCE_M) { - return null; - } - const half = hw[k] + (hw[k + 1] - hw[k]) * t; - return { row: k, t, side: o >= 0 ? "left" : "right", outward: Math.abs(o) - half }; - } - - /** 한 행 기준 변형 높이 — 비탈 끝 안에서만 성토선 위로 · 절토선 아래로 맞춘다. */ - function rowZ(r: number, si: number, s: number, z: number): number { - const anchor = si === 0 ? rows[r].left : rows[r].right; - if (s <= BOUNDARY_EPS_M) return anchor[2]; - let out = z; - const free = noExtend[r * 2 + si] === 1; - KINDS.forEach((kind, ki) => { - const slot = r * 4 + si * 2 + ki; - const profile = profiles[slot]; - const reach = catches[slot]; - if (!profile || reach <= 0) return; - // 덧댐(115-7) — 이은 비탈 끝 밖 2 m 는 비탈 끝 높이에서 원지반으로 내려 수직 면을 없앤다. - if (s >= reach && (!skirt[slot] || s >= reach + FILL_SKIRT_M)) return; - const pad = s < reach ? 0 : (s - reach) / FILL_SKIRT_M; - const rel = profileZ(profile, Math.min(s, reach), free); - if (rel === null) return; - let line = anchor[2] + rel; - line += (z - line) * pad; - // 안전 상한(115-5 P0-1 ④) — 단면 높이 + 1 m 넘게는 옮기지 않는다. 구조물에서 끝나는 - // 단면(BOX)은 지반까지 안 내려가 단면 높이가 기준이 못 된다. - const cap = heights[slot] + SAFETY_M; - if (!free && Math.abs(line - z) > cap) line = z + Math.sign(line - z) * cap; - if (kind === "fill" ? out < line : out > line) out = line; - }); - return out; - } - - /** 그 자리의 비탈 종류 — 앞뒤 행 비탈 끝을 보간한 선 안이면 그 종류(비탈 끝 브레이크라인과 같은 선). */ - function zoneOf(r: number, si: number, s: number, t: number): 0 | 1 | 2 { - for (let ki = 0; ki < 2; ki += 1) { - const a = catches[r * 4 + si * 2 + ki]; - const b = catches[(r + 1) * 4 + si * 2 + ki]; - if (s < a * (1 - t) + b * t) return ki === 0 ? 1 : 2; - } - return 0; - } - - function deformAt(x: number, y: number, z: number): DeformPoint { - const at = locate(x, y); - if (!at || at.outward > reachMax) return { z, zone: 0 }; - const si = at.side === "left" ? 0 : 1; - const s = Math.max(0, at.outward); - let out = rowZ(at.row, si, s, z) * (1 - at.t) + rowZ(at.row + 1, si, s, z) * at.t; - for (const limit of limits) { - if (out > z && insideLoop(limit.loop, x, y)) { - out = Math.min(out, Math.max(z, limit.ceiling ?? z)); - } - } - return { z: out, zone: at.outward < 0 ? 0 : zoneOf(at.row, si, s, at.t) }; - } - - return { - rows, - locate, - deformAt, - deformZ: (x, y, z) => deformAt(x, y, z).z, - catchAt: (r, si, ki) => catches[r * 4 + si * 2 + ki], - bendsAt: (r, si, ki) => bends[r * 4 + si * 2 + ki] ?? [], - pointAt: (r, si, s) => { - const anchor = si === 0 ? rows[r].left : rows[r].right; - const sign = si === 0 ? 1 : -1; - return [anchor[0] + ux[r] * sign * s, anchor[1] + uy[r] * sign * s]; - }, - }; -} diff --git a/B05_Profile/Old/B05_Profile_UI_Corridor_Deform_Breakline.ts b/B05_Profile/Old/B05_Profile_UI_Corridor_Deform_Breakline.ts deleted file mode 100644 index d2a71b246..000000000 --- a/B05_Profile/Old/B05_Profile_UI_Corridor_Deform_Breakline.ts +++ /dev/null @@ -1,612 +0,0 @@ -/* ============================================================================= - * B05_Profile_UI_Corridor_Deform_Breakline.ts - * 새 방식 지형의 **브레이크라인 국소 재삼각분할**(계획서 115-5 P1-3). - * - * 노면 끝(앵커) · 비탈 끝 · 단면 꺾임 · 구조물 구멍 선을 지형 메시에 끼워 넣는다. - * · 선이 걸친 삼각형만 그 삼각형 안 선 조각으로 면을 나눠(평면 그래프 면 걷기) - * `ShapeUtils.triangulateShape`(earcut)로 다시 삼각분할한다. - * · 삼각형 변과 선의 교점은 **변 꼭짓점 번호 순서로 정한 식**으로 내 이웃 삼각형과 - * 비트까지 같다 — 변형 뒤에도 T자 균열이 없다(`_Clip.ts` 무한직선 절단 대체). - * · 지울 자리(노면부 · 구멍) 면은 버리고, 남은 면은 무게중심 자리로 한 색(성토 · 절토 · - * 원지반)을 칠한다 — 색 경계 = 비탈 끝 선. - * · 손댈 자리(`touched` — 행별 비탈 끝 + 5 m 띠) 밖 삼각형은 번호만 넘긴다(P2-5). - * - * 원본 geometry 는 읽기만 한다 — 새 위치 · 속성 배열을 만든다. 법선은 만들지 않는다(`_Clip.ts`). - * ========================================================================== */ - -import * as THREE from "three"; -import type { P2 } from "./B05_Profile_UI_Corridor_Region"; - -/** 브레이크라인 하나 — 씬 평면(x, z) 꺾은선. 닫힌 고리는 끝점 = 첫 점. */ -export type Breakline = P2[]; - -/** 변형 결과(씬 Y) · 자리(0 원지반 · 1 성토 · 2 절토). */ -export interface BandPoint { - y: number; - zone: 0 | 1 | 2; -} - -export interface BandContext { - lines: ReadonlyArray; - /** 지울 자리(노면부 · 구조물 구멍). */ - removed: (p: P2) => boolean; - /** 손댈 자리 — 밖 삼각형은 그대로 넘긴다. */ - touched: (p: P2) => boolean; - /** 씬 좌표 정점 → 변형 높이 · 자리. */ - deform: (x: number, y: number, z: number) => BandPoint; - /** 자리별 색(r, g, b · 0~1). */ - tint: (zone: 1 | 2) => [number, number, number]; -} - -export interface BandStats { - split: number; - /** 선이 걸쳤는데 못 나눈 삼각형(퇴화) — 통째로 둔다. */ - fallback: number; - moved: number; - raised: number; - lowered: number; - maxRaiseM: number; - maxCutM: number; -} - -const CELL_M = 2; -/** 점 같음 판정(m) — 교점 · 꼭짓점 물림. */ -const SNAP_M = 1e-6; -/** 이보다 작게 움직이면 움직였다고 세지 않는다(m). */ -const MOVE_MIN_M = 0.01; - -/** 선분 격자 색인 — 선분 [px, pz, qx, qz] 와 칸별 선분 번호. */ -class SegmentGrid { - readonly segs: number[] = []; - private cells = new Map(); - private minX = Infinity; - private minZ = Infinity; - private cols = 1; - - constructor(lines: ReadonlyArray) { - for (const line of lines) { - for (let i = 1; i < line.length; i += 1) { - const p = line[i - 1]; - const q = line[i]; - if (Math.hypot(q.x - p.x, q.z - p.z) < SNAP_M) continue; - this.segs.push(p.x, p.z, q.x, q.z); - this.minX = Math.min(this.minX, p.x, q.x); - this.minZ = Math.min(this.minZ, p.z, q.z); - } - } - let maxX = -Infinity; - for (let s = 0; s < this.segs.length; s += 4) - maxX = Math.max(maxX, this.segs[s], this.segs[s + 2]); - this.cols = Math.max(1, Math.ceil((maxX - this.minX) / CELL_M) + 1); - for (let s = 0; s < this.segs.length; s += 4) { - const [c0, c1] = this.span(this.segs[s], this.segs[s + 2], this.minX); - const [r0, r1] = this.span(this.segs[s + 1], this.segs[s + 3], this.minZ); - for (let r = r0; r <= r1; r += 1) { - for (let c = c0; c <= c1; c += 1) { - const key = r * this.cols + c; - const list = this.cells.get(key); - if (list) list.push(s / 4); - else this.cells.set(key, [s / 4]); - } - } - } - } - - private span(a: number, b: number, origin: number): [number, number] { - return [ - Math.floor((Math.min(a, b) - origin) / CELL_M), - Math.floor((Math.max(a, b) - origin) / CELL_M), - ]; - } - - /** 상자에 걸칠 수 있는 선분 번호(중복 없음). */ - near(minX: number, maxX: number, minZ: number, maxZ: number): number[] { - if (!this.segs.length) return []; - const [c0, c1] = this.span(minX, maxX, this.minX); - const [r0, r1] = this.span(minZ, maxZ, this.minZ); - const out: number[] = []; - for (let r = r0; r <= r1; r += 1) { - for (let c = c0; c <= c1; c += 1) { - const list = this.cells.get(r * this.cols + c); - if (list) for (const s of list) if (!out.includes(s)) out.push(s); - } - } - return out; - } -} - -const cross = (ax: number, az: number, bx: number, bz: number): number => ax * bz - az * bx; - -function polygonArea(points: ReadonlyArray): number { - let area = 0; - for (let i = 0, j = points.length - 1; i < points.length; j = i, i += 1) { - area += points[j].x * points[i].z - points[i].x * points[j].z; - } - return area / 2; -} - -function insidePolygon(points: ReadonlyArray, p: P2): boolean { - let hit = false; - for (let i = 0, j = points.length - 1; i < points.length; j = i, i += 1) { - const a = points[i]; - const b = points[j]; - if (a.z > p.z !== b.z > p.z && p.x < ((b.x - a.x) * (p.z - a.z)) / (b.z - a.z) + a.x) - hit = !hit; - } - return hit; -} - -/** 삼각형 안 선 조각 한 끝 — 자리와, 경계 위면 어느 변(0~2) · 꼭짓점(3~5)인가. */ -interface PieceEnd { - u: number; - p: P2; - on: number[]; -} - -/** - * 삼각형(꼭짓점 번호 ids)을 선분들로 나눈 면들 — 면마다 [바깥 고리, ...구멍 고리]. - * 걸친 선이 없으면 null · 퇴화로 못 나누면 변 위 마디만 품은 삼각형 한 면(`fallback`). - */ -export function splitTriangle( - corners: [P2, P2, P2], - ids: [number, number, number], - segIds: ReadonlyArray, - segs: ReadonlyArray, -): Array<{ contour: P2[]; holes: P2[][]; fallback?: boolean }> | null { - const pieces: Array<{ a: PieceEnd; b: PieceEnd; cuts: Array<{ t: number; p: P2 }> }> = []; - /** 변 위 교점 전부 — 조각이 안 남아도(변에 스친 선) 마디로 둔다. 이웃 삼각형도 같은 교점을 - * 같은 식으로 내므로 양쪽이 늘 같은 마디를 갖는다(T자 균열 0). */ - const edgeHits: Array<{ e: number; p: P2 }> = []; - const triArea = polygonArea(corners); - if (Math.abs(triArea) < 1e-10) return null; - const sign = Math.sign(triArea); - const inside = (p: P2): boolean => { - for (let e = 0; e < 3; e += 1) { - const a = corners[e]; - const b = corners[(e + 1) % 3]; - if (sign * cross(b.x - a.x, b.z - a.z, p.x - a.x, p.z - a.z) <= 0) return false; - } - return true; - }; - for (const s of segIds) { - const P = { x: segs[s * 4], z: segs[s * 4 + 1] }; - const Q = { x: segs[s * 4 + 2], z: segs[s * 4 + 3] }; - const ends: PieceEnd[] = []; - let collinear = false; - for (let e = 0; e < 3 && !collinear; e += 1) { - // 변은 꼭짓점 번호 작은 쪽 → 큰 쪽으로 — 이웃 삼각형과 같은 식 · 같은 값. - const swap = ids[e] > ids[(e + 1) % 3]; - const lo = corners[swap ? (e + 1) % 3 : e]; - const hi = corners[swap ? e : (e + 1) % 3]; - const ex = hi.x - lo.x; - const ez = hi.z - lo.z; - const length = Math.hypot(ex, ez); - const d1 = cross(ex, ez, P.x - lo.x, P.z - lo.z) / length; - const d2 = cross(ex, ez, Q.x - lo.x, Q.z - lo.z) / length; - if (Math.abs(d1) < SNAP_M && Math.abs(d2) < SNAP_M) collinear = true; - if ((d1 > 0 && d2 > 0) || (d1 < 0 && d2 < 0) || d1 === d2) continue; - const u = d1 / (d1 - d2); - const x = P.x + (Q.x - P.x) * u; - const z = P.z + (Q.z - P.z) * u; - const w = ((x - lo.x) * ex + (z - lo.z) * ez) / (length * length); - if (w * length < -SNAP_M || (w - 1) * length > SNAP_M) continue; - const corner = - w * length < SNAP_M - ? swap - ? (e + 1) % 3 - : e - : (1 - w) * length < SNAP_M - ? swap - ? e - : (e + 1) % 3 - : -1; - if (corner >= 0) { - ends.push({ u, p: corners[corner], on: [corner + 3, corner, (corner + 2) % 3] }); - } else { - ends.push({ u, p: { x, z }, on: [e] }); - edgeHits.push({ e, p: { x, z } }); - } - } - if (collinear) continue; - if (inside(P)) ends.push({ u: 0, p: P, on: [] }); - if (inside(Q)) ends.push({ u: 1, p: Q, on: [] }); - if (ends.length < 2) continue; - ends.sort((m, n) => m.u - n.u); - const a = ends[0]; - const b = ends[ends.length - 1]; - if (Math.hypot(a.p.x - b.p.x, a.p.z - b.p.z) < SNAP_M) continue; - // 두 끝이 같은 변 위 — 경계를 따라가는 선이라 나눌 것이 없다. - if (a.on.some((k) => k < 3 && b.on.includes(k))) continue; - pieces.push({ a, b, cuts: [] }); - } - if (!pieces.length && !edgeHits.length) return null; - // 선 조각끼리 교차 — 두 조각 모두에 마디를 넣는다. - for (let i = 0; i < pieces.length; i += 1) { - for (let j = i + 1; j < pieces.length; j += 1) { - const A = pieces[i]; - const B = pieces[j]; - const rx = A.b.p.x - A.a.p.x; - const rz = A.b.p.z - A.a.p.z; - const sx = B.b.p.x - B.a.p.x; - const sz = B.b.p.z - B.a.p.z; - const den = cross(rx, rz, sx, sz); - if (Math.abs(den) < 1e-14) continue; - const qx = B.a.p.x - A.a.p.x; - const qz = B.a.p.z - A.a.p.z; - const t = cross(qx, qz, sx, sz) / den; - const v = cross(qx, qz, rx, rz) / den; - const ta = SNAP_M / Math.hypot(rx, rz); - const tb = SNAP_M / Math.hypot(sx, sz); - if (t < -ta || t > 1 + ta || v < -tb || v > 1 + tb) continue; - const innerA = t > ta && t < 1 - ta; - const innerB = v > tb && v < 1 - tb; - if (!innerA && !innerB) continue; // 끝끼리 닿음 — 같은 마디로 묶인다. - // 한쪽 끝이 다른 조각 가운데에 닿음(T) — 그 끝 자리를 가운데 쪽 조각의 마디로. - const p = - innerA && innerB - ? { x: A.a.p.x + rx * t, z: A.a.p.z + rz * t } - : innerA - ? v <= tb - ? B.a.p - : B.b.p - : t <= ta - ? A.a.p - : A.b.p; - if (innerA) A.cuts.push({ t, p }); - if (innerB) B.cuts.push({ t: v, p }); - } - } - - // 평면 그래프 — SNAP 안 마디는 하나로 묶는다(거의 겹친 마디는 각 순서를 흔들어 면 걷기를 깬다). - const nodes: P2[] = []; - const node = (p: P2): number => { - const found = nodes.findIndex((q) => Math.hypot(q.x - p.x, q.z - p.z) < SNAP_M * 10); - if (found >= 0) return found; - nodes.push(p); - return nodes.length - 1; - }; - corners.forEach(node); - const adj: Array> = []; - const link = (i: number, j: number): void => { - if (i === j) return; - (adj[i] ??= new Set()).add(j); - (adj[j] ??= new Set()).add(i); - }; - const onEdge: Array> = [[], [], []]; - for (const piece of pieces) { - const chain = [piece.a.p, ...piece.cuts.sort((m, n) => m.t - n.t).map((c) => c.p), piece.b.p]; - const idsOf = chain.map(node); - for (let k = 1; k < idsOf.length; k += 1) link(idsOf[k - 1], idsOf[k]); - } - for (const { e, p } of edgeHits) { - const from = corners[e]; - const to = corners[(e + 1) % 3]; - const w = Math.hypot(p.x - from.x, p.z - from.z) / Math.hypot(to.x - from.x, to.z - from.z); - onEdge[e].push({ w, id: node(p) }); - } - for (let e = 0; e < 3; e += 1) { - const stops = [ - { w: 0, id: e }, - ...onEdge[e].sort((m, n) => m.w - n.w), - { w: 1, id: (e + 1) % 3 }, - ]; - for (let k = 1; k < stops.length; k += 1) link(stops[k - 1].id, stops[k].id); - } - /** 퇴화 대비 — 안쪽 선은 버리고 변 위 마디만 품은 한 면(이웃과 T자 균열은 없다). */ - const boundaryOnly = (): Array<{ contour: P2[]; holes: P2[][]; fallback: boolean }> => [ - { - contour: [0, 1, 2].flatMap((e) => [corners[e], ...onEdge[e].map((stop) => nodes[stop.id])]), - holes: [], - fallback: true, - }, - ]; - // 매달린 선(한쪽 끝이 삼각형 안에서 끝남)은 면을 안 나눈다 — 걷어 낸다. - let pruned = true; - while (pruned) { - pruned = false; - adj.forEach((set, i) => { - if (set && set.size === 1) { - const [j] = set; - adj[j].delete(i); - set.clear(); - pruned = true; - } - }); - } - // 면 걷기 — 들어온 변을 뒤집은 방향에서 시계방향 다음 변. 양(반시계) 면 = 안쪽 면. - const order: number[][] = adj.map((set, i) => - set - ? [...set].sort( - (m, n) => - Math.atan2(nodes[m].z - nodes[i].z, nodes[m].x - nodes[i].x) - - Math.atan2(nodes[n].z - nodes[i].z, nodes[n].x - nodes[i].x), - ) - : [], - ); - const seen = new Set(); - const N = nodes.length; - const faces: P2[][] = []; - const faceIds: number[][] = []; - order.forEach((list, i) => - list.forEach((j) => { - if (seen.has(i * N + j)) return; - const ring: number[] = []; - let u = i; - let v = j; - while (!seen.has(u * N + v) && ring.length <= 4 * N) { - seen.add(u * N + v); - ring.push(u); - const around = order[v]; - const k = around.indexOf(u); - const w = around[(k - 1 + around.length) % around.length]; - u = v; - v = w; - } - faces.push(ring.map((id) => nodes[id])); - faceIds.push(ring); - }), - ); - const filled: Array<{ contour: P2[]; holes: P2[][]; ids: Set }> = []; - const holes: Array<{ ring: P2[]; ids: number[] }> = []; - let total = 0; - faces.forEach((ring, f) => { - // 면 걷기 규칙상 안쪽 면은 늘 반시계(넓이 +) — 삼각형 감김과 무관. - const area = polygonArea(ring); - if (area > 1e-12) { - filled.push({ contour: ring, holes: [], ids: new Set(faceIds[f]) }); - total += area; - } else if (area < -1e-12 && !faceIds[f].includes(0)) { - holes.push({ ring, ids: faceIds[f] }); - } - }); - // 삼각형 안에 따로 떠 있는 고리(작은 구멍) — 그 고리를 품은 면의 구멍으로. - for (const hole of holes) { - const host = filled - .filter((face) => !hole.ids.some((id) => face.ids.has(id))) - .filter((face) => insidePolygon(face.contour, hole.ring[0])) - .sort((m, n) => Math.abs(polygonArea(m.contour)) - Math.abs(polygonArea(n.contour)))[0]; - if (!host) return boundaryOnly(); - host.holes.push(hole.ring); - total -= Math.abs(polygonArea(hole.ring)); - } - // 면 넓이 합이 삼각형과 다르면(퇴화) 나누지 않는다. 면이 하나여도 변 위 마디(매달린 선이 - // 이웃 삼각형을 나눈 자리)를 품으므로 그대로 낸다 — 통째로 두면 T자 균열. - if (Math.abs(total - Math.abs(triArea)) > 1e-6 * Math.max(1, Math.abs(triArea))) { - return boundaryOnly(); - } - return filled.map(({ contour, holes: inner }) => ({ contour, holes: inner })); -} - -/** 넓이 0 에 가까운 삼각형 — 가장 긴 변에서 맞은 꼭짓점까지 높이가 SNAP 안. */ -function sliver(a: P2, b: P2, c: P2): boolean { - const twice = Math.abs(cross(b.x - a.x, b.z - a.z, c.x - a.x, c.z - a.z)); - const longest = Math.max( - Math.hypot(b.x - a.x, b.z - a.z), - Math.hypot(c.x - b.x, c.z - b.z), - Math.hypot(a.x - c.x, a.z - c.z), - ); - return twice < SNAP_M * longest; -} - -/** - * earcut 이 일직선 점을 걸러 내거나 넓이 0 귀로만 쓴 자리를 되살린다 — 변 위 마디가 빠지면 - * 이웃 면과 T자 균열. 넓이 0 삼각형은 버리고, 빠진 점을 변 위에 둔 삼각형을 그 점에서 둘로. - */ -function keepCollinear(ring: ReadonlyArray, tris: number[][]): number[][] { - const out = tris.filter(([a, b, c]) => !sliver(ring[a], ring[b], ring[c])).map((tri) => [...tri]); - const used = new Set(out.flat()); - ring.forEach((p, k) => { - if (used.has(k)) return; - for (let i = 0; i < out.length; i += 1) { - const tri = out[i]; - for (let e = 0; e < 3; e += 1) { - const a = ring[tri[e]]; - const b = ring[tri[(e + 1) % 3]]; - const ex = b.x - a.x; - const ez = b.z - a.z; - const length2 = ex * ex + ez * ez; - const along = (p.x - a.x) * ex + (p.z - a.z) * ez; - if (along <= 0 || along >= length2) continue; - if (Math.abs(cross(ex, ez, p.x - a.x, p.z - a.z)) / Math.sqrt(length2) > 1e-7) continue; - const c = tri[(e + 2) % 3]; - out.splice(i, 1, [tri[e], k, c], [k, tri[(e + 1) % 3], c]); - used.add(k); - return; - } - } - }); - return out; -} - -/** 보간해 이어 갈 속성 — 위치 · 법선을 뺀 나머지(`_Clip.ts` 와 같은 규칙). */ -function attributeSlots(geometry: THREE.BufferGeometry): Array<{ - name: string; - size: number; - source: THREE.BufferAttribute; - values: ArrayLike; - extra: number[]; -}> { - return Object.keys(geometry.attributes) - .filter((name) => name !== "position" && name !== "normal") - .map((name) => { - const source = geometry.getAttribute(name) as THREE.BufferAttribute; - const values = ( - source.array as unknown as { subarray(a: number, b: number): ArrayLike } - ).subarray(0, source.count * source.itemSize); - return { name, size: source.itemSize, source, values, extra: [] as number[] }; - }); -} - -/** - * 지형 한 조각을 브레이크라인으로 다시 나누고 높이를 옮긴 사본 — 칠한 정점 표식과 함께. - */ -export function retriangulateBand( - geometry: THREE.BufferGeometry, - ctx: BandContext, - stats: BandStats, -): { geometry: THREE.BufferGeometry; keep: Uint8Array } { - const position = geometry.getAttribute("position") as THREE.BufferAttribute; - const base = position.count; - const px = (position.array as Float32Array).subarray(0, base * 3); - const positions = Float32Array.from(px); - const index = geometry.getIndex(); - const triCount = index ? index.count / 3 : base / 3; - const slots = attributeSlots(geometry); - const color = slots.find((slot) => slot.name === "color"); - const scale = color?.source.normalized - ? color.source.array instanceof Uint8Array - ? 255 - : 65535 - : 1; - const grid = new SegmentGrid(ctx.lines); - const kept: number[] = []; - const extra: number[] = []; - const zones: number[] = []; - /** 원 정점 — 0 아직 · 1 원지반 · 2 성토 · 3 절토(+1). */ - const done = new Uint8Array(base); - - const count = (delta: number): void => { - if (Math.abs(delta) < MOVE_MIN_M) return; - stats.moved += 1; - if (delta > 0) { - stats.raised += 1; - stats.maxRaiseM = Math.max(stats.maxRaiseM, delta); - } else { - stats.lowered += 1; - stats.maxCutM = Math.max(stats.maxCutM, -delta); - } - }; - const settle = (v: number): void => { - if (done[v]) return; - const y = px[v * 3 + 1]; - const out = ctx.deform(px[v * 3], y, px[v * 3 + 2]); - positions[v * 3 + 1] = out.y; - done[v] = out.zone + 1; - count(out.y - y); - }; - - for (let t = 0; t < triCount; t += 1) { - const ids: [number, number, number] = [0, 1, 2].map((k) => - index ? index.getX(t * 3 + k) : t * 3 + k, - ) as [number, number, number]; - const corners = ids.map((v) => ({ x: px[v * 3], z: px[v * 3 + 2] })) as [P2, P2, P2]; - const centroid = { - x: (corners[0].x + corners[1].x + corners[2].x) / 3, - z: (corners[0].z + corners[1].z + corners[2].z) / 3, - }; - if (!corners.some(ctx.touched) && !ctx.touched(centroid)) { - kept.push(...ids); - continue; - } - const near = grid.near( - Math.min(corners[0].x, corners[1].x, corners[2].x), - Math.max(corners[0].x, corners[1].x, corners[2].x), - Math.min(corners[0].z, corners[1].z, corners[2].z), - Math.max(corners[0].z, corners[1].z, corners[2].z), - ); - const faces = near.length ? splitTriangle(corners, ids, near, grid.segs) : null; - if (faces?.some((face) => face.fallback)) stats.fallback += 1; - if (!faces) { - if (ctx.removed(centroid)) continue; - ids.forEach(settle); - kept.push(...ids); - continue; - } - stats.split += 1; - // 원 삼각형 평면 · 속성 무게중심 보간. - const [a, b, c] = corners; - const den = (b.z - c.z) * (a.x - c.x) + (c.x - b.x) * (a.z - c.z); - const weights = (p: P2): [number, number, number] => { - const w0 = ((b.z - c.z) * (p.x - c.x) + (c.x - b.x) * (p.z - c.z)) / den; - const w1 = ((c.z - a.z) * (p.x - c.x) + (a.x - c.x) * (p.z - c.z)) / den; - return [w0, w1, 1 - w0 - w1]; - }; - const sign = Math.sign(den); - for (const face of faces) { - const ring = [...face.contour, ...face.holes.flat()]; - const tris = keepCollinear( - ring, - THREE.ShapeUtils.triangulateShape( - face.contour.map((p) => new THREE.Vector2(p.x, p.z)), - face.holes.map((hole) => hole.map((p) => new THREE.Vector2(p.x, p.z))), - ), - ); - for (const tri of tris) { - const pts = tri.map((k) => ring[k]); - const mid = { - x: (pts[0].x + pts[1].x + pts[2].x) / 3, - z: (pts[0].z + pts[1].z + pts[2].z) / 3, - }; - if (ctx.removed(mid)) continue; - const w = weights(mid); - const zone = ctx.deform( - mid.x, - w[0] * px[ids[0] * 3 + 1] + w[1] * px[ids[1] * 3 + 1] + w[2] * px[ids[2] * 3 + 1], - mid.z, - ).zone; - const turn = cross( - pts[1].x - pts[0].x, - pts[1].z - pts[0].z, - pts[2].x - pts[0].x, - pts[2].z - pts[0].z, - ); - // 원 삼각형과 같은 감김(앞면 그대로). - const ordered = Math.sign(turn) === sign ? pts : [pts[0], pts[2], pts[1]]; - for (const p of ordered) { - const pw = weights(p); - const y = - pw[0] * px[ids[0] * 3 + 1] + pw[1] * px[ids[1] * 3 + 1] + pw[2] * px[ids[2] * 3 + 1]; - const out = ctx.deform(p.x, y, p.z); - count(out.y - y); - kept.push(base + extra.length / 3); - extra.push(p.x, out.y, p.z); - zones.push(zone); - for (const slot of slots) { - for (let k = 0; k < slot.size; k += 1) { - slot.extra.push( - pw[0] * slot.values[ids[0] * slot.size + k] + - pw[1] * slot.values[ids[1] * slot.size + k] + - pw[2] * slot.values[ids[2] * slot.size + k], - ); - } - } - } - } - } - } - - const out = new THREE.BufferGeometry(); - const all = new Float32Array(base * 3 + extra.length); - all.set(positions, 0); - all.set(extra, base * 3); - out.setAttribute("position", new THREE.BufferAttribute(all, 3)); - const keep = new Uint8Array(base + zones.length); - const paint = (values: { [i: number]: number }, v: number, zone: number): void => { - if (!color || zone < 1) return; - const rgb = ctx.tint(zone as 1 | 2); - for (let k = 0; k < 3; k += 1) - values[v * color.size + k] = scale === 1 ? rgb[k] : Math.round(rgb[k] * scale); - keep[v] = 1; - }; - for (const slot of slots) { - const ctor = slot.source.array.constructor as new (length: number) => THREE.TypedArray; - const merged = new ctor(base * slot.size + slot.extra.length); - merged.set(slot.values as ArrayLike, 0); - merged.set( - slot.source.normalized ? slot.extra.map((value) => Math.round(value)) : slot.extra, - base * slot.size, - ); - if (slot === color) { - for (let v = 0; v < base; v += 1) paint(merged, v, done[v] - 1); - zones.forEach((zone, i) => paint(merged, base + i, zone)); - } - out.setAttribute( - slot.name, - new THREE.BufferAttribute(merged, slot.size, slot.source.normalized), - ); - } - out.setIndex(kept); - out.computeBoundingSphere(); - out.computeBoundingBox(); - return { geometry: out, keep }; -} diff --git a/B05_Profile/Old/B05_Profile_UI_Corridor_Deform_Structure.ts b/B05_Profile/Old/B05_Profile_UI_Corridor_Deform_Structure.ts deleted file mode 100644 index 240e465d1..000000000 --- a/B05_Profile/Old/B05_Profile_UI_Corridor_Deform_Structure.ts +++ /dev/null @@ -1,430 +0,0 @@ -/* ============================================================================= - * B05_Profile_UI_Corridor_Deform_Structure.ts - * 새 방식 지형 변형의 **구조물 마스크**(계획서 115-2) — 순수 기하. - * - * · 구조물 구간 단면 = 소유 측점 실루엣(`_Station_Structure.ts`)의 상부 포락선. - * 구간 안은 고정, 구간 끝 → 이웃 측점까지 제어점 테이퍼(`_Frames.ts`). - * · 지형 구멍 = 구조물 풋프린트 · BOX 아령 외곽(`_Plan_Footprint.ts`). 솔리드가 메운다. - * · 성토 금지 고리 = 날개 안쪽 유로 · 에이프런 — 원지반(세월교는 바닥판 상단) 위로 안 올린다. - * - * 구조물 솔리드(`_Structures*.ts`)는 읽기만 한다. - * ========================================================================== */ - -import type { CrossSection } from "../B06_Section/B06_Section_Api_Fetch"; -import type { CorridorStructure, StructureFrame } from "./B05_Profile_UI_Corridor_Structures"; -import { - classifyStation, - PIECE_COLS, - pieceKey, - resample, - type OffsetPoint, - type StationPieces, -} from "./B05_Profile_UI_Corridor_Station"; -import { structureSilhouettes } from "./B05_Profile_UI_Corridor_Station_Structure"; -import { - structureFootprintLoops, - type StationAxis, -} from "./B05_Profile_UI_Corridor_Plan_Footprint"; -import type { - DeformKind, - DeformLimit, - DeformSide, - StructureSlope, -} from "./B05_Profile_UI_Corridor_Deform"; - -/** - * 상부 포락선 — 바깥거리(`offset_m` = s ≥ 0)마다 실루엣 마디 중 가장 높은 표고. - * 벽 앞면(거의 수직) · 다단 계단처럼 바깥으로 갔다 돌아오는 줄을 높이장 한 줄로 편다. - */ -export function upperEnvelope(points: ReadonlyArray): OffsetPoint[] { - if (points.length < 2) return points.map((point) => ({ ...point })); - const xs = [...new Set(points.map((point) => point.offset_m))].sort((a, b) => a - b); - return xs.map((x) => { - let best = -Infinity; - for (let i = 0; i < points.length - 1; i += 1) { - const a = points[i]; - const b = points[i + 1]; - const lo = Math.min(a.offset_m, b.offset_m); - const hi = Math.max(a.offset_m, b.offset_m); - if (x < lo - 1e-9 || x > hi + 1e-9) continue; - const z = - hi - lo < 1e-9 - ? Math.max(a.elevation_m, b.elevation_m) - : a.elevation_m + - ((b.elevation_m - a.elevation_m) * (x - a.offset_m)) / (b.offset_m - a.offset_m); - if (z > best) best = z; - } - return { offset_m: x, elevation_m: best }; - }); -} - -/** 측점의 노면부 바깥 끝(측구 > 노견) — 행 앵커와 같은 자리. */ -function anchorOf(station: StationPieces, side: DeformSide): OffsetPoint | null { - const piece = - station.pieces.get(pieceKey("ditch", side)) ?? station.pieces.get(pieceKey("shoulder", side)); - if (!piece || piece.length < 2) return null; - return side === "left" ? piece[piece.length - 1] : piece[0]; -} - -/** - * 실루엣 → 앵커 기준 상대 단면(측점 단면 `slopeRelative` 와 같은 꼴 · 같은 열 수). - * 앵커 안쪽(노면부)은 잘라 내고, 앵커 자리 교차점을 첫 점으로 세운다. - */ -function silhouetteSlope( - points: ReadonlyArray, - side: DeformSide, - anchor: OffsetPoint, - kind: DeformKind, -): OffsetPoint[] | null { - const sign = side === "left" ? 1 : -1; - const outward = points.map((point) => ({ - offset_m: (point.offset_m - anchor.offset_m) * sign, - elevation_m: point.elevation_m - anchor.elevation_m, - })); - const kept: OffsetPoint[] = []; - outward.forEach((point, i) => { - if (point.offset_m < 0) return; - const previous = outward[i - 1]; - if (!kept.length && previous && previous.offset_m < 0) { - const t = -previous.offset_m / (point.offset_m - previous.offset_m); - kept.push({ - offset_m: 0, - elevation_m: previous.elevation_m + (point.elevation_m - previous.elevation_m) * t, - }); - } - kept.push(point); - }); - if (kept.length && kept[0].offset_m > 1e-9) kept.unshift({ offset_m: 0, elevation_m: 0 }); - const envelope = upperEnvelope(kept); - if (envelope.length < 2 || envelope[envelope.length - 1].offset_m < 1e-3) return null; - const sampled = resample(envelope, PIECE_COLS[kind]); - // 측점 단면 꼴로 — 좌측은 바깥거리 그대로, 우측은 부호를 뒤집어 오름차순(도로측 원점이 끝). - return side === "left" - ? sampled - : sampled - .map((point) => ({ offset_m: -point.offset_m, elevation_m: point.elevation_m })) - .reverse(); -} - -/** 구조물 측점마다 그 측 실루엣을 구간 단면으로 — `stationProfileSource` 두 번째 인자. */ -export function structureSlopes(sections: ReadonlyArray): StructureSlope[] { - const slopes: StructureSlope[] = []; - for (const section of sections) { - const silhouettes = structureSilhouettes(section); - if (!silhouettes.length) continue; - const station = classifyStation(section); - const mode = section.design?.section_mode; - if (!station || !mode) continue; - for (const silhouette of silhouettes) { - const side = silhouette.side; - const anchor = anchorOf(station, side); - if (!anchor || !silhouette.points.length) continue; - // auto 는 기울기로 — 바깥 끝이 앵커보다 낮으면 성토(115-5 P0-1 ③). 단면 모드로 가르면 - // both_cut 유출측 내려가는 선이 절토가 돼 구덩이를 팠다. - const sign = side === "left" ? 1 : -1; - const outer = silhouette.points.reduce((best, point) => - (point.offset_m - best.offset_m) * sign > 0 ? point : best, - ); - const kind: DeformKind = - silhouette.kind === "auto" - ? outer.elevation_m < anchor.elevation_m - ? "fill" - : "cut" - : silhouette.kind; - const points = silhouetteSlope(silhouette.points, side, anchor, kind); - if (!points) continue; - slopes.push({ - chainage_m: section.chainage_m, - side, - kind, - span: silhouette.span, - points, - endsAtStructure: silhouette.endsAtStructure === true, - }); - } - } - return slopes; -} - -/** 측점 프레임 축. */ -function axisOf(section: CrossSection): StationAxis { - return { - cx: section.center_x, - cy: section.center_y, - leftX: section.frame.left_xy[0], - leftY: section.frame.left_xy[1], - }; -} - -/** 그 측점에 선 구조물 한 벌. */ -function setOf( - section: CrossSection, - structures: ReadonlyArray, -): CorridorStructure[] { - return structures.filter((s) => Math.abs(s.chainage_m - section.chainage_m) < 1e-6); -} - -/** 윗면으로 볼 높이 여유(m) — 링 폴리곤 최고점에서 이만큼 안. */ -const TOP_TOLERANCE_M = 0.05; -/** 구조물 둘레 드레이프 띠(m) — 풋프린트 밖 이만큼까지 지형을 윗면 아래로(115-5 P1-4). */ -export const DRAPE_BAND_M = 0.75; -/** 관 입구 구멍 — 끝에서 안쪽으로 볼 길이 · 걸음(m). */ -const MOUTH_REACH_M = 3; -const MOUTH_STEP_M = 0.1; - -/** 링 폴리곤의 윗면 점만 — 기초 · 뒷굽은 빼고 솔리드 윗면 외곽만 구멍으로(115-5 P1-4). */ -function topOnly(polygon: ReadonlyArray<[number, number]>): Array<[number, number]> { - const top = Math.max(...polygon.map((point) => point[1])); - return polygon.filter((point) => point[1] >= top - TOP_TOLERANCE_M); -} - -function topStructure(structure: CorridorStructure): CorridorStructure { - return { - ...structure, - polygon: structure.polygon && topOnly(structure.polygon), - polygons: structure.polygons?.map(topOnly), - }; -} - -/** - * 지형 구멍 고리(모델 좌표) — 구조물 **윗면** 외곽 · BOX 아령 외곽. 도로부 안쪽은 뺀다. - * 기초 · 뒷굽은 지형이 묻는다(둘레 드레이프 `structureTopAt`). - */ -export function structureHoleLoops( - sections: ReadonlyArray, - structures: ReadonlyArray, -): Array> { - const loops: Array> = []; - for (const section of sections) { - const set = setOf(section, structures).map(topStructure); - const edges = section.design?.road_edges; - if (!set.length || !edges) continue; - (["left", "right"] as const).forEach((side) => { - const roadEdge = side === "left" ? edges.left.offset_m : edges.right.offset_m; - structureFootprintLoops(set, side, roadEdge, 0, axisOf(section)).forEach((loop) => - loops.push(loop.map(([x, y]) => [x, y] as [number, number])), - ); - }); - } - return loops; -} - -/** 볼록 껍질(단조 사슬) — 날개 두 장의 링 중심을 감싸 날개 안쪽 유로 자리를 낸다. */ -function convexHull(points: Array<[number, number]>): Array<[number, number]> { - const sorted = [...points].sort((a, b) => a[0] - b[0] || a[1] - b[1]); - if (sorted.length < 3) return sorted; - const cross = (o: [number, number], a: [number, number], b: [number, number]): number => - (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]); - const lower: Array<[number, number]> = []; - for (const point of sorted) { - while (lower.length >= 2 && cross(lower[lower.length - 2], lower[lower.length - 1], point) <= 0) - lower.pop(); - lower.push(point); - } - const upper: Array<[number, number]> = []; - for (let i = sorted.length - 1; i >= 0; i -= 1) { - const point = sorted[i]; - while (upper.length >= 2 && cross(upper[upper.length - 2], upper[upper.length - 1], point) <= 0) - upper.pop(); - upper.push(point); - } - return [...lower.slice(0, -1), ...upper.slice(0, -1)]; -} - -function polygonArea(loop: ReadonlyArray<[number, number]>): number { - let area = 0; - for (let i = 0, j = loop.length - 1; i < loop.length; j = i, i += 1) { - area += loop[j][0] * loop[i][1] - loop[i][0] * loop[j][1]; - } - return Math.abs(area) / 2; -} - -/** - * 성토 금지 고리 — 측마다 날개(날개벽 · 날개 에이프런) 링 중심을 감싼 볼록 껍질. - * 천장 = 세월교 바닥판 상단(그 측 실루엣) · BOX 는 원지반. - */ -export function noFillLimits( - sections: ReadonlyArray, - structures: ReadonlyArray, -): DeformLimit[] { - const limits: DeformLimit[] = []; - for (const section of sections) { - const set = setOf(section, structures); - if (!set.length) continue; - const axis = axisOf(section); - const floors = new Map(); - structureSilhouettes(section).forEach((silhouette) => { - if (silhouette.floorTopZ !== undefined) floors.set(silhouette.side, silhouette.floorTopZ); - }); - (["left", "right"] as const).forEach((side) => { - const centers: Array<[number, number]> = []; - for (const structure of set) { - const rings = structure.rings; - if (!structure.wing || structure.kind === "pipe" || !rings || rings.length < 2) continue; - const middle = rings[Math.floor(rings.length / 2)]; - const offset = (middle.cx - axis.cx) * axis.leftX + (middle.cy - axis.cy) * axis.leftY; - if ((offset >= 0 ? "left" : "right") !== side) continue; - rings.forEach((ring) => centers.push([ring.cx, ring.cy])); - } - const loop = convexHull(centers); - if (loop.length < 3 || polygonArea(loop) < 0.01) return; - limits.push({ loop, ceiling: floors.get(side) ?? null }); - }); - } - return limits; -} - -/** 한 구조물의 링 사이 마디 — 둘레 드레이프 조회용. */ -interface DrapeSpan { - a: StructureFrame; - b: StructureFrame; - /** 링마다 [편거리 lo, hi, 윗면 표고(dz 포함)]. */ - wa: [number, number, number]; - wb: [number, number, number]; - /** 끝 마디 밖으로 늘릴 비율(띠 / 링 간격). */ - before: number; - after: number; - box: [number, number, number, number]; -} - -/** - * 구조물 둘레 드레이프(115-5 P1-4) — 풋프린트 안 · 둘레 띠 자리의 솔리드 윗면 표고(모델 - * 좌표). 지형은 `min(z', 윗면)` — 절토 어깨 · 성토가 벽 상단에서 멈춘다. 없으면 null. - * 겹친 부재는 가장 높은 윗면. 솔리드는 읽기만 한다. - */ -export function structureTopAt( - structures: ReadonlyArray, -): (x: number, y: number) => number | null { - const spans: DrapeSpan[] = []; - for (const structure of structures) { - const rings = structure.rings; - if (structure.kind === "pipe" || !rings || rings.length < 2) continue; - const window = (index: number): [number, number, number] | null => { - const polygon = structure.polygons?.[index] ?? structure.polygon; - if (!polygon?.length) return null; - const offsets = polygon.map((point) => point[0]); - const top = Math.max(...polygon.map((point) => point[1])); - return [Math.min(...offsets), Math.max(...offsets), top + rings[index].dz]; - }; - for (let i = 0; i < rings.length - 1; i += 1) { - const wa = window(i); - const wb = window(i + 1); - if (!wa || !wb) continue; - const a = rings[i]; - const b = rings[i + 1]; - const gap = Math.hypot(b.cx - a.cx, b.cy - a.cy); - if (gap < 1e-6) continue; - const corners = [a, b].flatMap((frame, k) => { - const w = k ? wb : wa; - return [w[0] - DRAPE_BAND_M, w[1] + DRAPE_BAND_M].map((o) => [ - frame.cx + frame.leftX * o, - frame.cy + frame.leftY * o, - ]); - }); - const xs = corners.map((p) => p[0]); - const ys = corners.map((p) => p[1]); - spans.push({ - a, - b, - wa, - wb, - before: i === 0 ? DRAPE_BAND_M / gap : 0, - after: i === rings.length - 2 ? DRAPE_BAND_M / gap : 0, - box: [ - Math.min(...xs) - DRAPE_BAND_M, - Math.max(...xs) + DRAPE_BAND_M, - Math.min(...ys) - DRAPE_BAND_M, - Math.max(...ys) + DRAPE_BAND_M, - ], - }); - } - } - // 칸 색인 — 정점마다 마디 전부를 훑지 않는다. - const CELL = 4; - const cells = new Map(); - for (const span of spans) { - const [x0, x1, y0, y1] = span.box; - for (let c = Math.floor(x0 / CELL); c <= Math.floor(x1 / CELL); c += 1) { - for (let g = Math.floor(y0 / CELL); g <= Math.floor(y1 / CELL); g += 1) { - const key = `${c},${g}`; - const list = cells.get(key); - if (list) list.push(span); - else cells.set(key, [span]); - } - } - } - return (x, y) => { - let best: number | null = null; - for (const span of cells.get(`${Math.floor(x / CELL)},${Math.floor(y / CELL)}`) ?? []) { - const [x0, x1, y0, y1] = span.box; - if (x < x0 || x > x1 || y < y0 || y > y1) continue; - const { a, b, wa, wb } = span; - const along = (f: StructureFrame): number => (x - f.cx) * f.leftY - (y - f.cy) * f.leftX; - const a0 = along(a); - const a1 = along(b); - if (Math.abs(a0 - a1) < 1e-12) continue; - const t = a0 / (a0 - a1); - if (t < -span.before || t > 1 + span.after) continue; - const k = Math.min(1, Math.max(0, t)); - const oa = (x - a.cx) * a.leftX + (y - a.cy) * a.leftY; - const ob = (x - b.cx) * b.leftX + (y - b.cy) * b.leftY; - const o = oa + (ob - oa) * k; - const lo = wa[0] + (wb[0] - wa[0]) * k - DRAPE_BAND_M; - const hi = wa[1] + (wb[1] - wa[1]) * k + DRAPE_BAND_M; - if (o < lo || o > hi) continue; - const top = wa[2] + (wb[2] - wa[2]) * k; - if (best === null || top > best) best = top; - } - return best; - }; -} - -/** - * 관 입구 구멍(115-5 P1-4) — 관 끝에서 안쪽으로 관 머리가 변형 지형 위로 드러난 만큼의 - * 관경 폭 사각 고리(모델 좌표). 묻힌 끝은 구멍 없음. - */ -export function pipeMouthLoops( - structures: ReadonlyArray, - groundAfter: (x: number, y: number) => number | null, -): Array> { - const loops: Array> = []; - for (const structure of structures) { - const { pipe, frame } = structure; - if (structure.kind !== "pipe" || !pipe || !frame) continue; - const length = Math.abs(pipe.end[0] - pipe.start[0]); - if (length < 0.2) continue; - const half = pipe.diameterM / 2; - const plan = (o: number, lateral: number): [number, number] => [ - frame.cx + frame.leftX * o + frame.leftY * lateral, - frame.cy + frame.leftY * o - frame.leftX * lateral, - ]; - const crownAt = (o: number): number => { - const t = (o - pipe.start[0]) / (pipe.end[0] - pipe.start[0]); - return pipe.start[1] + (pipe.end[1] - pipe.start[1]) * t + pipe.diameterM; - }; - for (const [from, to] of [ - [pipe.start[0], pipe.end[0]], - [pipe.end[0], pipe.start[0]], - ]) { - const dir = Math.sign(to - from); - let reach = 0; - for (let d = 0; d <= Math.min(MOUTH_REACH_M, length / 2) + 1e-9; d += MOUTH_STEP_M) { - const o = from + dir * d; - const [x, y] = plan(o, 0); - const ground = groundAfter(x, y); - if (ground === null || crownAt(o) <= ground) break; - reach = d; - } - if (reach < MOUTH_STEP_M) continue; - const inner = from + dir * reach; - loops.push([ - plan(from, -half), - plan(inner, -half), - plan(inner, half), - plan(from, half), - plan(from, -half), - ]); - } - } - return loops; -} diff --git a/B05_Profile/Old/B05_Profile_UI_Corridor_Deform_Terrain.ts b/B05_Profile/Old/B05_Profile_UI_Corridor_Deform_Terrain.ts deleted file mode 100644 index 4ff87d5d8..000000000 --- a/B05_Profile/Old/B05_Profile_UI_Corridor_Deform_Terrain.ts +++ /dev/null @@ -1,317 +0,0 @@ -/* ============================================================================= - * B05_Profile_UI_Corridor_Deform_Terrain.ts - * 새 방식 지형 — 밴드 사본에 브레이크라인(노면 끝 · 비탈 끝 · 단면 꺾임 · 구조물 윗면 - * 외곽 · 관 입구)을 끼워 다시 삼각분할하고(`_Deform_Breakline.ts`) 사본 정점의 높이만 - * 변형 장(`_Corridor_Deform.ts`)대로 옮긴다(계획서 115-1 · 115-5). - * - * 원본 · 분할본 정점 버퍼는 건드리지 않는다 — 새 위치 배열을 만든다. [예상형상] OFF 면 - * 뷰어가 원본을 그대로 보인다. 법선은 새로 만들지 않는다(`_Clip.ts`). - * 색 = 자리(비탈 끝 안 성토 · 절토) — 면 단위 · 경계 = 비탈 끝 선 · 빗금 없음. - * 구조물 둘레는 솔리드 윗면 아래로 드레이프(115-5 P1-4). 손대는 자리는 행별 비탈 끝 + 5 m - * 띠(P2-5) — 그 밖 지형은 1 mm 도 안 움직인다. - * ========================================================================== */ - -import * as THREE from "three"; -import type { ModelBounds } from "./B05_Profile_UI_Markers"; -import type { CorridorBuildResult } from "./B05_Profile_UI_Corridor_Build_Types"; -import type { TerrainBandSplit } from "./B05_Profile_UI_Corridor_Split"; -import type { TerrainHeightIndex } from "./B05_Profile_UI_Corridor_Terrain"; -import { CellRegion, scenePlanMapper, type P2 } from "./B05_Profile_UI_Corridor_Region"; -import { - buildDeformField, - stationProfileSource, - type DeformField, - type DeformRow, -} from "./B05_Profile_UI_Corridor_Deform"; -import { - noFillLimits, - pipeMouthLoops, - structureHoleLoops, - structureSlopes, - structureTopAt, -} from "./B05_Profile_UI_Corridor_Deform_Structure"; -import { - retriangulateBand, - type BandStats, - type Breakline, -} from "./B05_Profile_UI_Corridor_Deform_Breakline"; - -/** 절토 · 성토 색 — 코리도 리본 색(`_Mesh.ts` KIND_COLORS)과 같은 값. */ -const CUT_COLOR = new THREE.Color(0xb5179e); -const FILL_COLOR = new THREE.Color(0x06b6d4); -/** 손대는 띠 — 비탈 끝 밖 여유(m) · 칸(m). */ -const BAND_PAD_M = 5; -const MASK_CELL_M = 2; -/** 비탈 끝 · 꺾임 선으로 세울 최소 거리(m) — 앵커 · 비탈 끝에 겹친 선은 세우지 않는다. */ -const LINE_MIN_M = 0.05; - -/** 노면부 띠 — 행마다 좌우 앵커 사이 사각형. 지형은 이 안을 도려낸다. */ -class RoadStrip extends CellRegion { - constructor(rows: ReadonlyArray, bounds: ModelBounds) { - super(); - const toScene = scenePlanMapper(bounds); - const left = rows.map((row) => toScene(row.left[0], row.left[1])); - const right = rows.map((row) => toScene(row.right[0], row.right[1])); - for (let i = 0; i < rows.length - 1; i += 1) { - this.addCell(left[i], right[i], right[i + 1], left[i + 1]); - this.addSegment(left[i], left[i + 1]); - this.addSegment(right[i], right[i + 1]); - } - this.addSegment(left[0], right[0]); - this.addSegment(left[rows.length - 1], right[rows.length - 1]); - this.buildIndex(); - } -} - -/** 지형 변형 요약 — 화면 밖 수치 확인용(`__corridorDeform`). */ -export interface DeformStats extends BandStats { - rows: number; - holes: number; - lines: number; -} - -/** 브레이크라인 — 노면 끝(앵커 · 마구리) · 행별 비탈 끝 · 단면 꺾임(씬 평면). */ -export function deformBreaklines( - field: DeformField, - toScene: (x: number, y: number) => P2, -): Breakline[] { - const rows = field.rows; - const n = rows.length; - const lines: Breakline[] = [ - rows.map((row) => toScene(row.left[0], row.left[1])), - rows.map((row) => toScene(row.right[0], row.right[1])), - [toScene(rows[0].left[0], rows[0].left[1]), toScene(rows[0].right[0], rows[0].right[1])], - [ - toScene(rows[n - 1].left[0], rows[n - 1].left[1]), - toScene(rows[n - 1].right[0], rows[n - 1].right[1]), - ], - ]; - const at = (r: number, si: number, s: number): P2 => toScene(...field.pointAt(r, si, s)); - for (let si = 0; si < 2; si += 1) { - for (let ki = 0; ki < 2; ki += 1) { - let run: P2[] = []; - const runs = new Map(); - const flush = (line: P2[]): void => { - if (line.length >= 2) lines.push(line); - }; - let start = -1; - for (let r = 0; r < n; r += 1) { - const reach = field.catchAt(r, si, ki); - if (reach > LINE_MIN_M) { - if (!run.length) start = r; - run.push(at(r, si, reach)); - } - // 비탈이 시작 · 끝나는 행 — 앵커 → 비탈 끝 마구리 선(색 경계 · 끝 단차). - const ends = run.length && (reach <= LINE_MIN_M || r === n - 1); - if (ends) { - const last = reach > LINE_MIN_M ? r : r - 1; - for (const k of new Set([start, last])) { - lines.push([at(k, si, 0), at(k, si, field.catchAt(k, si, ki))]); - } - } - if (reach <= LINE_MIN_M) { - flush(run); - run = []; - } - const cols = new Set(); - for (const [col, s] of field.bendsAt(r, si, ki)) { - if (s < LINE_MIN_M || s > reach - LINE_MIN_M) continue; - cols.add(col); - const line = runs.get(col) ?? []; - line.push(at(r, si, s)); - runs.set(col, line); - } - runs.forEach((line, col) => { - if (cols.has(col)) return; - flush(line); - runs.delete(col); - }); - } - flush(run); - runs.forEach(flush); - } - } - return lines; -} - -/** 손대는 자리 — 행마다 노면부 + 좌우 비탈 끝 + 5 m 띠, 구멍 상자 둘레(P2-5). */ -function bandMask( - field: DeformField, - toScene: (x: number, y: number) => P2, - holes: ReadonlyArray, -): (p: P2) => boolean { - const marks: P2[] = []; - field.rows.forEach((row, r) => { - const width = Math.hypot(row.left[0] - row.right[0], row.left[1] - row.right[1]); - const reach = (si: number): number => - Math.max(field.catchAt(r, si, 0), field.catchAt(r, si, 1)) + BAND_PAD_M; - for (let s = -width; s <= reach(0); s += 1) marks.push(toScene(...field.pointAt(r, 0, s))); - for (let s = 0; s <= reach(1); s += 1) marks.push(toScene(...field.pointAt(r, 1, s))); - }); - holes.forEach((loop) => { - const xs = loop.map((p) => p.x); - const zs = loop.map((p) => p.z); - for (let x = Math.min(...xs) - 1.5; x <= Math.max(...xs) + 1.5; x += 1) { - for (let z = Math.min(...zs) - 1.5; z <= Math.max(...zs) + 1.5; z += 1) marks.push({ x, z }); - } - }); - if (!marks.length) return () => false; - let minX = Infinity; - let minZ = Infinity; - let maxX = -Infinity; - let maxZ = -Infinity; - for (const p of marks) { - minX = Math.min(minX, p.x); - minZ = Math.min(minZ, p.z); - maxX = Math.max(maxX, p.x); - maxZ = Math.max(maxZ, p.z); - } - minX -= MASK_CELL_M * 2; - minZ -= MASK_CELL_M * 2; - const cols = Math.ceil((maxX - minX) / MASK_CELL_M) + 3; - const lines = Math.ceil((maxZ - minZ) / MASK_CELL_M) + 3; - const mask = new Uint8Array(cols * lines); - for (const p of marks) { - const c = Math.floor((p.x - minX) / MASK_CELL_M); - const g = Math.floor((p.z - minZ) / MASK_CELL_M); - for (let dg = -1; dg <= 1; dg += 1) { - for (let dc = -1; dc <= 1; dc += 1) mask[(g + dg) * cols + c + dc] = 1; - } - } - return (p) => { - const c = Math.floor((p.x - minX) / MASK_CELL_M); - const g = Math.floor((p.z - minZ) / MASK_CELL_M); - return c >= 0 && g >= 0 && c < cols && g < lines && mask[g * cols + c] === 1; - }; -} - -function insideLoop(loop: ReadonlyArray, p: P2): boolean { - let hit = false; - for (let i = 0, j = loop.length - 1; i < loop.length; j = i, i += 1) { - const a = loop[i]; - const b = loop[j]; - if (a.z > p.z !== b.z > p.z && p.x < ((b.x - a.x) * (p.z - a.z)) / (b.z - a.z) + a.x) - hit = !hit; - } - return hit; -} - -/** - * 새 방식 지형 — 원거리 조각은 그대로, 밴드 조각만 다시 나눠 변형한다. 변형 장을 못 세우면 - * (노면부 행 부족 · 측점 단면 없음) null — 부르는 쪽이 옛 방식으로 그린다. - */ -export function deformTerrain( - split: TerrainBandSplit, - build: CorridorBuildResult, - bounds: ModelBounds, - heightIndex: TerrainHeightIndex, -): { root: THREE.Object3D; stats: DeformStats } | null { - const sections = build.sections; - if (!sections?.length) return null; - const structures = build.structures ?? []; - const ox = (bounds.x[0] + bounds.x[1]) / 2; - const oy = (bounds.y[0] + bounds.y[1]) / 2; - const oz = (bounds.z[0] + bounds.z[1]) / 2; - const groundAt = (x: number, y: number): number | null => { - const height = heightIndex.heightAt(x - ox, -(y - oy)); - return height === null ? null : height + oz; - }; - // 구조물 구간 = 실루엣 상부 포락선 + 이웃 측점 테이퍼 · 날개 안쪽 성토 금지(115-2). - const source = stationProfileSource(sections, structureSlopes(sections)); - const field = buildDeformField(build, source, groundAt, noFillLimits(sections, structures)); - if (!field) return null; - const toScene = scenePlanMapper(bounds); - // 지형 구멍 = 노면부 띠 + 구조물 윗면 외곽 · BOX 아령 외곽 + 관 입구 — 솔리드가 메운다. - const road = new RoadStrip(field.rows, bounds); - const groundAfter = (x: number, y: number): number | null => { - const ground = groundAt(x, y); - return ground === null ? null : field.deformZ(x, y, ground); - }; - const holes = [ - ...structureHoleLoops(sections, structures), - ...pipeMouthLoops(structures, groundAfter), - ] - .map((loop) => loop.map(([x, y]) => toScene(x, y))) - .filter((loop) => loop.length >= 3); - const closed = holes.map((loop) => { - const [first] = loop; - const last = loop[loop.length - 1]; - return first.x === last.x && first.z === last.z ? loop : [...loop, first]; - }); - const lines = [...deformBreaklines(field, toScene), ...closed]; - const boxes = holes.map((loop) => { - const xs = loop.map((p) => p.x); - const zs = loop.map((p) => p.z); - return [Math.min(...xs), Math.max(...xs), Math.min(...zs), Math.max(...zs)]; - }); - const topAt = structureTopAt(structures); - const stats: DeformStats = { - rows: field.rows.length, - holes: holes.length, - lines: lines.length, - split: 0, - fallback: 0, - moved: 0, - raised: 0, - lowered: 0, - maxRaiseM: 0, - maxCutM: 0, - }; - const context = { - lines, - removed: (p: P2): boolean => - road.contains(p) || - holes.some( - (loop, i) => - p.x >= boxes[i][0] && - p.x <= boxes[i][1] && - p.z >= boxes[i][2] && - p.z <= boxes[i][3] && - insideLoop(loop, p), - ), - touched: bandMask(field, toScene, holes), - deform: (x: number, y: number, z: number) => { - const mx = x + ox; - const my = -z + oy; - const at = field.deformAt(mx, my, y + oz); - const top = topAt(mx, my); - const out = top !== null && at.z > top ? top : at.z; - return { y: out - oz, zone: at.zone }; - }, - tint: (zone: 1 | 2): [number, number, number] => { - const color = zone === 1 ? FILL_COLOR : CUT_COLOR; - return [color.r, color.g, color.b]; - }, - }; - const root = new THREE.Group(); - root.name = "terrain-deformed"; - split.near.forEach((part) => { - if (part.points) { - // 포인트클라우드는 변형 대상이 아니다 — 분할본 그대로 얹는다. - const points = new THREE.Points(part.geometry, part.material as THREE.Material); - points.userData.owned = false; - root.add(points); - return; - } - const { geometry, keep } = retriangulateBand(part.geometry, context, stats); - const normal = part.geometry.getAttribute("normal") as THREE.BufferAttribute | undefined; - if (normal) { - // 법선은 원본 것만 물려받는다 — 새 정점은 위 방향(`_Clip.ts` 와 같은 규칙). - const count = geometry.getAttribute("position").count; - const normals = new Float32Array(count * 3); - normals.set((normal.array as Float32Array).subarray(0, normal.count * 3), 0); - for (let v = normal.count; v < count; v += 1) normals[v * 3 + 1] = 1; - geometry.setAttribute("normal", new THREE.BufferAttribute(normals, 3)); - } - const mesh = new THREE.Mesh(geometry, part.material); - mesh.userData.owned = true; - mesh.userData.keepColor = keep; - root.add(mesh); - }); - split.far.forEach((part) => { - const mesh = new THREE.Mesh(part.geometry, part.material); - mesh.userData.owned = false; - root.add(mesh); - }); - return { root, stats }; -} diff --git a/B05_Profile/Old/B05_Profile_UI_Corridor_Envelope.ts b/B05_Profile/Old/B05_Profile_UI_Corridor_Envelope.ts deleted file mode 100644 index 960302b92..000000000 --- a/B05_Profile/Old/B05_Profile_UI_Corridor_Envelope.ts +++ /dev/null @@ -1,331 +0,0 @@ -/* ============================================================================= - * B05_Profile_UI_Corridor_Envelope.ts - * 코리도 저장본 형식 — 버전·해시·직렬화 한 벌. **브라우저와 서버가 같은 것을 쓴다.** - * - * 서버(전처리 체인 끝)가 같은 빌더로 코리도를 미리 만들어 저장하므로(2026-09-04 사용자 - * 확정), 저장 형식과 버전 해시가 두 벌이 되면 브라우저가 서버 저장본을 남으로 보고 - * 통째로 다시 만든다. 그래서 이 파일 하나만 둔다 — 화면 API 를 쓰지 않아 Node 에서도 - * 그대로 돈다(`btoa`·`atob` 는 Node 16+ 전역). - * - * 원래 `_UI_Corridor.ts` 안에 있던 것을 그대로 옮겼다(2026-09-04 분리, 내용 불변). - * ========================================================================== */ - -import type { SectionDetailResponse } from "../B06_Section/B06_Section_Api_Fetch"; -import type { RoutePoint } from "./B05_Profile_Api_Fetch"; -import type { CorridorBuildResult, CorridorRibbon } from "./B05_Profile_UI_Corridor_Build_Types"; -import { structureHashParts } from "./B05_Profile_UI_Corridor_Structures"; - -/** - * 저장본 **형식(스키마)** 버전 — 담는 필드가 바뀔 때만 올린다. - * - * 기하 계산이 바뀐 것은 여기가 아니라 아래 `BUILD_VERSION`으로 만료시킨다. - * - * 3 = 리본에 셀 마스크·축 고정 커브 조각 삼각형 추가 — 2026-08-26. - * 4 = 절취 측벽 서피스(`cutWalls`) 추가 — 2026-08-27. - * 5 = 측벽에서 빗금(UV)을 뺀다 — 벽은 방향성을 표현하지 않는다(2026-08-27 사용자). - * 6 = 리본의 `patch`·`patchClip` 표식을 담는다 — 안 담아서 저장본으로 다시 연 코리도가 - * 패치인 줄 모르고 지형 스냅에 끌려갔다(2026-09-02). - */ -export const ENVELOPE_VERSION = 6; - -export interface CorridorEnvelope { - version: number; - hash: string; - ribbons: Array<{ - kind: CorridorRibbon["kind"]; - side: CorridorRibbon["side"]; - colCount: number; - chainages: number[]; - positionsBase64: string; - /** - * 구조물 패치 리본 표식(2026-09-02) — 빌드 때만 쓰는 값이 아니라 **뷰어가 그릴 때도 - * 본다**(`B05_Profile_UI_Viewer.ts` 지형 스냅 제외). 안 담으면 저장본으로 다시 연 - * 코리도가 패치인 줄 몰라 바깥 끝이 원지반까지 끌려 내려간다. - */ - patch?: boolean; - patchClip?: boolean; - /** 셀 마스크(2026-08-26 투영 커브 안쪽 지우기) — 없으면 구멍 없는 리본. */ - cellMaskBase64?: string; - /** 축 고정 커브가 걸친 셀의 조각 삼각형·UV(2026-08-26). */ - trimTrisBase64?: string; - trimUvsBase64?: string; - }>; - outline: CorridorBuildResult["outline"]; - /** 시·종점 마구리(구 저장본엔 없음 — 없으면 빈 배열로 복원). */ - caps?: CorridorBuildResult["caps"]; - /** 배수관 세트 구조물(2026-08-23 — 구 저장본엔 없음, 없으면 빈 배열). */ - structures?: CorridorBuildResult["structures"]; - /** - * 구조물 세트별 탑뷰 투영 커브(2026-08-26) — 저장본에도 담는다. 안 담으면 - * 저장본으로 복원한 코리도에서는 커브가 통째로 사라진다. - */ - planCurves?: CorridorBuildResult["planCurves"]; - /** 절취 측벽 서피스(2026-08-27) — 구 저장본엔 없다. 없으면 빈 배열로 복원. */ - cutWalls?: Array<{ - side: "left" | "right"; - positionsBase64: string; - }>; -} - -/** FNV-1a 32bit — 설계 입력 요약 문자열의 버전 해시. */ -function fnv1a(text: string): string { - let hash = 0x811c9dc5; - for (let i = 0; i < text.length; i += 1) { - hash ^= text.charCodeAt(i); - hash = Math.imul(hash, 0x01000193); - } - return (hash >>> 0).toString(16).padStart(8, "0"); -} - -/** - * 빌드 로직 판번호 — **기하를 바꾸는 수정을 하면 반드시 올린다.** - * - * 해시는 종횡단 입력만 요약하므로, 빌드 코드를 고쳐도 입력이 그대로면 해시가 같아 - * 옛 저장본을 그대로 다시 쓴다 — 코드를 고쳤는데 화면이 안 바뀌는 일이 생긴다 - * (2026-08-23 사용자 보고: "원복이 안 된 것 같다가 갑자기 반영됨"). 판번호를 해시에 - * 섞어 두면 배포와 동시에 저장본이 만료된다. - */ -// 42 = 탑뷰 투영선(빨간 선)·구조물 구간 경계 행·파란 스케치 안쪽 기준 정정· -// 투영 커브 안쪽 성토면 절단 — 커브 다각형 기준(2026-08-26). -// ※ 40에 머물러 있는 동안 기하 수정 3회가 통째로 묻혔다 — 저장본이 그대로 -// 복원되어 화면·데이터가 하나도 안 바뀌었다. 기하를 고치면 **반드시** 올릴 것. -// 56 = 측벽에서 **노견 쪽·성토 끝단 쪽 리본 경계 벽을 뺀다** — 거기서 잘린 게 -// 아니라 리본이 끝난 자리다(2026-08-27 사용자). -// 64 = 변형 성토면(패치) 마감 3종 — ① 행별 지반 절단(빨간 교차선에서 끝냄) + -// 바깥 끝 수직 스커트, ② 날개 사다리꼴 몫만큼 패치 행 범위 확장(BOX는 축 고정), -// ③ 구조물 안착 자리 원지반 재노출(원본 성토 스케치 − 구조물 ∪ 변형 성토) -// (2026-08-27 사용자). -// 65 = 날개벽 평면 스케치까지 변형 성토면에 포함 — 늘린 행에 벽 트림을 걸지 않는다 -// (2026-08-27 사용자). -// 66 = 패치 행을 그 행 지반선과 직접 교차시켜 **자르고 연장**한다 — 날개 사다리꼴 -// 바깥변(원본 성토 끝선)까지 성토면이 이어진다(2026-08-27 사용자). -// 67 = (주석만 앞서 나갔던 번호 — 실제로 들어간 것은 66까지다. 2026-08-27 확인) -// 68 = 패치 스윕 범위를 **주황 날개 평면 스케치(`wing-box`)까지** 넓힌다 — 행별 지반 -// 핀(절단·연장)과 스커트는 그대로 두고, 스케치가 더 바깥일 때만 그 행을 설계 -// 성토 물매로 스케치 경계까지 덧이어 낸다(2026-08-27 사용자: 그 자리에 변형 -// 성토면이 있어야 한다 — 삭제가 아니다). -// 69 = 날개 스케치 확장에서 **BOX암거는 제외**한다 — 날개벽은 세월교·BOX 두 곳에서 -// 쓰이지만 로직이 분리돼 있고, BOX는 이미 채워져 있어(구멍 5~12%) 손대면 행 폭이 -// 0.74 → 4.53m로 뛴다(2026-08-27 사용자: "박스 암거는 로직 원복"). -// 70 = 세월교 날개 사다리꼴 A안 — ① 종방향 범위를 **노선 실투영**으로 잰다 -// (`wingSpanOnRoute`, 직선 투영은 곡선+먼 편거리에서 3m 어긋났다), -// ② 행 편거리를 스케치의 **안쪽·바깥쪽 끝**까지 늘린다(`wingRangeAt`, -// 좌측 고리는 패치보다 1.5m 안쪽에 있었다). BOX암거는 둘 다 제외 — 종전 그대로. -// 71 = 안쪽 확장을 **세월교 바닥판 상단 Z(`slabTopElevation`)로 트림**한다 — 성토 -// 물매 역연장이 유로 위로 솟아 바닥 상단과 안 만나는 자리로 튀었다(2026-08-27 사용자). -// 72 = 날개 스윕 범위를 **날개벽 끝점 횡단선**까지로 바꾼다 — 고리 전체가 아니라 -// 날개벽 선 두 끝을 노선에 실투영해 재고, 구조물 점유 구간 ±5m 여유 안에서 -// 트림한다. 고리와 안 만나는 중간 행도 더는 끊지 않는다(2026-08-27 사용자: -// "5m 늘려 서피스 만들고 날개벽 끝점 횡단선으로 트림"). -// 73 = ① 패치 점 중 **세월교 바닥판 상단 Z를 넘는 것은 지운다**(`clipRunBelow`, -// 종단 shift 반영·천장 교차점 삽입), ② 날개 스팬을 다시 **고리 전체 점**으로 -// 잰다 — 벽 끝점만 쓰면 좌측이 143.2~157.3 → 144.4~155.9로 줄었다 -// (2026-08-27 사용자: "좌측 스윕 더 가야 함"). -// 74 = (날개 몫 행 트림 시도 — 사용자 지시로 **원복**. 안쪽까지 자르면 구조물 구간과 -// 날개 사이가 벌어졌고, 바깥선만 자르는 판은 버전을 안 올려 화면에 못 올라갔다.) -// 75 = 73 상태로 원복 — 트림 없음. 날개 몫 행은 지반 핀 + 스케치 확장 + 바닥 상단 -// Z 초과 삭제까지만 한다(2026-08-27 사용자: "2번 전으로"). -// (76~78의 트림 도구 `wingOuterLines`·`wingTrimAt`·`clipRunToRange` 는 2026-09-02에 -// 제거했다 — 원복 뒤 다시 배선되지 않아 참조가 0이었다. 아래는 이력으로만 남긴다.) -// 76 = 날개 몫 행을 **주황 고리의 바깥선**에서 끝낸다(`wingOuterLines`+`wingTrimAt`). -// 고리가 그 행까지 모자라면 **끝 마디를 복사해 20m 연장**해서 만나게 한다 -// (2026-08-27 사용자). 안쪽은 안 자른다. -// 77 = 주황 바깥선 트림을 **모든 행**에 건다 — 구조물 점유 구간 안 행을 빼놨더니 -// 그 구간이 커브 밖으로 삐져나왔다. 자르는 게 아니라 커브를 새 바깥 끝으로 -// 삼아 줄을 다시 잡고 `resample`이 열을 그 위에 다시 깔아 **메시가 재배치**된다 -// (2026-08-27 사용자: "메시의 재배치가 필요하잖아, 그래야 잘리지"). -// 78 = 날개 몫 로직을 **구조물 종류로 가른다**(2026-08-27 사용자: "세월교, 박스암거, -// 기슭막이") — `section.ford`일 때만 실투영 스팬·스케치 확장·바깥선 트림을 태우고 -// BOX암거·기슭막이는 종전 경로 그대로. 트림선은 **날개 하나 단위**로 나눠 행마다 -// 누가거리가 가장 가까운 날개 것만 쓴다(20m 연장선이 서로 먹던 것을 막는다). -// 79 = 세월교 패치를 **절취 영역 셀 마스크의 반대 규칙**으로 클립한다 — 영역 밖 셀은 -// 지우고 걸친 셀은 안쪽 조각만 남긴다(`patchClip`). 행 단위 바깥선 트림은 폐기 — -// 비스듬한 사다리꼴 경계를 행 끝 하나로는 못 맞춰 74셀이 삐져나왔다(실측). -// 지우는 면과 채우는 면이 같은 경계를 공유한다(2026-08-27 사용자). -// 80 = 배수관 세트(기슭막이) **노견 → 벽 이음선 상단 성토 사면**을 되메움 리본 배열 -// (`fillRuns`)에도 담는다 — 78/79의 포켓은 노견까지 파는데 79까지의 배열에는 -// 벽 아래 다단·집수정 성토선만 있어 **기슭막이 상단부가 통째로 비었다** -// (2026-08-27 사용자). 새 면이 아니라 실루엣이 이미 쓰던 `designTrim` 사면선 -// 그대로다. 벽 루프·집수정 분기가 같은 선을 담으므로 측당 한 번만 등록한다. -// 81 = `slope-deformed` 커브를 **패치 리본과 같은 규칙**으로 끝낸다 — 실루엣 바깥 -// 끝이 그 측점 지반에 안 닿으면(BOX암거: 구체 최상단) 외삽하지 않고 실루엣이 -// 끝나는 자리를 바깥 끝으로 쓴다. 종전에는 이 커브만 무조건 최대 30m 외삽해 -// 성토 끝선까지 갔고, BOX는 기본값에서 변형 성토선이 원본 성토선과 정확히 -// collinear가 되어 `원본 − (구조물 ∪ 변형)` = ∅ — **구체 밖 원지반 재노출이 -// 통째로 미실행**이었다(2026-08-27 사용자 확정: "구체 밖은 원지반이어야 한다"). -// 실루엣이 구조물에서 끝나는지는 기하로 재지 않고 `endsAtStructure` 표식으로 -// 가른다 — 낙차로 재면 기슭막이 유출측처럼 지반보다 바깥으로 나간 실루엣까지 -// 걸려 원래 짧게 잘리던 것이 안 잘린다(실측 84.3 우측 5.10 → 5.69m). -// 아울러 BOX 날개 사다리꼴(`wing-box`)을 원지반 재노출 후보에 넣는다 — 절취 -// 소스에는 있고 되살릴 목록에는 없어 그 자리가 성토면도 지반도 없는 구멍이었다 -// (2026-08-27 사용자: "날개부의 평면 스케치 영역까지 원지반이 같이 노출"). -// 세월교는 제외 — 그 자리는 되메움 패치가 덮는다(`wingRangeAt`, isFord 전용). -// 82 = BOX암거 `structure` 탑뷰 커브를 부재별 7고리가 아니라 **아령 외곽선 한 줄**로 -// 낸다(2026-08-27 사용자: "바닥을 위에서 보면 아령형상이고 제일 외곽에 있는 선 -// 성분을 평면 투영하면 경계"). 상판+에이프런에 `planOutline` 표식을 달고 -// `_Plan_Footprint.ts`가 링 자리마다 편거리 창을 합친다 — 기하 불리언 없음 -// (BOX 부재는 전부 같은 고정축 위 평행 링이다). 종전 부재별 고리에는 날개벽이 -// **패널 두께 슬리버**로 섞여 있었고(실측 0.40㎡ / bbox 1.69×1.40 ×2), 그 슬리버가 -// "덮인 자리"로 빠져 그 자리만 성토면도 지형도 없는 검은 줄이 됐다. 에이프런은 -// 날개벽 발치를 얹으려고 넓혀 둔 부재라 외곽선이 날개를 이미 품는다. -// 83 = 원지반 재노출 영역을 **`cut-merged`**(성토면에서 실제로 도려낸 셀의 윤곽)로 -// 바꾼다. 종전에는 `원본 성토 스케치 − (구조물 ∪ 변형 성토 스케치)`로 영역을 -// **다시 조립**해, 지우는 쪽과 되살리는 쪽이 다른 커브를 쓰며 경계가 어긋났다 -// (날개벽 패널 두께 슬리버 자리에 검은 줄). 이제 지우는 쪽이 남긴 자국을 그대로 -// 쓰므로 **원본 지형 메시가 그 자리에 그대로 남는다** — 새 서피스를 만들지 않는다 -// (2026-08-27 사용자: "원지반 오리지널 데이터에서 가져오라니까"). -// 84 = 잘린 자리 윤곽(`cut-merged`) 추적을 고친다 — 갈림목에서 아무 변이나 집어 -// walk가 남의 고리로 새 나가고, 막다른 길에서 끊긴 **열린 사슬이 고리로 담겼다**. -// 표시용일 땐 안 보였지만 이제 이 커브가 되살릴 영역의 근거라 그대로 구멍이 된다 -// (실측: 도려낸 셀의 18% — 성토 끝단 쪽 col 40~46 — 이 영역에서 빠졌다. -// 2026-08-27 사용자: "유출구에 일부 성토부에 터짐 있음"). 오른손 규칙으로 갈림목을 -// 가르고 **닫힌 고리만** 담는다. -// 85 = 물넘이포장 자리 노면을 판다 — 횡단도와 같은 산식(fordDeckElevationAt)으로 노면·노견 -// 조각 표고를 내린다. 노면 밖 비탈은 그대로라 노견 끝에 수직 단차가 선다(2026-08-28 사용자). -// 86 = 독립 기슭막이(구조물 정본 D군)를 3D에 세운다 — 횡단 카드와 같은 폴리곤 -// (computeRevetmentLayout)을 기준측점 전/후 길이만큼 스윕한다(2026-08-28 사용자). -// 87 = 독립 기슭막이 **다단** — 단마다 솔리드를 세운다. 단 사이 성토는 1:1.2, -// 다음 단 시작점은 윗단 하단 수평선 +근입과 전면 경사선의 교차점(2026-08-22 규칙 승계). -// 88 = 독립 기슭막이 기준을 **맨 위 단**으로 되돌리고 추가 단은 아래로 붙인다(배관 세트와 -// 같은 규칙). 기준 자리는 사용자 이동값(기준 올림·좌우)을 탄다(2026-08-28 사용자). -// 89 = BOX암거 패치도 **절취 영역 안쪽으로 자른다**(2026-09-02). 그 규칙이 세월교에만 -// 걸려 있어 BOX 패치는 한 번도 안 잘리고 영역 밖으로 넘쳤다 — 실측에서 BOX 패치 -// 리본 두 벌(198.4~203.5m)에 셀 마스크가 없었다. BOX도 `wing-box` 커브로 절취 -// 영역을 내므로 같은 반대 규칙이 성립한다. -// 90 = 잘린 자리 윤곽(`cut-merged`)의 **시작 모서리 유실**을 고친다 — `dropCollinear`가 -// 고리의 닫힘 중복점(머리=꼬리)을 순환 이웃으로 셈해 머리 모서리를 지우고, 그 양옆 -// 일직선 점까지 빠져 첫 행이 대각선으로 잘렸다(실측: 8고리 전부, 63셀 구멍 — -// 우 197.6~198.2m BOX 앞 쐐기 33셀이 가장 큼). 되살릴 영역이 이 커브라 저장본을 만료한다. -// 91 = 구조물 솔리드에 **부재키**(`key`)를 싣는다 — 3D 개별 선택이 그 표로 부재를 -// 가려낸다(2026-09-04). 옛 저장본엔 키가 없어 3D에서 골라도 B06 조정창으로 넘길 -// 부재를 못 정하므로 만료한다. -// 92 = 세월교·BOX암거 부재까지 **부재키를 일원화**한다 — 횡단도가 부르는 이름 그대로 -// (세월교 유입·유출, BOX 좌·우, 본체는 `body`). 91 저장본엔 배수관 세트 키만 있다. -// 93 = 측점 사이 행 자리를 **노선 길이 직선 보간**으로(115-5 P0-2) — 균일 Catmull-Rom 이 -// 행을 최대 7.2 m 미끄러뜨리고 되접었다. 모든 행 자리가 바뀌어 저장본을 만료한다. -const BUILD_VERSION = 93; - -/** 종횡단 정본에서 코리도에 영향을 주는 입력만 요약해 해시 — 갱신 감지 기준. */ -export function corridorHash(detail: SectionDetailResponse, routePoints: RoutePoint[]): string { - const parts: Array = [`v${BUILD_VERSION}`, routePoints.length]; - routePoints.forEach((p) => parts.push(p.x.toFixed(2), p.y.toFixed(2))); - detail.cross_sections.forEach((section) => { - const design = section.design; - parts.push( - section.chainage_m.toFixed(3), - section.center_x.toFixed(2), - section.center_y.toFixed(2), - ); - if (!design) { - parts.push("nodesign"); - return; - } - parts.push( - design.design_elevation_m.toFixed(3), - design.section_mode, - design.ditch_side ?? "", - design.ditch_type ?? "", - String(design.ditch_enabled ?? ""), - design.design_line.length, - ); - design.design_line.forEach((point) => - parts.push(point.offset_m.toFixed(3), point.elevation_m.toFixed(3)), - ); - // 구조물(배수관 세트) 입력 — 길이·전후·형식이 바뀌면 재빌드되어야 한다. - structureHashParts(section).forEach((part) => parts.push(part)); - }); - return fnv1a(parts.join("|")); -} - -function base64FromFloats(values: Float64Array): string { - const bytes = new Uint8Array(values.buffer, values.byteOffset, values.byteLength); - let binary = ""; - const CHUNK = 0x8000; - for (let i = 0; i < bytes.length; i += CHUNK) { - binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK)); - } - return btoa(binary); -} - -function base64FromBytes(bytes: Uint8Array): string { - let binary = ""; - const CHUNK = 0x8000; - for (let i = 0; i < bytes.length; i += CHUNK) { - binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK)); - } - return btoa(binary); -} - -function bytesFromBase64(encoded: string): Uint8Array { - const binary = atob(encoded); - const bytes = new Uint8Array(binary.length); - for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i); - return bytes; -} - -function floatsFromBase64(encoded: string): Float64Array { - const binary = atob(encoded); - const bytes = new Uint8Array(binary.length); - for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i); - return new Float64Array(bytes.buffer); -} - -export function serialize(build: CorridorBuildResult, hash: string): CorridorEnvelope { - return { - version: ENVELOPE_VERSION, - hash, - ribbons: build.ribbons.map((ribbon) => ({ - kind: ribbon.kind, - side: ribbon.side, - colCount: ribbon.colCount, - chainages: ribbon.chainages, - positionsBase64: base64FromFloats(ribbon.positions), - ...(ribbon.patch ? { patch: true } : {}), - ...(ribbon.patchClip ? { patchClip: true } : {}), - ...(ribbon.cellMask ? { cellMaskBase64: base64FromBytes(ribbon.cellMask) } : {}), - ...(ribbon.trimTris && ribbon.trimUvs - ? { - trimTrisBase64: base64FromFloats(ribbon.trimTris), - trimUvsBase64: base64FromBytes(new Uint8Array(ribbon.trimUvs.buffer.slice(0))), - } - : {}), - })), - outline: build.outline, - caps: build.caps, - structures: build.structures, - planCurves: build.planCurves, - cutWalls: (build.cutWalls ?? []).map((wall) => ({ - side: wall.side, - positionsBase64: base64FromFloats(wall.positions), - })), - }; -} - -export function deserialize(envelope: CorridorEnvelope): CorridorBuildResult { - return { - ribbons: envelope.ribbons.map((ribbon) => ({ - kind: ribbon.kind, - side: ribbon.side, - colCount: ribbon.colCount, - chainages: ribbon.chainages, - positions: floatsFromBase64(ribbon.positionsBase64), - ...(ribbon.patch ? { patch: true } : {}), - ...(ribbon.patchClip ? { patchClip: true } : {}), - ...(ribbon.cellMaskBase64 ? { cellMask: bytesFromBase64(ribbon.cellMaskBase64) } : {}), - ...(ribbon.trimTrisBase64 && ribbon.trimUvsBase64 - ? { - trimTris: floatsFromBase64(ribbon.trimTrisBase64), - trimUvs: new Float32Array(bytesFromBase64(ribbon.trimUvsBase64).buffer), - } - : {}), - })), - outline: envelope.outline, - caps: envelope.caps ?? [], - structures: envelope.structures ?? [], - planCurves: envelope.planCurves ?? [], - cutWalls: (envelope.cutWalls ?? []).map((wall) => ({ - side: wall.side, - positions: floatsFromBase64(wall.positionsBase64), - })), - }; -} diff --git a/B05_Profile/Old/B05_Profile_UI_Corridor_Legacy.ts b/B05_Profile/Old/B05_Profile_UI_Corridor_Legacy.ts deleted file mode 100644 index 6ff5b3a61..000000000 --- a/B05_Profile/Old/B05_Profile_UI_Corridor_Legacy.ts +++ /dev/null @@ -1,60 +0,0 @@ -/* ============================================================================= - * B05_Profile_UI_Corridor_Legacy.ts - * 3D 옛 방식 A(115-4 · 08-25 `6e87dd6cd` 「실루엣 갈아 끼우기」) — 비교용 빌드. - * - * 저장하지 않는다(저장본 · BUILD_VERSION · 해시 · 서버 사전생성 무관). [옛 방식 A] 단추를 - * 처음 켤 때 브라우저가 즉석으로 만들고 해시별로 메모한다. 입력은 본체 `refreshCorridor` - * 가 코리도를 뷰어에 넘길 때 그 빌드에 묶어 둔다. - * ========================================================================== */ - -import type { SectionDetailResponse } from "../B06_Section/B06_Section_Api_Fetch"; -import type { RoutePoint } from "./B05_Profile_Api_Fetch"; -import { buildCorridor, type CorridorBuildResult } from "./B05_Profile_UI_Corridor_Build"; -import { corridorHash } from "./B05_Profile_UI_Corridor_Envelope"; - -interface LegacyInputs { - detail: SectionDetailResponse; - routePoints: RoutePoint[]; - designSamples?: Array<{ chainage_m: number; elevation_m: number; ground_elevation_m?: number }>; - hash?: string; -} - -const inputsOf = new WeakMap(); -let memo: { hash: string; build: CorridorBuildResult | null } | null = null; - -/** 본체 코리도 빌드에 옛 방식 A 입력을 묶어 둔다(메모리만). */ -export function rememberLegacyInputs(build: CorridorBuildResult, inputs: LegacyInputs): void { - inputsOf.set(build, inputs); -} - -function hashOf(inputs: LegacyInputs): string { - inputs.hash ??= corridorHash(inputs.detail, inputs.routePoints); - return inputs.hash; -} - -/** 이 빌드의 옛 방식 A 가 이미 만들어져 있는가(입력이 없으면 만들 것도 없다). */ -export function legacyReady(build: CorridorBuildResult): boolean { - const inputs = inputsOf.get(build); - return !inputs || memo?.hash === hashOf(inputs); -} - -/** 옛 방식 A 빌드 — 해시가 같으면 메모를 쓴다. 입력이 없거나 빌드 실패면 null. */ -export function legacyCorridorOf(build: CorridorBuildResult): CorridorBuildResult | null { - const inputs = inputsOf.get(build); - if (!inputs) return null; - const hash = hashOf(inputs); - if (memo?.hash !== hash) { - const { detail, routePoints, designSamples } = inputs; - memo = { - hash, - build: buildCorridor( - detail.cross_sections, - routePoints, - designSamples ?? detail.longitudinal.design_profiles?.[0]?.samples, - undefined, - { legacy: true }, - ), - }; - } - return memo.build; -} diff --git a/B05_Profile/Old/B05_Profile_UI_Corridor_Mesh.ts b/B05_Profile/Old/B05_Profile_UI_Corridor_Mesh.ts deleted file mode 100644 index 855fd5899..000000000 --- a/B05_Profile/Old/B05_Profile_UI_Corridor_Mesh.ts +++ /dev/null @@ -1,538 +0,0 @@ -/* ============================================================================= - * B05_Profile_UI_Corridor_Mesh.ts - * 코리도 리본(모델 좌표 격자) → Three.js 메쉬 그룹. - * - * 종류별 색으로 구분된 멀티 서피스 조합(2026-08-23 계획) — 하나의 서피스가 - * 아니라 차도/노견/측구/절토비탈/성토비탈 리본을 개별 Mesh로 만든다. - * 정점은 뷰어 공통 규약(modelToScene)으로 씬 좌표(Y-up, bounds 중심 원점)로 - * 변환해 담는다. - * - * 품질 4종(2026-08-23 사용자 확정): ① 지형 고도색(주황~녹)과 안 겹치는 색 - * 재설계 ② 종류 경계 윤곽선 ③ 절·성토 빗금(해칭) 텍스처 ④ 시·종점 마구리 봉인. - * ========================================================================== */ - -import * as THREE from "three"; -import type { ModelBounds } from "./B05_Profile_UI_Markers"; -import type { - CorridorBuildResult, - CorridorCap, - CorridorKind, - CorridorRibbon, - CorridorStructure, - PlanCurve, -} from "./B05_Profile_UI_Corridor_Build"; - -/** - * 종류별 색 — 지형은 주황~황~녹 고도 램프라 그 대역을 피한다. - * 도로계는 무채색(회), 측구는 청, 절토는 자주(마젠타 계열), 성토는 청록. - * 절·성토를 적갈/초록으로 두면 지형색에 묻혀 구분이 안 됐다(2026-08-23 보고). - */ -const KIND_COLORS: Record = { - carriageway: 0x3f4451, - shoulder: 0x9aa2b1, - ditch: 0x38bdf8, - cut: 0xb5179e, - fill: 0x06b6d4, -}; - -/** 탑뷰 투영 커브 색(2026-08-26 사용자) — 종류별. 지형·코리도 색과 안 겹친다. */ -const PLAN_CURVE_COLORS: Record = { - structure: 0xffd93d, - "slope-deformed": 0xff2d78, - "slope-original": 0x38bdf8, - // 날개벽 성토끝 박스 — 구조물 노랑과 구분되게 주황(2026-08-26 사용자). - "wing-box": 0xff8c1a, - // 스케치 파란 커브를 절·성토면에 얹은 투영선 — 빨강(2026-08-26 사용자 지정). - "slope-projected": 0xff3b30, - // 실제로 잘린 자리 윤곽(2026-08-27 사용자) — 새 데이텀(+3m)에 얹어 라임으로 낸다. - "cut-merged": 0x84cc16, - // 측벽이 설 자리 — 벽 서피스로만 쓰고 선으로는 안 그린다(아래에서 걸러진다). - "cut-wall": 0x000000, -}; - -/** - * 평면 스케치(투영 커브) 묶음의 이름 — 뷰어가 이 이름으로 찾아 켜고 끈다. - * - * **최종 결과물에서는 숨긴다**(2026-09-02 사용자). 이 커브들은 구조물이 어디를 지우고 - * 어디를 다시 채우는지 눈으로 좇으려고 만든 것이라 설계 산출물이 아니다. 다만 절취·패치 - * 기하를 다시 들여다볼 때 쓸모가 있어 **지우지 않고 감춰만 둔다**. - */ -export const PLAN_CURVE_GROUP = "corridor-plan-sketch"; - -/** 평면 스케치를 켜 둔 브라우저인가 — 기본은 꺼짐. 디버깅용 스위치다. */ -export const PLAN_CURVE_FLAG = "frd_debug_plan_curves"; - -export function planCurvesVisible(): boolean { - try { - return window.localStorage.getItem(PLAN_CURVE_FLAG) === "1"; - } catch { - return false; // 저장소가 막힌 환경(사생활 보호 모드 등) — 꺼진 것으로 본다. - } -} - -/** 유출측 파선 눈금(m) — 유입은 실선이다. */ -const PLAN_DASH_M = { dashSize: 0.6, gapSize: 0.4 }; - -/** 마구리(시·종점 절단면) 색 — 절단면임이 드러나게 어둡게. */ -const CAP_COLOR = 0x2a2f3a; - -/** 종류 경계 윤곽선 색·굵기 — 도면처럼 종류 경계를 또렷하게. */ -const OUTLINE_COLOR = 0x0f172a; - -/** 빗금(해칭) 방향 — 절토는 우상향, 성토는 좌상향으로 방향까지 구분한다. */ -const HATCH_DIRECTION: Partial> = { cut: 1, fill: -1 }; - -/** 지형과의 z-fight 방지용 폴리곤 오프셋 — 코리도가 항상 살짝 앞에 그려진다. */ -const POLYGON_OFFSET_FACTOR = -1; - -/** 빗금 텍스처 캐시 — 리본마다 캔버스를 새로 그리면 편집 반복 시 낭비다. */ -const hatchCache = new Map(); - -/** 대각 빗금 캔버스 텍스처(반복). direction=1 우상향, -1 좌상향. */ -function hatchTexture(direction: 1 | -1): THREE.Texture { - const cached = hatchCache.get(direction); - if (cached) return cached; - const size = 32; - const canvas = document.createElement("canvas"); - canvas.width = size; - canvas.height = size; - const context = canvas.getContext("2d"); - if (context) { - context.fillStyle = "#ffffff"; - context.fillRect(0, 0, size, size); - context.strokeStyle = "rgba(0,0,0,0.42)"; - context.lineWidth = 3; - // 타일 경계에서 끊기지 않도록 3줄(−size, 0, +size)을 겹쳐 긋는다. - for (const shift of [-size, 0, size]) { - context.beginPath(); - if (direction > 0) { - context.moveTo(shift, size); - context.lineTo(shift + size, 0); - } else { - context.moveTo(shift, 0); - context.lineTo(shift + size, size); - } - context.stroke(); - } - } - const texture = new THREE.CanvasTexture(canvas); - texture.wrapS = THREE.RepeatWrapping; - texture.wrapT = THREE.RepeatWrapping; - hatchCache.set(direction, texture); - return texture; -} - -interface SceneOrigin { - cx: number; - cy: number; - cz: number; -} - -function originOf(bounds: ModelBounds): SceneOrigin { - return { - cx: (bounds.x[0] + bounds.x[1]) / 2, - cy: (bounds.y[0] + bounds.y[1]) / 2, - cz: (bounds.z[0] + bounds.z[1]) / 2, - }; -} - -function ribbonGeometry(ribbon: CorridorRibbon, origin: SceneOrigin): THREE.BufferGeometry { - const rowCount = ribbon.chainages.length; - const cols = ribbon.colCount; - const positions = new Float32Array(rowCount * cols * 3); - // UV — u는 종방향 누가거리(m), v는 횡방향 열 비율. 빗금이 실척으로 반복된다. - const uvs = new Float32Array(rowCount * cols * 2); - const startChainage = ribbon.chainages[0]; - for (let row = 0; row < rowCount; row += 1) { - for (let col = 0; col < cols; col += 1) { - const i = row * cols + col; - const x = ribbon.positions[i * 3]; - const y = ribbon.positions[i * 3 + 1]; - const z = ribbon.positions[i * 3 + 2]; - positions[i * 3] = x - origin.cx; - positions[i * 3 + 1] = z - origin.cz; - positions[i * 3 + 2] = -(y - origin.cy); - uvs[i * 2] = (ribbon.chainages[row] - startChainage) / 2; // 2m마다 1타일. - uvs[i * 2 + 1] = col / Math.max(1, cols - 1); - } - } - const indices: number[] = []; - // 셀 마스크가 선 자리는 삼각형을 빼 그 자리를 비운다 — 투영 커브 안쪽 지우기 - // (2026-08-26). 정점은 그대로 두므로 남는 면의 형상은 하나도 안 바뀐다. - const mask = ribbon.cellMask; - for (let row = 0; row < rowCount - 1; row += 1) { - for (let col = 0; col < cols - 1; col += 1) { - if (mask?.[row * (cols - 1) + col]) continue; - const a = row * cols + col; - const b = a + 1; - const c = a + cols; - const d = c + 1; - indices.push(a, c, b, b, c, d); - } - } - // 축 고정 커브가 걸친 셀은 격자로 못 담는다 — 잘라 되살린 조각 삼각형을 뒤에 붙인다. - const trim = ribbon.trimTris; - const trimCount = trim ? trim.length / 3 : 0; - const allPositions = new Float32Array((rowCount * cols + trimCount) * 3); - const allUvs = new Float32Array((rowCount * cols + trimCount) * 2); - allPositions.set(positions); - allUvs.set(uvs); - if (trim) { - const base = rowCount * cols; - for (let v = 0; v < trimCount; v += 1) { - allPositions[(base + v) * 3] = trim[v * 3] - origin.cx; - allPositions[(base + v) * 3 + 1] = trim[v * 3 + 2] - origin.cz; - allPositions[(base + v) * 3 + 2] = -(trim[v * 3 + 1] - origin.cy); - allUvs[(base + v) * 2] = ribbon.trimUvs?.[v * 2] ?? 0; - allUvs[(base + v) * 2 + 1] = ribbon.trimUvs?.[v * 2 + 1] ?? 0; - if (v % 3 === 2) indices.push(base + v - 2, base + v - 1, base + v); - } - } - const geometry = new THREE.BufferGeometry(); - geometry.setAttribute("position", new THREE.BufferAttribute(allPositions, 3)); - geometry.setAttribute("uv", new THREE.BufferAttribute(allUvs, 2)); - geometry.setIndex(indices); - geometry.computeVertexNormals(); - return geometry; -} - -/** 리본 양 가장자리(종류 경계) 선 — 도면처럼 경계를 또렷하게 한다. */ -function ribbonOutline(ribbon: CorridorRibbon, origin: SceneOrigin): THREE.BufferGeometry | null { - const rowCount = ribbon.chainages.length; - const cols = ribbon.colCount; - if (rowCount < 2) return null; - const points: number[] = []; - const sceneAt = (row: number, col: number): [number, number, number] => { - const i = (row * cols + col) * 3; - return [ - ribbon.positions[i] - origin.cx, - // 지형·서피스 위로 살짝 띄워 z-fight로 점선처럼 깜빡이는 것을 막는다. - ribbon.positions[i + 2] - origin.cz + 0.02, - -(ribbon.positions[i + 1] - origin.cy), - ]; - }; - const push = (row: number, col: number): void => { - points.push(...sceneAt(row, col)); - }; - for (const col of [0, cols - 1]) { - for (let row = 0; row < rowCount - 1; row += 1) { - push(row, col); - push(row + 1, col); - } - } - if (points.length < 6) return null; - return new THREE.BufferGeometry().setAttribute( - "position", - new THREE.Float32BufferAttribute(points, 3), - ); -} - -/** 마구리(시·종점 절단면) — 설계선과 지반선 사이를 세로로 잇는 띠. */ -function capGeometry(cap: CorridorCap, origin: SceneOrigin): THREE.BufferGeometry | null { - if (cap.points.length < 2) return null; - const count = cap.points.length; - const positions = new Float32Array(count * 2 * 3); - cap.points.forEach(([x, y, zDesign, zGround], index) => { - const sceneX = x - origin.cx; - const sceneZ = -(y - origin.cy); - positions[index * 6] = sceneX; - positions[index * 6 + 1] = zDesign - origin.cz; - positions[index * 6 + 2] = sceneZ; - positions[index * 6 + 3] = sceneX; - positions[index * 6 + 4] = zGround - origin.cz; - positions[index * 6 + 5] = sceneZ; - }); - const indices: number[] = []; - for (let i = 0; i < count - 1; i += 1) { - const a = i * 2; - indices.push(a, a + 1, a + 2, a + 1, a + 3, a + 2); - } - const geometry = new THREE.BufferGeometry(); - geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3)); - geometry.setIndex(indices); - geometry.computeVertexNormals(); - return geometry; -} - -/** 구조물 모서리 추출 임계각(도) — 이보다 꺾인 모서리만 검은선으로 긋는다. - * 스윕 분할선(거의 평평한 이음)은 걸러지고 단면 모서리·마구리만 남는다. */ -const STRUCTURE_EDGE_ANGLE_DEG = 18; - -/** 배수관 세트 구조물 색 — 기슭막이(돌빛 황갈), 집수정(콘크리트 회청), 배관(강관 청). */ -const STRUCTURE_COLORS: Record = { - revet: 0xc9a86a, - basin: 0x8d93a3, - pipe: 0x4f83cc, -}; - -/** 단면 폴리곤(offset/표고)을 스윕 프레임 열에 앉혀 노선을 따라 로프트한 솔리드 - * (2026-08-23 사용자 — 직선 압출이 아니라 노선 스윕). */ -function structureLoftGeometry( - structure: CorridorStructure, - origin: SceneOrigin, -): THREE.BufferGeometry | null { - const rings = structure.rings; - // 링별 폴리곤이 오면 그것을, 아니면 공통 폴리곤 하나를 모든 링에 쓴다(2026-08-25). - const perRing = structure.polygons; - const polygon = perRing?.[0] ?? structure.polygon; - if (!polygon || polygon.length < 3 || !rings || rings.length < 2) return null; - if (perRing && perRing.length !== rings.length) return null; - const count = polygon.length; - const ringCount = rings.length; - const positions = new Float32Array(ringCount * count * 3); - rings.forEach((frame, ring) => { - (perRing?.[ring] ?? polygon).forEach(([offset, elevation], index) => { - const x = frame.cx + frame.leftX * offset; - const y = frame.cy + frame.leftY * offset; - const i = (ring * count + index) * 3; - positions[i] = x - origin.cx; - // 종단 경사 — 프레임마다 계획고 차이를 얹는다(2026-08-23 사용자). - positions[i + 1] = elevation + frame.dz - origin.cz; - positions[i + 2] = -(y - origin.cy); - }); - }); - const indices: number[] = []; - const lastBase = (ringCount - 1) * count; - for (let i = 1; i < count - 1; i += 1) { - indices.push(0, i, i + 1); // 앞 마구리(부채꼴 — 단면 폴리곤은 볼록 사각형이다) - indices.push(lastBase, lastBase + i + 1, lastBase + i); // 뒷 마구리(반대 감김) - } - for (let ring = 0; ring < ringCount - 1; ring += 1) { - const a0 = ring * count; - const b0 = a0 + count; - for (let i = 0; i < count; i += 1) { - const j = (i + 1) % count; - indices.push(a0 + i, b0 + i, a0 + j, a0 + j, b0 + i, b0 + j); - } - } - const geometry = new THREE.BufferGeometry(); - geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3)); - geometry.setIndex(indices); - // 면마다 자기 법선(flat) — 인덱스를 공유한 채 평균 법선을 쓰면 모서리가 뭉개져 - // 구조물이 찌그러져 보인다(2026-08-23 사용자 지적: 엣지를 살릴 것). - const flat = geometry.toNonIndexed(); - geometry.dispose(); - flat.computeVertexNormals(); - return flat; -} - -/** - * 배관 — 관 하단 시·끝점(횡단면 안)을 축으로 하는 **중공 튜브**(2026-08-25 사용자: - * 내부 유로 반영). 바깥·안쪽 원통을 열린 채로 겹치고 양 끝을 고리로 막는다. - */ -function structurePipeMesh( - structure: CorridorStructure, - origin: SceneOrigin, -): THREE.Object3D | null { - const pipe = structure.pipe; - const frame = structure.frame; - if (!pipe || !frame) return null; - const { cx, cy, leftX, leftY } = frame; - const toScene = ([offset, elevation]: [number, number]): THREE.Vector3 => - new THREE.Vector3( - cx + leftX * offset - origin.cx, - elevation - origin.cz, - -(cy + leftY * offset - origin.cy), - ); - const start = toScene(pipe.start); - const end = toScene(pipe.end); - const axis = end.clone().sub(start); - const length = axis.length(); - if (!(length > 0.1)) return null; - // 관 하단선이 축이므로 반지름만큼 위로 올려 관 중심을 맞춘다. - const radius = pipe.diameterM / 2; - // 관벽 두께가 없으면 관경의 2%를 임시로 준다 — 면이 겹쳐 z-파이팅이 나지 않을 정도. - const wall = Math.min(Math.max(pipe.wallThicknessM ?? pipe.diameterM * 0.02, 0.01), radius * 0.9); - const inner = radius - wall; - const material = (side: THREE.Side): THREE.Material => - new THREE.MeshLambertMaterial({ color: STRUCTURE_COLORS.pipe, side }); - const group = new THREE.Group(); - group.add( - new THREE.Mesh( - new THREE.CylinderGeometry(radius, radius, length, 20, 1, true), - material(THREE.FrontSide), - ), - // 안쪽 벽은 관 속에서 보이므로 법선을 뒤집는다(내부 유로 면). - new THREE.Mesh( - new THREE.CylinderGeometry(inner, inner, length, 20, 1, true), - material(THREE.BackSide), - ), - ); - // 양 끝 관벽 단면 — 고리로 막아 튜브가 열린 파이프로 보이게 한다. - for (const sign of [1, -1]) { - const ring = new THREE.Mesh( - new THREE.RingGeometry(inner, radius, 20), - material(THREE.DoubleSide), - ); - ring.rotation.x = Math.PI / 2; - ring.position.y = (sign * length) / 2; - group.add(ring); - } - group.position.copy(start.clone().add(end).multiplyScalar(0.5)); - group.position.y += radius; - group.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), axis.normalize()); - return group; -} - -/** 빌드 결과 → 씬에 넣을 그룹. 호출부가 dispose(disposeObject)를 책임진다. */ -export function createCorridorGroup(build: CorridorBuildResult, bounds: ModelBounds): THREE.Group { - const group = new THREE.Group(); - group.name = "corridor"; - const origin = originOf(bounds); - build.ribbons.forEach((ribbon) => { - if (ribbon.chainages.length < 2) return; - const direction = HATCH_DIRECTION[ribbon.kind]; - const material = new THREE.MeshLambertMaterial({ - color: KIND_COLORS[ribbon.kind], - // 절·성토만 빗금 — 도면 관례대로 방향으로도 구분된다. - map: direction ? hatchTexture(direction) : null, - side: THREE.DoubleSide, - polygonOffset: true, - polygonOffsetFactor: POLYGON_OFFSET_FACTOR, - polygonOffsetUnits: POLYGON_OFFSET_FACTOR, - }); - // 도로부(차도·노견·측구)는 구조물 위를 지나므로 도려내지 않는다 — 노면은 복토 - // 위를 덮는 게 맞다. 절·성토 비탈만 구조물에 자리를 내준다. - const raw = ribbonGeometry(ribbon, origin); - // 절단은 Build가 행 단면 트림으로 이미 끝냈다(2026-08-25) — 여기서는 그대로 그린다. - const mesh = new THREE.Mesh(raw, material); - mesh.name = `corridor:${ribbon.kind}:${ribbon.side}`; - group.add(mesh); - const outline = ribbonOutline(ribbon, origin); - if (outline) { - const line = new THREE.LineSegments( - outline, - new THREE.LineBasicMaterial({ color: OUTLINE_COLOR, transparent: true, opacity: 0.8 }), - ); - line.name = `corridor-outline:${ribbon.kind}:${ribbon.side}`; - group.add(line); - } - }); - // 탑뷰 투영 커브(2026-08-26 사용자) — 구조물 세트마다 **그 세트 구조물 상단 +5m** - // 평면에 눕힌 닫힌 고리다. 종류는 색(구조물 노랑 / 변형 성토선 분홍 / 원본 하늘), - // 유입·유출은 실선·파선으로 나눈다. 표기 전용 — 서피스는 안 건드린다. - // - // **최종 결과물에서는 숨긴다**(2026-09-02 사용자). 지우지는 않는다 — 절취·패치 기하를 - // 다시 들여다볼 때 쓰므로 묶음째 감춰 두고 `__corridorPlanCurves(true)` 로 되켠다. - const planGroup = new THREE.Group(); - planGroup.name = PLAN_CURVE_GROUP; - planGroup.visible = planCurvesVisible(); - group.add(planGroup); - (build.planCurves ?? []).forEach((curve) => { - // 벽 자리 커브는 표시용이 아니다 — 벽 서피스로만 나간다(2026-08-27 사용자). - if (curve.source === "cut-wall") return; - const color = PLAN_CURVE_COLORS[curve.source]; - const dashed = curve.role === "outlet"; - curve.loops.forEach((loop, index) => { - if (loop.length < 2) return; - const positions: number[] = []; - // 점의 z를 그대로 쓴다 — 평면 커브는 z에 planZ가 들어 있어 동작이 같고, - // 투영선(`slope-projected`)만 점마다 절·성토면 표고를 들고 온다(2026-08-26). - for (const [x, y, z] of loop) { - positions.push(x - origin.cx, z - origin.cz, -(y - origin.cy)); - } - const geometry = new THREE.BufferGeometry().setAttribute( - "position", - new THREE.Float32BufferAttribute(positions, 3), - ); - const line = new THREE.Line( - geometry, - dashed - ? new THREE.LineDashedMaterial({ color, ...PLAN_DASH_M }) - : new THREE.LineBasicMaterial({ color }), - ); - if (dashed) line.computeLineDistances(); - const station = curve.setChainageM.toFixed(2); - line.name = `corridor-plan:${curve.source}:${curve.role}:${curve.side}:${station}:${index}`; - planGroup.add(line); - }); - }); - // 절취 측벽 — 리본과 분리된 **별도 서피스**(2026-08-27 사용자). 성토면과 같은 색·빗금이라 - // 한 몸으로 보이지만 객체가 달라 다음 단계(바닥면·트림)에서 따로 다룰 수 있다. - (build.cutWalls ?? []).forEach((wall, index) => { - const count = wall.positions.length / 3; - if (!count) return; - const positions = new Float32Array(count * 3); - for (let v = 0; v < count; v += 1) { - positions[v * 3] = wall.positions[v * 3] - origin.cx; - positions[v * 3 + 1] = wall.positions[v * 3 + 2] - origin.cz; - positions[v * 3 + 2] = -(wall.positions[v * 3 + 1] - origin.cy); - } - const geometry = new THREE.BufferGeometry(); - geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3)); - if (wall.uvs && wall.uvs.length === count * 2) { - geometry.setAttribute("uv", new THREE.BufferAttribute(wall.uvs, 2)); - } - geometry.computeVertexNormals(); - const mesh = new THREE.Mesh( - geometry, - new THREE.MeshLambertMaterial({ - // 절취 측벽은 **일반 서피스**(빗금 없음) — 벽은 방향성을 표현할 면이 아니다 - // (2026-08-27 사용자). 패치 스커트는 성토면의 연장이라 **빗금 있는 메시 - // 서피스**로 그린다(2026-08-27 사용자) — UV를 싣고 온 판만 해당된다. - color: KIND_COLORS.fill, - map: wall.uvs ? hatchTexture(HATCH_DIRECTION.fill ?? -1) : null, - side: THREE.DoubleSide, - polygonOffset: true, - polygonOffsetFactor: POLYGON_OFFSET_FACTOR, - polygonOffsetUnits: POLYGON_OFFSET_FACTOR, - }), - ); - mesh.name = wall.patchSkirt - ? `corridor-patch-skirt:${wall.side}:${index}` - : `corridor-cut-wall:${wall.side}:${index}`; - group.add(mesh); - }); - build.caps.forEach((cap, index) => { - const geometry = capGeometry(cap, origin); - if (!geometry) return; - const mesh = new THREE.Mesh( - geometry, - new THREE.MeshLambertMaterial({ color: CAP_COLOR, side: THREE.DoubleSide }), - ); - mesh.name = `corridor-cap:${index}`; - group.add(mesh); - }); - // 배수관 세트 구조물(기슭막이·집수정·배관) — B06 횡단 계산 그대로의 솔리드 - // (2026-08-23 사용자). 리본 격자와 무관한 독립 물체라 개별 Mesh로 얹는다. - (build.structures ?? []).forEach((structure, index) => { - // 신원표 — 3D 클릭이 이 값으로 부재를 가려낸다(2026-09-04). 이름 규칙은 그대로 둔다 - // (다른 코드가 이름으로 찾는다). 배관은 겉·속 두 겹이라 자식까지 같은 표를 단다. - const tag = { chainageM: structure.chainage_m, kind: structure.kind, key: structure.key }; - if (structure.kind === "pipe") { - const mesh = structurePipeMesh(structure, origin); - if (mesh) { - mesh.name = `corridor-structure:pipe:${index}`; - mesh.traverse((object) => { - object.userData = tag; - }); - group.add(mesh); - } - return; - } - const geometry = structureLoftGeometry(structure, origin); - if (!geometry) return; - const mesh = new THREE.Mesh( - geometry, - new THREE.MeshLambertMaterial({ - color: STRUCTURE_COLORS[structure.kind], - side: THREE.DoubleSide, - polygonOffset: true, - polygonOffsetFactor: POLYGON_OFFSET_FACTOR, - polygonOffsetUnits: POLYGON_OFFSET_FACTOR, - }), - ); - mesh.name = `corridor-structure:${structure.kind}:${index}`; - mesh.userData = tag; - group.add(mesh); - // 모서리 검은선 — 성토부·노폭 리본과 같은 규칙으로 경계를 또렷하게 - // (2026-08-23 사용자). 임계각 위 모서리만 뽑아 스윕 분할선은 남기지 않는다. - const edges = new THREE.LineSegments( - new THREE.EdgesGeometry(geometry, STRUCTURE_EDGE_ANGLE_DEG), - new THREE.LineBasicMaterial({ color: OUTLINE_COLOR, transparent: true, opacity: 0.9 }), - ); - edges.name = `corridor-structure-edge:${structure.kind}:${index}`; - group.add(edges); - }); - return group; -} diff --git a/B05_Profile/Old/B05_Profile_UI_Corridor_Modes.ts b/B05_Profile/Old/B05_Profile_UI_Corridor_Modes.ts deleted file mode 100644 index eb73d357d..000000000 --- a/B05_Profile/Old/B05_Profile_UI_Corridor_Modes.ts +++ /dev/null @@ -1,207 +0,0 @@ -/* ============================================================================= - * B05_Profile_UI_Corridor_Modes.ts - * 3D 예상형상 **방식 등록부**(계획서 115-5) — 3D 보기 줄 「3D 방식: 지금 · 옛 A · 새 ① · - * 제안 ②」 고르기 한 줄이 이 표를 본다. - * - * 방식 하나 = 이름 + 만들기 함수 둘 — `corridor`(코리도 그룹에 그릴 빌드) · `terrain`(지형 - * · 없으면 그 빌드로 띠만 자른다). 제안 ② 는 다른 창이 새 파일을 만들고 아래 - * `CORRIDOR_MODES` 에 한 줄 등록한다. 등록이 없으면 그 단추는 꺼진다. - * 설정 키는 `corridor-mode` 하나 — 옛 두 단추(115-1 `corridor-deform` · 115-4 - * `corridor-legacy`) 값은 읽어서 옮긴다. 기본 = 새 ①. - * ========================================================================== */ - -import type * as THREE from "three"; -import type { ModelBounds } from "./B05_Profile_UI_Markers"; -import type { CorridorBuildResult } from "./B05_Profile_UI_Corridor_Build"; -import type { TerrainHeightIndex } from "./B05_Profile_UI_Corridor_Terrain"; -import type { TerrainBandSplit } from "./B05_Profile_UI_Corridor_Split"; -import { buildPatchSkirts } from "./B05_Profile_UI_Corridor_Skirt"; -import { deformTerrain } from "./B05_Profile_UI_Corridor_Deform_Terrain"; -import { tinTerrain } from "./B05_Profile_UI_Corridor_Tin"; -import { loftTerrain } from "./B05_Profile_UI_Corridor_Loft"; -import { legacyCorridorOf, legacyReady } from "./B05_Profile_UI_Corridor_Legacy"; -import { readStateRaw, writeStateRaw } from "../A00_Common/b_page_state"; - -/** 고르기 한 줄의 자리 — 순서 · 이름. 등록이 없는 자리는 단추가 꺼진다. */ -export const MODE_SLOTS = [ - ["current", "지금"], - ["legacy-a", "옛 A"], - ["new1", "새 ①"], - ["proposal2", "제안 ②"], - ["proposal3", "제안 ③"], -] as const; - -export type CorridorModeId = (typeof MODE_SLOTS)[number][0]; - -export const DEFAULT_MODE: CorridorModeId = "new1"; - -/** 만들기 함수가 받는 것 — 모델 좌표 범위와 지형 높이 색인(없으면 null). */ -export interface CorridorModeContext { - bounds: ModelBounds; - heightIndex: TerrainHeightIndex | null; -} - -export interface CorridorModeTerrainInput extends CorridorModeContext { - /** 본체 빌드(종횡단 정본 그대로 · 읽기만). */ - build: CorridorBuildResult; - /** 이 방식의 `corridor` 가 낸 빌드. */ - corridor: CorridorBuildResult; - /** 지형 밴드 분할본(near/far) — 읽기만 · 사본을 만들어 쓴다. */ - split: TerrainBandSplit; -} - -export interface CorridorMode { - id: CorridorModeId; - /** 처음 만들기가 무거운가 — 참이면 알림을 먼저 띄우고 다음 틈에 만든다. */ - slow?: (build: CorridorBuildResult) => boolean; - /** 코리도 그룹에 그릴 빌드 — null 이면 기본 방식으로 그린다. */ - corridor: (build: CorridorBuildResult, ctx: CorridorModeContext) => CorridorBuildResult | null; - /** 지형 — 없거나 null 이면 `corridor` 빌드로 띠만 자른다(`clipTerrain`). */ - terrain?: (input: CorridorModeTerrainInput) => { root: THREE.Object3D; stats?: unknown } | null; -} - -/** 모델 좌표 원지반 높이 — 색인이 없으면 null. */ -function groundOf(ctx: CorridorModeContext): ((x: number, y: number) => number | null) | null { - const index = ctx.heightIndex; - if (!index) return null; - const ox = (ctx.bounds.x[0] + ctx.bounds.x[1]) / 2; - const oy = (ctx.bounds.y[0] + ctx.bounds.y[1]) / 2; - const oz = (ctx.bounds.z[0] + ctx.bounds.z[1]) / 2; - return (x, y) => { - const height = index.heightAt(x - ox, -(y - oy)); - return height === null ? null : height + oz; - }; -} - -/** - * 비탈 최외곽(catch) 정점 z를 지형에 투영 — 샘플러·preview 메쉬 간 심 틈 방지. - * 조회는 격자 색인(TerrainHeightIndex)으로 한다 — Raycaster는 삼각형을 전부 - * 훑어 편집마다 초 단위로 멎었다(2026-08-23 실측 3.6초 단일 블록). - */ -function snapCorridorEdges(build: CorridorBuildResult, ctx: CorridorModeContext): void { - const groundAt = groundOf(ctx); - if (!groundAt) return; - build.ribbons.forEach((ribbon) => { - // 측구는 제외 — 바깥 상단은 노견과 같은 Z가 규칙(2026-08-23 사용자 지시). - // 지형 스냅하면 측구 바깥벽이 원지반까지 끌려 올라가 U형이 깨진다. - if (ribbon.kind !== "cut" && ribbon.kind !== "fill") return; - // 구조물 구간 패치도 제외(2026-08-27) — B06 성토 구간은 벽 뒷면이나 구체 - // 최상단에서 **끝나는 게 정상**이라 바깥 끝이 지반이 아니다. 스냅하면 그 - // 자리가 원지반까지 끌려 내려가 세로 지느러미가 생긴다(실측 4.1m). - if (ribbon.patch) return; - const cols = ribbon.colCount; - const outerCol = ribbon.side === "right" ? 0 : cols - 1; - const innerCol = ribbon.side === "right" ? cols - 1 : 0; - for (let row = 0; row < ribbon.chainages.length; row += 1) { - const outer = (row * cols + outerCol) * 3; - const inner = (row * cols + innerCol) * 3; - // 축퇴 구간(전이부, 폭≈0)은 그대로 둔다 — 노견 끝을 지형에 끌어붙이지 않는다. - const width = Math.hypot( - ribbon.positions[outer] - ribbon.positions[inner], - ribbon.positions[outer + 1] - ribbon.positions[inner + 1], - ); - if (width < 1e-4) continue; - const height = groundAt(ribbon.positions[outer], ribbon.positions[outer + 1]); - if (height !== null) ribbon.positions[outer + 2] = height; - } - }); -} - -/** - * 패치(변형 성토면) 바깥 끝 스커트를 붙인다 — 지형 메시 색인이 있어야 밑선을 - * 읽으므로 뷰어에서 뒤늦게 만든다(2026-08-27 사용자). 붙인 판은 절취 측벽과 같은 - * `cutWalls` 경로로 그려진다. 다시 붙일 땐 옛 스커트를 먼저 걷어낸다. - */ -function attachPatchSkirts(build: CorridorBuildResult, ctx: CorridorModeContext): void { - const groundAt = groundOf(ctx); - if (!groundAt) return; - const { walls, stats } = buildPatchSkirts(build.ribbons, groundAt); - const kept = (build.cutWalls ?? []).filter((wall) => !wall.patchSkirt); - build.cutWalls = [...kept, ...walls]; - (window as unknown as { __corridorSkirt?: unknown }).__corridorSkirt = stats; -} - -/** 새 방식이 그리는 코리도 — 노면부 리본 · 마구리 · 구조물만(비탈은 지형 변형 몫). */ -function roadOnly(build: CorridorBuildResult): CorridorBuildResult { - return { - ...build, - ribbons: build.ribbons.filter((ribbon) => ribbon.kind !== "cut" && ribbon.kind !== "fill"), - planCurves: [], - cutWalls: [], - }; -} - -/** 방식 등록부 — 제안 ② 는 아래 주석 자리에 한 줄. */ -export const CORRIDOR_MODES: CorridorMode[] = [ - // 지금 — 비탈 리본 · 스케치 · 패치 · 지형은 띠만 자름(115-1 전 방식). - { - id: "current", - corridor: (build, ctx) => { - snapCorridorEdges(build, ctx); - attachPatchSkirts(build, ctx); - return build; - }, - }, - // 옛 A(115-4) — 08-25 실루엣 갈아 끼우기 · 저장 안 함(브라우저 즉석 · 해시별 메모). - { - id: "legacy-a", - slow: (build) => !legacyReady(build), - corridor: (build, ctx) => { - const legacy = legacyCorridorOf(build); - if (legacy) snapCorridorEdges(legacy, ctx); - return legacy; - }, - }, - // 새 ①(115-1 · 115-5) — 노면부만 그리고 비탈은 지형 메시 변형 + 브레이크라인. - { - id: "new1", - corridor: (build) => roadOnly(build), - terrain: ({ split, build, bounds, heightIndex }) => - heightIndex ? deformTerrain(split, build, bounds, heightIndex) : null, - }, - // 제안 ②(115-6) — 노면부만 그리고 비탈 · 지형 이음은 단일 삼각망(TIN). - { - id: "proposal2", - corridor: (build) => roadOnly(build), - terrain: ({ split, build, bounds, heightIndex }) => - heightIndex ? tinTerrain(split, build, bounds, heightIndex) : null, - }, - // 제안 ③(115-8) — 노면부만 그리고 비탈은 측점 횡단 보간(평면도 외곽선 규칙) · 지형 이음은 ② 기계. - { - id: "proposal3", - corridor: (build) => roadOnly(build), - terrain: ({ split, build, bounds, heightIndex }) => - heightIndex ? loftTerrain(split, build, bounds, heightIndex) : null, - }, -]; - -export function modeOf(id: CorridorModeId): CorridorMode | null { - return CORRIDOR_MODES.find((mode) => mode.id === id) ?? null; -} - -const listeners = new Set<(id: CorridorModeId) => void>(); - -/** 고른 방식 — 저장값이 없으면 옛 두 단추 값을 옮겨 읽는다. */ -export function corridorModeId(): CorridorModeId { - const saved = readStateRaw("corridor-mode"); - const known = MODE_SLOTS.find(([id]) => id === saved); - if (known) return known[0]; - if (readStateRaw("corridor-legacy") === "1") return "legacy-a"; - if (readStateRaw("corridor-deform") === "0") return "current"; - return DEFAULT_MODE; -} - -/** 실제로 그릴 방식 — 고른 자리가 비었으면(제안 ② 미등록) 기본. */ -export function activeCorridorMode(): CorridorMode { - return modeOf(corridorModeId()) ?? (modeOf(DEFAULT_MODE) as CorridorMode); -} - -export function setCorridorMode(id: CorridorModeId): void { - writeStateRaw("corridor-mode", id); - listeners.forEach((listener) => listener(id)); -} - -export function onCorridorMode(listener: (id: CorridorModeId) => void): () => void { - listeners.add(listener); - return () => listeners.delete(listener); -} diff --git a/B05_Profile/Old/B05_Profile_UI_Corridor_Plan.ts b/B05_Profile/Old/B05_Profile_UI_Corridor_Plan.ts deleted file mode 100644 index e1c179cfd..000000000 --- a/B05_Profile/Old/B05_Profile_UI_Corridor_Plan.ts +++ /dev/null @@ -1,629 +0,0 @@ -/* ============================================================================= - * B05_Profile_UI_Corridor_Plan.ts - * 구조물 세트별 **탑뷰 투영 커브**(2026-08-26 사용자 확정). - * - * 규칙: - * · 투영 평면 = **구조물 세트당 하나** — 그 세트 구조물 최고 표고 + 5m. - * 세트끼리 높이가 다르다(경사 임도에서 각 구조물 위 5m를 지킨다). - * · 유입/유출을 **따로** 낸다 — 도로 기준으로 유입·유출 형태가 다를 수 있다. - * 상단측(`uphill_side`)이 유입이다. - * · 도로부(노견 안쪽)는 전 커브에서 뺀다 — 좌우가 끊겨 세트당 두 루프가 된다. - * · 구조물 커브와 비탈 커브는 **합치지 않는다**(사용자: 따로 2개). - * · 비탈 커브는 **변형 실루엣**·**원본 성토선** 두 벌을 다 낸다 — 어느 쪽을 - * 남길지는 화면 확인 후 별도 확정(2026-08-26 사용자). - * - * 절단은 하지 않는다 — 어디가 덮이는지 눈으로 보고 판단하기 위한 표기다. - * ========================================================================== */ - -import type { CrossSection } from "../B06_Section/B06_Section_Api_Fetch"; -import type { CorridorStructure, RouteFrame } from "./B05_Profile_UI_Corridor_Structures"; -import { structureSilhouettes } from "./B05_Profile_UI_Corridor_Station_Structure"; -import { assembleStripLoops } from "./B05_Profile_UI_Corridor_Carve"; -import { - castToToe, - structureFootprintLoops, - wingBoxLoops, -} from "./B05_Profile_UI_Corridor_Plan_Footprint"; -import { mixedGround, trimSlopeToGround } from "./B05_Profile_UI_Corridor_Station"; -import { groundInterpolator } from "../B06_Section/B06_Section_UI_Cross_Culvert_Solve"; -import type { PXY } from "./B05_Profile_UI_Corridor_Union"; - -export type PlanCurveSource = - | "structure" - | "slope-deformed" - | "slope-original" - | "wing-box" - /** 파란 스케치 커브(`slope-original`)를 절·성토면에 탑뷰 투영한 **빨간 선** - * (2026-08-26 사용자). 스케치는 평면에 그대로 남고 이 커브만 서피스에 얹힌다. */ - | "slope-projected" - /** 측벽이 설 자리(2026-08-27 사용자) — 절취 커브에서 **노견선·성토 끝선 변을 뺀** - * 열린 폴리라인. 스트립은 양 끝 횡단선, 날개 사다리꼴은 [끝선→날개→끝선]이다. - * 표시는 하지 않고 벽 세우기에만 쓴다. */ - | "cut-wall" - /** 실제로 잘린 자리 윤곽(2026-08-27 사용자) — 비탈 투영선 ∪ 날개벽 사다리꼴을 합쳐 - * 셀 마스크에서 되짚은 커브. **현 평면 대비 +3m 새 데이텀**에 얹는다. */ - | "cut-merged"; - -/** 투영 커브 한 벌 — 한 구조물 세트의 한 측(유입 또는 유출), 한 종류. */ -export interface PlanCurve { - /** 세트 소유 측점 누가거리(m) — 세트 식별자. */ - setChainageM: number; - source: PlanCurveSource; - role: "inlet" | "outlet"; - side: "left" | "right"; - /** 투영 평면 표고(모델 z) = 세트 구조물 최고 표고 + 5m. */ - planZ: number; - /** 모델 좌표 닫힌 고리들(z는 전부 planZ). */ - loops: Array>; - /** - * 가로 방향이 **노선이 아니라 구조물 축에 고정**된 커브(BOX암거). 커브가 코리도 - * 격자와 비스듬해 셀 마스크만으로는 경계가 지그재그가 된다 — 절단 쪽에서 이 커브만 - * 골라 셀을 커브로 쪼갠다(2026-08-26 사용자: 이원화하여 관리). - */ - axisLocked?: boolean; -} - -/** 구조물 상단에서 투영 평면을 띄우는 높이(m) — 2026-08-26 사용자. */ -const PLANE_LIFT_M = 5; - -/** 비탈 스트립 종방향 걸음(m). */ -const STEP_M = 0.5; - -/** 안팎 폭이 이만큼은 돼야 커브를 만든다(m). */ -const MIN_WIDTH_M = 0.05; - -/** 코리도 바깥 윤곽(행별 좌·우 최외곽 XY) — `CorridorBuildResult.outline`. */ -export interface CorridorOutline { - chainages: number[]; - left: Array<[number, number]>; - right: Array<[number, number]>; -} - -type Side = "left" | "right"; - -/** 편거리를 실좌표로 — 노선 프레임이든 구조물 링 프레임이든 좌향 벡터만 있으면 된다. */ -interface PlanFrame { - cx: number; - cy: number; - leftX: number; - leftY: number; -} - -/** 누가거리 → 그 행의 지반선. 인접 두 측점 지반을 섞는다(코리도 빌드와 같은 방식). */ -export function buildGroundAt( - crossSections: ReadonlyArray, -): (chainageM: number) => ((offset: number) => number) | null { - const rows = crossSections - .map((section) => ({ - chainage: section.chainage_m, - ground: groundInterpolator(section.samples), - })) - .sort((a, b) => a.chainage - b.chainage); - return (chainageM) => { - if (!rows.length) return null; - for (let i = 0; i < rows.length - 1; i += 1) { - const lo = rows[i]; - const hi = rows[i + 1]; - if (chainageM < lo.chainage || chainageM > hi.chainage) continue; - const span = hi.chainage - lo.chainage; - const t = span > 1e-6 ? (chainageM - lo.chainage) / span : 0; - return mixedGround(lo.ground, hi.ground, t); - } - return chainageM <= rows[0].chainage ? rows[0].ground : rows[rows.length - 1].ground; - }; -} - -function at(frame: PlanFrame, offset: number): PXY { - return { x: frame.cx + frame.leftX * offset, y: frame.cy + frame.leftY * offset }; -} - -/** - * 성토 끝선에서 세트 근처 구간만 — 노선 누가거리 ± 이만큼(m)을 본다. - * - * 넉넉해야 한다. 곡선 안쪽 측은 종방향이 압축돼 누가거리 20m를 잘라도 측점 로컬 - * 종방향으로는 5.2m밖에 안 나왔고, 종방향 6.4m가 필요한 날개가 끝선을 못 만났다 - * (2026-08-26 실측, 149.73 세월교 우측). 엉뚱한 자리에 걸리는 건 `RAY_LIMIT_M`이 막는다. - */ -const TOE_WINDOW_M = 40; - -/** 그 측 성토 끝선(코리도 바깥 윤곽)에서 세트 근처 마디만 잘라낸다. */ -function nearToe( - outline: CorridorOutline, - side: Side, - setChainageM: number, -): Array<[number, number]> { - const points: Array<[number, number]> = []; - outline.chainages.forEach((chainageM, index) => { - if (Math.abs(chainageM - setChainageM) > TOE_WINDOW_M) return; - points.push(side === "left" ? outline.left[index] : outline.right[index]); - }); - return points; -} - -/** 세트 구조물의 최고 표고 — 폴리곤 표고에 링 종단 보정(dz)을 더한 값의 최대. */ -function topElevationOf(set: ReadonlyArray): number | null { - let top: number | null = null; - for (const structure of set) { - const rings = structure.rings; - if (!rings || !rings.length) continue; - const perRing = structure.polygons; - const shared = structure.polygon; - rings.forEach((frame, index) => { - const polygon = perRing?.[index] ?? shared; - if (!polygon) return; - for (const [, elevation] of polygon) { - const z = elevation + frame.dz; - if (top === null || z > top) top = z; - } - }); - } - return top; -} - -/** 스트립 행([안쪽, 바깥쪽] XY)을 평면 표고에 얹어 닫힌 고리로 만든다. */ -function stripLoops( - rows: ReadonlyArray<[PXY, PXY] | null>, - planZ: number, -): Array> { - return assembleStripLoops( - rows.map((row) => - row - ? ([ - [row[0].x, row[0].y, planZ], - [row[1].x, row[1].y, planZ], - ] as [[number, number, number], [number, number, number]]) - : null, - ), - ); -} - -/** 두 점이 커브를 만들 만큼 떨어졌나. */ -function apart(a: PXY, b: PXY): boolean { - return Math.hypot(b.x - a.x, b.y - a.y) > MIN_WIDTH_M; -} - -/** 절·성토면 조회 묶음 — 표고와 **면이 시작하는 안쪽 끝**을 같은 색인에서 낸다. */ -interface SurfaceLookup { - /** 그 행·그 측 서피스의 z. 밑에 면이 없으면 null. */ - zAt: (chainageM: number, side: Side, x: number, y: number) => number | null; - /** - * 그 행·그 측 서피스가 **실제로 시작하는 안쪽 끝**(노견 확장·측구를 이미 반영한 - * 자리). 파란 스케치의 안쪽 기준을 여기에 맞춰야 커브가 면 경계와 겹친다 - * (2026-08-26 사용자 지적: 노견 확장 개념 미반영). - */ - innerAt: (chainageM: number, side: Side, nearX: number, nearY: number) => PXY | null; -} - -function buildSurfaceZAt(ribbons: ReadonlyArray): SurfaceLookup { - /** - * `측(side)|누가거리` → 그 행의 [x, y, z] 열 목록. 절·성토만 담고, 언제나 - * **안쪽(도로) → 바깥(끝선)** 순으로 뒤집어 담는다. - * - * 비탈 조각은 편거리 오름차순으로 저장된다. 좌표 규약이 `+offset = 좌측`이라 - * 좌측은 안쪽→바깥이지만 **우측은 바깥→안쪽**이 된다(2026-08-26 실측, 세월교 - * 149.73 우측: 0열이 차도 끝에서 5.97m, 마지막 열이 0m). 방향을 여기서 한 번 - * 맞춰 두지 않으면 안쪽 끝 조회도, 수직면 무승부 처리도 우측에서 뒤집힌다. - */ - const rowsAt = new Map>>(); - for (const ribbon of ribbons) { - if (ribbon.kind !== "cut" && ribbon.kind !== "fill") continue; - ribbon.chainages.forEach((chainageM, row) => { - const cols: Array<[number, number, number]> = []; - for (let col = 0; col < ribbon.colCount; col += 1) { - const i = (row * ribbon.colCount + col) * 3; - cols.push([ribbon.positions[i], ribbon.positions[i + 1], ribbon.positions[i + 2]]); - } - if (ribbon.side === "right") cols.reverse(); - const key = `${ribbon.side}|${chainageM}`; - const bucket = rowsAt.get(key); - if (bucket) bucket.push(cols); - else rowsAt.set(key, [cols]); - }); - } - /** - * 폴리라인 위 XY 최근접점의 z. 폴리라인 밖이면 양 끝으로 클램프된다. - * - * 단면에는 **거의 수직인 면**(다단 기슭막이 벽 앞면)이 있어 같은 XY에 z가 여러 개 - * 걸린다 — 탑뷰 투영이 원래 모호한 자리다. 거리가 같으면 **바깥 열이 이기게** 한다: - * 열은 안쪽→바깥 순이고, 커브 바깥 끝이 뜻하는 자리는 벽 상단이 아니라 **성토 끝선** - * 이다(2026-08-26 실측: 275.71 좌측에서 끝선이 0.69m 떠 있었다). - */ - const zOnRow = ( - cols: Array<[number, number, number]>, - x: number, - y: number, - ): { distance: number; z: number } | null => { - let best: { distance: number; z: number } | null = null; - for (let i = 1; i < cols.length; i += 1) { - const [ax, ay, az] = cols[i - 1]; - const [bx, by, bz] = cols[i]; - const dx = bx - ax; - const dy = by - ay; - const lengthSq = dx * dx + dy * dy; - const t = - lengthSq > 1e-12 ? Math.min(1, Math.max(0, ((x - ax) * dx + (y - ay) * dy) / lengthSq)) : 0; - const px = ax + dx * t; - const py = ay + dy * t; - const distance = Math.hypot(x - px, y - py); - const z = az + (bz - az) * t; - if (!best || distance < best.distance - 1e-6) best = { distance, z }; - // 무승부(수직면) — 뒤에 오는 바깥 열로 갈아탄다. 최소 거리는 유지한다. - else if (distance < best.distance + 1e-6) - best = { distance: Math.min(distance, best.distance), z }; - } - return best; - }; - return { - zAt: (chainageM, side, x, y) => { - const buckets = rowsAt.get(`${side}|${chainageM}`); - if (!buckets) return null; - let best: { distance: number; z: number } | null = null; - for (const cols of buckets) { - // 정점에 얹힌 XY(면 안쪽 끝·바깥 끝)는 **그 정점 z를 그대로** 쓴다 — 보간· - // 최근접 판정을 태우면 수직면(다단 벽 앞면)에서 엉뚱한 단이 이긴다. 허용오차는 - // 1mm — 1e-6으로 잡았더니 끝점이 0.5mm 어긋난 행에서 최대 1.01m 튀었다 - // (2026-08-26 실측, 세월교 149.73). 열은 안쪽→바깥이라 무승부면 바깥이 이긴다. - let exact: { distance: number; z: number } | null = null; - for (const [px, py, pz] of cols) { - const distance = Math.hypot(x - px, y - py); - if (distance > VERTEX_SNAP_M) continue; - if (!exact || distance < exact.distance + 1e-9) exact = { distance, z: pz }; - } - const hit = exact ?? zOnRow(cols, x, y); - if (!hit) continue; - if (!best || hit.distance < best.distance) best = hit; - } - return best ? best.z : null; - }, - innerAt: (chainageM, side, nearX, nearY) => { - const buckets = rowsAt.get(`${side}|${chainageM}`); - if (!buckets) return null; - // 한 행에 절·성토가 같이 있으면 **도로에 가까운 쪽**의 안쪽 끝을 쓴다. - let best: { distance: number; point: PXY } | null = null; - for (const cols of buckets) { - const [x, y] = cols[0]; - const distance = Math.hypot(nearX - x, nearY - y); - if (!best || distance < best.distance) best = { distance, point: { x, y } }; - } - return best ? best.point : null; - }, - }; -} - -/** 표고 조회에 필요한 리본 최소 형태 — Build의 `CorridorRibbon`이 그대로 들어맞는다. */ -export interface CorridorRibbonLike { - kind: string; - /** "left" | "right" | "center" — 비탈 리본은 좌·우뿐이라 center는 걸러진다. */ - side: string; - colCount: number; - chainages: ReadonlyArray; - positions: ArrayLike; -} - -/** 투영 점을 리본 정점으로 붙여 보는 허용오차(m) — 수직면에서 단이 뒤바뀌는 것 방지. */ -const VERTEX_SNAP_M = 0.001; - -/** 투영선을 서피스에서 띄우는 높이(m) — z-fighting 방지. */ -const PROJECT_LIFT_M = 0.02; - -/** XY 다각형 안팎 판정(광선 투사). */ -function insideRing(ring: ReadonlyArray<[number, number]>, x: number, y: number): boolean { - let hit = false; - for (let i = 0, j = ring.length - 1; i < ring.length; j = i, i += 1) { - const [xi, yi] = ring[i]; - const [xj, yj] = ring[j]; - if (yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) hit = !hit; - } - return hit; -} - -/** - * 벽 자리를 **머지 영역의 경계**만 남긴다(2026-08-27 사용자). - * - * 절취 영역은 본 구조물 스트립과 날개 사다리꼴을 **합친 것**이라, 한쪽 영역의 변이 - * 다른 영역 **안쪽**을 지나면 그건 경계가 아니라 속살이다. 변을 다른 영역 테두리와의 - * 교점에서 쪼갠 뒤, 조각 가운데가 다른 영역 안이면 버린다. - */ -function trimToUnionBoundary( - parts: ReadonlyArray<{ points: ReadonlyArray<[number, number, number]>; owner: number }>, - areas: ReadonlyArray>, -): Array> { - const lines: Array> = []; - for (const part of parts) { - for (let i = 0; i < part.points.length - 1; i += 1) { - const from = part.points[i]; - const to = part.points[i + 1]; - const rx = to[0] - from[0]; - const ry = to[1] - from[1]; - if (Math.hypot(rx, ry) < 1e-9) continue; - const cuts = [0, 1]; - areas.forEach((ring, index) => { - if (index === part.owner) return; - for (let k = 0, j = ring.length - 1; k < ring.length; j = k, k += 1) { - const sx = ring[k][0] - ring[j][0]; - const sy = ring[k][1] - ring[j][1]; - const denominator = rx * sy - ry * sx; - if (Math.abs(denominator) < 1e-12) continue; - const dx = ring[j][0] - from[0]; - const dy = ring[j][1] - from[1]; - const t = (dx * sy - dy * sx) / denominator; - const u = (dx * ry - dy * rx) / denominator; - if (t > 1e-9 && t < 1 - 1e-9 && u >= 0 && u <= 1) cuts.push(t); - } - }); - cuts.sort((a, b) => a - b); - const length = Math.hypot(rx, ry); - for (let c = 0; c < cuts.length - 1; c += 1) { - const t0 = cuts[c]; - const t1 = cuts[c + 1]; - // 2cm 미만 조각은 버린다 — 교점이 겹쳐 생기는 부스러기다. - if ((t1 - t0) * length < 0.02) continue; - const mid = (t0 + t1) / 2; - const mx = from[0] + rx * mid; - const my = from[1] + ry * mid; - const buried = areas.some( - (ring, index) => index !== part.owner && insideRing(ring, mx, my), - ); - if (buried) continue; - const at = (t: number): [number, number, number] => [ - from[0] + rx * t, - from[1] + ry * t, - from[2] + (to[2] - from[2]) * t, - ]; - lines.push([at(t0), at(t1)]); - } - } - } - return lines; -} - -/** 스트립 행을 **점마다 다른 표고**로 얹어 닫힌 고리로 만든다(투영선용). */ -function projectedLoops( - rows: ReadonlyArray<[[number, number, number], [number, number, number]] | null>, -): Array> { - return assembleStripLoops(rows); -} - -/** - * 구조물 세트별 탑뷰 투영 커브. 세트 = 같은 측점이 소유한 구조물 묶음 - * (기슭막이 + 집수정 + 배관, 또는 BOX암거·세월교 한 벌). - */ -export function buildPlanCurves( - crossSections: ReadonlyArray, - structures: ReadonlyArray, - frameAt: (chainageM: number) => RouteFrame | null, - outline: CorridorOutline, - /** 절·성토 리본 — 투영선 표고를 여기서 읽는다. 없으면 투영선을 안 낸다. */ - ribbons: ReadonlyArray = [], -): PlanCurve[] { - const curves: PlanCurve[] = []; - const groundAt = buildGroundAt(crossSections); - const surface = buildSurfaceZAt(ribbons); - for (const section of crossSections) { - const set = structures.filter( - (structure) => Math.abs(structure.chainage_m - section.chainage_m) < 1e-6, - ); - if (!set.length) continue; - const top = topElevationOf(set); - if (top === null) continue; - const planZ = top + PLANE_LIFT_M; - const edges = section.design?.road_edges; - if (!edges) continue; - const fallback: RouteFrame = { - cx: section.center_x, - cy: section.center_y, - leftX: section.frame.left_xy[0], - leftY: section.frame.left_xy[1], - designZ: null, - }; - const routeFrameOf = (chainageM: number): RouteFrame => frameAt(chainageM) ?? fallback; - /** - * BOX암거는 비탈 커브도 **박스 방향 하나**로 긋는다(2026-08-26 사용자: 날개벽처럼 - * 단일방향). 행 자리는 노선 위 그대로 두고 **가로 방향만** 기준 측점 좌향으로 - * 고정한다 — 곡선에서 행마다 방향이 틀어져 커브가 뒤틀리던 걸 막는다. - * 세월교·배수관은 종전대로 그 행의 노선 프레임을 쓴다. - */ - const boxAxis = section.box ? section.frame.left_xy : null; - const frameOf = (chainageM: number): RouteFrame => { - const frame = routeFrameOf(chainageM); - if (!boxAxis) return frame; - return { ...frame, leftX: boxAxis[0], leftY: boxAxis[1] }; - }; - const inletSide: Side = (section.uphill_side ?? "left") === "left" ? "left" : "right"; - const roleOf = (side: Side): PlanCurve["role"] => (side === inletSide ? "inlet" : "outlet"); - // 노견 끝 편거리는 소유 측점 값을 구간 내내 쓴다 — 구간이 수 m라 확폭 변화는 무시. - const roadEdgeOf = (side: Side): number => - side === "left" ? edges.left.offset_m : edges.right.offset_m; - - const push = ( - source: PlanCurveSource, - side: Side, - loops: Array>, - ): void => { - if (!loops.length) return; - curves.push({ - setChainageM: section.chainage_m, - source, - role: roleOf(side), - side, - planZ, - loops, - ...(boxAxis ? { axisLocked: true } : {}), - }); - }; - - const axis = { - cx: section.center_x, - cy: section.center_y, - leftX: section.frame.left_xy[0], - leftY: section.frame.left_xy[1], - }; - // 벽 자리 후보와 그 근거 영역 — 세트가 끝나면 **머지 경계**만 남겨 한 번에 낸다. - const wallParts = new Map< - Side, - Array<{ points: Array<[number, number, number]>; owner: number }> - >(); - const wallAreas = new Map>>(); - const addArea = (side: Side, loop: ReadonlyArray<[number, number, number]>): number => { - const bucket = wallAreas.get(side) ?? []; - bucket.push(loop.map((point) => [point[0], point[1]] as [number, number])); - wallAreas.set(side, bucket); - return bucket.length - 1; - }; - const addWall = (side: Side, points: Array<[number, number, number]>, owner: number): void => { - const bucket = wallParts.get(side) ?? []; - bucket.push({ points, owner }); - wallParts.set(side, bucket); - }; - (["left", "right"] as const).forEach((side) => { - push("structure", side, structureFootprintLoops(set, side, roadEdgeOf(side), planZ, axis)); - // 날개벽만 — 성토 끝선까지 이어진 사다리꼴(2026-08-26 사용자). 성토 끝선은 - // 노선 전 구간 폴리라인이라 세트 근처만 잘라 넘긴다 — 안 자르면 헤어핀 - // 건너편 성토선에 광선이 얻어걸린다(패치 리본이 겪은 것과 같은 함정). - const toe = nearToe(outline, side, section.chainage_m); - const wing = wingBoxLoops(set, side, planZ, axis, toe); - push("wing-box", side, wing.loops); - // 날개 사다리꼴의 벽 자리 — 성토 끝선 변은 빠져 있다(2026-08-27 사용자). - wing.loops.forEach((loop, index) => { - const owner = addArea(side, loop); - const line = wing.walls[index]; - if (line) addWall(side, line, owner); - }); - }); - - for (const silhouette of structureSilhouettes(section)) { - const side = silhouette.side; - const sign = side === "left" ? 1 : -1; - const roadEdge = roadEdgeOf(side); - // 변형 실루엣 — [노견 확장끝 → 실루엣이 그 행 지반과 만나는 자리]. - // 실루엣은 소유 측점 단면 한 장이라 그대로 밀면 구간 내내 같은 폭이 된다 - // (2026-08-26 사용자 ②) — 행마다 지반선에 트림해 실제 발끝을 따라가게 한다. - // 서피스는 여전히 안 건드린다 — 표기 커브만 바뀐다. - const inner = silhouette.widenTo?.offset_m ?? roadEdge; - const from = section.chainage_m - silhouette.span.beforeM; - const to = section.chainage_m + silhouette.span.afterM; - const stationGround = groundInterpolator(section.samples); - // "auto" = 실루엣 가운데가 지반보다 높으면 성토, 낮으면 절토(패치 리본과 같은 판정). - let kind: "cut" | "fill" = silhouette.kind === "auto" ? "fill" : silhouette.kind; - if (silhouette.kind === "auto") { - const mid = silhouette.points[Math.floor(silhouette.points.length / 2)]; - if (stationGround && mid.elevation_m < stationGround(mid.offset_m) - 0.05) kind = "cut"; - } - const baseZ = frameOf(section.chainage_m).designZ; - const steps: number[] = []; - for (let atM = from; atM < to; atM += STEP_M) steps.push(atM); - steps.push(to); - const deformedRows = steps.map((atM): [PXY, PXY] | null => { - const frame = frameOf(atM); - const ground = groundAt(atM); - if (!ground) return null; - // 종단 경사만큼 실루엣을 통째로 올리고 내린다 — 측점 단면은 그 측점 계획고 기준이다. - const shift = baseZ != null && frame.designZ != null ? frame.designZ - baseZ : 0; - const placed = silhouette.points.map((point) => ({ - offset_m: point.offset_m, - elevation_m: point.elevation_m + shift, - })); - // **구조물에서 끝나는 실루엣**(BOX암거 — 구체 최상단 모서리)은 지반까지 - // 연장하지 않는다. 실루엣이 끝나는 자리가 곧 변형 성토면이 끝나는 자리이고, - // 그 바깥은 구조물 솔리드와 **원지반** 몫이다(2026-08-27 사용자 확정). - // 연장하면 변형 성토선이 원본 성토선과 겹쳐 원지반 재노출 공식 - // `원본 − (구조물 ∪ 변형)`이 공집합이 된다(`_Corridor_Clip.ts`). - let outer: number; - if (silhouette.endsAtStructure) { - outer = placed[placed.length - 1].offset_m; - } else { - const trimmed = trimSlopeToGround(placed, kind, ground); - if (!trimmed) return null; - outer = trimmed[trimmed.length - 1].offset_m; - } - if ((outer - inner) * sign <= MIN_WIDTH_M) return null; - return [at(frame, inner), at(frame, outer)]; - }); - push("slope-deformed", side, stripLoops(deformedRows, planZ)); - // 원본 성토·절토선 — [면 안쪽 끝 → 코리도 바깥 윤곽(catch)]. 구조물이 없다고 본 면이다. - // 안쪽 끝은 **그 행 절·성토면이 실제로 시작하는 자리**를 쓴다 — 소유 측점 노견 - // 편거리를 그대로 쓰면 노폭 연장·측구를 못 따라가 커브가 면 밖(도로 위)에 얹힌다 - // (2026-08-26 사용자 지적. 275.71 좌측 실측: 노견 확장 4m만큼 안쪽으로 들어가 있었다). - const rows: Array<[PXY, PXY] | null> = []; - // BOX암거는 가로 방향이 고정이라 그 행의 윤곽 점을 그대로 쓰면 방향이 틀어진다. - // 대신 고정 방향으로 쏜 선이 성토 끝선과 만나는 자리를 바깥 끝으로 삼는다 — - // 끝은 여전히 실제 성토 끝선 위다(2026-08-26 사용자). - const boxToe = boxAxis ? nearToe(outline, side, section.chainage_m) : null; - /** - * BOX암거는 **안쪽 끝도** 박스 축 위여야 한다(2026-08-26 사용자: 평면 스케치의 - * 횡단 방향은 박스가 놓인 **전역 단방향**이고 노선 흐름을 타면 안 된다). - * 바깥 끝처럼, 고정 축으로 쏜 선이 **성토면 안쪽 끝선**과 만나는 자리를 쓴다 — - * 한쪽만 축을 맞추면 가로대가 행을 가로질러 비스듬해진다. - */ - const boxInner = boxAxis - ? outline.chainages - .filter((chainageM) => Math.abs(chainageM - section.chainage_m) <= TOE_WINDOW_M) - .map((chainageM) => { - const routeFrame = routeFrameOf(chainageM); - const edge = at(routeFrame, roadEdge); - const start = surface.innerAt(chainageM, side, edge.x, edge.y) ?? edge; - return [start.x, start.y] as [number, number]; - }) - : null; - /** 같은 행을 절·성토면 표고에 얹은 것 — 빨간 투영선(2026-08-26 사용자). */ - const projected: Array<[[number, number, number], [number, number, number]] | null> = []; - outline.chainages.forEach((chainageM, index) => { - if (chainageM < from - 1e-9 || chainageM > to + 1e-9) return; - const frame = frameOf(chainageM); - const edgeXY = at(frame, roadEdge); - const surfaceStart = boxInner - ? castToToe(frame.cx, frame.cy, frame.leftX * sign, frame.leftY * sign, boxInner) - : surface.innerAt(chainageM, side, edgeXY.x, edgeXY.y); - let outerXY: PXY; - if (boxToe) { - const hit = castToToe(frame.cx, frame.cy, frame.leftX * sign, frame.leftY * sign, boxToe); - if (!hit) { - rows.push(null); - projected.push(null); - return; - } - outerXY = { x: hit.x, y: hit.y }; - } else { - const [ox, oy] = side === "left" ? outline.left[index] : outline.right[index]; - outerXY = { x: ox, y: oy }; - } - // 면 시작점이 바깥 끝과 붙어 버리는 행(그 측 비탈이 거의 없는 자리)에서는 - // 노견 편거리로 되돌린다 — 안 그러면 그 측 커브가 통째로 사라진다. - const innerXY = surfaceStart && apart(surfaceStart, outerXY) ? surfaceStart : edgeXY; - const keep = apart(innerXY, outerXY); - rows.push(keep ? [innerXY, outerXY] : null); - // 서피스 표고를 못 읽는 자리(밑에 절·성토 리본이 없는 행)는 선을 끊는다 — - // 없는 면에 억지로 붙이지 않는다. - const innerZ = keep ? surface.zAt(chainageM, side, innerXY.x, innerXY.y) : null; - const outerZ = keep ? surface.zAt(chainageM, side, outerXY.x, outerXY.y) : null; - projected.push( - innerZ === null || outerZ === null - ? null - : [ - [innerXY.x, innerXY.y, innerZ + PROJECT_LIFT_M], - [outerXY.x, outerXY.y, outerZ + PROJECT_LIFT_M], - ], - ); - }); - push("slope-original", side, stripLoops(rows, planZ)); - const stripLoopsXY = projectedLoops(projected); - push("slope-projected", side, stripLoopsXY); - // 측벽이 설 자리 = 스트립의 **양 끝 횡단선**뿐. 긴 변(노견선·성토 끝선)은 뺀다 - // (거기서 잘린 게 아니라 리본이 끝난 자리다 — 2026-08-27 사용자). - const spans = projected.filter( - (pair): pair is [[number, number, number], [number, number, number]] => pair !== null, - ); - if (spans.length >= 2 && stripLoopsXY.length) { - const owner = addArea(side, stripLoopsXY[0]); - addWall(side, [spans[0][0], spans[0][1]], owner); - addWall(side, [spans[spans.length - 1][0], spans[spans.length - 1][1]], owner); - } - } - // 본 구조물 영역 ∪ 날개 영역의 **경계만** 벽으로 낸다 — 다른 영역 안에 잠긴 - // 구간은 속살이라 버린다(2026-08-27 사용자). - wallParts.forEach((parts, side) => { - push("cut-wall", side, trimToUnionBoundary(parts, wallAreas.get(side) ?? [])); - }); - } - return curves; -} diff --git a/B05_Profile/Old/B05_Profile_UI_Corridor_Plan_Footprint.ts b/B05_Profile/Old/B05_Profile_UI_Corridor_Plan_Footprint.ts deleted file mode 100644 index a53604928..000000000 --- a/B05_Profile/Old/B05_Profile_UI_Corridor_Plan_Footprint.ts +++ /dev/null @@ -1,348 +0,0 @@ -/* ============================================================================= - * B05_Profile_UI_Corridor_Plan_Footprint.ts - * 구조물의 **탑뷰 풋프린트 외곽 고리**(2026-08-26 사용자 재구현 지시). - * - * 기하 합집합(옛 `_Corridor_Union.ts`)을 쓰지 않는다 — 겹친 스트립에서 외곽선이 - * 무너졌다(같은 토막이 짝수 번 나오면 버리는 규칙이 외곽의 공유 변까지 지웠다). - * - * 대신 구조물이 **스윕 솔리드**라는 성질을 그대로 쓴다: - * ① 부재마다 **따로** 낸다 — 겹친 부재를 하나로 머지하지 않는다 - * (2026-08-26 사용자: "구조물은 머지하지 말고 표현할 것, 나중에 한번에"). - * ② 링마다 그 링 폴리곤이 차지하는 편거리 창을 도로 밖으로 자른다. - * ③ 창이 있는 링이 이어지는 동안 한 고리 — 창이 없는 링에서 끊는다. - * - * 기하 불리언이 없어 퇴화(같은 사각형 겹침·포함 관계·맞닿은 변)가 없다. - * ========================================================================== */ - -import type { CorridorStructure, StructureFrame } from "./B05_Profile_UI_Corridor_Structures"; - -/** 안팎 폭이 이만큼은 돼야 커브를 만든다(m). */ -const MIN_WIDTH_M = 0.05; - -/** 폭 판정 여유(m). */ -const EPS_M = 1e-6; - -type Side = "left" | "right"; - -/** 편거리 구간 — 항상 `lo <= hi`(부호 있는 편거리 그대로). */ -type Span = [number, number]; - -/** 링 폴리곤이 그 링에서 차지하는 편거리 폭. */ -function polygonSpan(polygon: ReadonlyArray | undefined): Span | null { - if (!polygon || !polygon.length) return null; - let lo = Infinity; - let hi = -Infinity; - for (const [offset] of polygon) { - if (offset < lo) lo = offset; - if (offset > hi) hi = offset; - } - return hi - lo > EPS_M ? [lo, hi] : null; -} - -/** 도로부(노견 안쪽)를 잘라낸 나머지 — 폭이 없으면 null. */ -function clipOutsideRoad(span: Span, side: Side, roadEdgeOffset: number): Span | null { - const lo = side === "left" ? Math.max(span[0], roadEdgeOffset) : span[0]; - const hi = side === "left" ? span[1] : Math.min(span[1], roadEdgeOffset); - return hi - lo > MIN_WIDTH_M ? [lo, hi] : null; -} - -/** 편거리를 실좌표로 — 링 프레임의 좌향 벡터를 탄다. */ -function at(frame: StructureFrame, offset: number): [number, number] { - return [frame.cx + frame.leftX * offset, frame.cy + frame.leftY * offset]; -} - -/** 링 한 줄 — 그 링의 프레임과 살아남은 편거리 창. */ -interface Row { - frame: StructureFrame; - span: Span; -} - -/** 이어진 링 줄을 닫힌 고리로 — 바깥 가장자리를 앞으로, 안쪽을 뒤로 이어 닫는다. */ -function runLoop( - run: ReadonlyArray, - side: Side, - planZ: number, -): Array<[number, number, number]> | null { - if (run.length < 2) return null; - const outerOf = (span: Span): number => (side === "left" ? span[1] : span[0]); - const innerOf = (span: Span): number => (side === "left" ? span[0] : span[1]); - const loop: Array<[number, number, number]> = []; - for (const row of run) { - const [x, y] = at(row.frame, outerOf(row.span)); - loop.push([x, y, planZ]); - } - for (let i = run.length - 1; i >= 0; i -= 1) { - const [x, y] = at(run[i].frame, innerOf(run[i].span)); - loop.push([x, y, planZ]); - } - loop.push(loop[0]); - return loop; -} - -/** 측점 프레임(중심 + 좌향 단위벡터) — 날개벽이 어느 측인지 가리는 기준이다. */ -export interface StationAxis { - cx: number; - cy: number; - leftX: number; - leftY: number; -} - -/** 그 자리의 노선 편거리(+ = 좌측) — 측점 프레임 기준. */ -function routeOffset(axis: StationAxis, x: number, y: number): number { - return (x - axis.cx) * axis.leftX + (y - axis.cy) * axis.leftY; -} - -/** 날개벽이 붙은 측 — 링 가운데의 노선 편거리 부호로 가린다. */ -function wingSide(rings: ReadonlyArray, axis: StationAxis): Side { - const middle = rings[Math.floor(rings.length / 2)]; - return routeOffset(axis, middle.cx, middle.cy) >= 0 ? "left" : "right"; -} - -/** 링 한 줄 + 그 링의 **축 방향 좌표** — 부재끼리 링 자리를 맞추는 기준이다. */ -interface AxisRow extends Row { - t: number; -} - -/** 고정축을 따라 잰 좌표 — 좌향 벡터에 수직인 방향. 부호는 일관되기만 하면 된다. */ -function axisCoord(axis: StationAxis, cx: number, cy: number): number { - return (cx - axis.cx) * axis.leftY - (cy - axis.cy) * axis.leftX; -} - -/** 그 부재의 축 좌표 `t`에서의 편거리 창 — 링 사이는 선형 보간, 범위 밖은 null. */ -function spanAt(rows: ReadonlyArray, t: number): Span | null { - if (t < rows[0].t - EPS_M || t > rows[rows.length - 1].t + EPS_M) return null; - for (let i = 0; i < rows.length - 1; i += 1) { - const a = rows[i]; - const b = rows[i + 1]; - if (t < a.t - EPS_M || t > b.t + EPS_M) continue; - const width = b.t - a.t; - const k = width > EPS_M ? (t - a.t) / width : 0; - return [a.span[0] + (b.span[0] - a.span[0]) * k, a.span[1] + (b.span[1] - a.span[1]) * k]; - } - return rows[rows.length - 1].span; -} - -/** - * **아령 외곽선**(2026-08-27 사용자) — `planOutline` 표식이 붙은 부재(BOX암거 상판 + - * 에이프런)를 하나로 합친 탑뷰 경계 고리. 표식이 없으면 `null`. - * - * 사용자 원문: "박스암거의 경우에는 바닥을 위에서 보면 아령형상이고 제일 외곽에 있는 - * 선 성분을 가져가 평면 투영하면 경계가 되고 이걸 경계로 사용하면 가능할 것 같은데?" - * - * 기하 불리언은 여전히 쓰지 않는다(옛 `unionOutline`이 겹친 스트립에서 무너진 이유는 - * 이 파일 머리말 참조). BOX 부재는 전부 **같은 고정축 위 평행 링**이라, 링 자리마다 - * 각 부재의 편거리 창을 재서 **제일 바깥·제일 안쪽**을 취하면 그게 곧 외곽선이다. - * 퇴화(겹침·포함·맞닿은 변)가 생길 여지가 없다. - */ -function outlineLoop( - set: ReadonlyArray, - side: Side, - roadEdgeOffset: number, - planZ: number, - axis: StationAxis, -): Array<[number, number, number]> | null { - const members: AxisRow[][] = []; - for (const structure of set) { - if (!structure.planOutline) continue; - const rings = structure.rings; - if (!rings || rings.length < 2) continue; - const rows: AxisRow[] = []; - rings.forEach((frame, index) => { - const span = polygonSpan(structure.polygons?.[index] ?? structure.polygon); - if (span) rows.push({ frame, span, t: axisCoord(axis, frame.cx, frame.cy) }); - }); - if (rows.length >= 2) members.push(rows.sort((a, b) => a.t - b.t)); - } - if (!members.length) return null; - // 링 자리는 부재마다 다르다(상판 ±halfSpan·±innerHalf, 에이프런 ±reach·±halfSpan) — - // 전부 모아 그 자리마다 창을 합친다. 프레임은 그 자리에 실제로 링이 있는 부재 것을 - // 그대로 쓴다(전부 같은 축이라 프레임 보간이 필요 없다). - const stops = new Map(); - for (const rows of members) { - for (const row of rows) { - const key = Math.round(row.t / EPS_M) * EPS_M; - if (!stops.has(key)) stops.set(key, row.frame); - } - } - const rows: Row[] = []; - for (const t of [...stops.keys()].sort((a, b) => a - b)) { - let lo = Infinity; - let hi = -Infinity; - for (const member of members) { - const span = spanAt(member, t); - if (!span) continue; - if (span[0] < lo) lo = span[0]; - if (span[1] > hi) hi = span[1]; - } - if (hi - lo <= EPS_M) continue; - const clipped = clipOutsideRoad([lo, hi], side, roadEdgeOffset); - const frame = stops.get(t); - if (clipped && frame) rows.push({ frame, span: clipped }); - } - return runLoop(rows, side, planZ); -} - -/** - * 구조물 세트의 그 측 탑뷰 외곽 고리들 — **부재마다 따로**, 도로부(노견 안쪽)는 뺀다. - * - * 배관은 뺀다 — 다른 부재가 감싸고 있어 투영 면적에 안 들어간다(2026-08-26 사용자). - * - * 날개벽(`wing`)은 링이 노선이 아니라 **날개 축**을 타 링 폴리곤의 편거리가 노선 - * 편거리가 아니라 **패널 두께**다. 도로부 클립을 그대로 걸면 전부 죽으므로 클립을 - * 생략하고, 대신 링 위치로 측을 가린다 — 날개는 애초에 노견 밖에서 시작한다. - */ -export function structureFootprintLoops( - set: ReadonlyArray, - side: Side, - roadEdgeOffset: number, - planZ: number, - axis: StationAxis, -): Array> { - // 아령 외곽선을 낼 수 있는 세트(BOX암거)는 **그 한 줄만** 낸다 — 부재별 고리를 - // 함께 내면 날개 패널 슬리버가 다시 섞여 지형 재노출에 구멍을 만든다. - // 저판·측벽은 상판 폭 안, 날개는 에이프런 안이라 외곽선이 전부 품는다. - const outline = outlineLoop(set, side, roadEdgeOffset, planZ, axis); - if (outline) return [outline]; - const loops: Array> = []; - for (const structure of set) { - if (structure.kind === "pipe") continue; - const rings = structure.rings; - if (!rings || rings.length < 2) continue; - if (structure.wing && wingSide(rings, axis) !== side) continue; - let run: Row[] = []; - const flush = (): void => { - const loop = runLoop(run, side, planZ); - if (loop) loops.push(loop); - run = []; - }; - rings.forEach((frame, index) => { - const polygon = structure.polygons?.[index] ?? structure.polygon; - const span = polygonSpan(polygon); - const clipped = structure.wing - ? span - : span - ? clipOutsideRoad(span, side, roadEdgeOffset) - : null; - if (clipped) run.push({ frame, span: clipped }); - else flush(); - }); - flush(); - } - return loops; -} - -/** 광선이 성토 끝선(폴리라인)과 만난 자리 — 교점과 그 앞 마디 번호. */ -export interface ToeHit { - x: number; - y: number; - segment: number; -} - -/** - * 이보다 멀리서 만난 자리는 안 쓴다(m) — 헤어핀 건너편 성토선에 얻어걸리는 걸 막는다. - * 실측상 날개 끝↔성토 끝선은 1.6~2.9m다. 넉넉히 잡아도 이 정도면 충분하다. - */ -const RAY_LIMIT_M = 15; - -/** - * 원점을 지나 방향 `(dx, dy)`를 타는 **직선**이 성토 끝선과 만나는 가장 가까운 자리. - * - * 광선(바깥쪽만)이 아니라 직선인 이유 — 세월교 날개는 끝이 성토 끝선 **밖으로** - * 나간다(2026-08-26 실측: 날개 끝 편거리 4.16, 그 자리 성토 끝선 2.60). 바깥으로만 - * 쏘면 영영 안 만나 박스가 통째로 빠진다. 안쪽에서 만나도 "성토 끝선과 만나게"라는 - * 규칙은 그대로 성립한다. - */ -export function castToToe( - ox: number, - oy: number, - dx: number, - dy: number, - toe: ReadonlyArray, -): ToeHit | null { - let best: ToeHit | null = null; - let bestT = Infinity; - for (let i = 0; i < toe.length - 1; i += 1) { - const [ax, ay] = toe[i]; - const [bx, by] = toe[i + 1]; - const ex = bx - ax; - const ey = by - ay; - const denominator = dx * ey - dy * ex; - if (Math.abs(denominator) < 1e-12) continue; - const t = ((ax - ox) * ey - (ay - oy) * ex) / denominator; - const u = ((ax - ox) * dy - (ay - oy) * dx) / denominator; - const reach = Math.abs(t); - if (reach < 1e-6 || reach > RAY_LIMIT_M || u < -1e-9 || u > 1 + 1e-9 || reach >= bestT) { - continue; - } - bestT = reach; - best = { x: ox + dx * t, y: oy + dy * t, segment: i }; - } - return best; -} - -/** - * 날개벽 **성토끝 박스**(2026-08-26 사용자 협의 확정) — 다른 구조물과 달리 날개벽은 - * 성토 바깥까지 이어진 영역을 하나 더 그린다. - * - * · 밑변 = 날개벽 **시작 모서리 → 끝 모서리**. - * · 양 옆선 = 그 두 점에서 **구조물 축과 평행**하게 바깥으로. 축은 측점 좌향 - * 벡터(노선 수직)의 **고정 직선**이다 — 노선 곡률을 따라 각도가 바뀌지 않는다. - * · 바깥 변 = 두 교점 사이의 **원본 성토면 최하단 끝선** 그대로. - */ -export function wingBoxLoops( - set: ReadonlyArray, - side: Side, - planZ: number, - axis: StationAxis, - toe: ReadonlyArray, -): { - loops: Array>; - /** 측벽이 설 자리 — [끝선 교점 → 날개 시작 → 날개 끝 → 끝선 교점]. 성토 끝선 구간은 - * 뺀다(거기서 잘린 게 아니라 리본이 끝난 자리다 — 2026-08-27 사용자). */ - walls: Array>; -} { - if (toe.length < 2) return { loops: [], walls: [] }; - const sign = side === "left" ? 1 : -1; - const loops: Array> = []; - const walls: Array> = []; - for (const structure of set) { - const rings = structure.rings; - // 날개벽(revet)만 — 세월교 바닥 에이프런(basin)도 `wing` 표식을 달지만 박스는 없다. - if (!structure.wing || structure.kind !== "revet" || !rings || rings.length < 2) continue; - if (wingSide(rings, axis) !== side) continue; - // 옆선 방향 — 세월교는 그 날개가 붙은 자리의 노선 좌향(구조물이 실어 보낸다), - // BOX암거는 기준 측점의 좌향 그대로다(2026-08-26 사용자: "박스 암거는 유지"). - const [ax, ay] = structure.wingAxis ?? [axis.leftX, axis.leftY]; - const dx = ax * sign; - const dy = ay * sign; - const start = rings[0]; - const end = rings[rings.length - 1]; - const startHit = castToToe(start.cx, start.cy, dx, dy, toe); - const endHit = castToToe(end.cx, end.cy, dx, dy, toe); - if (!startHit || !endHit) continue; - const loop: Array<[number, number, number]> = [ - [start.cx, start.cy, planZ], - [end.cx, end.cy, planZ], - [endHit.x, endHit.y, planZ], - ]; - // 두 교점 사이 성토 끝선을 그대로 탄다 — 마디 번호 순서대로 훑는다. - const step = endHit.segment <= startHit.segment ? 1 : -1; - for ( - let i = endHit.segment + (step > 0 ? 1 : 0); - step > 0 ? i <= startHit.segment : i > startHit.segment; - i += step - ) { - loop.push([toe[i][0], toe[i][1], planZ]); - } - loop.push([startHit.x, startHit.y, planZ]); - loop.push(loop[0]); - loops.push(loop); - walls.push([ - [startHit.x, startHit.y, planZ], - [start.cx, start.cy, planZ], - [end.cx, end.cy, planZ], - [endHit.x, endHit.y, planZ], - ]); - } - return { loops, walls }; -} diff --git a/B05_Profile/Old/B05_Profile_UI_Corridor_Region.ts b/B05_Profile/Old/B05_Profile_UI_Corridor_Region.ts deleted file mode 100644 index e7562c5f2..000000000 --- a/B05_Profile/Old/B05_Profile_UI_Corridor_Region.ts +++ /dev/null @@ -1,404 +0,0 @@ -/* ============================================================================= - * B05_Profile_UI_Corridor_Region.ts - * 씬 수평면(x,z) 위의 **평면 영역**(사각 셀 격자 + 경계 선분) — 절단기 - * (`_Corridor_Clip.ts`)가 "이 안쪽을 도려내라"고 넘기는 자료다. 700줄 제한으로 - * `_Corridor_Clip.ts`에서 분리했다(2026-08-25). - * - * 영역은 `CorridorStrip`(코리도 외곽 — 좌우 catch line이 만드는 띠) 하나다. 지형에서 - * 절·성토 점유분을 도려낼 때 쓴다. 구조물 절단은 여기 없다 — Build가 행 단면을 - * 파라메트릭 경계(`_Corridor_Carve.ts`)로 미리 트림한다(2026-08-25). - * - * 셀(사각형) 단위 내부 판정이라 급곡선에서 외곽 폴리곤이 자기교차해도 견고하다. - * ========================================================================== */ - -import type { ModelBounds } from "./B05_Profile_UI_Markers"; -import type { CorridorBuildResult } from "./B05_Profile_UI_Corridor_Build"; - -export interface P2 { - x: number; - z: number; -} - -const EPS = 1e-9; - -/** 구조물 상단과 서피스가 "같은 높이"로 볼 여유(m) — 딱 붙는 자리의 z-fight 방지. */ -const COVER_TOLERANCE_M = 0.02; - -export function cross2(ax: number, az: number, bx: number, bz: number): number { - return ax * bz - az * bx; -} - -export function pointInTri2(p: P2, a: P2, b: P2, c: P2): boolean { - const d1 = cross2(b.x - a.x, b.z - a.z, p.x - a.x, p.z - a.z); - const d2 = cross2(c.x - b.x, c.z - b.z, p.x - b.x, p.z - b.z); - const d3 = cross2(a.x - c.x, a.z - c.z, p.x - c.x, p.z - c.z); - const hasNeg = d1 < -EPS || d2 < -EPS || d3 < -EPS; - const hasPos = d1 > EPS || d2 > EPS || d3 > EPS; - return !(hasNeg && hasPos); -} - -export function segmentsIntersect(a: P2, b: P2, c: P2, d: P2): boolean { - const d1 = cross2(b.x - a.x, b.z - a.z, c.x - a.x, c.z - a.z); - const d2 = cross2(b.x - a.x, b.z - a.z, d.x - a.x, d.z - a.z); - const d3 = cross2(d.x - c.x, d.z - c.z, a.x - c.x, a.z - c.z); - const d4 = cross2(d.x - c.x, d.z - c.z, b.x - c.x, b.z - c.z); - return d1 * d2 < -EPS && d3 * d4 < -EPS; -} - -/** 경계 선분 하나 — AABB를 함께 들고 있어 격자 후보 판정이 싸다. */ -export interface RegionSegment { - p: P2; - q: P2; - minX: number; - maxX: number; - minZ: number; - maxZ: number; -} - -/** 절단기가 보는 평면 영역의 최소 규약. */ -export interface PlanRegion { - readonly minX: number; - readonly maxX: number; - readonly minZ: number; - readonly maxZ: number; - /** - * 이 평면 점이 영역 안인가. `sceneY`를 주면 **그 표고까지 덮는 자리**만 안으로 본다 - * — 구조물이 성토면 **아래**로 지나가면(복토 밑 구체 같은) 성토면을 지우면 안 된다 - * (2026-08-25 사용자: 탑뷰에서 "노출되어야 하는" 면적만 투영한다). - */ - contains(p: P2, sceneY?: number): boolean; - segmentsNear(minX: number, maxX: number, minZ: number, maxZ: number): RegionSegment[]; -} - -/** - * 사각 셀 + 경계 선분을 균일격자로 색인한 평면 영역. - * - * 격자가 없으면 지형 삼각형 수 × 셀 수가 되어 편집 반복 시 초 단위로 멎는다 - * (2026-08-23 프리즈 보고). 격자로 후보만 본다. - */ -export class CellRegion implements PlanRegion { - protected cells: Array<{ - a: P2; - b: P2; - c: P2; - d: P2; - minX: number; - maxX: number; - minZ: number; - maxZ: number; - }> = []; - /** 셀마다의 상단 씬 Y — 이 위로는 못 덮는다. 표고 개념이 없으면 Infinity. */ - protected cellTops: number[] = []; - protected segments: RegionSegment[] = []; - minX = Infinity; - maxX = -Infinity; - minZ = Infinity; - maxZ = -Infinity; - private grid = 1; - private gridCols = 1; - private gridRows = 1; - private cellBuckets: number[][] = []; - private segmentBuckets: number[][] = []; - - /** 셀(사각형) 하나 추가 — 꼭짓점은 a→b→c→d 한 바퀴 순서, `top`은 상단 씬 Y다. */ - protected addCell(a: P2, b: P2, c: P2, d: P2, top = Infinity): void { - [a, b, c, d].forEach((point) => { - if (point.x < this.minX) this.minX = point.x; - if (point.x > this.maxX) this.maxX = point.x; - if (point.z < this.minZ) this.minZ = point.z; - if (point.z > this.maxZ) this.maxZ = point.z; - }); - this.cellTops.push(top); - this.cells.push({ - a, - b, - c, - d, - minX: Math.min(a.x, b.x, c.x, d.x), - maxX: Math.max(a.x, b.x, c.x, d.x), - minZ: Math.min(a.z, b.z, c.z, d.z), - maxZ: Math.max(a.z, b.z, c.z, d.z), - }); - } - - protected addSegment(p: P2, q: P2): void { - // 선분만 있는 영역(고리 영역)도 경계 상자를 갖도록 여기서도 넓힌다 — 셀 영역은 - // 선분이 셀 위에 놓이므로 값이 달라지지 않는다. - [p, q].forEach((point) => { - if (point.x < this.minX) this.minX = point.x; - if (point.x > this.maxX) this.maxX = point.x; - if (point.z < this.minZ) this.minZ = point.z; - if (point.z > this.maxZ) this.maxZ = point.z; - }); - this.segments.push({ - p, - q, - minX: Math.min(p.x, q.x), - maxX: Math.max(p.x, q.x), - minZ: Math.min(p.z, q.z), - maxZ: Math.max(p.z, q.z), - }); - } - - /** 셀·선분을 균일격자 버킷에 넣는다(격자 한 칸 ≈ 셀 한 변). */ - protected buildIndex(): void { - // 셀이 없으면 선분 크기로 격자를 잡는다 — 고리 영역(`PlanLoopRegion`)은 셀 없이 - // 경계 선분만 들고 있다(2026-08-27). - const sources: Array<{ minX: number; maxX: number; minZ: number; maxZ: number }> = this.cells - .length - ? this.cells - : this.segments; - if (!sources.length) return; - const meanCell = - sources.reduce((sum, item) => sum + (item.maxX - item.minX) + (item.maxZ - item.minZ), 0) / - sources.length; - this.grid = Math.max(2, meanCell); - this.gridCols = Math.max(1, Math.ceil((this.maxX - this.minX) / this.grid) + 1); - this.gridRows = Math.max(1, Math.ceil((this.maxZ - this.minZ) / this.grid) + 1); - this.cellBuckets = Array.from({ length: this.gridCols * this.gridRows }, () => []); - this.segmentBuckets = Array.from({ length: this.gridCols * this.gridRows }, () => []); - const put = ( - buckets: number[][], - index: number, - minX: number, - maxX: number, - minZ: number, - maxZ: number, - ): void => { - const c0 = this.colOf(minX); - const c1 = this.colOf(maxX); - const r0 = this.rowOf(minZ); - const r1 = this.rowOf(maxZ); - for (let r = r0; r <= r1; r += 1) { - for (let c = c0; c <= c1; c += 1) buckets[r * this.gridCols + c].push(index); - } - }; - this.cells.forEach((cell, i) => - put(this.cellBuckets, i, cell.minX, cell.maxX, cell.minZ, cell.maxZ), - ); - this.segments.forEach((s, i) => put(this.segmentBuckets, i, s.minX, s.maxX, s.minZ, s.maxZ)); - } - - private colOf(x: number): number { - return Math.min(this.gridCols - 1, Math.max(0, Math.floor((x - this.minX) / this.grid))); - } - - private rowOf(z: number): number { - return Math.min(this.gridRows - 1, Math.max(0, Math.floor((z - this.minZ) / this.grid))); - } - - /** 셀이 하나도 없으면 아무것도 안 지운다(빈 영역). */ - get isEmpty(): boolean { - return this.cells.length === 0; - } - - contains(p: P2, sceneY?: number): boolean { - if (p.x < this.minX || p.x > this.maxX || p.z < this.minZ || p.z > this.maxZ) return false; - const bucket = this.cellBuckets[this.rowOf(p.z) * this.gridCols + this.colOf(p.x)]; - if (!bucket) return false; - for (const index of bucket) { - const cell = this.cells[index]; - if (p.x < cell.minX || p.x > cell.maxX || p.z < cell.minZ || p.z > cell.maxZ) continue; - // 서피스가 셀 상단보다 위에 있으면 그 자리는 구조물에 가려지지 않는다 — 남긴다. - // 두 면이 딱 붙는 자리는 z-fight가 나므로 여유(COVER_TOLERANCE_M)만큼 봐 준다. - if (sceneY !== undefined && sceneY > this.cellTops[index] + COVER_TOLERANCE_M) continue; - if (pointInTri2(p, cell.a, cell.b, cell.c) || pointInTri2(p, cell.a, cell.c, cell.d)) { - return true; - } - } - return false; - } - - /** 삼각형 AABB와 겹치는 경계 선분들 — 정밀 절단 후보(격자 후보만 검사). */ - segmentsNear(minX: number, maxX: number, minZ: number, maxZ: number): RegionSegment[] { - if (!this.segmentBuckets.length) return []; - const seen = new Set(); - const result: RegionSegment[] = []; - const c0 = this.colOf(minX); - const c1 = this.colOf(maxX); - const r0 = this.rowOf(minZ); - const r1 = this.rowOf(maxZ); - for (let r = r0; r <= r1; r += 1) { - for (let c = c0; c <= c1; c += 1) { - for (const index of this.segmentBuckets[r * this.gridCols + c]) { - if (seen.has(index)) continue; - seen.add(index); - const s = this.segments[index]; - if (s.maxX >= minX && s.minX <= maxX && s.maxZ >= minZ && s.minZ <= maxZ) result.push(s); - } - } - } - return result; - } -} - -/** 모델 좌표(동·북) → 씬 수평면(x, z) 변환기 — 뷰어 공통 규약. */ -export function scenePlanMapper(bounds: ModelBounds): (mx: number, my: number) => P2 { - const cx = (bounds.x[0] + bounds.x[1]) / 2; - const cy = (bounds.y[0] + bounds.y[1]) / 2; - return (mx: number, my: number): P2 => ({ x: mx - cx, z: -(my - cy) }); -} - -/** 코리도 스트립 — 좌우 catch line 사이를 프레임 사각형으로 담는다. */ -export class CorridorStrip extends CellRegion { - constructor(build: CorridorBuildResult, bounds: ModelBounds) { - super(); - const toScene = scenePlanMapper(bounds); - const left = build.outline.left.map(([mx, my]) => toScene(mx, my)); - const right = build.outline.right.map(([mx, my]) => toScene(mx, my)); - const count = Math.min(left.length, right.length); - for (let i = 0; i < count - 1; i += 1) { - this.addCell(left[i], right[i], right[i + 1], left[i + 1]); - } - for (let i = 0; i < count - 1; i += 1) { - this.addSegment(left[i], left[i + 1]); - this.addSegment(right[i], right[i + 1]); - } - if (count > 0) { - this.addSegment(left[0], right[0]); - this.addSegment(left[count - 1], right[count - 1]); - } - this.buildIndex(); - } -} - -/** - * 닫힌 고리들이 덮는 평면 영역(2026-08-27 사용자 ③) — 구조물 풋프린트·날개 사다리꼴처럼 - * 격자에 안 실리는 임의 다각형을 담는다. 오목해도 옳도록 내부 판정은 **광선 투사**다 - * (부채꼴 삼각분할은 오목 고리에서 틀린다). 경계 선분은 상속받은 격자로 색인해 - * 절단기가 삼각형마다 후보만 보게 한다. - */ -/** 고리 마디 하나로 볼 최소 길이(m)·꺾임 여유(m) — 절단선 수를 줄이는 단순화 기준. */ -const LOOP_MIN_SEGMENT_M = 0.25; -const LOOP_COLLINEAR_M = 0.02; - -/** - * 고리를 **절단선 수 기준으로** 단순화한다(2026-08-27). 경계 선분 하나가 무한직선 하나로 - * 삼각형을 자르므로, 구조물 풋프린트처럼 점이 촘촘한 고리를 그대로 쓰면 한 삼각형이 - * 수십 번 쪼개져 조각이 폭증한다(실측: 구멍 134㎡ 자리에 7,948조각). 짧은 마디와 거의 - * 일직선인 점을 걷어내면 형상은 그대로 두고 절단선만 준다. - */ -function simplifyLoop(points: ReadonlyArray): P2[] { - const kept: P2[] = []; - for (const point of points) { - const last = kept[kept.length - 1]; - if (last && Math.hypot(point.x - last.x, point.z - last.z) < LOOP_MIN_SEGMENT_M) continue; - kept.push(point); - } - // 시작·끝이 겹치면 하나로 — 광선 투사는 자동으로 닫는다. - if (kept.length > 1) { - const first = kept[0]; - const last = kept[kept.length - 1]; - if (Math.hypot(first.x - last.x, first.z - last.z) < LOOP_MIN_SEGMENT_M) kept.pop(); - } - if (kept.length < 4) return kept; - const thinned: P2[] = []; - for (let i = 0; i < kept.length; i += 1) { - const previous = thinned[thinned.length - 1] ?? kept[kept.length - 1]; - const point = kept[i]; - const next = kept[(i + 1) % kept.length]; - // 앞뒤를 잇는 직선에서 이 점이 거의 안 벗어나면 뺀다(꺾임이 없다). - const dx = next.x - previous.x; - const dz = next.z - previous.z; - const length = Math.hypot(dx, dz); - const offset = - length < 1e-9 - ? Math.hypot(point.x - previous.x, point.z - previous.z) - : Math.abs(dx * (point.z - previous.z) - dz * (point.x - previous.x)) / length; - if (offset > LOOP_COLLINEAR_M) thinned.push(point); - } - return thinned.length >= 3 ? thinned : kept; -} - -export class PlanLoopRegion extends CellRegion { - private loops: Array<{ points: P2[]; minX: number; maxX: number; minZ: number; maxZ: number }> = - []; - - constructor(loops: ReadonlyArray>, bounds: ModelBounds) { - super(); - const toScene = scenePlanMapper(bounds); - for (const loop of loops) { - if (loop.length < 3) continue; - const points = simplifyLoop(loop.map(([mx, my]) => toScene(mx, my))); - if (points.length < 3) continue; - let minX = Infinity; - let maxX = -Infinity; - let minZ = Infinity; - let maxZ = -Infinity; - points.forEach((point) => { - if (point.x < minX) minX = point.x; - if (point.x > maxX) maxX = point.x; - if (point.z < minZ) minZ = point.z; - if (point.z > maxZ) maxZ = point.z; - }); - this.loops.push({ points, minX, maxX, minZ, maxZ }); - for (let i = 0, j = points.length - 1; i < points.length; j = i, i += 1) { - this.addSegment(points[j], points[i]); - } - } - this.buildIndex(); - } - - override get isEmpty(): boolean { - return this.loops.length === 0; - } - - override contains(p: P2): boolean { - if (p.x < this.minX || p.x > this.maxX || p.z < this.minZ || p.z > this.maxZ) return false; - for (const loop of this.loops) { - if (p.x < loop.minX || p.x > loop.maxX || p.z < loop.minZ || p.z > loop.maxZ) continue; - const points = loop.points; - let hit = false; - for (let i = 0, j = points.length - 1; i < points.length; j = i, i += 1) { - const a = points[i]; - const b = points[j]; - if (a.z > p.z !== b.z > p.z && p.x < ((b.x - a.x) * (p.z - a.z)) / (b.z - a.z) + a.x) { - hit = !hit; - } - } - if (hit) return true; - } - return false; - } -} - -/** - * **차집합 영역**(2026-08-27 사용자 ③) — 바탕에서 구멍을 뺀 자리만 "안"으로 본다. - * 구조물이 안착한 뒤 그 평면 영역에서 원지반이 다시 드러나야 하므로, 지형을 도려낼 때 - * 코리도 스트립에서 구조물·날개 영역을 뺀다. 경계 선분은 **양쪽 합집합**이라 구멍 - * 가장자리에서도 지형이 깨끗하게 재절단된다. - */ -export class SubtractedRegion implements PlanRegion { - constructor( - private readonly base: PlanRegion, - private readonly holes: ReadonlyArray, - ) {} - - get minX(): number { - return this.base.minX; - } - - get maxX(): number { - return this.base.maxX; - } - - get minZ(): number { - return this.base.minZ; - } - - get maxZ(): number { - return this.base.maxZ; - } - - contains(p: P2, sceneY?: number): boolean { - if (!this.base.contains(p, sceneY)) return false; - // 구멍은 표고를 따지지 않는다 — 그 평면 자리는 원지반이 드러나는 게 규칙이다. - return !this.holes.some((hole) => hole.contains(p)); - } - - segmentsNear(minX: number, maxX: number, minZ: number, maxZ: number): RegionSegment[] { - const result = this.base.segmentsNear(minX, maxX, minZ, maxZ); - for (const hole of this.holes) result.push(...hole.segmentsNear(minX, maxX, minZ, maxZ)); - return result; - } -} diff --git a/B05_Profile/Old/B05_Profile_UI_Corridor_Skirt.ts b/B05_Profile/Old/B05_Profile_UI_Corridor_Skirt.ts deleted file mode 100644 index caed3e430..000000000 --- a/B05_Profile/Old/B05_Profile_UI_Corridor_Skirt.ts +++ /dev/null @@ -1,223 +0,0 @@ -/* ============================================================================= - * B05_Profile_UI_Corridor_Skirt.ts - * **변형 성토면(패치 리본) 바깥 끝 스커트** — 2026-08-27 사용자. - * - * 왜: 기본 성토면은 행마다 지반선에 트림되고(`_Corridor_Build.ts` `trimSlopeToGround`) - * 최외곽 정점을 지형 메시에 스냅해(`_UI_Viewer.ts` `snapCorridorEdges`) 지반과 맞물린다. - * 패치 리본은 구조물 측점 단면 한 장을 종단 shift만 얹어 복사한 것이라 지반 트림이 - * 없고, 스냅 대상에서도 빠져 있다(스냅하면 벽 뒷면에서 끝나야 할 끝이 원지반까지 - * 끌려 내려가 4.1m 세로 지느러미가 생겼다 — 2026-08-27 실측). 그래서 구간을 따라 - * 지반이 오르내리면 패치 바깥 끝이 공중에 뜬다. - * - * 방식: 정점은 **하나도 건드리지 않는다**. 바깥 끝 모서리에서 원지반까지 판만 세운다 - * (`_Corridor_Cut.ts`의 절취 측벽과 같은 수법). 지느러미를 막는 두 조건: - * · 마디 양 끝이 **지반보다 위**일 때만 세운다 — 잠긴 자리는 지형이 덮는다. - * · 낙차가 `PATCH_SKIRT_MAX_M`을 넘는 마디는 건너뛴다 — 벽 뒷면·구체 상단에서 - * 끝나는 줄이 통째로 원지반까지 떨어지는 걸 막는다. - * ========================================================================== */ - -import type { CorridorCutWall, CorridorRibbon } from "./B05_Profile_UI_Corridor_Build"; - -/** 밑단을 원지반 **아래로 더 내리는 여유**(m) — 지반 보간 오차만큼 밑이 뜨는 걸 막는다. - * 절취 측벽(`_Corridor_Cut.ts` `SKIRT_MARGIN_M`)과 같은 값·같은 이유다. */ -const SKIRT_MARGIN_M = 1; - -/** 세울 수 있는 최대 낙차(m) — 이걸 넘으면 지반과의 틈이 아니라 **끝나는 게 정상인 - * 자리**(벽 뒷면·구체 상단)로 본다. 넘는 마디는 건너뛴다(2026-08-27 사용자). */ -const PATCH_SKIRT_MAX_M = 3; - -/** 판을 세울 만한 최소 낙차(m) — 이 아래는 지형이 덮어 보이지 않는다. */ -const SKIRT_MIN_DROP_M = 0.02; - -/** 빗금 한 타일이 덮는 실거리(m) — 성토 리본의 종방향 규칙(2m마다 1타일)과 같다. */ -const HATCH_TILE_M = 2; - -/** - * **지반에서 끝나는 줄**로 볼 허용 낙차(m) — 소유 측점(구간 한가운데) 행에서 잰다. - * - * 왜 필요한가(2026-08-27 사용자 지적): 다단 성토는 `성토 > 구조물 > 성토`라 패치가 - * 여러 줄이고, 안쪽 줄들은 **벽 뒷면·구체 상단에서 끝나는 게 정상**이다. 그 줄에도 - * 판을 세우면 BOX암거 앞면처럼 없던 서피스가 생긴다(실측: 구간 201에서 바깥 끝이 - * 지반 위 1.96~2.2m). B06 실루엣은 소유 측점에서 지반에 트림돼 있으므로, 그 행의 - * 낙차가 이 값 안이면 "지반까지 내려오는 줄", 넘으면 "구조물에서 끝나는 줄"이다. - */ -const PATCH_TOE_TOLERANCE_M = 0.5; - -/** 검증용 요약 — 화면 밖에서 수치로 확인한다(`window.__corridorSkirt`). */ -export interface PatchSkirtStats { - /** 스커트가 붙은 패치 리본 수. */ - ribbons: number; - /** 세운 삼각형 수. */ - triangles: number; - /** 실제로 세운 마디의 최대 낙차(m). */ - maxDropM: number; - /** 낙차 상한을 넘겨 건너뛴 마디 수. */ - skippedTall: number; - /** 지반 아래(또는 붙어 있어) 건너뛴 마디 수. */ - skippedSubmerged: number; - /** 지반 높이를 못 읽어 건너뛴 마디 수. */ - skippedNoGround: number; - /** 리본별 계측 — 어느 열이 실제 바깥인지, 낙차가 어디서 나는지 확인용. */ - detail: Array<{ - side: string; - rows: number; - cols: number; - from: number; - to: number; - /** 첫 행 기준 양 끝 열의 [설계z, 지반z, 낙차]. */ - col0: [number, number, number]; - colLast: [number, number, number]; - /** 소유 측점 행 바깥 끝의 낙차(m)와 그 판정 — 지반에서 끝나는 줄만 스커트 대상. */ - gateDrop: number; - reachesGround: boolean; - triangles: number; - }>; -} - -/** - * 패치 리본 **바깥 끝 모서리**에 스커트를 세운다. - * - * `groundAt`은 모델 좌표 (x, y) 자리의 **원지반 모델 z**를 돌려준다(지형 밖이면 null). - * 보이는 틈은 렌더된 지형 메시와의 틈이므로 호출부가 지형 메시 색인을 넘긴다 — - * 횡단 샘플러 보간값을 쓰면 지반이 두 벌이 되어 또 미세 틈이 난다. - */ -export function buildPatchSkirts( - ribbons: ReadonlyArray, - groundAt: (x: number, y: number) => number | null, -): { walls: CorridorCutWall[]; stats: PatchSkirtStats } { - const walls: CorridorCutWall[] = []; - const stats: PatchSkirtStats = { - ribbons: 0, - triangles: 0, - maxDropM: 0, - skippedTall: 0, - skippedSubmerged: 0, - skippedNoGround: 0, - detail: [], - }; - for (const ribbon of ribbons) { - if (!ribbon.patch || ribbon.kind !== "fill") continue; - if (ribbon.side !== "left" && ribbon.side !== "right") continue; - const cols = ribbon.colCount; - const rows = ribbon.chainages.length; - if (cols < 2 || rows < 2) continue; - // 패치 리본은 단면을 **수집 순서(도로측 → 바깥)** 그대로 담는다 — Build 리본과 - // 달리 우측을 뒤집지 않으므로 좌·우 모두 마지막 열이 바깥 끝이다(`_Carve.ts`). - const outerCol = cols - 1; - const tris: number[] = []; - // 계측용 — 첫 행 양 끝 열의 설계z·지반z·낙차(어느 열이 실제 바깥인지 확인). - const probe = (col: number): [number, number, number] => { - const i = col * 3; - const z = ribbon.positions[i + 2]; - const g = groundAt(ribbon.positions[i], ribbon.positions[i + 1]); - return [z, g ?? NaN, g === null ? NaN : z - g]; - }; - const col0 = probe(0); - const colLast = probe(cols - 1); - // **지반에서 끝나는 줄인가** — 소유 측점(구간 한가운데) 행에서 잰다. 벽 뒷면·구체 - // 상단에서 끝나는 줄이면 여기서 빠진다(2026-08-27 사용자: BOX암거 앞면에 없던 - // 서피스가 생겼다). - const middle = Math.floor(rows / 2); - const gateIndex = (middle * cols + outerCol) * 3; - const gateGround = groundAt(ribbon.positions[gateIndex], ribbon.positions[gateIndex + 1]); - const gateDrop = gateGround === null ? NaN : ribbon.positions[gateIndex + 2] - gateGround; - const reachesGround = gateGround !== null && gateDrop <= PATCH_TOE_TOLERANCE_M; - // UV — u는 모서리를 따라 걸은 실거리, v는 표고. 둘 다 2m마다 한 타일이라 빗금이 - // 성토 리본과 같은 실척으로 이어진다(2026-08-27 사용자: 스커트는 메시 서피스). - const uvs: number[] = []; - let walked = 0; - let previous: { x: number; y: number; top: number; bottom: number } | null = null; - for (let row = 0; reachesGround && row < rows; row += 1) { - const i = (row * cols + outerCol) * 3; - const x = ribbon.positions[i]; - const y = ribbon.positions[i + 1]; - const top = ribbon.positions[i + 2]; - const ground = groundAt(x, y); - const current = - ground === null ? null : { x, y, top, bottom: Math.min(top, ground) - SKIRT_MARGIN_M }; - const dropOf = (point: NonNullable): number => - point.top - (point.bottom + SKIRT_MARGIN_M); - if (previous && current) { - const dropA = dropOf(previous); - const dropB = dropOf(current); - if (dropA < SKIRT_MIN_DROP_M && dropB < SKIRT_MIN_DROP_M) { - stats.skippedSubmerged += 1; - } else if (Math.max(dropA, dropB) > PATCH_SKIRT_MAX_M) { - stats.skippedTall += 1; - } else { - const uPrev = walked / HATCH_TILE_M; - const uCurrent = - (walked + Math.hypot(current.x - previous.x, current.y - previous.y)) / HATCH_TILE_M; - tris.push( - previous.x, - previous.y, - previous.top, - current.x, - current.y, - current.top, - current.x, - current.y, - current.bottom, - ); - uvs.push( - uPrev, - previous.top / HATCH_TILE_M, - uCurrent, - current.top / HATCH_TILE_M, - uCurrent, - current.bottom / HATCH_TILE_M, - ); - tris.push( - previous.x, - previous.y, - previous.top, - current.x, - current.y, - current.bottom, - previous.x, - previous.y, - previous.bottom, - ); - uvs.push( - uPrev, - previous.top / HATCH_TILE_M, - uCurrent, - current.bottom / HATCH_TILE_M, - uPrev, - previous.bottom / HATCH_TILE_M, - ); - stats.triangles += 2; - stats.maxDropM = Math.max(stats.maxDropM, dropA, dropB); - } - } else if (previous || current) { - stats.skippedNoGround += 1; - } - if (previous && current) { - walked += Math.hypot(current.x - previous.x, current.y - previous.y); - } - previous = current; - } - stats.detail.push({ - side: ribbon.side, - rows, - cols, - from: ribbon.chainages[0], - to: ribbon.chainages[rows - 1], - col0, - colLast, - gateDrop, - reachesGround, - triangles: tris.length / 9, - }); - if (tris.length) { - walls.push({ - side: ribbon.side, - positions: new Float64Array(tris), - patchSkirt: true, - uvs: new Float32Array(uvs), - }); - stats.ribbons += 1; - } - } - return { walls, stats }; -} diff --git a/B05_Profile/Old/B05_Profile_UI_Corridor_Station.ts b/B05_Profile/Old/B05_Profile_UI_Corridor_Station.ts deleted file mode 100644 index c3571196a..000000000 --- a/B05_Profile/Old/B05_Profile_UI_Corridor_Station.ts +++ /dev/null @@ -1,607 +0,0 @@ -/* ============================================================================= - * B05_Profile_UI_Corridor_Station.ts - * 코리도 측점 분류 · 행 지반 트림 유틸 (Build에서 분리 — 700줄 제한). - * - * classifyStation: 측점 하나의 design_line을 종류별(차도/노견/측구/절·성토 비탈) - * 조각으로 자르고 리샘플한다. 계산식 재구현 금지 — 백엔드 design_line을 offset - * 경계로 분류만 한다. - * trimSlopeToGround: 비탈을 행 지반선과 직접 교차시켜 종결 — 절성토 완전 분리 - * (2026-08-23 사용자 지시)의 핵심. - * ========================================================================== */ - -import { fordDeckElevationAt } from "../B06_Section/B06_Section_UI_Cross_Ford_Pavement"; -import type { CrossSection } from "../B06_Section/B06_Section_Api_Fetch"; -import { structureSilhouettes } from "./B05_Profile_UI_Corridor_Station_Structure"; - -export type CorridorKind = "carriageway" | "shoulder" | "ditch" | "cut" | "fill"; -export type CorridorSide = "left" | "right" | "center"; - -/** 조각별 고정 열 수 — 보간 시 t-리샘플 기준. 비탈은 무릎(2단 경사)까지 담게 넉넉히. - * 절·성토는 구조물 구간에서 [노견 → 연장 → 벽 상단 → 벽 전면 → 성토부선 → 다단 - * 벽들 → 지반]까지 한 줄로 담아야 해 넉넉히 잡는다(2026-08-23 사용자 — 기슭막이 - * 하단 성토사면·추가 기슭막이 반영). */ -export const PIECE_COLS: Record = { - carriageway: 3, - // 노견 3열 = [차도끝, 원 노견끝, 바깥끝]. 2열이면 배수관 측점의 노폭 연장을 담을 때 - // 원 노견 끝의 꺾임이 사라져 노견 물매(3%)가 연장분까지 퍼진다(2026-08-24 사용자). - shoulder: 3, - ditch: 5, - cut: 20, - // 성토 조각은 다단 기슭막이의 계단(거의 수직인 앞면)을 담아야 한다. 20열이면 호길이 - // 균등 재추출이 수직면을 뭉갠다(2026-08-24). 열 수는 모든 측점이 같아야 리본이 - // 꼬이지 않으므로, 꼭짓점 보존 대신 **열을 늘려** 해상도로 해결한다. - fill: 48, -}; - -export interface XY { - x: number; - y: number; -} - -export interface OffsetPoint { - offset_m: number; - elevation_m: number; -} - -/** 측점 하나의 분류·리샘플 결과 — (kind,side)별 colCount 고정 폴리라인. */ -export interface StationPieces { - chainage_m: number; - center: XY; - left: XY; - /** kind:side 키 → 리샘플된 단면 점(offset/z). 없는 조각은 키 부재. */ - pieces: Map; - /** 축퇴 기준점 — 좌/우 road edge (조각이 없는 측점에서 리본 폭 0 수렴용). */ - roadEdge: { left: OffsetPoint; right: OffsetPoint }; - /** 마구리용 단면 — catch 사이 [offset, 설계z, 지반z] (시·종점에서만 쓴다). */ - capSection: Array<[number, number, number]>; - /** - * 측구 조각을 **도로 끝(road edge) 기준 상대 좌표**로도 들고 있는다. - * - * 도로부(차도·노견·측구)는 한 몸이다 — 그런데 측구는 있는 측점과 없는 측점이 - * 갈려서, 절대 좌표로 따로 보간하면 도로 끝은 다음 측점을 향해 내려가는데 측구만 - * 제자리에 남아 최대 0.9m까지 벌어졌다(2026-08-23 실측). 상대 좌표로 들고 있다가 - * 프레임의 도로 끝에 얹으면 무슨 일이 있어도 노견에 붙는다. - */ - ditchRelative: Map; - /** - * 절·성토 비탈을 **비탈 시작점(도로부 바깥 끝) 기준 상대 좌표**로도 들고 있는다. - * - * 측구와 같은 규칙(도로부 한 몸 제어) — 행마다 도로부 끝 앵커에 얹으면 경사 - * 형상(1:1.2 등)이 그대로 유지된 채 위치만 따라온다(2026-08-23 완전 분리). - */ - slopeRelative: Map; - /** 지반선 보간기 — 행 지반선(두 측점 혼합) 계산용. 샘플 부실이면 null. */ - ground: ((offset: number) => number) | null; - /** - * 배수관 세트 기슭막이의 종방향 점유 구간(2026-08-23 사용자 — 성토부 형태 변경). - * 이 구간 안 행은 그 측의 비탈을 이 측점의 트림 사면(노견 연장 + 벽 상단 종결) - * 그대로 쓰고 **지반 트림을 하지 않는다** — 벽에서 끝난 사면을 지반까지 도로 - * 늘리면 안 된다. - */ - wallSpans?: Partial>; - /** - * 노견 확장 구간(2026-08-25 사용자 ①) — 구조물 실루엣의 수평 확장점까지 넓힌 - * 노견을 구조물 구간 내내 유지하기 위한 제어점 범위. 비탈 교체(wallSpans)와 - * 달리 **노견 조각에만** 걸린다. - */ - shoulderSpans?: Partial>; -} - -/** - * 측점 분류 선택(115-4). - * - * `silhouette` — 구조물 실루엣으로 **비탈 조각을 강제로 갈아 끼우던** 옛 방식 A - * (08-25 `6e87dd6cd` 「실루엣 갈아 끼우기」). 기본 끔(2026-08-25 사용자 지시: 기존 성토면을 - * 강제 변형시키던 로직 비활성화) — 끄면 비탈은 측점 설계선 그대로 지반까지 가고 구조물과 - * 겹치는 자리는 탑뷰 투영으로 도려낸다. 켜면 그날 그대로 **배수관 세트만** 갈아 끼우고 - * (BOX · 세월교는 원본 비탈) 노견 확장 상시 적용(08-25 ①, 그날 뒤)은 하지 않는다. - */ -export interface ClassifyOptions { - silhouette?: boolean; -} - -export function pieceKey(kind: CorridorKind, side: CorridorSide): string { - return `${kind}:${side}`; -} - -/** design_line에서 [a,b] 구간 서브폴리라인 추출(경계점은 선형 보간으로 삽입). */ -function slicePolyline(line: OffsetPoint[], a: number, b: number): OffsetPoint[] { - const lo = Math.min(a, b); - const hi = Math.max(a, b); - if (hi - lo < 1e-9 || line.length < 2) return []; - const zAt = (offset: number): number => { - if (offset <= line[0].offset_m) return line[0].elevation_m; - for (let i = 1; i < line.length; i += 1) { - const p0 = line[i - 1]; - const p1 = line[i]; - if (offset <= p1.offset_m + 1e-12) { - const span = p1.offset_m - p0.offset_m; - if (span <= 1e-12) return p1.elevation_m; - const t = (offset - p0.offset_m) / span; - return p0.elevation_m + (p1.elevation_m - p0.elevation_m) * t; - } - } - return line[line.length - 1].elevation_m; - }; - const result: OffsetPoint[] = [{ offset_m: lo, elevation_m: zAt(lo) }]; - for (const point of line) { - if (point.offset_m > lo + 1e-9 && point.offset_m < hi - 1e-9) result.push(point); - } - result.push({ offset_m: hi, elevation_m: zAt(hi) }); - return result; -} - -/** 폴리라인을 호길이 비례 t(0..1)로 cols개 점으로 리샘플 — 프레임 간 열 대응용. */ -export function resample(points: OffsetPoint[], cols: number): OffsetPoint[] { - if (points.length === 0) return []; - if (points.length === 1) return Array.from({ length: cols }, () => ({ ...points[0] })); - const lengths: number[] = [0]; - for (let i = 1; i < points.length; i += 1) { - const dOffset = points[i].offset_m - points[i - 1].offset_m; - const dz = points[i].elevation_m - points[i - 1].elevation_m; - lengths.push(lengths[i - 1] + Math.hypot(dOffset, dz)); - } - const total = lengths[lengths.length - 1]; - const result: OffsetPoint[] = []; - for (let c = 0; c < cols; c += 1) { - const target = total <= 1e-12 ? 0 : (total * c) / (cols - 1); - let index = 1; - while (index < points.length - 1 && lengths[index] < target) index += 1; - const p0 = points[index - 1]; - const p1 = points[index]; - const span = lengths[index] - lengths[index - 1]; - const t = span <= 1e-12 ? 0 : (target - lengths[index - 1]) / span; - result.push({ - offset_m: p0.offset_m + (p1.offset_m - p0.offset_m) * t, - elevation_m: p0.elevation_m + (p1.elevation_m - p0.elevation_m) * t, - }); - } - return result; -} - -/** 지반선 보간기 — samples(offset, elevation)로 지반고를 되짚는다. */ -function groundSampler(section: CrossSection): ((offset: number) => number) | null { - const points = section.samples - .map((sample) => ({ - offset_m: sample.offset_m, - elevation_m: sample.elevation_m ?? sample.z, - })) - .filter( - (p): p is OffsetPoint => - p.offset_m !== undefined && p.elevation_m !== undefined && p.elevation_m !== null, - ) - .sort((a, b) => a.offset_m - b.offset_m); - if (points.length < 2) return null; - return (offset: number): number => { - if (offset <= points[0].offset_m) return points[0].elevation_m; - for (let i = 1; i < points.length; i += 1) { - if (offset <= points[i].offset_m) { - const span = points[i].offset_m - points[i - 1].offset_m; - const t = span <= 1e-12 ? 0 : (offset - points[i - 1].offset_m) / span; - return points[i - 1].elevation_m + (points[i].elevation_m - points[i - 1].elevation_m) * t; - } - } - return points[points.length - 1].elevation_m; - }; -} - -/** 설계선-지반 허용차(m) — 이보다 가까우면 "지반에 닿았다"(catch)로 본다. */ -export const CATCH_EPS_M = 0.005; - -/** - * 비탈 시작점에서 바깥으로 스캔해 설계선이 지반과 만나는 catch point를 찾는다. - * design_line은 샘플 반폭 끝까지 이어지고 catch 밖은 지반을 그대로 따르므로, - * 여기서 잘라야 코리도·클리핑 폭이 실제 절·성토 점유 범위가 된다. - */ -function catchOffset( - line: OffsetPoint[], - groundAt: (offset: number) => number, - start: number, - direction: 1 | -1, - end: number, -): number { - const probes = line - .filter((p) => (direction > 0 ? p.offset_m > start + 1e-9 : p.offset_m < start - 1e-9)) - .sort((a, b) => (a.offset_m - b.offset_m) * direction); - for (const probe of probes) { - if (Math.abs(probe.elevation_m - groundAt(probe.offset_m)) <= CATCH_EPS_M) { - return probe.offset_m; - } - } - return end; // 반폭 안에서 지반을 못 만남(깊은 절토·높은 성토) — 샘플 끝까지. -} - -/** 측구 조각을 도로 끝 기준 상대 좌표로 바꾼다(도로부 한 몸 제어용). */ -function relativeToRoadEdge( - pieces: Map, - roadEdge: { left: OffsetPoint; right: OffsetPoint }, -): Map { - const relative = new Map(); - (["left", "right"] as const).forEach((side) => { - const key = pieceKey("ditch", side); - const points = pieces.get(key); - if (!points) return; - const edge = roadEdge[side]; - relative.set( - key, - points.map((point) => ({ - offset_m: point.offset_m - edge.offset_m, - elevation_m: point.elevation_m - edge.elevation_m, - })), - ); - }); - return relative; -} - -/** 측점 하나를 종류별 조각으로 분류·리샘플. design 없거나 설계선 부실 → null. */ -export function classifyStation( - section: CrossSection, - /** `.map(classifyStation)` 이 넘기는 인덱스(number)는 끔으로 읽는다. */ - options?: ClassifyOptions | number, -): StationPieces | null { - const design = section.design; - const line = design?.design_line; - if (!design || !line || line.length < 2) return null; - const roadL = design.road_edges.left.offset_m; - const roadR = design.road_edges.right.offset_m; - // 물넘이포장 — 노면(노견 포함)이 파인다. 횡단도와 **같은 산식**으로 표고를 내려 - // 3D도 같은 자리에 같은 깊이로 팬다(2026-08-28 사용자 지시). 노면 밖 비탈은 그대로라 - // 노견 끝에 파임 벽(수직 단차)이 선다 — 횡단도 그림과 같은 표현이다. - const ford = section.ford_pavement; - const carved = - ford && ford.depth_m - ? line.map((point) => - point.offset_m <= roadL + 1e-9 && point.offset_m >= roadR - 1e-9 - ? { - ...point, - elevation_m: fordDeckElevationAt(design, ford, point.offset_m, point.elevation_m), - } - : point, - ) - : line; - const sorted = [...carved].sort((a, b) => a.offset_m - b.offset_m); - const cwL = design.carriageway_edges?.left.offset_m ?? roadL; - const cwR = design.carriageway_edges?.right.offset_m ?? roadR; - - // 측구 폭 — road edge에서 갭 없이 시작(엔진 ditch_points 규약). - const ditch = design.ditch; - const ditchEnabled = design.ditch_enabled ?? (ditch != null && ditch.type !== "none"); - const ditchWidth = - !ditchEnabled || !ditch || ditch.type === "none" - ? 0 - : ditch.type === "standard" - ? ditch.top_width_m - : ditch.width_m; - const ditchSide: "left" | "right" = design.ditch_side === "right" ? "right" : "left"; - - // 좌/우 비탈 종류 — 엔진이 echo한 resolved section_mode 기준. - const mode = design.section_mode; - const leftRole: CorridorKind = mode === "left_cut" || mode === "both_cut" ? "cut" : "fill"; - const rightRole: CorridorKind = mode === "right_cut" || mode === "both_cut" ? "cut" : "fill"; - - const pieces = new Map(); - const put = (kind: CorridorKind, side: CorridorSide, a: number, b: number): void => { - const sub = slicePolyline(sorted, a, b); - if (sub.length >= 2) pieces.set(pieceKey(kind, side), resample(sub, PIECE_COLS[kind])); - }; - - put("carriageway", "center", cwR, cwL); - put("shoulder", "left", cwL, roadL); - put("shoulder", "right", roadR, cwR); - // 좌측: road edge → (측구) → 비탈끝. 우측은 대칭(음수 방향). - let slopeStartL = roadL; - let slopeStartR = roadR; - if (ditchWidth > 0) { - if (ditchSide === "left") { - put("ditch", "left", roadL, roadL + ditchWidth); - slopeStartL = roadL + ditchWidth; - } else { - put("ditch", "right", roadR - ditchWidth, roadR); - slopeStartR = roadR - ditchWidth; - } - // 측구 바깥(노견 반대측) 상단은 **노견과 같은 Z**로 맞춘다 — 설계선 표본이 - // 모서리를 비껴가거나 L형처럼 바닥에서 끝나면 그 자리가 노견보다 낮게(또는 - // 지반을 따라) 그려진다(2026-08-23 사용자 지시). - const ditchKey = pieceKey("ditch", ditchSide); - const ditchPiece = pieces.get(ditchKey); - if (ditchPiece && ditchPiece.length >= 2) { - const edgeZ = design.road_edges[ditchSide].elevation_m; - const outer = ditchSide === "left" ? ditchPiece[ditchPiece.length - 1] : ditchPiece[0]; - outer.elevation_m = edgeZ; - } - } - // 비탈 끝 = catch point(설계선-지반 교차) — 그 밖은 설계선이 지반을 따라갈 뿐 - // 절·성토 점유가 아니므로 코리도·클리핑에서 제외한다(2026-08-23 화면 검증 수정). - const ground = groundSampler(section); - const sampleEndL = sorted[sorted.length - 1].offset_m; - const sampleEndR = sorted[0].offset_m; - const endL = ground ? catchOffset(sorted, ground, slopeStartL, 1, sampleEndL) : sampleEndL; - const endR = ground ? catchOffset(sorted, ground, slopeStartR, -1, sampleEndR) : sampleEndR; - if (endL > slopeStartL + 1e-9) put(leftRole, "left", slopeStartL, endL); - if (endR < slopeStartR - 1e-9) put(rightRole, "right", endR, slopeStartR); - - // ── 배수관 세트 측점(2026-08-23 사용자) — 구조물이 선 측의 단면을 B06 횡단 - // 그대로 다시 그린다. 그 측 비탈 한 줄에 담는 것: - // 노견 → (성토로 늘어난 노폭 = 수평 연장) → 구조물 상단 → 구조물 전면 → - // 벽 하단 성토부선 → 추가(다단) 기슭막이들 → 지반(또는 절토선 끝). - // 노폭 연장분은 비탈이 아니라 **노견 리본**을 넓히고, 집수정이 원지반을 - // 파고들면 절토선 끝까지 이어 그 만큼 원지반을 걷어낸다. - let wallSpans: StationPieces["wallSpans"]; - - /** - * 구조물 실루엣 적용 — 그 측 비탈 조각을 통째로 갈아 끼우고 종방향 점유 구간을 - * 등록한다. 배수관 세트·BOX암거·세월교가 공유한다(2026-08-25 사용자: 성토면 반영). - * - * `keepOrder`면 수집 순서(도로 → 바깥)를 지키고 내림차순일 때만 뒤집는다 — 거의 - * 수직인 벽 앞면·다단 계단은 편거리 정렬이 순서를 보장하지 못한다. 뒤쪽 코드 - * (`slopeRelative`)가 도로측 끝점을 **배열 위치**로 집으므로 항상 오름차순이어야 한다. - */ - const applySilhouette = ( - side: "left" | "right", - rawPoints: OffsetPoint[], - span: { beforeM: number; afterM: number }, - wanted: CorridorKind, - keepOrder: boolean, - ): void => { - if (rawPoints.length < 2) return; - const outward = side === "left" ? 1 : -1; - const edge = { - offset_m: side === "left" ? roadL : roadR, - elevation_m: design.road_edges[side].elevation_m, - }; - let silhouette = rawPoints; - // 노폭 연장(성토로 늘어난 노견)은 비탈이 아니라 노견 조각을 넓힌다. - if (!(ditchWidth > 0 && ditchSide === side)) { - let widening: OffsetPoint | null = null; - for (const point of silhouette) { - const outer = (point.offset_m - edge.offset_m) * outward; - if (outer > 1e-6 && Math.abs(point.elevation_m - edge.elevation_m) < 1e-6) { - if (!widening || (point.offset_m - widening.offset_m) * outward > 0) widening = point; - } - } - if (widening) { - const shoulder = pieces.get(pieceKey("shoulder", side)); - if (shoulder && shoulder.length >= 2) { - // 원 노견 끝을 꼭짓점으로 남긴다 — 바깥 정점만 밀면 [차도끝→연장끝] 직선 - // 하나가 되어 노견 물매가 연장분 전체로 퍼진다(2026-08-24 사용자 지적). - const inner = side === "left" ? shoulder[0] : shoulder[shoulder.length - 1]; - const knee = { offset_m: edge.offset_m, elevation_m: edge.elevation_m }; - const outer = { offset_m: widening.offset_m, elevation_m: widening.elevation_m }; - pieces.set( - pieceKey("shoulder", side), - side === "left" ? [inner, knee, outer] : [outer, knee, inner], - ); - } - const cutAt = widening; - silhouette = silhouette.filter( - (point) => (point.offset_m - cutAt.offset_m) * outward > -1e-9, - ); - // 연장분을 노견이 가져갔으면 비탈은 **연장 끝에서** 시작해야 한다. 남는 점이 - // 없다고 그냥 빠지면 원 노견 끝에서 시작하는 옛 비탈이 살아남아 노견 밑을 - // 파고든다(2026-08-24 실측: 성토비탈이 연장부와 0.9m 겹침). - if (silhouette.length < 2) silhouette = [widening, widening]; - } - } - if (silhouette.length < 2) return; - if (!keepOrder) { - silhouette = [...silhouette].sort((a, b) => a.offset_m - b.offset_m); - } else if (silhouette[0].offset_m > silhouette[silhouette.length - 1].offset_m) { - silhouette = [...silhouette].reverse(); - } - // 종류가 바뀌면 옛 조각을 지운다 — 두 종류가 겹쳐 그려지면 안 된다. - (["cut", "fill"] as const).forEach((other) => { - if (other !== wanted) pieces.delete(pieceKey(other, side)); - }); - pieces.set(pieceKey(wanted, side), resample(silhouette, PIECE_COLS[wanted])); - wallSpans = wallSpans ?? {}; - wallSpans[side] = span; - }; - - // ── 구조물 측점(배수관 세트·BOX암거·세월교) — 그 측 비탈을 B06 횡단 실루엣으로 - // 갈아 끼우고 종방향 점유 구간을 등록한다. 기본은 꺼져 있다(options.silhouette · - // 옛 방식 A 단추만 켬) — 성토면은 그대로 두고 투영커브로 도려낸다. - - const silhouettes = structureSilhouettes(section); - const legacy = typeof options === "object" && options.silhouette === true; - const drainageOnly = section.box || section.ford ? [] : silhouettes; - for (const silhouette of legacy ? drainageOnly : []) { - const side = silhouette.side; - const kind: CorridorKind = - silhouette.kind === "auto" ? (side === "left" ? leftRole : rightRole) : silhouette.kind; - applySilhouette(side, silhouette.points, silhouette.span, kind, silhouette.keepOrder); - } - - // ── 노견 확장(2026-08-25 사용자 ① — 상시 적용): 구조물 실루엣의 수평 확장점까지 - // 노견 조각을 넓힌다. 원 노견 끝을 꼭짓점으로 남긴다(무릎 — 물매 유지). 넓힌 - // 노견은 shoulderSpans 제어점으로 구조물 구간 내내 유지된다. - let shoulderSpans: StationPieces["shoulderSpans"]; - for (const silhouette of legacy ? [] : silhouettes) { - const widen = silhouette.widenTo; - const side = silhouette.side; - if (!widen) continue; - if (ditchWidth > 0 && ditchSide === side) continue; // 측구 측은 종전대로. - const shoulder = pieces.get(pieceKey("shoulder", side)); - if (!shoulder || shoulder.length < 2) continue; - const edge = { - offset_m: side === "left" ? roadL : roadR, - elevation_m: design.road_edges[side].elevation_m, - }; - const inner = side === "left" ? shoulder[0] : shoulder[shoulder.length - 1]; - const knee = { offset_m: edge.offset_m, elevation_m: edge.elevation_m }; - const outer = { offset_m: widen.offset_m, elevation_m: widen.elevation_m }; - pieces.set( - pieceKey("shoulder", side), - side === "left" ? [inner, knee, outer] : [outer, knee, inner], - ); - shoulderSpans = shoulderSpans ?? {}; - shoulderSpans[side] = silhouette.span; - } - // 비탈 상대 좌표 — 안쪽(도로측) 끝점을 원점으로 둔다(행 앵커에 얹는 용도). - const slopeRelative = new Map(); - (["left", "right"] as const).forEach((side) => { - (["cut", "fill"] as const).forEach((kind) => { - const key = pieceKey(kind, side); - const points = pieces.get(key); - if (!points || points.length < 2) return; - const inner = side === "right" ? points[points.length - 1] : points[0]; - slopeRelative.set( - key, - points.map((point) => ({ - offset_m: point.offset_m - inner.offset_m, - elevation_m: point.elevation_m - inner.elevation_m, - })), - ); - }); - }); - return { - chainage_m: section.chainage_m, - center: { x: section.center_x, y: section.center_y }, - left: { x: section.frame.left_xy[0], y: section.frame.left_xy[1] }, - pieces, - roadEdge: { - left: { offset_m: roadL, elevation_m: design.road_edges.left.elevation_m }, - right: { offset_m: roadR, elevation_m: design.road_edges.right.elevation_m }, - }, - slopeRelative, - ground, - wallSpans, - shoulderSpans, - ditchRelative: relativeToRoadEdge(pieces, { - left: { offset_m: roadL, elevation_m: design.road_edges.left.elevation_m }, - right: { offset_m: roadR, elevation_m: design.road_edges.right.elevation_m }, - }), - // 마구리(시·종점 봉인)용 단면 — catch 사이 설계선 점마다 지반고를 짝지운다. - capSection: slicePolyline(sorted, endR, endL).map( - (point) => - [ - point.offset_m, - point.elevation_m, - ground ? ground(point.offset_m) : point.elevation_m, - ] as [number, number, number], - ), - }; -} - -/** - * 단면(도로측 → 바깥)에서 **경계 편거리 안쪽을 잘라낸다**(2026-08-25 구조물 절단). - * 경계 바깥 조각만 남기고, 경계 지점을 보간해 첫 점으로 세운다 — 이 점이 행마다 - * 이어져 절단선(메시 모서리)이 된다. 전부 안쪽이면 null(조각 드롭). - */ -export function cutInnerTo( - inner2outer: OffsetPoint[], - boundaryOffset: number, - side: "left" | "right", -): OffsetPoint[] | null { - const sign = side === "left" ? 1 : -1; - const beyond = (point: OffsetPoint): number => (point.offset_m - boundaryOffset) * sign; - const kept: OffsetPoint[] = []; - for (let i = 0; i < inner2outer.length; i += 1) { - const point = inner2outer[i]; - if (beyond(point) >= -1e-9) { - if (!kept.length && i > 0) { - // 경계 통과 지점을 보간해 첫 점으로. - const prev = inner2outer[i - 1]; - const span = point.offset_m - prev.offset_m; - const t = Math.abs(span) < 1e-12 ? 0 : (boundaryOffset - prev.offset_m) / span; - kept.push({ - offset_m: boundaryOffset, - elevation_m: prev.elevation_m + (point.elevation_m - prev.elevation_m) * t, - }); - } - kept.push({ ...point }); - } - } - if (kept.length < 2) return null; - const width = Math.abs(kept[kept.length - 1].offset_m - kept[0].offset_m); - return width < SLOPE_MIN_WIDTH_M ? null : kept; -} - -/** 비탈 폭 하한(m) — 트림 후 이보다 좁으면 그 행에서 비탈이 없는 것으로 본다. */ -export const SLOPE_MIN_WIDTH_M = 0.02; -/** 폴리라인 안에서 지반을 못 만날 때 마지막 구배로 연장하는 한계(m)·걸음(m). */ -const EXTEND_MAX_M = 30; -const EXTEND_STEP_M = 0.25; - -/** 두 측점 지반선을 t로 섞은 행 지반선 — 한쪽뿐이면 그쪽을 그대로 쓴다. */ -export function mixedGround( - a: ((offset: number) => number) | null, - b: ((offset: number) => number) | null, - t: number, -): ((offset: number) => number) | null { - if (!a || !b) return a ?? b; - return (offset: number): number => a(offset) * (1 - t) + b(offset) * t; -} - -/** - * 비탈 폴리라인(안→밖)을 행 지반선과 직접 교차시켜 종결한다(2026-08-23 사용자: - * 절성토 완전 분리 + 주변 데이터 반영). - * - 절토는 설계선이 지반 **아래**(파는 쪽)에서 시작해 위로 올라가 만나야 하고, - * 성토는 지반 **위**에서 시작해 내려가 만나야 한다. 시작부터 반대편이면 이 행에 - * 그 종류는 없다 → null(호출부가 조각을 지워 리본이 끊긴다). - * - 폴리라인 안에서 지반을 통과하면 그 지점까지 자르고, 못 만나면 마지막 구배로 - * 한 걸음씩 연장해 찾는다(EXTEND_MAX_M 한도 — 그래도 없으면 원래 끝 유지). - */ -export function trimSlopeToGround( - inner2outer: OffsetPoint[], - kind: "cut" | "fill", - groundAt: (offset: number) => number, -): OffsetPoint[] | null { - if (inner2outer.length < 2) return null; - // e = (설계 - 지반) × 부호: 유효 구간에서 양수, 지반 접점에서 0, 반대편이면 음수. - const sign = kind === "cut" ? -1 : 1; - const eOf = (point: OffsetPoint): number => (point.elevation_m - groundAt(point.offset_m)) * sign; - const first = inner2outer[0]; - if (eOf(first) < -CATCH_EPS_M) return null; - const kept: OffsetPoint[] = [{ ...first }]; - let prev = first; - let ePrev = eOf(first); - let closed = false; - for (let i = 1; i < inner2outer.length && !closed; i += 1) { - const point = inner2outer[i]; - const eNow = eOf(point); - if (eNow > CATCH_EPS_M) { - kept.push({ ...point }); - prev = point; - ePrev = eNow; - continue; - } - // 이 구간에서 지반 통과 — 교차 offset을 선형보간하고 z는 지반에 얹는다. - const ratio = ePrev - eNow <= 1e-12 ? 0 : ePrev / (ePrev - eNow); - const offset = prev.offset_m + (point.offset_m - prev.offset_m) * ratio; - kept.push({ offset_m: offset, elevation_m: groundAt(offset) }); - closed = true; - } - if (!closed) { - // 깊은 절토·높은 성토가 단면 밖으로 나간 경우 — 마지막 구배로 연장해 지반을 찾는다. - const tail = inner2outer[inner2outer.length - 1]; - const before = inner2outer[inner2outer.length - 2]; - const runOffset = tail.offset_m - before.offset_m; - const runLength = Math.hypot(runOffset, tail.elevation_m - before.elevation_m); - if (Math.abs(runOffset) > 1e-9 && runLength > 1e-9) { - const stepOffset = (runOffset / runLength) * EXTEND_STEP_M; - const stepZ = ((tail.elevation_m - before.elevation_m) / runLength) * EXTEND_STEP_M; - let cursor = { ...tail }; - let eCursor = eOf(cursor); - for (let step = 0; step * EXTEND_STEP_M < EXTEND_MAX_M; step += 1) { - const next = { - offset_m: cursor.offset_m + stepOffset, - elevation_m: cursor.elevation_m + stepZ, - }; - const eNext = eOf(next); - if (eNext <= CATCH_EPS_M) { - const ratio = eCursor - eNext <= 1e-12 ? 0 : eCursor / (eCursor - eNext); - const offset = cursor.offset_m + stepOffset * ratio; - kept.push({ offset_m: offset, elevation_m: groundAt(offset) }); - closed = true; - break; - } - cursor = next; - eCursor = eNext; - kept.push({ ...cursor }); - } - } - } - const width = Math.abs(kept[kept.length - 1].offset_m - kept[0].offset_m); - if (kept.length < 2 || width < SLOPE_MIN_WIDTH_M) return null; - return kept; -} diff --git a/B05_Profile/Old/B05_Profile_UI_Corridor_Tin.ts b/B05_Profile/Old/B05_Profile_UI_Corridor_Tin.ts deleted file mode 100644 index 6a1c3afd3..000000000 --- a/B05_Profile/Old/B05_Profile_UI_Corridor_Tin.ts +++ /dev/null @@ -1,375 +0,0 @@ -/* ============================================================================= - * B05_Profile_UI_Corridor_Tin.ts - * 3D 제안 ② **단일 삼각망(TIN) 완성 지반**(계획서 115-6 · Civil 3D · OpenRoads 와 같은 원리). - * - * ① 설계 점 · 브레이크라인(`_Tin_Design.ts`) — 측점 단면 · 1 m 보간 단면 · 비탈 끝 폐곡선. - * ② 지형 이음 — 설계 구역(+ 구조물 구멍)에 닿는 지형 삼각형만 버리고, 그 삼각형의 바깥 - * 정점 · 변(원래 그대로)을 점 · 제약으로 넣어 제약 들로네 한 번 → 닿지 않은 지형은 원본, - * 버린 자리 안 바깥 지형도 원래 변이 제약이라 원래 삼각형 그대로 · 틈 0. - * ③ 구조물 솔리드는 그대로 — 윗면 외곽 안 삼각형만 지운다. - * ④ 색 = 삼각형마다 구역(성토 · 절토 = 면 색 · 원지반 = 원래 정점 색) · 법선 = 원본과 같게. - * - * 원본 · 분할본 geometry 는 건드리지 않는다(사본만). 변형 장 · 구조물 모듈은 읽기만 한다. - * ========================================================================== */ - -import * as THREE from "three"; -import type { ModelBounds } from "./B05_Profile_UI_Markers"; -import type { CorridorBuildResult } from "./B05_Profile_UI_Corridor_Build_Types"; -import type { TerrainBandSplit } from "./B05_Profile_UI_Corridor_Split"; -import type { TerrainHeightIndex } from "./B05_Profile_UI_Corridor_Terrain"; -import { CellRegion } from "./B05_Profile_UI_Corridor_Region"; -import { buildDeformField, stationProfileSource } from "./B05_Profile_UI_Corridor_Deform"; -import { noFillLimits, structureSlopes } from "./B05_Profile_UI_Corridor_Deform_Structure"; -import { buildTinDesign, type TinDesign } from "./B05_Profile_UI_Corridor_Tin_Design"; -import { - EDGE_TERRAIN, - LoopIndex, - segmentTouchesTriangle, - SegGrid, - TAG_CUT, - TAG_FILL, - TAG_TERRAIN, - TriGrid, -} from "./B05_Profile_UI_Corridor_Tin_Pslg"; - -/** 절토 · 성토 색 — 새 방식(`_Deform_Terrain.ts`) · 리본 색과 같은 값. */ -const CUT_COLOR = new THREE.Color(0xb5179e); -const FILL_COLOR = new THREE.Color(0x06b6d4); -/** 원지반 색을 못 찾은 점(지형 밖) — 회색. */ -const FALLBACK_RGB = [0.6, 0.6, 0.6]; -/** 꼬리표 없는 설계 삼각형을 절성토로 칠할 높이 차(m). */ -const TINT_MIN_M = 0.01; -const GRID_M = 2; -/** 설계 구역 · 구멍 둘레에 이만큼 붙은 지형 정점은 안으로 본다(m). */ -const ON_BOUNDARY_M = 1e-3; - -/** 노면부 띠 — 행마다 좌우 노면 끝 사이 사각형(리본이 덮는 자리 · 망에서 뺀다). */ -class RoadStrip extends CellRegion { - constructor(road: TinDesign["road"]) { - super(); - const p = ([x, z]: [number, number]) => ({ x, z }); - for (let i = 0; i + 1 < road.left.length; i += 1) { - this.addCell(p(road.left[i]), p(road.right[i]), p(road.right[i + 1]), p(road.left[i + 1])); - } - this.buildIndex(); - } -} - -/** 화면 밖 수치 확인용(`__corridorTin`). */ -export interface TinStats { - rows: number; - sections: number; - bowtie: number; - bendLines: number; - holes: number; - splits: number; - removed: number; - points: number; - constraints: number; - failed: number; - triangles: number; - ms: number; -} - -/** 지형 조각 하나에서 설계 구역에 닿는 삼각형을 골라 낸다 — 바깥 정점 · 변은 PSLG 로. */ -function harvestPart( - geometry: THREE.BufferGeometry, - design: TinDesign, - inRegion: (x: number, z: number) => boolean, - boundary: SegGrid, - box: { minX: number; maxX: number; minZ: number; maxZ: number }, - removedGrid: TriGrid, - removedRgb: number[], -): { kept: number[]; removed: number } { - const position = geometry.getAttribute("position") as THREE.BufferAttribute; - const color = geometry.getAttribute("color") as THREE.BufferAttribute | undefined; - const index = geometry.getIndex(); - const triCount = index ? index.count / 3 : position.count / 3; - const state = new Int8Array(position.count); // 0 모름 · 1 안 · 2 밖 - const inside = (v: number): boolean => { - if (!state[v]) { - const x = position.getX(v); - const z = position.getZ(v); - // 경계 선 위 정점도 안으로(제약이 점을 관통하지 않게). - state[v] = inRegion(x, z) || boundary.passesNear(x, z, ON_BOUNDARY_M) ? 1 : 2; - } - return state[v] === 1; - }; - const ids = new Int32Array(position.count).fill(-1); - const pslg = design.pslg; - const idOf = (v: number): number => { - if (ids[v] < 0) { - ids[v] = pslg.addPoint(position.getX(v), position.getZ(v), position.getY(v), TAG_TERRAIN); - if (color && pslg.tag[ids[v]] === TAG_TERRAIN) { - pslg.rgb[ids[v] * 3] = color.getX(v); - pslg.rgb[ids[v] * 3 + 1] = color.getY(v); - pslg.rgb[ids[v] * 3 + 2] = color.getZ(v); - } - } - return ids[v]; - }; - const kept: number[] = []; - const plan = new Float64Array(6); - let removed = 0; - for (let t = 0; t < triCount; t += 1) { - const v = [0, 1, 2].map((k) => (index ? index.getX(t * 3 + k) : t * 3 + k)); - for (let k = 0; k < 3; k += 1) { - plan[k * 2] = position.getX(v[k]); - plan[k * 2 + 1] = position.getZ(v[k]); - } - const minX = Math.min(plan[0], plan[2], plan[4]); - const maxX = Math.max(plan[0], plan[2], plan[4]); - const minZ = Math.min(plan[1], plan[3], plan[5]); - const maxZ = Math.max(plan[1], plan[3], plan[5]); - let hit = false; - if (!(maxX < box.minX || minX > box.maxX || maxZ < box.minZ || minZ > box.maxZ)) { - hit = v.some(inside); - if (!hit) { - const s = boundary.segs; - hit = boundary - .near(minX, minZ, maxX, maxZ) - .some((id) => - segmentTouchesTriangle(s[id * 4], s[id * 4 + 1], s[id * 4 + 2], s[id * 4 + 3], plan), - ); - } - } - if (!hit) { - kept.push(v[0], v[1], v[2]); - continue; - } - removed += 1; - removedGrid.add(plan); - v.forEach((vertex) => { - if (color) removedRgb.push(color.getX(vertex), color.getY(vertex), color.getZ(vertex)); - else removedRgb.push(...FALLBACK_RGB); - }); - // 바깥 정점 · 바깥 변 — 원래 모양 그대로 다시 세우는 제약. - for (let k = 0; k < 3; k += 1) { - const a = v[k]; - const b = v[(k + 1) % 3]; - if (inside(a) || inside(b)) continue; - if ( - boundary.touches( - plan[k * 2], - plan[k * 2 + 1], - plan[((k + 1) % 3) * 2], - plan[((k + 1) % 3) * 2 + 1], - ) - ) - continue; - pslg.addEdge(idOf(a), idOf(b), EDGE_TERRAIN); - } - v.forEach((vertex) => { - if (!inside(vertex)) idOf(vertex); - }); - } - return { kept, removed }; -} - -/** 설계 점 · 브레이크라인 · 구멍을 내는 자리 — `groundAt` = 원지반 높이(모델 좌표). 못 세우면 null. */ -export type TinDesigner = ( - build: CorridorBuildResult, - groundAt: (x: number, y: number) => number | null, - bounds: ModelBounds, -) => TinDesign | null; - -/** 제안 ② 설계 — 새 방식 변형 장(행마다 비탈 끝)에서. */ -const deformDesign: TinDesigner = (build, groundAt, bounds) => { - const sections = build.sections ?? []; - const structures = build.structures ?? []; - const source = stationProfileSource(sections, structureSlopes(sections)); - const limits = noFillLimits(sections, structures); - const field = buildDeformField(build, source, groundAt, limits); - if (!field) return null; - return buildTinDesign( - field, - source, - groundAt, - limits, - structures, - sections.map((section) => section.chainage_m), - bounds, - ); -}; - -/** - * 제안 ② 지형 — 밴드 조각에서 설계 구역에 닿는 삼각형을 단일 TIN 으로 바꾼다. 설계를 못 - * 세우면(노면부 행 부족 · 측점 단면 없음) null — 부르는 쪽이 다른 방식으로 그린다. - * `designer` 를 바꾸면 지형 이음 · 구멍 · 구역 색은 그대로 두고 설계 점 원천만 바뀐다(제안 ③). - * `finish` = 다 만든 TIN 망 손질(제안 ③ 사면 부드러운 법선). - */ -export function tinTerrain( - split: TerrainBandSplit, - build: CorridorBuildResult, - bounds: ModelBounds, - heightIndex: TerrainHeightIndex, - designer: TinDesigner = deformDesign, - /** 망을 다 만든 뒤 손질(제안 ③ 사면 법선) — 없으면 그대로. */ - finish?: (mesh: THREE.Mesh) => void, -): { root: THREE.Object3D; stats: TinStats; design: TinDesign } | null { - const started = performance.now(); - if (!build.sections?.length) return null; - const ox = (bounds.x[0] + bounds.x[1]) / 2; - const oy = (bounds.y[0] + bounds.y[1]) / 2; - const oz = (bounds.z[0] + bounds.z[1]) / 2; - const groundAt = (x: number, y: number): number | null => { - const height = heightIndex.heightAt(x - ox, -(y - oy)); - return height === null ? null : height + oz; - }; - const design = designer(build, groundAt, bounds); - if (!design) return null; - - const outer = new LoopIndex([design.outer]); - const holes = new LoopIndex(design.holes); - const inRegion = (x: number, z: number): boolean => outer.contains(x, z) || holes.contains(x, z); - const boundary = new SegGrid(GRID_M); - let segment = 0; - [design.outer, ...design.holes].forEach((loop) => { - for (let i = 0, j = loop.length - 1; i < loop.length; j = i, i += 1) { - boundary.add(segment, loop[j][0], loop[j][1], loop[i][0], loop[i][1]); - segment += 1; - } - }); - const box = { - minX: Math.min(outer.minX, holes.minX), - maxX: Math.max(outer.maxX, holes.maxX), - minZ: Math.min(outer.minZ, holes.minZ), - maxZ: Math.max(outer.maxZ, holes.maxZ), - }; - - const root = new THREE.Group(); - root.name = "terrain-tin"; - const removedGrid = new TriGrid(GRID_M); - const removedRgb: number[] = []; - let removed = 0; - let material = null as THREE.Material | null; - let itemSize = 3; - let hasColor = false; - let hasNormal = false; - split.near.forEach((part) => { - if (part.points) { - // 포인트클라우드는 망 대상이 아니다 — 분할본 그대로 얹는다. - const points = new THREE.Points(part.geometry, part.material as THREE.Material); - points.userData.owned = false; - root.add(points); - return; - } - const harvest = harvestPart( - part.geometry, - design, - inRegion, - boundary, - box, - removedGrid, - removedRgb, - ); - removed += harvest.removed; - if (harvest.removed && !material) { - material = Array.isArray(part.material) ? part.material[0] : part.material; - const color = part.geometry.getAttribute("color"); - hasColor = Boolean(color); - itemSize = color?.itemSize ?? 3; - hasNormal = Boolean(part.geometry.getAttribute("normal")); - } - // 남은 원래 삼각형 — 사본(정점 버퍼 그대로 복제 · 인덱스만 추림). - const geometry = part.geometry.clone(); - geometry.setIndex(harvest.kept); - const mesh = new THREE.Mesh(geometry, part.material); - mesh.userData.owned = true; - root.add(mesh); - }); - - const pslg = design.pslg; - // 설계 · 구멍 점의 원지반 색 — 버린 지형 삼각형 안 무게중심 보간. - for (let i = 0; i < pslg.count; i += 1) { - if (!Number.isNaN(pslg.rgb[i * 3])) continue; - const t = removedGrid.find(pslg.x[i], pslg.z[i]); - const w = t >= 0 ? removedGrid.weights(t, pslg.x[i], pslg.z[i]) : null; - for (let c = 0; c < 3; c += 1) { - pslg.rgb[i * 3 + c] = w - ? w[0] * removedRgb[t * 9 + c] + - w[1] * removedRgb[t * 9 + 3 + c] + - w[2] * removedRgb[t * 9 + 6 + c] - : FALLBACK_RGB[c]; - } - } - - const { triangles, failed } = pslg.triangulate(); - const road = new RoadStrip(design.road); - const positions: number[] = []; - const colors: number[] = []; - const keep: number[] = []; - for (let t = 0; t + 2 < triangles.length; t += 3) { - let a = triangles[t]; - let b = triangles[t + 1]; - let c = triangles[t + 2]; - const cx = (pslg.x[a] + pslg.x[b] + pslg.x[c]) / 3; - const cz = (pslg.z[a] + pslg.z[b] + pslg.z[c]) / 3; - // 버린 지형 자리 밖(볼록 껍질 채움)은 뺀다 — 설계 구역 안은 지형 빈 곳(DTM 결측) 위라도 남긴다. - const inDesign = outer.contains(cx, cz); - if (!inDesign && removedGrid.find(cx, cz) < 0) continue; - if (road.contains({ x: cx, z: cz }) || holes.contains(cx, cz)) continue; - // 위를 보게 감기(씬 Y-up) — 평면 (x, z) 에서 시계 방향이면 뒤집는다. - const turn = - (pslg.x[b] - pslg.x[a]) * (pslg.z[c] - pslg.z[a]) - - (pslg.z[b] - pslg.z[a]) * (pslg.x[c] - pslg.x[a]); - if (turn > 0) [b, c] = [c, b]; - // 구역 — 설계 구역 안이면 꼬리표 다수결(없으면 높이 차 부호) · 밖이면 원지반. - let zone = 0; - if (inDesign) { - let fill = 0; - let cut = 0; - [a, b, c].forEach((id) => { - if (pslg.tag[id] === TAG_FILL) fill += 1; - if (pslg.tag[id] === TAG_CUT) cut += 1; - }); - if (fill || cut) zone = fill >= cut ? TAG_FILL : TAG_CUT; - else { - const ground = heightIndex.heightAt(cx, cz); - const delta = ground === null ? 0 : (pslg.y[a] + pslg.y[b] + pslg.y[c]) / 3 - ground; - if (Math.abs(delta) >= TINT_MIN_M) zone = delta > 0 ? TAG_FILL : TAG_CUT; - } - } - const tint = zone === TAG_FILL ? FILL_COLOR : zone === TAG_CUT ? CUT_COLOR : null; - [a, b, c].forEach((id) => { - positions.push(pslg.x[id], pslg.y[id], pslg.z[id]); - const rgb = tint - ? [tint.r, tint.g, tint.b] - : [pslg.rgb[id * 3], pslg.rgb[id * 3 + 1], pslg.rgb[id * 3 + 2]]; - colors.push(...rgb); - if (itemSize === 4) colors.push(1); - keep.push(tint ? 1 : 0); - }); - } - const geometry = new THREE.BufferGeometry(); - geometry.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3)); - if (hasColor) geometry.setAttribute("color", new THREE.Float32BufferAttribute(colors, itemSize)); - // 원본에 법선이 있으면 면 법선(비색인 = 면마다 따로) · 없으면 원본처럼 flatShading 재질 몫. - if (hasNormal) geometry.computeVertexNormals(); - geometry.computeBoundingSphere(); - geometry.computeBoundingBox(); - const mesh = new THREE.Mesh( - geometry, - material ?? new THREE.MeshLambertMaterial({ vertexColors: true }), - ); - mesh.name = "terrain-tin-mesh"; - mesh.userData.owned = true; - mesh.userData.keepColor = Uint8Array.from(keep); - finish?.(mesh); - root.add(mesh); - split.far.forEach((part) => { - const far = new THREE.Mesh(part.geometry, part.material); - far.userData.owned = false; - root.add(far); - }); - const stats: TinStats = { - ...design.stats, - removed, - points: pslg.count, - constraints: pslg.kinds.length, - failed, - triangles: positions.length / 9, - ms: Math.round(performance.now() - started), - }; - return { root, stats, design }; -} diff --git a/B05_Profile/Old/B05_Profile_UI_Corridor_Tin_Design.ts b/B05_Profile/Old/B05_Profile_UI_Corridor_Tin_Design.ts deleted file mode 100644 index 548b15e96..000000000 --- a/B05_Profile/Old/B05_Profile_UI_Corridor_Tin_Design.ts +++ /dev/null @@ -1,349 +0,0 @@ -/* ============================================================================= - * B05_Profile_UI_Corridor_Tin_Design.ts - * 3D 제안 ② 단일 삼각망(TIN)의 **설계 점 · 브레이크라인**(계획서 115-6) — 순수 기하. - * - * · 행 = 새 방식 변형 장(`_Deform.ts` buildDeformField)의 노면부 행(0.2 m · 노선 길이 기준). - * 비탈 끝 · 단면 꺾임은 그 장이 측점 단면(classifyStation)에서 메시 지반으로 찾은 값. - * · 노면 끝 선(행마다) · 비탈 끝 선(행마다) = 종방향 브레이크라인 · 둘이 설계 구역 둘레. - * · 단면 행(측점 + 1 m 걸음) = 횡방향 선(노면 끝 → 꺾임 → 비탈 끝) · 같은 열 꺾임을 잇는 종방향 선. - * · 곡선 안쪽 겹침(bow-tie) = 앞 행 단면과 교차하는 행은 그 측에서 뺀다(겹친 점 제거). - * · 구조물 = 솔리드 윗면 외곽 고리(구멍) · 둘레 높이 = min(설계면, 윗면). - * - * 변형 장 · 단면 출처는 읽기만 한다. - * ========================================================================== */ - -import type { ModelBounds } from "./B05_Profile_UI_Markers"; -import type { CorridorStructure } from "./B05_Profile_UI_Corridor_Structures"; -import { - profileZ, - type DeformField, - type DeformLimit, - type ProfileSource, -} from "./B05_Profile_UI_Corridor_Deform"; -import { - EDGE_ANCHOR, - EDGE_DESIGN, - EDGE_HOLE, - Pslg, - SegGrid, - TAG_ANCHOR, - TAG_CUT, - TAG_FILL, - TAG_HOLE, -} from "./B05_Profile_UI_Corridor_Tin_Pslg"; - -/** 단면 행 걸음(m) — 측점 사이 보간 단면. */ -export const SECTION_STEP_M = 1; -/** 이보다 짧은 비탈은 없는 것으로(비탈 끝 = 노면 끝). */ -const MIN_CATCH_M = 0.02; -/** 비탈 끝에서 이 안쪽 꺾임만 점으로 둔다(m). */ -const BEND_GAP_M = 0.05; -/** 비탈 끝 높이와 지반 차가 이보다 작으면 끝 점만 지반으로(단면 안 보정 없음 · 측점 단면 그대로)(m). */ -const DRAPE_MIN_M = 0.1; -/** 윗면으로 보는 높이 여유(m) · 윗면 최소 폭(m). */ -const TOP_TOLERANCE_M = 0.05; -const TOP_MIN_WIDTH_M = 0.05; - -const SIDES = ["left", "right"] as const; - -export interface TinDesign { - pslg: Pslg; - /** 설계 구역 둘레(씬 평면) — 좌 비탈 끝 → 끝 마구리 → 우 비탈 끝 → 시작 마구리. */ - outer: Array<[number, number]>; - /** 구조물 윗면 구멍 고리(씬 평면). */ - holes: Array>; - /** 노면부 띠 행(씬 평면) — 좌 · 우 노면 끝. */ - road: { left: Array<[number, number]>; right: Array<[number, number]> }; - stats: { - rows: number; - sections: number; - bowtie: number; - bendLines: number; - holes: number; - splits: number; - }; -} - -/** 구조물 솔리드 윗면 외곽 고리(모델 좌표 · 점마다 윗면 높이) — 관은 뺀다(다른 부재가 감쌈). */ -export function structureTopLoops( - structures: ReadonlyArray, -): Array> { - const loops: Array> = []; - for (const structure of structures) { - const rings = structure.rings; - if (structure.kind === "pipe" || !rings || rings.length < 2) continue; - let run: Array<{ - x: number; - y: number; - ux: number; - uy: number; - lo: number; - hi: number; - z: number; - }> = []; - const flush = (): void => { - if (run.length >= 2) { - const loop: Array<[number, number, number]> = run.map((r) => [ - r.x + r.ux * r.lo, - r.y + r.uy * r.lo, - r.z, - ]); - for (let i = run.length - 1; i >= 0; i -= 1) { - const r = run[i]; - loop.push([r.x + r.ux * r.hi, r.y + r.uy * r.hi, r.z]); - } - loops.push(loop); - } - run = []; - }; - rings.forEach((frame, i) => { - const polygon = structure.polygons?.[i] ?? structure.polygon; - if (!polygon?.length) return flush(); - let top = -Infinity; - polygon.forEach(([, elevation]) => (top = Math.max(top, elevation))); - let lo = Infinity; - let hi = -Infinity; - polygon.forEach(([offset, elevation]) => { - if (elevation < top - TOP_TOLERANCE_M) return; - lo = Math.min(lo, offset); - hi = Math.max(hi, offset); - }); - if (hi - lo < TOP_MIN_WIDTH_M) return flush(); - run.push({ - x: frame.cx, - y: frame.cy, - ux: frame.leftX, - uy: frame.leftY, - lo, - hi, - z: top + frame.dz, - }); - }); - flush(); - } - return loops; -} - -/** 점이 고리 안인가(광선 투사 · 모델 좌표). */ -export function insideLoop(loop: ReadonlyArray<[number, number]>, x: number, y: number): boolean { - let hit = false; - for (let i = 0, j = loop.length - 1; i < loop.length; j = i, i += 1) { - const [xi, yi] = loop[i]; - const [xj, yj] = loop[j]; - if (yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) hit = !hit; - } - return hit; -} - -/** - * 설계 점 · 브레이크라인 · 구멍을 PSLG 로 모은다(교차 제약은 나눠 둠). 지형 점은 부르는 쪽이 더한다. - * `groundAt` = 원지반 높이(모델 좌표 · 메시). - */ -export function buildTinDesign( - field: DeformField, - source: ProfileSource, - groundAt: (x: number, y: number) => number | null, - limits: ReadonlyArray, - structures: ReadonlyArray, - stationChainages: ReadonlyArray, - bounds: ModelBounds, -): TinDesign { - const ox = (bounds.x[0] + bounds.x[1]) / 2; - const oy = (bounds.y[0] + bounds.y[1]) / 2; - const oz = (bounds.z[0] + bounds.z[1]) / 2; - const sx = (mx: number): number => mx - ox; - const sz = (my: number): number => -(my - oy); - const pslg = new Pslg(); - const rows = field.rows; - const n = rows.length; - const stats = { rows: n, sections: 0, bowtie: 0, bendLines: 0, holes: 0, splits: 0 }; - - // 노면 끝 선 — 리본 바깥 끝 그대로(높이 = 리본). - const anchors = SIDES.map((side) => - rows.map((row) => { - const [x, y, z] = side === "left" ? row.left : row.right; - return pslg.addPoint(sx(x), sz(y), z - oz, TAG_ANCHOR); - }), - ); - anchors.forEach((ids) => pslg.addPath(ids, EDGE_ANCHOR)); - pslg.addEdge(anchors[0][0], anchors[1][0], EDGE_ANCHOR); - pslg.addEdge(anchors[0][n - 1], anchors[1][n - 1], EDGE_ANCHOR); - - /** 그 행 · 측 단면의 설계 높이(모델) — 성토는 지반 위로 · 절토는 아래로만 · 성토 금지 고리. - * `lift` = 드레이프 보정(비탈 끝에서 지형에 맞춘 몫 × s / 비탈 끝). */ - const designZ = (r: number, si: number, ki: number, s: number, lift = 0): number | null => { - const row = rows[r]; - const anchor = si === 0 ? row.left : row.right; - const shapes = source(row.chainage_m, SIDES[si]); - const profile = ki === 0 ? shapes.fill : shapes.cut; - if (!profile) return null; - const rel = profileZ(profile, s, shapes.noExtend); - if (rel === null) return null; - const [x, y] = field.pointAt(r, si, s); - const ground = groundAt(x, y); - let z = anchor[2] + rel + lift; - if (ground !== null) { - z = ki === 0 ? Math.max(z, ground) : Math.min(z, ground); - for (const limit of limits) { - if (z > ground && insideLoop(limit.loop, x, y)) - z = Math.min(z, Math.max(ground, limit.ceiling ?? ground)); - } - } - return z; - }; - - /** 행 · 측의 비탈 — 종류(0 성토 · 1 절토)와 비탈 끝 거리. 없으면 null. */ - const slopeOf = (r: number, si: number): { ki: number; reach: number } | null => { - const fill = field.catchAt(r, si, 0); - const cut = field.catchAt(r, si, 1); - const ki = fill >= cut ? 0 : 1; - const reach = Math.max(fill, cut); - return reach >= MIN_CATCH_M ? { ki, reach } : null; - }; - - const isStation = (chainage: number): boolean => - stationChainages.some((c) => Math.abs(c - chainage) < 1e-3); - - // 비탈 끝 점 · 곡선 안쪽 겹침 청소 — 앞 행들 단면 선분에 닿으면 처음 닿는 자리에서 자른다(겹친 점 제거). - const kindOf = SIDES.map(() => new Int8Array(n).fill(-1)); - const reachOf = SIDES.map(() => new Float64Array(n)); - const liftOf = SIDES.map(() => new Float64Array(n)); - const daylight = SIDES.map(() => new Int32Array(n).fill(-1)); - SIDES.forEach((_, si) => { - const fan = new SegGrid(2); - for (let r = 0; r < n; r += 1) { - const slope = slopeOf(r, si); - const anchor = anchors[si][r]; - daylight[si][r] = anchor; - if (!slope) continue; - let reach = slope.reach; - const ax = pslg.x[anchor]; - const az = pslg.z[anchor]; - const [dx, dy] = field.pointAt(r, si, reach); - const hit = fan.firstHit(ax, az, sx(dx), sz(dy)); - if (hit < 1) { - reach *= hit; - stats.bowtie += 1; - if (reach < MIN_CATCH_M) continue; - } - const [px, py] = field.pointAt(r, si, reach); - const line = designZ(r, si, slope.ki, reach); - const ground = groundAt(px, py); - // 드레이프(115-8 · 제안 ③ 과 같은 규칙) — 비탈 끝 높이 = 그 자리 지형 · 보정량은 단면에 s 비율로. - // 비탈 끝이 짧아진 행(115-7 튐 자름 · 못 닫힘)에서 설계면 높이 끝이 바깥 지형과 턱 지던 자리. - // 측점 행은 B06 단면 그대로(끝 점만 0.1 m 안이면 지반). - const step = ground !== null && line !== null ? ground - line : 0; - const station = isStation(rows[r].chainage_m); - if (hit >= 1 && !station && Math.abs(step) >= DRAPE_MIN_M) liftOf[si][r] = step; - const z = - hit >= 1 && ground !== null && (!station || line === null || Math.abs(step) < DRAPE_MIN_M) - ? ground - : (line ?? ground ?? rows[r].left[2]); - daylight[si][r] = pslg.addPoint(sx(px), sz(py), z - oz, slope.ki === 0 ? TAG_FILL : TAG_CUT); - kindOf[si][r] = slope.ki; - reachOf[si][r] = reach; - fan.add(r, ax, az, sx(px), sz(py)); - } - }); - - // 설계 구역 둘레 — 좌 비탈 끝(오름) → 끝 마구리 → 우 비탈 끝(내림) → 시작 마구리. - const left: number[] = []; - const right: number[] = []; - for (let r = 0; r < n; r += 1) { - left.push(daylight[0][r]); - right.push(daylight[1][r]); - } - pslg.addPath(left, EDGE_DESIGN); - pslg.addPath(right, EDGE_DESIGN); - pslg.addPath([anchors[0][0], left[0]], EDGE_DESIGN); - pslg.addPath([anchors[1][0], right[0]], EDGE_DESIGN); - pslg.addPath([anchors[0][n - 1], left[left.length - 1]], EDGE_DESIGN); - pslg.addPath([anchors[1][n - 1], right[right.length - 1]], EDGE_DESIGN); - const loopIds = [ - anchors[0][0], - ...left, - anchors[0][n - 1], - anchors[1][n - 1], - ...right.reverse(), - anchors[1][0], - ]; - const outer = loopIds - .filter((id, i) => i === 0 || id !== loopIds[i - 1]) - .map((id): [number, number] => [pslg.x[id], pslg.z[id]]); - - // 단면 행 — 측점 + 1 m 걸음. 횡방향 선(노면 끝 → 꺾임 → 비탈 끝) · 같은 열 꺾임 종방향 선. - const sectionRows: number[] = []; - for (let r = 0; r < n; r += 1) { - const chainage = rows[r].chainage_m; - const previous = sectionRows.length - ? rows[sectionRows[sectionRows.length - 1]].chainage_m - : -Infinity; - if ( - r === 0 || - r === n - 1 || - isStation(chainage) || - chainage - previous >= SECTION_STEP_M - 1e-6 - ) { - sectionRows.push(r); - } - } - stats.sections = sectionRows.length; - SIDES.forEach((_, si) => { - let previous: { r: number; ki: number; bends: Map } | null = null; - for (const r of sectionRows) { - const ki = kindOf[si][r]; - const reach = reachOf[si][r]; - if (ki < 0) { - previous = null; - continue; - } - const bends = new Map(); - const path = [anchors[si][r]]; - for (const [col, s] of field.bendsAt(r, si, ki)) { - if (s <= MIN_CATCH_M || s >= reach - BEND_GAP_M) continue; - const z = designZ(r, si, ki, s, (liftOf[si][r] * s) / reach); - if (z === null) continue; - const [x, y] = field.pointAt(r, si, s); - const id = pslg.addPoint(sx(x), sz(y), z - oz, ki === 0 ? TAG_FILL : TAG_CUT); - path.push(id); - bends.set(col, [id, s]); - } - path.push(daylight[si][r]); - pslg.addPath(path, EDGE_DESIGN); - if (previous && previous.ki === ki) { - for (const [col, [id, s]] of bends) { - const before = previous.bends.get(col); - if (!before) continue; - // 사이 행 비탈 끝이 그 꺾임보다 안쪽이면 잇지 않는다(선이 설계 구역 밖으로 나감). - let inside = true; - for (let k = previous.r + 1; k < r && inside; k += 1) { - const t = (k - previous.r) / (r - previous.r); - if (reachOf[si][k] < before[1] + (s - before[1]) * t + BEND_GAP_M) inside = false; - } - if (!inside) continue; - pslg.addEdge(before[0], id, EDGE_DESIGN); - stats.bendLines += 1; - } - } - previous = { r, ki, bends }; - } - }); - - // 구조물 윗면 구멍 — 둘레 높이 = min(설계면, 윗면)(드레이프). - const holes: Array> = []; - for (const loop of structureTopLoops(structures)) { - const ids = loop.map(([x, y, top]) => { - const ground = groundAt(x, y); - const surface = field.deformZ(x, y, ground ?? top); - return pslg.addPoint(sx(x), sz(y), Math.min(surface, top) - oz, TAG_HOLE); - }); - pslg.addPath(ids, EDGE_HOLE, true); - holes.push(loop.map(([x, y]): [number, number] => [sx(x), sz(y)])); - } - stats.holes = holes.length; - stats.splits = pslg.splitCrossings(); - - const plan = (si: number): Array<[number, number]> => - anchors[si].map((id): [number, number] => [pslg.x[id], pslg.z[id]]); - return { pslg, outer, holes, road: { left: plan(0), right: plan(1) }, stats }; -} diff --git a/B05_Profile/Old/B05_Profile_UI_Corridor_Union.ts b/B05_Profile/Old/B05_Profile_UI_Corridor_Union.ts deleted file mode 100644 index 527209369..000000000 --- a/B05_Profile/Old/B05_Profile_UI_Corridor_Union.ts +++ /dev/null @@ -1,34 +0,0 @@ -/* ============================================================================= - * B05_Profile_UI_Corridor_Union.ts - * 스윕 링 사이의 **볼록 사각형** 한 칸 — 풋프린트 교차 판정(`CarveFootprint.outerAt`)이 - * 쓰는 자료형이다. - * - * 옛 기하 합집합(`unionOutline`)은 2026-08-26 삭제했다 — 겹친 스트립에서 외곽선이 - * 무너졌다(같은 토막이 짝수 번 나오면 버리는 규칙이 외곽의 공유 변까지 지웠다). - * 구조물 탑뷰 외곽은 `_Corridor_Plan_Footprint.ts`가 링별 구간 병합으로 낸다. - * ========================================================================== */ - -/** 실좌표 XY 점. */ -export interface PXY { - x: number; - y: number; -} - -/** 볼록 사각형 — 꼭짓점 4개(한 바퀴 순서) + AABB. */ -export interface UnionQuad { - points: [PXY, PXY, PXY, PXY]; - minX: number; - maxX: number; - minY: number; - maxY: number; -} - -export function unionQuadOf(a: PXY, b: PXY, c: PXY, d: PXY): UnionQuad { - return { - points: [a, b, c, d], - minX: Math.min(a.x, b.x, c.x, d.x), - maxX: Math.max(a.x, b.x, c.x, d.x), - minY: Math.min(a.y, b.y, c.y, d.y), - maxY: Math.max(a.y, b.y, c.y, d.y), - }; -} diff --git a/B05_Profile/Old/B05_Profile_UI_Panel_View.ts b/B05_Profile/Old/B05_Profile_UI_Panel_View.ts deleted file mode 100644 index 9e1ceec70..000000000 --- a/B05_Profile/Old/B05_Profile_UI_Panel_View.ts +++ /dev/null @@ -1,143 +0,0 @@ -/* ============================================================================= - * B05_Profile_UI_Panel_View.ts - * 3D 화면 위에 얹는 **보기 조작 줄**(ISO·TOP·… · 표시 켬끔 · 뷰 초기화 · [3D 업데이트]). - * 좌측 패널 칸이 아니라 뷰어에 붙는다 — `B05_Profile_UI_Panel.ts` 에서 떼어냈다(계획서 24-2b). - * ========================================================================== */ - -import { L, button, toggleButton } from "./B05_Profile_UI_Panel_Fields"; -import { - corridorModeId, - MODE_SLOTS, - modeOf, - onCorridorMode, - setCorridorMode, -} from "./B05_Profile_UI_Corridor_Modes"; - -/** 뷰 조작 줄이 부르는 것 — 좌측 패널 콜백의 일부다. */ -export interface ViewControlCallbacks { - onSurfaceVisible: (visible: boolean) => void; - onCorridorVisible: (visible: boolean) => void; - onContoursVisible: (visible: boolean) => void; - onAxesVisible: (visible: boolean) => void; - onStationLinesVisible: (visible: boolean) => void; - onStationLabelsVisible: (visible: boolean) => void; - onSurfaceGrayscale: (grayscale: boolean) => void; - onView: (view: "iso" | "top" | "front" | "side") => void; - onProjection: (kind: "perspective" | "ortho") => void; - onResetView: () => void; - onCorridorRefresh: () => void | Promise; -} - -export function createViewControls(callbacks: ViewControlCallbacks): { - viewControls: HTMLElement; - /** [3D 업데이트] 대기 표시 — 편집이 3D에 아직 안 들어갔음을 버튼에 알린다. */ - setCorridorPending: (pending: boolean) => void; -} { - const viewControls = document.createElement("div"); - viewControls.className = "b05-route__view-controls"; - const viewButtons = document.createElement("div"); - viewButtons.className = "b05-route__view-group"; - (["iso", "top", "front", "side"] as const).forEach((preset) => - viewButtons.append(button(preset.toUpperCase(), () => callbacks.onView(preset), "glass")), - ); - // 원근·직교 두 단추 — 지금 켜진 쪽을 강조한다. 기본은 직교(카메라 기본과 같음). - const projectionButtons = ( - [ - ["perspective", "원근"], - ["ortho", "직교"], - ] as const - ).map(([kind, label]) => { - const element = button( - label, - () => { - projectionButtons.forEach(({ kind: other, element: el }) => { - el.classList.toggle("is-active", other === kind); - el.setAttribute("aria-pressed", String(other === kind)); - }); - callbacks.onProjection(kind); - }, - "glass", - ); - element.classList.toggle("is-active", kind === "ortho"); - element.setAttribute("aria-pressed", String(kind === "ortho")); - return { kind, element }; - }); - viewButtons.append(...projectionButtons.map(({ element }) => element)); - // 3D 방식 고르기 한 줄(115-5) — 원근 · 직교와 같은 꼴. 등록 안 된 자리(제안 ②)는 꺼짐. - const modeButtons = document.createElement("div"); - modeButtons.className = "b05-route__view-group"; - const modeLabel = document.createElement("span"); - modeLabel.className = "b05-route__view-label"; - modeLabel.textContent = "3D 방식"; - const modeChoices = MODE_SLOTS.map(([id, label]) => { - const element = button(label, () => setCorridorMode(id), "glass"); - element.disabled = !modeOf(id); - if (element.disabled) element.title = "아직 없는 방식입니다."; - return { id, element }; - }); - const showMode = (): void => { - const current = corridorModeId(); - modeChoices.forEach(({ id, element }) => { - element.classList.toggle("is-active", id === current); - element.setAttribute("aria-pressed", String(id === current)); - }); - }; - showMode(); - onCorridorMode(showMode); - modeButtons.append(modeLabel, ...modeChoices.map(({ element }) => element)); - const visibilityButtons = document.createElement("div"); - visibilityButtons.className = "b05-route__view-group"; - visibilityButtons.append( - toggleButton("지표면", true, callbacks.onSurfaceVisible), - // 예상형상(2026-08-23): ON=공사 후 형상(기본), OFF=원지반 완전체. - toggleButton("예상형상", true, callbacks.onCorridorVisible), - toggleButton("등고선", true, callbacks.onContoursVisible), - toggleButton("축 표시", false, callbacks.onAxesVisible), - toggleButton(L("B05_Route_Field_StationLines"), true, callbacks.onStationLinesVisible), - toggleButton(L("B05_Route_Field_StationLabels"), true, callbacks.onStationLabelsVisible), - // 무지개 고도색이 헷갈릴 때 명도만 남기는 흑백 표시(2026-08-05 사용자 요청). - // 기본 켜짐(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; - const separator4 = separator1.cloneNode() as HTMLSpanElement; - viewControls.append( - viewButtons, - separator1, - modeButtons, - separator4, - visibilityButtons, - separator2, - button("뷰 초기화", callbacks.onResetView, "glass"), - separator3, - corridorRefresh, - ); - - return { - viewControls, - setCorridorPending(pending: boolean) { - corridorRefresh.classList.toggle("is-pending", pending); - }, - }; -} diff --git a/B05_Profile/Old/B05_Profile_UI_RouteCalc.ts b/B05_Profile/Old/B05_Profile_UI_RouteCalc.ts deleted file mode 100644 index adffbd73a..000000000 --- a/B05_Profile/Old/B05_Profile_UI_RouteCalc.ts +++ /dev/null @@ -1,192 +0,0 @@ -/* Old — 54-24 · 나중에 삭제 */ -/* ============================================================================= - * B05_Profile_UI_RouteCalc.ts - * 좌측 패널 「경로 계산 설정」 칸 — 포인트 팔레트 · 알고리즘 · 조건 · [최적 경로 계산](숨김). - * `B05_Profile_UI_Panel_Route.ts` · `_Panel_Sections.ts` · `_Page_Actions.ts` · `_Page_Helpers.ts` - * 에서 떼어 옮겼다. 어디서도 import 하지 않는다(tsconfig 제외). - * ========================================================================== */ - -import { - createSelectField, - hideLoadingOverlay, - showLoadingOverlay, - showToast, -} from "@ui/ui_template_elements"; -import { - createSectionFrame, - part, - registerPanelSection, -} from "../../A00_Common/design/design_panel"; -import { solveRoute, type CirclePoint, type RoutePoint } from "../B05_Profile_Api_Fetch"; -import type { PlacedRoutePoint, RoutePointKind } from "../B05_Profile_UI_Markers"; -import { button, checkbox, numberField, parseOptional } from "../B05_Profile_UI_Panel_Fields"; -import type { PageActionContext } from "../B05_Profile_UI_Page_Actions"; - -export function createRouteCalcPart(callbacks: { onSolve: () => void }) { - const paletteGrid = document.createElement("div"); - paletteGrid.className = "b05-route__palette"; - const pointLabels: Record = { - bp: "BP 시작점", - ep: "EP 종료점", - cp: "CP 경유점", - ap: "AP 회피구역", - fp: "FP 금지구역", - }; - (Object.keys(pointLabels) as RoutePointKind[]).forEach((kind) => { - const chip = document.createElement("div"); - chip.className = `b05-route__chip is-${kind}`; - chip.draggable = true; - chip.textContent = pointLabels[kind]; - chip.addEventListener("dragstart", (event) => event.dataTransfer?.setData("pointType", kind)); - paletteGrid.append(chip); - }); - - // 드롭다운은 공통 컴포넌트(createSelectField) 재사용. `.select`로 받아 이하 로직 불변. - const algorithmField = createSelectField({ - label: "알고리즘", - options: [ - { value: "dijkstra", text: "Dijkstra" }, - { value: "ridge_valley", text: "능선·계곡" }, - ], - }); - const algorithm = algorithmField.select; - const minCurveRadius = numberField("최소 곡선반경 (m)"); - const maxUphillGrade = numberField("오르막 경사 상한 (%)"); - const maxDownhillGrade = numberField("내리막 경사 상한 (%)"); - const minUphillGrade = numberField("오르막 경사 하한 (%)"); - const minDownhillGrade = numberField("내리막 경사 하한 (%)"); - const paved = checkbox("포장 임도", false); - const avoidPass = checkbox("회피구역 통과 허용", false); - const details = document.createElement("details"); - const summary = document.createElement("summary"); - summary.textContent = "사용한 조건"; - details.append( - summary, - minCurveRadius.wrapper, - maxUphillGrade.wrapper, - maxDownhillGrade.wrapper, - minUphillGrade.wrapper, - minDownhillGrade.wrapper, - ); - // [최적 경로 계산] 입구는 2026-09-06 사용자 확정으로 화면에서 내렸다 — 평면만 보고 - // 노선을 정하는 방식이라 실제 판단(평면·종단·횡단·유토곡선을 함께 봄)과 맞지 않는다. - const solveButton = button("최적 경로 계산", callbacks.onSolve, "filled"); - solveButton.hidden = true; - - return { - /** 「경로 계산 설정」 본문 — 화면 순서대로. */ - calcNodes: [ - paletteGrid, - algorithmField.root, - paved.wrapper, - avoidPass.wrapper, - details, - solveButton, - ] as HTMLElement[], - fields: { - algorithm, - paved, - avoidPass, - minCurveRadius, - maxUphillGrade, - maxDownhillGrade, - minUphillGrade, - minDownhillGrade, - }, - }; -} - -export type RouteCalcPart = ReturnType; - -registerPanelSection({ - id: "route-calc", - title: "경로 계산 설정", - views: ["profile"], - order: 40, - build: (ctx, view) => { - const frame = createSectionFrame(view, "경로 계산 설정", true); - frame.body.append(...part(ctx, "routeCalc").calcNodes); - // 경로 계산 설정 — 사용여부 미결정으로 조작 불가(숨기지 않는다, 2026-08-18 사용자 - // 지시). inert가 본문 전체 입력·드래그를 막고, is-disabled가 흐림을 그린다. - // 제목 클릭(접기/펼치기)은 본문 밖이라 그대로 산다. - frame.root.classList.add("is-disabled"); - frame.body.inert = true; - return frame.root; - }, -}); - -export function routePoint(point: PlacedRoutePoint): RoutePoint { - // 서버 스키마는 표고를 빼면 "모름"으로 받는다 — null은 undefined로 바꿔 보낸다. - return { x: point.x, y: point.y, z: point.z ?? undefined }; -} - -export function circlePoint(point: PlacedRoutePoint): CirclePoint { - return { ...routePoint(point), radius_m: point.radius_m ?? 25 }; -} - -/** [경로 계산] — 마커·패널 값으로 노선을 풀고 종횡단까지 다시 받는다. */ -export async function solveRouteAction( - ctx: PageActionContext, - calc: RouteCalcPart["fields"], -): Promise { - const latest = ctx.latest(); - const confirmedSurface = ctx.confirmedSurface(); - if (!confirmedSurface || !latest) return; - const points = ctx.viewer().markers.getPoints(); - if (!points.bp || !points.ep) { - showToast("BP와 EP를 지형에 배치하세요.", "error"); - return; - } - const values = ctx.panel().values(); - showLoadingOverlay(); - try { - const solved = await solveRoute(ctx.projectId, { - filter_key: latest.surface_params.source_filter, - method: latest.surface_params.method, - smooth: latest.surface_params.smooth, - surface_model_id: confirmedSurface.model_id ?? undefined, - algorithm: calc.algorithm.value, - bp: routePoint(points.bp), - ep: routePoint(points.ep), - cp: points.cp.map(routePoint), - ap: points.ap.map(circlePoint), - fp: points.fp.map(circlePoint), - grade_class: values.gradeClass, - paved: calc.paved.checked, - min_curve_radius_m: parseOptional(calc.minCurveRadius), - max_uphill_grade: parseOptional(calc.maxUphillGrade), - max_downhill_grade: parseOptional(calc.maxDownhillGrade), - min_uphill_grade: parseOptional(calc.minUphillGrade), - min_downhill_grade: parseOptional(calc.minDownhillGrade), - allow_avoid_pass_through: calc.avoidPass.checked, - station_interval_m: values.stationInterval, - cross_half_width_m: null, - cross_sample_interval_m: values.crossSampleInterval, - long_sample_interval_m: values.longSampleInterval, - terrain_type: values.terrainType, - design_speed_kph: values.designSpeed, - max_grade_pct: values.maxGradePct, - min_vertical_radius_m: values.minVerticalRadius, - min_tangent_length_m: values.minTangentLength, - start_elevation_offset_m: values.startElevationOffset, - end_elevation_offset_m: values.endElevationOffset, - enforce_pipe_clearance: values.enforcePipeClearance, - }); - // 새 경로는 측점 구성이 달라지므로 이전 상단측 변경분을 폐기한다(자동 판정 재사용). - ctx.uphillOverrides.clear(); - ctx.persistUphillOverrides(); - ctx.renderLatest(await ctx.loadLatest(true)); - await ctx.restoreSections(solved.route_id); - if (solved.cross_section_count === null) { - showToast("경로는 저장되었지만 종횡단 생성에 실패했습니다.", "error"); - } else if (solved.grade_summary === null) { - showToast("경로·종횡단은 저장되었지만 계획선 산출에 실패했습니다.", "error"); - } else { - showToast("최적 경로 계산이 완료되었습니다.", "success"); - } - } catch (error) { - showToast(error instanceof Error ? error.message : "경로 계산에 실패했습니다.", "error"); - } finally { - hideLoadingOverlay(); - } -} diff --git a/B05_Profile/Old/B05_Profile_UI_Viewer_Corridor.ts b/B05_Profile/Old/B05_Profile_UI_Viewer_Corridor.ts deleted file mode 100644 index a4ae28898..000000000 --- a/B05_Profile/Old/B05_Profile_UI_Viewer_Corridor.ts +++ /dev/null @@ -1,317 +0,0 @@ -/* ============================================================================= - * B05_Profile_UI_Viewer_Corridor.ts - * 3D 뷰어의 예상형상(코리도) 조각 — 원본/클리핑 지형 스왑 + 계획 서피스 조합. - * - * 뷰어 본체(`B05_Profile_UI_Viewer.ts`)가 700줄을 넘어 이 부분만 뗐다(계획서 24-3a). - * 지형 · 범위 · 높이 색인은 뷰어가 쥐고, 여기서는 받아 읽기만 한다. - * ========================================================================== */ - -import * as THREE from "three"; -import type { ModelBounds } from "./B05_Profile_UI_Markers"; -import type { CorridorBuildResult } from "./B05_Profile_UI_Corridor_Build"; -import { - createCorridorGroup, - PLAN_CURVE_FLAG, - PLAN_CURVE_GROUP, -} from "./B05_Profile_UI_Corridor_Mesh"; -import { clipTerrain } from "./B05_Profile_UI_Corridor_Clip"; -import { setCorridorBuildSummary } from "./B05_Profile_UI_Viewer_Debug"; -import type { TerrainHeightIndex } from "./B05_Profile_UI_Corridor_Terrain"; -import { BAND_MARGIN_M, TerrainBandSplit, type SceneBox } from "./B05_Profile_UI_Corridor_Split"; -import { - activeCorridorMode, - DEFAULT_MODE, - MODE_SLOTS, - modeOf, - onCorridorMode, - type CorridorMode, -} from "./B05_Profile_UI_Corridor_Modes"; -import { showToast } from "@ui/ui_template_elements"; - -/** 서피스 삼각형 수 — 클리핑이 실제로 걷어냈는지 확인하는 계측용. */ -function countTriangles(root: THREE.Object3D | null): number { - let total = 0; - root?.traverse((child) => { - if (!(child instanceof THREE.Mesh)) return; - const index = child.geometry.getIndex(); - const position = child.geometry.getAttribute("position"); - total += Math.floor((index ? index.count : (position?.count ?? 0)) / 3); - }); - return total; -} - -/** 서피스 진단 요약 — 색·법선·재질이 원본과 어긋났는지 화면 밖에서 확인한다. */ -function describeSurface(root: THREE.Object3D | null): unknown { - const out: unknown[] = []; - root?.traverse((child) => { - if (!(child instanceof THREE.Mesh) || out.length >= 2) return; - const geometry = child.geometry; - const color = geometry.getAttribute("color") as THREE.BufferAttribute | undefined; - const normal = geometry.getAttribute("normal") as THREE.BufferAttribute | undefined; - const material = ( - Array.isArray(child.material) ? child.material[0] : child.material - ) as THREE.MeshLambertMaterial; - out.push({ - type: material?.type, - vertexColors: material?.vertexColors, - matColor: material?.color?.getHexString(), - side: material?.side, - colorSize: color?.itemSize, - color0: color ? [color.getX(0), color.getY(0), color.getZ(0)] : null, - normal0: normal ? [normal.getX(0), normal.getY(0), normal.getZ(0)] : null, - hasNormal: Boolean(normal), - }); - }); - return out; -} - -export function disposeObject(object: THREE.Object3D | null): void { - object?.traverse((child) => { - if ( - child instanceof THREE.Mesh || - child instanceof THREE.Points || - child instanceof THREE.Line - ) { - child.geometry.dispose(); - const materials = Array.isArray(child.material) ? child.material : [child.material]; - materials.forEach((material) => material.dispose()); - } - }); -} - -export interface ViewerCorridorDeps { - scene: THREE.Scene; - terrain: () => THREE.Object3D | null; - bounds: () => ModelBounds | null; - heightIndex: () => TerrainHeightIndex | null; - /** 코리도 그룹을 새로 만들었을 때 — 구조물 강조를 다시 입힌다. */ - onGroup: () => void; - /** 클리핑본에도 흑백 토글을 입힌다. */ - applyGrayscale: (target: THREE.Object3D | null) => void; -} - -export interface ViewerCorridor { - set: (build: CorridorBuildResult | null) => void; - setVisible: (visible: boolean) => void; - setSurfaceVisible: (visible: boolean) => void; - /** 지형이 바뀌기 전 — 밴드 분할본을 버린다. */ - resetTerrain: () => void; - /** 지형 · 범위가 준비된 뒤 — 미뤄 둔 코리도를 다시 조립한다. */ - rebuild: () => void; - group: () => THREE.Group | null; - clipped: () => THREE.Object3D | null; - dispose: () => void; -} - -export function createViewerCorridor(deps: ViewerCorridorDeps): ViewerCorridor { - const { scene } = deps; - let corridorBuild: CorridorBuildResult | null = null; - let corridorGroup: THREE.Group | null = null; - let clippedTerrain: THREE.Object3D | null = null; - /** 지형 near/far 분할본 — 편집마다 노선 주변만 재트림하려고 1회만 만든다. */ - let bandSplit: TerrainBandSplit | null = null; - let corridorOn = true; // [예상형상] 기본 ON — 계획서피스가 보이는 게 기본값. - let surfaceOn = true; // 기존 [지표면] 토글 상태(코리도 스왑과 조합). - /** 지금 그리는 방식(115-5 고르기 한 줄)과 그 방식이 낸 코리도 빌드. */ - let mode: CorridorMode = activeCorridorMode(); - let drawnBuild: CorridorBuildResult | null = null; - const offMode = onCorridorMode(() => { - if (corridorBuild) set(corridorBuild); - }); - - /** 표시 상태 일괄 적용 — 클리핑본이 준비되기 전에는 원본 지형을 그대로 둔다. */ - function applyCorridorVisibility(): void { - const terrain = deps.terrain(); - const swapped = corridorOn && clippedTerrain !== null; - if (terrain) terrain.visible = surfaceOn && !swapped; - if (clippedTerrain) clippedTerrain.visible = surfaceOn && swapped; - if (corridorGroup) corridorGroup.visible = corridorOn; - } - - function disposeClippedTerrain(): void { - if (!clippedTerrain) return; - scene.remove(clippedTerrain); - // 분할본이 들고 있는 geometry·material은 여기서 해제하지 않는다 — 다음 재트림에 - // 그대로 다시 쓴다. 이 그룹이 새로 만든 것(userData.owned)만 정리한다. - clippedTerrain.traverse((child) => { - if (!(child instanceof THREE.Mesh) && !(child instanceof THREE.Points)) return; - if (child.userData.owned) child.geometry.dispose(); - }); - clippedTerrain = null; - } - - /** 코리도를 넉넉히 감싼 밴드(씬 좌표) — 이 안쪽만 편집마다 다시 트림한다. */ - function corridorBand(build: CorridorBuildResult): SceneBox | null { - const bounds = deps.bounds(); - if (!bounds) return null; - const ox = (bounds.x[0] + bounds.x[1]) / 2; - const oy = (bounds.y[0] + bounds.y[1]) / 2; - let minX = Infinity; - let maxX = -Infinity; - let minZ = Infinity; - let maxZ = -Infinity; - [...build.outline.left, ...build.outline.right].forEach(([mx, my]) => { - const x = mx - ox; - const z = -(my - oy); - if (x < minX) minX = x; - if (x > maxX) maxX = x; - if (z < minZ) minZ = z; - if (z > maxZ) maxZ = z; - }); - if (!Number.isFinite(minX)) return null; - return { minX, maxX, minZ, maxZ }; - } - - /** 밴드 분할본 확보 — 코리도가 밴드를 벗어났을 때만 다시 가른다. */ - function ensureBandSplit(build: CorridorBuildResult): TerrainBandSplit | null { - const terrain = deps.terrain(); - if (!terrain) return null; - const box = corridorBand(build); - if (!box) return null; - if (bandSplit?.covers(box)) return bandSplit; - bandSplit?.dispose(); - bandSplit = new TerrainBandSplit(terrain, { - minX: box.minX - BAND_MARGIN_M, - maxX: box.maxX + BAND_MARGIN_M, - minZ: box.minZ - BAND_MARGIN_M, - maxZ: box.maxZ + BAND_MARGIN_M, - }); - return bandSplit; - } - - function disposeCorridorGroup(): void { - if (!corridorGroup) return; - scene.remove(corridorGroup); - disposeObject(corridorGroup); - corridorGroup = null; - } - - /** 클리핑본 재생성 — 무거우므로 코리도 표시 후 다음 프레임에 수행(비동기 스왑). */ - function scheduleTerrainClip(): void { - disposeClippedTerrain(); - if (!corridorBuild || !deps.terrain() || !deps.bounds()) { - applyCorridorVisibility(); - return; - } - const buildAtSchedule = corridorBuild; - requestAnimationFrame(() => { - const terrain = deps.terrain(); - const bounds = deps.bounds(); - // 예약 사이에 코리도가 교체·제거됐으면 이 클립은 폐기한다. - if (corridorBuild !== buildAtSchedule || !terrain || !bounds) return; - const drawn = drawnBuild ?? buildAtSchedule; - const split = ensureBandSplit(drawn); - if (!split) return; - // 방식이 지형을 안 내면 그 방식 빌드로 띠만 자른다(지금 · 옛 A). - const shaped = - mode.terrain?.({ - build: buildAtSchedule, - corridor: drawn, - split, - bounds, - heightIndex: deps.heightIndex(), - }) ?? null; - (window as unknown as { __corridorDeform?: unknown }).__corridorDeform = - shaped?.stats ?? null; - const clipped = shaped?.root ?? clipTerrain(split, drawn, bounds); - disposeClippedTerrain(); - clippedTerrain = clipped; - scene.add(clipped); - // 검증용 요약 — 원지반이 실제로 잘렸는지 화면 밖에서 수치로 확인한다. - (window as unknown as { __corridorClip?: unknown }).__corridorClip = { - source: countTriangles(terrain), - clipped: countTriangles(clipped), - detail: describeSurface(clipped), - sourceDetail: describeSurface(terrain), - }; - deps.applyGrayscale(clipped); // 흑백 토글 상태 유지. - applyCorridorVisibility(); - }); - applyCorridorVisibility(); - } - - function set(build: CorridorBuildResult | null): void { - const picked = activeCorridorMode(); - // 처음 만들기가 무거운 방식(옛 A 즉석 빌드 · 몇 초) — 알림을 먼저 띄우고 다음 틈에 만든다. - if (build && picked.slow?.(build)) { - corridorBuild = build; - const label = MODE_SLOTS.find(([id]) => id === picked.id)?.[1] ?? picked.id; - showToast(`3D 방식 「${label}」 형상을 만드는 중입니다…`, "info"); - window.setTimeout(() => { - if (corridorBuild !== build || activeCorridorMode() !== picked) return; - const bounds = deps.bounds(); - if (bounds) picked.corridor(build, { bounds, heightIndex: deps.heightIndex() }); - set(build); - }, 50); - return; - } - disposeCorridorGroup(); - corridorBuild = build; - mode = picked; - drawnBuild = null; - const bounds = deps.bounds(); - if (build && bounds) { - const ctx = { bounds, heightIndex: deps.heightIndex() }; - drawnBuild = mode.corridor(build, ctx); - // 그 방식이 못 그리면(옛 A 입력 없음) 기본 방식으로. - if (!drawnBuild) { - mode = modeOf(DEFAULT_MODE) as CorridorMode; - drawnBuild = mode.corridor(build, ctx); - } - } - setCorridorBuildSummary(drawnBuild ?? build); - if (drawnBuild && bounds) { - corridorGroup = createCorridorGroup(drawnBuild, bounds); - scene.add(corridorGroup); - deps.onGroup(); - } - // 평면 스케치 되켜기 — 최종 결과물에서는 숨기지만 절취·패치 기하를 다시 볼 때 쓴다 - // (2026-09-02 사용자: "나중에 디버깅을 위해 재사용 가능성 있음"). - // `__corridorPlanCurves(true)` 로 켜고 `(false)` 로 끈다. 선택은 이 브라우저에 남아 - // 다음에 열 때도 그대로다. 인자 없이 부르면 지금 상태를 돌려준다. - ( - window as unknown as { __corridorPlanCurves?: (on?: boolean) => boolean } - ).__corridorPlanCurves = (on?: boolean): boolean => { - const sketch = corridorGroup?.getObjectByName(PLAN_CURVE_GROUP); - if (on !== undefined) { - try { - window.localStorage.setItem(PLAN_CURVE_FLAG, on ? "1" : "0"); - } catch { - // 저장소가 막힌 환경 — 이번 화면에만 적용한다. - } - if (sketch) sketch.visible = on; - } - return sketch?.visible ?? false; - }; - scheduleTerrainClip(); - } - - return { - set, - setVisible(visible) { - corridorOn = visible; - applyCorridorVisibility(); - }, - setSurfaceVisible(visible) { - surfaceOn = visible; - applyCorridorVisibility(); - }, - resetTerrain() { - bandSplit?.dispose(); - bandSplit = null; - }, - rebuild() { - if (corridorBuild) set(corridorBuild); - }, - group: () => corridorGroup, - clipped: () => clippedTerrain, - dispose() { - offMode(); - corridorBuild = null; // 예약된 클립 콜백 무효화. - disposeCorridorGroup(); - disposeClippedTerrain(); - bandSplit?.dispose(); - bandSplit = null; - }, - }; -} diff --git a/B05_Profile/Old/B05_Profile_UI_Viewer_Debug.ts b/B05_Profile/Old/B05_Profile_UI_Viewer_Debug.ts deleted file mode 100644 index b5964869a..000000000 --- a/B05_Profile/Old/B05_Profile_UI_Viewer_Debug.ts +++ /dev/null @@ -1,55 +0,0 @@ -/* ============================================================================= - * B05_Profile_UI_Viewer_Debug.ts - * 뷰어 **검증용 요약** — 코리도 빌드 결과를 `window.__corridorBuild` 에 얹는다 - * (`__corridorClip`·`__corridorScene` 과 같은 용도). - * - * `B05_Profile_UI_Viewer` 에서 그대로 떼어냈다(700줄 제한, 2026-09-04). 계산은 없고 - * 요약 형태만 있으므로 화면 동작에는 영향이 없다. - * ========================================================================== */ - -import type { CorridorBuildResult } from "./B05_Profile_UI_Corridor_Build"; - -/** - * 격자 재배치·투영선이 리본을 흔들었는지 화면 밖에서 수치로 확인한다(2026-08-26). - * `raw` 는 원본 참조라 투영선이 실제로 서피스에 얹혔는지 좌표로 대조할 때 쓴다. - */ -export function setCorridorBuildSummary(build: CorridorBuildResult | null): void { - (window as unknown as { __corridorBuild?: unknown }).__corridorBuild = build - ? { - ribbons: build.ribbons.map((ribbon) => ({ - kind: ribbon.kind, - side: ribbon.side, - rows: ribbon.chainages.length, - cols: ribbon.colCount, - first: ribbon.chainages[0], - last: ribbon.chainages[ribbon.chainages.length - 1], - })), - outlineRows: build.outline.chainages.length, - outline: build.outline, - // 구조물 솔리드 요약 — 어떤 시설이 몇 개 섰는지 화면 밖에서 센다(2026-08-28). - // 부재키(2026-09-04)도 함께 낸다 — 3D 개별 선택이 무엇을 집었는지 대조한다. - structures: (build.structures ?? []).map((solid) => ({ - kind: solid.kind, - key: solid.key ?? null, - at: solid.chainage_m, - rings: solid.rings?.length ?? 0, - points: solid.polygon?.length ?? 0, - })), - raw: { - ribbons: build.ribbons, - planCurves: build.planCurves ?? [], - cutWalls: build.cutWalls ?? [], - }, - planCurves: (build.planCurves ?? []).map((curve) => ({ - source: curve.source, - role: curve.role, - side: curve.side, - at: curve.setChainageM, - loops: curve.loops.length, - points: curve.loops.reduce((sum, loop) => sum + loop.length, 0), - z0: curve.loops[0]?.[0]?.[2] ?? null, - planZ: curve.planZ, - })), - } - : null; -} diff --git a/resources/tester/test_route_polyline_browser_mirror.py b/resources/tester/test_route_polyline_browser_mirror.py new file mode 100644 index 000000000..91218c038 --- /dev/null +++ b/resources/tester/test_route_polyline_browser_mirror.py @@ -0,0 +1,243 @@ +"""계획노선 편집 폴리라인 — 파이썬 ↔ TS 거울 비교 (계획서 0-2-A). + +`common_util/common_util_route_polyline.py` 의 **편집 갈래**(`simplify=False` + +`curve_flags`/`radii`)와 `B05_Profile/B05_Profile_UI_RouteEdit_Curve.ts` 의 +`buildEditedPolyline` 은 한 벌이다. 한쪽만 고치면 **끌 때 보이는 선**(브라우저)과 +**[확인] 뒤 저장되는 선**(서버)이 갈린다. + +실행: ./venv/Scripts/python.exe -m pytest resources/tester/test_route_polyline_browser_mirror.py -q +""" + +import json +import math +import subprocess +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from common_util.common_util_route_polyline import build_planned_polyline # noqa: E402 + +TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc" + +MIN_RADIUS_M = 12.0 + +# (이름, 점, 곡선켬끔, 못박은 R) +CASES: list[tuple[str, list[tuple[float, float]], list[bool], list[float | None]]] = [ + ( + "직각 한 번 · R 자동", + [(0.0, 0.0), (100.0, 0.0), (100.0, 100.0)], + [True, True, True], + [None, None, None], + ), + ( + "지그재그 · 전부 곡선", + [(0.0, 0.0), (80.0, 0.0), (120.0, 60.0), (200.0, 60.0), (240.0, 0.0)], + [True] * 5, + [None] * 5, + ), + ( + "가운데 곡선 지움", + [(0.0, 0.0), (80.0, 0.0), (120.0, 60.0), (200.0, 60.0), (240.0, 0.0)], + [True, True, False, True, True], + [None] * 5, + ), + ( + "R 못박음 30m", + [(0.0, 0.0), (100.0, 0.0), (100.0, 100.0)], + [True, True, True], + [None, 30.0, None], + ), + ( + "R 이 접선 자리보다 커서 눌림", + [(0.0, 0.0), (40.0, 0.0), (40.0, 40.0)], + [True, True, True], + [None, 500.0, None], + ), + ( + "완만한 꺾임(내각 큼)", + [(0.0, 0.0), (100.0, 0.0), (200.0, 12.0)], + [True, True, True], + [None, None, None], + ), + ( + "배향곡선처럼 되꺾임", + [(0.0, 0.0), (60.0, 0.0), (58.0, 30.0), (120.0, 30.0)], + [True] * 4, + [None] * 4, + ), + ( + "겹친 점(0.3m) 섞임 — 서버와 같이 버려야 함", + [(0.0, 0.0), (100.0, 0.0), (100.2, 0.2), (100.0, 100.0)], + [True] * 4, + [None] * 4, + ), +] + +DRIVER = """ +import { buildEditedPolyline } from "./B05_Profile_UI_RouteEdit_Curve.js"; +import fs from "node:fs"; +const cases = JSON.parse(fs.readFileSync(process.argv[2], "utf-8")); +const out = cases.map((c) => + buildEditedPolyline(c.points, c.curveOn, c.curveRadius, c.minRadiusM), +); +process.stdout.write(JSON.stringify(out)); +""" + + +@pytest.fixture(scope="module") +def ts_result(tmp_path_factory: pytest.TempPathFactory) -> list[dict]: + build_dir = tmp_path_factory.mktemp("ts_routeedit_build") + subprocess.run( # noqa: S603 — 고정 실행 파일 + [ + "node", + str(TSC), + str(ROOT / "B05_Profile" / "B05_Profile_UI_RouteEdit_Curve.ts"), + "--outDir", + str(build_dir), + "--module", + "esnext", + "--target", + "es2022", + "--moduleResolution", + "bundler", + "--ignoreConfig", + ], + cwd=str(ROOT), + check=True, + capture_output=True, + ) + payload = [ + { + "points": [list(point) for point in points], + "curveOn": flags, + "curveRadius": radii, + "minRadiusM": MIN_RADIUS_M, + } + for _name, points, flags, radii in CASES + ] + cases_path = build_dir / "cases.json" + cases_path.write_text(json.dumps(payload), encoding="utf-8") + (build_dir / "driver.mjs").write_text(DRIVER, encoding="utf-8") + run = subprocess.run( # noqa: S603 + ["node", str(build_dir / "driver.mjs"), str(cases_path)], + capture_output=True, + text=True, + cwd=str(build_dir), + ) + assert run.returncode == 0, f"node 실행 실패:\n{run.stdout}{run.stderr}" + return json.loads(run.stdout) + + +def _point_to_polyline_m(point: tuple[float, float], line: list[tuple[float, float]]) -> float: + """점에서 폴리라인까지 가장 가까운 거리(m).""" + best = float("inf") + for start, end in zip(line, line[1:]): + dx, dy = end[0] - start[0], end[1] - start[1] + span = dx * dx + dy * dy + t = ( + 0.0 + if span <= 0 + else max( + 0.0, min(1.0, ((point[0] - start[0]) * dx + (point[1] - start[1]) * dy) / span) + ) + ) + best = min(best, math.hypot(start[0] + t * dx - point[0], start[1] + t * dy - point[1])) + return best + + +ARC_STEP_DEG = 5.0 + + +def _sampling_tolerance_m(radii: list[float]) -> float: + """원호 표본이 한 칸 어긋났을 때 생길 수 있는 최대 벌어짐(m) — 현(弦)의 중앙종거. + + 원호 위 점을 **몇 개** 찍을지는 `int(도수/5)` 라 90° 처럼 딱 떨어지는 자리에서 개수가 + 하나 갈릴 수 있다 — `math.sin` 이 파이썬(C libm)과 노드(V8)에서 마지막 자리 하나가 달라 + 중심이 1e-14 m 어긋나고 그것이 정수 나눗셈 경계를 넘긴다(2026-09-07 실측: + 89.99999999999992 vs 90.0). **같은 원 위의 같은 호**라 선의 모양은 같지만, 한쪽 점이 + 다른 쪽 현의 가운데에 놓이면 R(1−cos(스텝/2)) 만큼 떨어져 보인다. 표본 개수가 갈리면 + 상대 쪽 현이 한 칸 넓어지므로 **현 하나분**(R(1−cos(5°)))을 문턱으로 잡는다. + 공식이 어긋나면 점이 **미터 단위**로 움직이므로 이 문턱으로도 충분히 걸린다. + """ + if not radii: + return 1e-9 + return max(radii) * (1 - math.cos(math.radians(ARC_STEP_DEG))) + 1e-9 + + +@pytest.mark.parametrize("index", range(len(CASES))) +def test_python_and_ts_draw_the_same_line(ts_result, index: int) -> None: + name, points, flags, radii = CASES[index] + expected = build_planned_polyline( + list(points), + min_radius_m=MIN_RADIUS_M, + simplify=False, + curve_flags=list(flags), + radii=list(radii), + ) + actual = ts_result[index] + + got = [(x, y) for x, y in actual["vertices"]] + assert abs(len(got) - len(expected.vertices)) <= 1, f"[{name}] 정점 개수가 크게 다름" + # 끝점은 정확히 같아야 한다 — 노선의 시작·끝은 표본 간격과 무관하다. + for axis in (0, 1): + assert abs(got[0][axis] - expected.vertices[0][axis]) < 1e-9, f"[{name}] 시작점이 다름" + assert abs(got[-1][axis] - expected.vertices[-1][axis]) < 1e-9, f"[{name}] 끝점이 다름" + + if len(got) == len(expected.vertices): + # 표본 개수가 같으면 **자리까지 똑같아야** 한다 — 여기서 봐주면 시험이 헐거워진다. + for (px, py), (tx, ty) in zip(expected.vertices, got): + assert abs(px - tx) < 1e-9, f"[{name}] x 가 다름" + assert abs(py - ty) < 1e-9, f"[{name}] y 가 다름" + else: + tolerance = _sampling_tolerance_m([curve.radius_m for curve in expected.curves]) + for vertex in got: + assert _point_to_polyline_m(vertex, expected.vertices) < tolerance, ( + f"[{name}] TS 정점이 서버 선에서 벗어남" + ) + for vertex in expected.vertices: + assert _point_to_polyline_m(vertex, got) < tolerance, ( + f"[{name}] 서버 정점이 TS 선에서 벗어남" + ) + + assert len(actual["curves"]) == len(expected.curves), f"[{name}] 곡선 개수가 다름" + for want, got in zip(expected.curves, actual["curves"]): + assert abs(want.radius_m - got["radius_m"]) < 1e-9, f"[{name}] 반지름이 다름" + assert abs(want.tangent_m - got["tangent_m"]) < 1e-9, f"[{name}] 접선 길이가 다름" + assert abs(want.inner_angle_deg - got["inner_angle_deg"]) < 1e-9, f"[{name}] 내각이 다름" + for axis in (0, 1): + assert abs(want.apex[axis] - got["apex"][axis]) < 1e-9, f"[{name}] 교각점이 다름" + assert abs(want.start[axis] - got["start"][axis]) < 1e-9, f"[{name}] 곡선 시작이 다름" + assert abs(want.end[axis] - got["end"][axis]) < 1e-9, f"[{name}] 곡선 끝이 다름" + assert want.violations == got["violations"], f"[{name}] 위반 문구가 다름" + + +def test_ts_reports_curve_seats_in_the_caller_index(ts_result) -> None: + """겹친 점을 버려도 TS 가 돌려주는 자리 번호는 **넘긴 목록 기준**이어야 한다. + + 화면이 잡고 있는 배열은 사용자가 끄는 노드 목록 그대로다. 서버처럼 걸러낸 뒤 번호를 + 돌려주면 손잡이가 엉뚱한 노드에 붙는다. + """ + index = next(i for i, case in enumerate(CASES) if "겹친 점" in case[0]) + _name, points, _flags, _radii = CASES[index] + curves = ts_result[index]["curves"] + assert len(curves) == 1, "겹친 점을 버리면 꺾임점은 하나만 남는다" + seat = curves[0]["node_first"] + # 버려진 것은 index 2 (100.2, 0.2) 이므로 곡선이 앉는 자리는 원래 목록의 1번이다. + assert seat == 1 + assert points[seat] == (100.0, 0.0) + assert len(ts_result[index]["nodes"]) == len(points), "노드 요약은 넘긴 목록과 자리가 같다" + + +def test_turning_a_curve_off_leaves_a_sharp_corner(ts_result) -> None: + """곡선을 지운 자리는 원호 없이 꺾여야 한다 — 화면에서 눈으로 확인하는 것과 같은 값.""" + on_index = next(i for i, case in enumerate(CASES) if case[0] == "지그재그 · 전부 곡선") + off_index = next(i for i, case in enumerate(CASES) if case[0] == "가운데 곡선 지움") + assert len(ts_result[on_index]["curves"]) == 3 + assert len(ts_result[off_index]["curves"]) == 2 + # 지운 자리(120, 60)가 폴리라인에 **꼭짓점 그대로** 남는다. + assert [120.0, 60.0] in ts_result[off_index]["vertices"] + assert [120.0, 60.0] not in ts_result[on_index]["vertices"] diff --git a/tsconfig.json b/tsconfig.json index 746b2b900..30bf838b6 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -35,5 +35,5 @@ } }, "include": ["A00_Common", "A0*", "B0*", "B1*", "M0*", "Z0*", "ui_template", "config/**/*", "common_util"], - "exclude": ["0_old", "B05_Profile/Old", "node_modules", "venv", "dist"] + "exclude": ["0_old", "node_modules", "venv", "dist"] } From 3d6c5584d187986f0846f3f01320afead7eb2109 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 16 Oct 2026 11:06:18 +0900 Subject: [PATCH 08/18] =?UTF-8?q?fix(=EB=AC=B6=EC=9D=8C):=20342-4=20?= =?UTF-8?q?=EC=84=9C=EB=B2=84=20Node=20=EB=AC=B6=EC=9D=8C=20=EB=82=A1?= =?UTF-8?q?=EC=9D=8C=20=ED=8C=90=EC=A0=95=20=EB=B3=B4=EA=B0=95=20=E2=80=94?= =?UTF-8?q?=20ui=5Ftemplate=20=C2=B7=20A00=5FCommon(b=5Fpage=5Fstate=20?= =?UTF-8?q?=EB=93=B1)=20=C2=B7=20config=20=EB=8D=94=ED=95=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SOURCE_DIRS 에 ui_template · A00_Common(하위 폴더째 · node_modules 뺌) · SOURCE_GLOBS 에 config/*.ts - 새 logic/ 하위 폴더 잡힘 · node_modules · CAD 앱은 안 봄 — 시험 test_342_4_bundle_stale.py Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013QyNXY2TUKXCFMwAca2APG --- common_util/common_util_node_bundle.py | 18 ++++-- resources/tester/test_342_4_bundle_stale.py | 68 +++++++++++++++++++++ 2 files changed, 81 insertions(+), 5 deletions(-) create mode 100644 resources/tester/test_342_4_bundle_stale.py diff --git a/common_util/common_util_node_bundle.py b/common_util/common_util_node_bundle.py index 1a5e6e022..d8de92f8f 100644 --- a/common_util/common_util_node_bundle.py +++ b/common_util/common_util_node_bundle.py @@ -19,13 +19,21 @@ from typing import Any logger = logging.getLogger(__name__) ROOT = Path(__file__).resolve().parents[1] -# 번들이 낡았는지 재는 대상 — 기하 계통이 걸쳐 있는 폴더 + 구조물도 식 풀이(B08, 2026-09-13). -SOURCE_DIRS = ("B05_Profile", "B06_Section", "B08_Quantity", "common_util") +# 번들이 낡았는지 재는 대상 — 기하 계통이 걸쳐 있는 폴더 + 구조물도 식 풀이(B08, 2026-09-13) · +# 묶음이 끌어가는 화면 공용(ui_template · A00_Common b_page_state · design) · 하위 폴더째. +SOURCE_DIRS = ( + "B05_Profile", + "B06_Section", + "B08_Quantity", + "common_util", + "ui_template", + "A00_Common", +) # 번들에 박히는 자료(JSON) — 벽 전면 기울기 표준경사 표·기슭막이 치수 표(2026-09-14 B5). 표만 바뀌어도 다시 빌드. SOURCE_DATA_DIRS = ("resources/data_masonry",) # 폴더째 훑지 않는 자리 — B07 은 CAD 앱(openwebcad · node_modules)이 함께 살아 맨 위 TS 만 본다 · -# 구조물 명세(A00 design)는 B07 CAD 칸 옵션 줄 번들이 끌어 씀(166-1). -SOURCE_GLOBS = ("B07_DesignDetail/*.ts", "A00_Common/design/*.ts") +# config 는 node_modules 가 함께 살아 맨 위 TS(config_frontend)만. +SOURCE_GLOBS = ("B07_DesignDetail/*.ts", "config/*.ts") # 번들 만들기·실행 상한(초). 실측 번들 실행 0.1초, 빌드 3초 수준이라 넉넉하다. BUILD_TIMEOUT_S = 300 RUN_TIMEOUT_S = 600 @@ -51,7 +59,7 @@ def bundle_stale(bundle: Path) -> bool: built_at = bundle.stat().st_mtime for directory in SOURCE_DIRS: for path in (ROOT / directory).rglob("*.ts"): - if path.stat().st_mtime > built_at: + if "node_modules" not in path.parts and path.stat().st_mtime > built_at: return True for directory in SOURCE_DATA_DIRS: for path in (ROOT / directory).glob("*.json"): diff --git a/resources/tester/test_342_4_bundle_stale.py b/resources/tester/test_342_4_bundle_stale.py new file mode 100644 index 000000000..39cee8cbb --- /dev/null +++ b/resources/tester/test_342_4_bundle_stale.py @@ -0,0 +1,68 @@ +"""342-4 서버 Node 묶음 낡음 판정 — 묶음이 끌어가는 화면 공용 · config · 새 logic/ 하위 폴더도 잡음. + + ./venv/Scripts/python.exe -m pytest resources/tester/test_342_4_bundle_stale.py -q +""" + +import os +from pathlib import Path + +import pytest + +from common_util import common_util_node_bundle as nb + +BUILT_AT = 1_000_000_000 + + +@pytest.fixture +def tree(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + monkeypatch.setattr(nb, "ROOT", tmp_path) + bundle = tmp_path / "dist" / "bundle.mjs" + bundle.parent.mkdir() + bundle.write_text("", encoding="utf-8") + os.utime(bundle, (BUILT_AT, BUILT_AT)) + return bundle + + +def _touch(root: Path, rel: str, when: int) -> None: + path = root / rel + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("", encoding="utf-8") + os.utime(path, (when, when)) + + +@pytest.mark.parametrize( + "rel", + [ + "B05_Profile/logic/B05_Profile_Footprint.ts", + "B06_Section/logic/B06_Section_Xxx.ts", + "ui_template/b_notice.ts", + "ui_template/locale/b_text.ts", + "A00_Common/b_page_state.ts", + "A00_Common/design/design_store.ts", + "config/config_frontend.ts", + "common_util/common_util_cross_design.ts", + "B07_DesignDetail/B07_DesignDetail_UI_Cad.ts", + ], +) +def test_새로_고친_TS_는_낡음(tree: Path, rel: str) -> None: + _touch(nb.ROOT, rel, BUILT_AT + 10) + assert nb.bundle_stale(tree) + + +def test_오래된_TS_뿐이면_그대로(tree: Path) -> None: + _touch(nb.ROOT, "B05_Profile/logic/B05_Profile_Footprint.ts", BUILT_AT - 10) + _touch(nb.ROOT, "ui_template/b_notice.ts", BUILT_AT - 10) + assert not nb.bundle_stale(tree) + + +@pytest.mark.parametrize( + "rel", + [ + "config/node_modules/typescript/lib/x.d.ts", + "A00_Common/config/node_modules/.vite/deps/x.ts", + "B07_DesignDetail/openwebcad/src/x.ts", + ], +) +def test_node_modules_CAD_앱은_안_봄(tree: Path, rel: str) -> None: + _touch(nb.ROOT, rel, BUILT_AT + 10) + assert not nb.bundle_stale(tree) From 2124bc85b92cd175151b562bdf7154802c103079 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 16 Oct 2026 11:06:47 +0900 Subject: [PATCH 09/18] =?UTF-8?q?fix(B05=20=EB=B0=B0=EC=88=98=EC=9C=A0?= =?UTF-8?q?=EC=97=AD):=20342-3=20=EC=B2=AB=20=EC=97=B4=EA=B8=B0=20?= =?UTF-8?q?=EC=84=B8=EB=B6=80=20=EC=9C=A0=EC=97=AD=20=EC=9A=94=EC=B2=AD=20?= =?UTF-8?q?11=EB=B2=88=20=E2=86=92=201=EB=B2=88=20=E2=80=94=20=EA=B0=99?= =?UTF-8?q?=EC=9D=80=20=ED=8B=B1=20analyze=20=EB=A5=BC=20=ED=95=9C=20?= =?UTF-8?q?=EB=B2=88=EC=9C=BC=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - B05_Profile_UI_Drainage_Pipes batched(같은 틱 부름 = 한 요청) · 배수유역 판 analyze 에 씌움 - 옛 소수 관 정수 맞춤(snapOldStations)이 관마다 부르던 POST detail-basins 가 한 번 · 계획고 따라가기 · 속성 이어받기 길은 그대로 - 시험 test_342_3_b05_detail_once(11번 부름 = 1번 · 다음 조작은 다시) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XWh5wNNycMkbwMpACz1rte --- B05_Profile/B05_Profile_UI_Drainage_Panel.ts | 7 ++-- B05_Profile/B05_Profile_UI_Drainage_Pipes.ts | 10 +++++ B05_Profile/B05_Profile_UI_Structures_Snap.ts | 2 +- resources/tester/helper_342_3_batched.cjs | 42 +++++++++++++++++++ .../tester/test_342_3_b05_detail_once.py | 22 ++++++++++ 5 files changed, 78 insertions(+), 5 deletions(-) create mode 100644 resources/tester/helper_342_3_batched.cjs create mode 100644 resources/tester/test_342_3_b05_detail_once.py diff --git a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts index 75a2d3ab5..5acb2fd98 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts @@ -29,7 +29,7 @@ import { type RoutePoint } from "./B05_Profile_Api_Fetch"; import type { FlowArrow } from "../B04_PreProcess/B04_PreProcess_UI_FlowArrows"; import { buildStrengthArray } from "../B04_PreProcess/B04_PreProcess_UI_FlowRamp"; import { resampleRoute } from "../B04_PreProcess/B04_PreProcess_UI_RouteSamples"; -import { createPipeEditor } from "./B05_Profile_UI_Drainage_Pipes"; +import { batched, createPipeEditor } from "./B05_Profile_UI_Drainage_Pipes"; import { followMovedPipe, PIPE_MATCH_M } from "../A00_Common/design/design_pipe_ids"; import { snapStructureStation } from "../common_util/common_util_structure_station"; import { createFacilityStore } from "./B05_Profile_UI_Drainage_Facility"; @@ -430,10 +430,9 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra * 옛 관이 살아난다(2026-08-02 사용자 보고). */ const resetPipes = (): Promise => run(() => resetDetailPipePoints(projectId as string)); - /** 세부유역을 다시 나눈다. 격자 해석은 하지 않으므로 즉시 끝난다. + /** 세부유역을 다시 나눈다(격자 해석 없음) — 같은 틱의 여러 부름은 한 번(342-3 · 옛 소수 관 옮김). * 시설 종류·구간·옵션은 계산기에서 사라지므로 요청에 되붙인다. */ - const analyze = (): Promise => - run(() => computeDetailBasins(projectId as string, pipeRequest())); + const analyze = batched(() => run(() => computeDetailBasins(projectId as string, pipeRequest()))); analyzeButton.addEventListener("click", () => void analyze()); deleteButton.addEventListener("click", () => void pipeEditor.deleteSelected()); diff --git a/B05_Profile/B05_Profile_UI_Drainage_Pipes.ts b/B05_Profile/B05_Profile_UI_Drainage_Pipes.ts index 12a99a0d6..34d56b1f3 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Pipes.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Pipes.ts @@ -60,6 +60,16 @@ export interface PipeEditor { /** `onChange`는 화면 갱신용, `onCommit`은 배치가 실제로 바뀌었을 때(추가·삭제·이동 완료). * 세부유역을 다시 나눠야 하는 시점이 정확히 `onCommit`이다(2026-08-01 사용자 지시). * `onMove`는 끌기를 마친 관의 옛 · 새 측점 — `onCommit` 앞에 불러 시설 속성이 관을 따라감. */ +/** 같은 틱에 여러 번 불러도 한 번만 돈다 — 옛 소수 관을 한꺼번에 옮겨도 세부유역 요청은 한 번(342-3). */ +export function batched(work: () => Promise): () => Promise { + let queued: Promise | null = null; + return () => + (queued ??= Promise.resolve().then(() => { + queued = null; + return work(); + })); +} + export function createPipeEditor( onChange: () => void, onCommit: () => void = () => {}, diff --git a/B05_Profile/B05_Profile_UI_Structures_Snap.ts b/B05_Profile/B05_Profile_UI_Structures_Snap.ts index d42ace5a7..29ac14a58 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Snap.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Snap.ts @@ -5,7 +5,7 @@ * * 맞춤 규칙은 공용 `common_util_structure_station`(파이썬 짝) 한 벌. 구조물은 한 번에, 관은 하나씩 * 폼 수정과 같은 길(`onPipeUpdate`)로 옮긴다 — 계획고 편집 따라가기(213-2) · 시설 속성 이어받기가 - * 손으로 옮길 때와 같다. 정본은 [저장]에서 나간다(알림 한 줄). + * 손으로 옮길 때와 같다 · 세부유역 요청은 한 번(배수유역 판 `batched` · 342-3). 정본은 [저장]에서 나간다(알림 한 줄). * ========================================================================== */ import { showToast } from "@ui/ui_template_elements"; diff --git a/resources/tester/helper_342_3_batched.cjs b/resources/tester/helper_342_3_batched.cjs new file mode 100644 index 000000000..1190cdc09 --- /dev/null +++ b/resources/tester/helper_342_3_batched.cjs @@ -0,0 +1,42 @@ +/* 342-3 B05 첫 열기 세부 유역 요청 한 번 헬퍼 — `B05_Profile_UI_Drainage_Pipes.ts` 의 `batched` 를 + * 그 자리에서 트랜스파일해 Node 로 돌리고 부른 수를 JSON 으로 낸다(틀 = helper_281_1). + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const cache = new Map(); + +function loadTs(file) { + const full = path.resolve(file); + if (cache.has(full)) return cache.get(full); + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box.exports); + const localRequire = (request) => { + if (request.endsWith(".json")) return require(path.join(path.dirname(full), request)); + if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`)); + if (request.startsWith("@ui/")) return { themeColor: () => "" }; + return require(request); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + cache.set(full, box.exports); + return box.exports; +} + +(async () => { + const { batched } = loadTs(path.join(ROOT, "B05_Profile", "B05_Profile_UI_Drainage_Pipes.ts")); + let runs = 0; + const analyze = batched(async () => { + runs += 1; + }); + const first = Array.from({ length: 11 }, () => analyze()); // 옛 소수 관 11개 옮김 + const samePromise = first.every((p) => p === first[0]); + await Promise.all(first); + const afterBurst = runs; + await analyze(); // 다음 조작은 다시 돈다 + process.stdout.write(JSON.stringify({ afterBurst, samePromise, afterNext: runs })); +})(); diff --git a/resources/tester/test_342_3_b05_detail_once.py b/resources/tester/test_342_3_b05_detail_once.py new file mode 100644 index 000000000..1558caec7 --- /dev/null +++ b/resources/tester/test_342_3_b05_detail_once.py @@ -0,0 +1,22 @@ +"""342-3 B05 첫 열기 — 옛 소수 관 11개를 정수 m 로 옮겨도 세부 유역 요청은 한 번. + +배수유역 판 `analyze` = `batched`(같은 틱 부름을 한 번으로) · Node 헬퍼가 TS 를 그 자리에서 돌린다. +""" + +import json +import os +import subprocess + +HERE = os.path.dirname(os.path.abspath(__file__)) + + +def test_burst_runs_once_then_again(): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_342_3_batched.cjs")], + capture_output=True, + text=True, + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + result = json.loads(proc.stdout) + assert result == {"afterBurst": 1, "samePromise": True, "afterNext": 2} From 73a251db264a9676b43593f0a1ef2ec6de8c001d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 16 Oct 2026 11:07:25 +0900 Subject: [PATCH 10/18] =?UTF-8?q?perf(342-2):=20[=ED=99=95=EC=A0=95]=20?= =?UTF-8?q?=C2=B7=20[=EC=A0=80=EC=9E=A5]=20=EC=84=9C=EB=B2=84=20=EC=9E=AC?= =?UTF-8?q?=EA=B3=84=EC=82=B0=20=EC=9E=85=EB=A0=A5=20=EC=A7=80=EB=AC=B8=20?= =?UTF-8?q?=C2=B7=20=EB=B0=94=EB=80=90=20=ED=96=89=EB=A7=8C=20=EC=94=80=20?= =?UTF-8?q?=C2=B7=20=ED=96=89=20=EC=9D=BD=EA=B8=B0=20=ED=95=9C=20=EB=B2=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 지문 = Node 입력 전부 + 번들 판 · 같은 입력이면 Node 건너뜀(틀 = B07 덧그림 기억 · TS 가 번들보다 새로우면 버림) - 측점 행 쓰기: 여러 벌을 읽기 한 번 · 바뀐 행만 한 문장(merge_cross_section_design_batches) · 앞 벌의 새 행을 뒤 벌이 찾음 - 종단 다시 읽기 지움(병렬 조회 한 번) · 유토곡선 같은 값이면 안 씀 · Node 결과 사본에 balloon 위치 - 시험 test_342_2_confirm_fingerprint Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD --- B06_Section/B06_Section_Repository_Bulk.py | 91 +++--- .../B06_Section_Server_Calc_Prebuild.py | 101 ++++--- .../tester/test_342_2_confirm_fingerprint.py | 273 ++++++++++++++++++ 3 files changed, 395 insertions(+), 70 deletions(-) create mode 100644 resources/tester/test_342_2_confirm_fingerprint.py diff --git a/B06_Section/B06_Section_Repository_Bulk.py b/B06_Section/B06_Section_Repository_Bulk.py index 72ebf0944..4cf3262e1 100644 --- a/B06_Section/B06_Section_Repository_Bulk.py +++ b/B06_Section/B06_Section_Repository_Bulk.py @@ -42,7 +42,23 @@ async def merge_cross_section_designs( 행이 없는 측점은 `project_id` 가 오면 새로 만든다 — 구조물(비정규) 측점은 B05 확정이 파일만 쓰고 DB 행을 안 만들기 때문이다(한 행짜리 함수와 같은 규칙). """ - if not entries: + return await merge_cross_section_design_batches( + connection, route_id=route_id, batches=[(entries, replace, project_id)] + ) + + +async def merge_cross_section_design_batches( + connection: aiomysql.Connection, + *, + route_id: int, + batches: list[tuple[list[tuple[float, dict[str, Any]]], bool, UUID | None]], +) -> int: + """여러 벌(`(entries, replace, project_id)`)을 차례로 얹되 읽기 한 번 · 쓰기 한 번(342-2). + + 앞 벌이 만든 새 행도 뒤 벌이 찾는다(따로 부르던 때와 같은 결과). 값이 그대로인 행은 쓰지 + 않는다 — 같은 입력의 [저장]·[확정]이 행을 다시 쓰지 않게. + """ + if not any(entries for entries, _, _ in batches): return 0 async with connection.cursor() as cursor: @@ -56,8 +72,11 @@ async def merge_cross_section_designs( for row_id, _chainage, raw in rows: data = json.loads(raw) if isinstance(raw, str) else raw existing[int(row_id)] = data if isinstance(data, dict) else {} + original = dict(existing) # 행 dict 는 제자리에서 안 고침(아래가 늘 사본을 만듦) — 대조 기준 # 같은 측점이 여러 행이면 뒤에 온 것(=큰 id)을 쓴다 — 한 행짜리 함수의 `ORDER BY id DESC`. ordered = [(float(chainage), int(row_id)) for row_id, chainage, _ in rows] + # 새 행 = 음수 id · 뒤 벌이 찾음(있는 행이 먼저 · 새 행끼리는 먼저 만든 것) · 끝에 INSERT + fresh: dict[int, tuple[str, float]] = {} def find(chainage_m: float) -> int | None: best: int | None = None @@ -68,40 +87,44 @@ async def merge_cross_section_designs( # 행마다 한 벌 — 같은 행에 여러 줄이 오면 앞 줄 결과 위에 얹음(`CASE id` 는 첫 WHEN 만 씀 · # 318-4 옛 조정값 비움 뒤 새 값) - updates: dict[int, str] = {} - inserts: list[tuple[str, int, float, str]] = [] - for chainage_m, payload in entries: - if not payload and not replace: - continue - # 얹기의 None = 그 열쇠 지움(JSON merge-patch · 318-4 관 종류 바꿈 옛 조정값) - drop = [] if replace else [key for key, value in payload.items() if value is None] - if drop: - payload = {key: value for key, value in payload.items() if value is not None} - row_id = find(chainage_m) - if row_id is None: - if project_id is None or not (payload or replace): + for entries, replace, project_id in batches: + for chainage_m, payload in entries: + if not payload and not replace: continue - inserts.append( - ( - str(project_id), - route_id, - chainage_m, - json.dumps({"design": payload}, ensure_ascii=False), - ) - ) - continue - data = dict(existing[row_id]) - if replace: - data["design"] = payload - else: - design = data.get("design") - design = dict(design) if isinstance(design, dict) else {} - design.update(payload) - for key in drop: - design.pop(key, None) - data["design"] = design - existing[row_id] = data - updates[row_id] = json.dumps(data, ensure_ascii=False) + # 얹기의 None = 그 열쇠 지움(JSON merge-patch · 318-4 관 종류 바꿈 옛 조정값) + drop = [] if replace else [key for key, value in payload.items() if value is None] + if drop: + payload = {key: value for key, value in payload.items() if value is not None} + row_id = find(chainage_m) + if row_id is None: + if project_id is None or not (payload or replace): + continue + row_id = -(len(fresh) + 1) + fresh[row_id] = (str(project_id), chainage_m) + ordered.append((chainage_m, row_id)) + existing[row_id] = {"design": payload} + continue + data = dict(existing[row_id]) + if replace: + data["design"] = payload + else: + design = data.get("design") + design = dict(design) if isinstance(design, dict) else {} + design.update(payload) + for key in drop: + design.pop(key, None) + data["design"] = design + existing[row_id] = data + + updates = { + row_id: json.dumps(data, ensure_ascii=False) + for row_id, data in existing.items() + if row_id > 0 and data != original[row_id] + } + inserts = [ + (owner, route_id, chainage_m, json.dumps(existing[row_id], ensure_ascii=False)) + for row_id, (owner, chainage_m) in fresh.items() + ] written = 0 async with connection.cursor() as cursor: diff --git a/B06_Section/B06_Section_Server_Calc_Prebuild.py b/B06_Section/B06_Section_Server_Calc_Prebuild.py index 2a965c06d..262bf28cc 100644 --- a/B06_Section/B06_Section_Server_Calc_Prebuild.py +++ b/B06_Section/B06_Section_Server_Calc_Prebuild.py @@ -23,6 +23,7 @@ from __future__ import annotations import asyncio +import hashlib import json import logging import time @@ -36,8 +37,8 @@ from B06_Section.B06_Section_Repository import ( get_longitudinal_section, merge_longitudinal_section_data, ) -from B06_Section.B06_Section_Repository_Bulk import merge_cross_section_designs -from common_util.common_util_node_bundle import run_bundle_json +from B06_Section.B06_Section_Repository_Bulk import merge_cross_section_design_batches +from common_util.common_util_node_bundle import bundle_stale, run_bundle_json from common_util.common_util_project_settings import ( haul_equipment_limits, mixed_conversion_factors, @@ -73,6 +74,37 @@ _AREA_KEYS = ( ) +# 입력 지문 → Node 결과(342-2 · 틀 = B07 덧그림 기억) — 같은 입력이면 Node 를 다시 안 돌림. +# 지문 = Node 에 넘기는 입력 **전부** + 번들 판(mtime) · TS 가 번들보다 새로우면 버림. +# ponytail: 프로세스 메모리 · 최근 몇 벌만. 서버 여러 대면 `_derived/` 파일로. +_NODE_CACHE: dict[str, Any] = {} +_NODE_CACHE_LIMIT = 8 + + +def _node_fingerprint(node_input: dict[str, Any]) -> str: + """Node 입력 전부 + 번들 판 → 지문. 입력 한 칸이라도 바뀌면 다른 값.""" + digest = hashlib.sha1( + json.dumps(node_input, sort_keys=True, default=str).encode("utf-8"), + usedforsecurity=False, + ) + digest.update(str(BUNDLE.stat().st_mtime_ns if BUNDLE.is_file() else 0).encode()) + return digest.hexdigest() + + +def _node_output(node_input: dict[str, Any]) -> Any: + """같은 입력이면 지난 결과 · 아니면 Node(면적 · 유토곡선)를 돌려 기억한다.""" + key = _node_fingerprint(node_input) + if key in _NODE_CACHE and not bundle_stale(BUNDLE): + return _NODE_CACHE[key] + output = run_bundle_json(BUNDLE, _NPM_SCRIPT, node_input) + if output is None: # 실패는 기억하지 않음 — 다음 [저장]이 다시 셈 + return None + if len(_NODE_CACHE) >= _NODE_CACHE_LIMIT: + _NODE_CACHE.pop(next(iter(_NODE_CACHE))) + _NODE_CACHE[_node_fingerprint(node_input)] = output # 셈 사이 번들을 다시 지었으면 새 판으로 + return output + + async def haul_inputs_for(project_id: Any) -> dict[str, Any]: """B08 이 낸 **구조물 몫**(채집석 공제·구조물 잔토)을 받아 온다. @@ -268,9 +300,7 @@ async def _recompute(project_id: UUID | str, route_id: int) -> int: marks.append(("포장·세월교 보정", time.perf_counter())) output = await asyncio.to_thread( - run_bundle_json, - BUNDLE, - _NPM_SCRIPT, + _node_output, { "detail": detail, "context": _mass_haul_context( @@ -303,43 +333,42 @@ async def _recompute(project_id: UUID | str, route_id: int) -> int: if not fixed and not rows and not mass_haul and not extra_walls: return 0 + stored = (longitudinal_row or {}).get("data") or {} + if isinstance(mass_haul, dict): + # balloon 위치는 **사용자가 끌어 옮긴 화면값**이다 — 서버가 만들지 않으므로 + # 저장분에서 떼어 새 유토곡선에 도로 붙인다(2026-09-06). 위 병렬 조회 한 번을 씀(342-2). + offsets = (stored.get("mass_haul") or {}).get("balloon_offsets") + if offsets is not None: # 사본에 붙임 — Node 결과는 지문 기억이 쥐고 있음 + mass_haul = {**mass_haul, "balloon_offsets": offsets} + if mass_haul == stored.get("mass_haul"): + mass_haul = None # 같은 값 — 다시 안 씀 + + area_entries: list[tuple[float, dict[str, Any]]] = [] + for row in rows if isinstance(rows, list) else []: + patch: dict[str, Any] = { + key: float(row[key]) for key in _AREA_KEYS if isinstance(row.get(key), (int, float)) + } + if patch: + area_entries.append((float(row["chainage_m"]), patch)) + updated = 0 async with pool.acquire() as connection: - # balloon 위치는 **사용자가 끌어 옮긴 화면값**이다 — 서버가 만들지 않으므로 - # 저장분에서 떼어 새 유토곡선에 도로 붙인다(2026-09-06). - if isinstance(mass_haul, dict): - existing = await get_longitudinal_section(connection, project_uuid, route_id) - stored = (existing or {}).get("data") or {} - offsets = (stored.get("mass_haul") or {}).get("balloon_offsets") - if offsets is not None: - mass_haul["balloon_offsets"] = offsets - await connection.begin() try: - # 행마다 쓰면 원격 DB 왕복이 행 수만큼 난다(측정: 22행 670ms) — 한 문장으로 묶는다. - await merge_cross_section_designs( + # 행 읽기 한 번 · 바뀐 행만 한 문장(342-2) — 보정 설계 갈아 끼움 → 면적 · 벽 얹기 차례. + updated = await merge_cross_section_design_batches( connection, route_id=route_id, - entries=[(float(item.get("chainage_m") or 0.0), item["design"]) for item in fixed], - replace=True, - project_id=project_uuid, + batches=[ + ( + [(float(item.get("chainage_m") or 0.0), item["design"]) for item in fixed], + True, + project_uuid, + ), + (area_entries, False, None), + (extra_walls, False, None), + ], ) - area_entries: list[tuple[float, dict[str, Any]]] = [] - for row in rows if isinstance(rows, list) else []: - patch: dict[str, Any] = { - key: float(row[key]) - for key in _AREA_KEYS - if isinstance(row.get(key), (int, float)) - } - if patch: - area_entries.append((float(row["chainage_m"]), patch)) - updated = await merge_cross_section_designs( - connection, route_id=route_id, entries=area_entries, replace=False - ) - if extra_walls: - await merge_cross_section_designs( - connection, route_id=route_id, entries=extra_walls, replace=False - ) if isinstance(mass_haul, dict): await merge_longitudinal_section_data( connection, route_id=route_id, data_patch={"mass_haul": mass_haul} @@ -351,7 +380,7 @@ async def _recompute(project_id: UUID | str, route_id: int) -> int: marks.append(("정본 저장", time.perf_counter())) _log_steps("서버 재계산 내부", marks) logger.info( - "서버 재계산: route_id=%s 설계 보정 %s곳, 구조물 면적 %s곳, 유토곡선 %s", + "서버 재계산: route_id=%s 설계 보정 %s곳, 바뀐 행 %s곳, 유토곡선 %s", route_id, len(fixed), updated, diff --git a/resources/tester/test_342_2_confirm_fingerprint.py b/resources/tester/test_342_2_confirm_fingerprint.py new file mode 100644 index 000000000..7b5580067 --- /dev/null +++ b/resources/tester/test_342_2_confirm_fingerprint.py @@ -0,0 +1,273 @@ +# -*- coding: utf-8 -*- +"""342-2 [확정] · [저장] 서버 재계산 — 입력 지문 · 바뀐 행만 · 행 읽기 한 번(부록 4 R23). + +① 지문 = Node 에 넘기는 입력 **전부** + 번들 판 — 같은 입력이면 Node 를 다시 안 돌림 · + 입력 어느 칸(깊은 칸 · 문맥 · 번들)이 바뀌어도 다시 돌림. +② Node 에 가는 입력과 지문이 같은 한 벌 — 지문 밖으로 새는 입력 길이 없음. +③ 행 쓰기 = 읽기 한 번 · 바뀐 행만 한 문장 · 앞 벌이 만든 새 행을 뒤 벌이 찾음. +④ 같은 입력으로 두 번 = 두 번째는 Node 0 번 · 행 쓰기 0 · 유토곡선 쓰기 0. +""" + +from __future__ import annotations + +import asyncio +import copy +import json +import os +import sys +from pathlib import Path +from types import SimpleNamespace +from uuid import uuid4 + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +import B06_Section.B06_Section_Router as b06_router # noqa: E402 +import B06_Section.B06_Section_Router_Design as b06_design # noqa: E402 +import B06_Section.B06_Section_Server_Calc_Prebuild as prebuild # noqa: E402 +from B06_Section.B06_Section_Repository_Bulk import ( # noqa: E402 + merge_cross_section_design_batches, +) + +DETAIL = { + "longitudinal": {"length_m": 40.0, "stations": [{"chainage_m": 0.0}]}, + "cross_sections": [ + {"chainage_m": 0.0, "design": {"ground_type": "soil", "slope": {"cut": 1.0}}}, + {"chainage_m": 20.0, "design": {"ground_type": "rock"}}, + ], +} +CONTEXT = {"earthwork_conversion": {"soil": {"L": 1.25, "C": 0.9}}, "haul_equipment_limits": []} + + +@pytest.fixture +def node(monkeypatch, tmp_path): + bundle = tmp_path / "bundle.js" + bundle.write_text("//", encoding="utf-8") + calls: list = [] + + def fake_run(path, script, payload): + calls.append(payload) + return {"areas": [], "mass_haul": {"total": len(calls)}} + + monkeypatch.setattr(prebuild, "BUNDLE", bundle) + monkeypatch.setattr(prebuild, "run_bundle_json", fake_run) + monkeypatch.setattr(prebuild, "bundle_stale", lambda path: False) + monkeypatch.setattr(prebuild, "_NODE_CACHE", {}) + return SimpleNamespace(calls=calls, bundle=bundle) + + +def _input(): + return {"detail": copy.deepcopy(DETAIL), "context": copy.deepcopy(CONTEXT)} + + +def test_같은_입력이면_Node_한_번(node): + first = prebuild._node_output(_input()) + second = prebuild._node_output(_input()) + assert len(node.calls) == 1 + assert first == second + + +@pytest.mark.parametrize( + "change", + [ + lambda data: data["detail"]["cross_sections"][0]["design"]["slope"].update(cut=1.5), + lambda data: data["detail"]["cross_sections"].append({"chainage_m": 40.0}), + lambda data: data["detail"]["longitudinal"].update(length_m=40.5), + lambda data: data["context"]["earthwork_conversion"]["soil"].update(C=0.85), + lambda data: data.update(context={**data["context"], "structure_spoil_m3": 3.0}), + ], + ids=["깊은 설계 칸", "측점 더함", "종단", "환산계수", "문맥 새 칸"], +) +def test_입력_어느_칸이_바뀌어도_다시(node, change): + prebuild._node_output(_input()) + changed = _input() + change(changed) + prebuild._node_output(changed) + assert len(node.calls) == 2 + + +def test_번들이_바뀌면_다시(node): + prebuild._node_output(_input()) + stat = node.bundle.stat() + os.utime(node.bundle, ns=(stat.st_atime_ns, stat.st_mtime_ns + 10_000_000)) + prebuild._node_output(_input()) + assert len(node.calls) == 2 + + +def test_TS_가_번들보다_새로우면_다시(node, monkeypatch): + prebuild._node_output(_input()) + monkeypatch.setattr(prebuild, "bundle_stale", lambda path: True) + prebuild._node_output(_input()) + assert len(node.calls) == 2 + + +def test_실패는_기억_안_함(node, monkeypatch): + monkeypatch.setattr(prebuild, "run_bundle_json", lambda *a: node.calls.append(a) or None) + assert prebuild._node_output(_input()) is None + assert prebuild._node_output(_input()) is None + assert len(node.calls) == 2 + + +# ── ③ 행 쓰기 ────────────────────────────────────────────────────────────── + + +class _Cursor: + def __init__(self, log, rows): + self.log, self.rows = log, rows + + async def __aenter__(self): + return self + + async def __aexit__(self, *exc): + return False + + async def execute(self, sql, params=None): + self.log.append((" ".join(sql.split()), list(params or []))) + + async def fetchall(self): + return self.rows + + +class _Connection: + def __init__(self, rows): + self.log: list = [] + self.rows = rows + + def cursor(self, *args): + return _Cursor(self.log, self.rows) + + async def begin(self): + return None + + async def commit(self): + return None + + async def rollback(self): + return None + + +def _rows(): + return [ + (1, 0.0, {"design": {"ground_type": "soil", "cut_area_m2": 2.0}}), + (2, 20.0, json.dumps({"design": {"ground_type": "rock"}})), + ] + + +def _kinds(log): + return [sql.split()[0] for sql, _ in log] + + +def test_읽기_한_번_바뀐_행만(tmp_path): + connection = _Connection(_rows()) + written = asyncio.run( + merge_cross_section_design_batches( + connection, + route_id=7, + batches=[ + ([(0.0, {"ground_type": "soil", "cut_area_m2": 2.0})], True, None), # 그대로 + ([(20.0, {"cut_area_m2": 3.0})], False, None), # 바뀜 + ([(40.0, {"ground_type": "soil"})], True, uuid4()), # 새 행 + ([(40.0, {"cut_area_m2": 1.0})], False, None), # 앞 벌의 새 행에 얹음 + ], + ) + ) + assert _kinds(connection.log) == ["SELECT", "UPDATE", "INSERT"] + update = connection.log[1][1] + assert update[0] == 2 and json.loads(update[1])["design"] == { + "ground_type": "rock", + "cut_area_m2": 3.0, + } + insert = connection.log[2][1] + assert json.loads(insert[3]) == {"design": {"ground_type": "soil", "cut_area_m2": 1.0}} + assert written == 2 + + +def test_모두_그대로면_쓰기_없음(): + connection = _Connection(_rows()) + written = asyncio.run( + merge_cross_section_design_batches( + connection, + route_id=7, + batches=[ + ([(0.0, {"cut_area_m2": 2.0})], False, None), + ([(20.0, {"ground_type": "rock"})], True, None), + ], + ) + ) + assert _kinds(connection.log) == ["SELECT"] + assert written == 0 + + +# ── ②④ 재계산 두 번 ────────────────────────────────────────────────────────── + + +class _Pool: + def __init__(self, connection): + self.connection = connection + + def acquire(self): + connection = self.connection + + class _Acquire: + async def __aenter__(self): + return connection + + async def __aexit__(self, *exc): + return False + + return _Acquire() + + +def test_같은_입력_두_번이면_Node_0_쓰기_0(node, monkeypatch, tmp_path): + rows = [(1, 0.0, {"design": DETAIL["cross_sections"][0]["design"]})] + connection = _Connection(rows) + stored = {"data": {"mass_haul": None}} + written_haul: list = [] + + async def detail(project_id, route_id): + return SimpleNamespace(model_dump=lambda mode: copy.deepcopy(DETAIL)) + + async def run_with(fn, *args): + if fn is prebuild.get_longitudinal_section: + return copy.deepcopy(stored) + if fn is prebuild.get_project_storage_relative_path: + return "p" + return {"source_filter": "csf", "method": "dtm", "smooth": False} + + async def write_haul(connection, *, route_id, data_patch): + written_haul.append(data_patch) + stored["data"]["mass_haul"] = data_patch["mass_haul"] + + def fake_run(path, script, payload): + node.calls.append(payload) + # Node 에 간 입력이 곧 지문 입력 — 다른 입력 길 없음 + assert set(payload) == {"detail", "context"} + return {"areas": [{"chainage_m": 0.0, "cut_area_m2": 5.0}], "mass_haul": {"total": 9.0}} + + monkeypatch.setattr(prebuild, "run_bundle_json", fake_run) + monkeypatch.setattr(b06_router, "get_section_detail", detail) + monkeypatch.setattr(prebuild, "run_with_connection", run_with) + monkeypatch.setattr(prebuild, "resolve_stored_project_path", lambda path: str(tmp_path)) + monkeypatch.setattr(prebuild, "get_db_pool", lambda: _Pool(connection)) + monkeypatch.setattr(prebuild, "merge_longitudinal_section_data", write_haul) + monkeypatch.setattr(prebuild, "quantity_settings", lambda root: {}) + monkeypatch.setattr(prebuild, "mixed_conversion_factors", lambda settings: CONTEXT) + monkeypatch.setattr(prebuild, "haul_equipment_limits", lambda settings: []) + monkeypatch.setattr(b06_design, "enforce_design_lines", lambda *a: None) + monkeypatch.setattr(prebuild, "_enforce_stored_designs", lambda *a: None) + + asyncio.run(prebuild.recompute_server_side(uuid4(), 3)) + assert len(node.calls) == 1 + assert _kinds(connection.log) == ["SELECT", "UPDATE"] + assert len(written_haul) == 1 + # 첫 번째가 쓴 값을 행에 반영(가짜 DB) — 두 번째는 같은 입력 + rows[0] = (1, 0.0, json.loads(connection.log[1][1][1])) + connection.log.clear() + + asyncio.run(prebuild.recompute_server_side(uuid4(), 3)) + assert len(node.calls) == 1 # Node 다시 안 돎 + assert _kinds(connection.log) == ["SELECT"] # 바뀐 행 없음 — 쓰기 0 + assert len(written_haul) == 1 # 유토곡선 같은 값 — 안 씀 From aac091dacc81dc2aa75bd7847df05399bdb8baca Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 16 Oct 2026 11:10:05 +0900 Subject: [PATCH 11/18] =?UTF-8?q?feat(B05=20=EB=B0=9C=EC=9E=90=EA=B5=AD):?= =?UTF-8?q?=20346-2=20=ED=8F=89=EB=A9=B4=20=EB=B0=9C=EC=9E=90=EA=B5=AD=20?= =?UTF-8?q?=EA=B3=84=EC=82=B0(=EC=88=9C=EC=88=98)=20=E2=80=94=20B07=20?= =?UTF-8?q?=ED=8F=89=EB=A9=B4=20=EB=B9=84=ED=83=88=EC=9D=98=20=E2=91=A0=20?= =?UTF-8?q?=EC=A7=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - B05_Profile/logic/B05_Profile_PlanFootprint.ts — 측점(중심 · 진행 방향) + 횡단 설계 결과 → 도로 끝 좌 · 우 · 비탈 끝 깎기 · 쌓기 · 눈금 - 짝 = B07_DesignDetail_Engine_Cad_PlanSlope.py(route_frame · side_is_cut · slope_end · slope_pieces) · 머리에 짝 표시 - 거울 시험 test_346_2_plan_footprint_mirror.py · 그리기는 2파(sub2) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013QyNXY2TUKXCFMwAca2APG --- .../logic/B05_Profile_PlanFootprint.ts | 289 ++++++++++++++++++ .../B07_DesignDetail_Engine_Cad_PlanSlope.py | 4 + .../test_346_2_plan_footprint_mirror.py | 184 +++++++++++ 3 files changed, 477 insertions(+) create mode 100644 B05_Profile/logic/B05_Profile_PlanFootprint.ts create mode 100644 resources/tester/test_346_2_plan_footprint_mirror.py diff --git a/B05_Profile/logic/B05_Profile_PlanFootprint.ts b/B05_Profile/logic/B05_Profile_PlanFootprint.ts new file mode 100644 index 000000000..ffa4d7d95 --- /dev/null +++ b/B05_Profile/logic/B05_Profile_PlanFootprint.ts @@ -0,0 +1,289 @@ +/* ============================================================================= + * B05_Profile/logic/B05_Profile_PlanFootprint.ts + * 평면 발자국 — 측점 횡단 설계 결과를 평면 선으로(도로 끝 좌 · 우 · 비탈 끝 깎기 · 쌓기 · 눈금) (346-2) + * + * ⚠⚠ 짝: `B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanSlope.py` + * (`route_frame` · `side_is_cut` · `slope_end` · `slope_pieces`) — 한쪽만 고치면 + * 종단 · 횡단 편집 중 평면과 B07 계획평면도가 갈린다. + * `footprintRecord` = `B07_DesignDetail_Router_Support_Plan.slope_records` 의 측점 한 줄. + * 거울 시험: `resources/tester/test_346_2_plan_footprint_mirror.py` + * + * 순수 계산 — 화면 · three · fetch · sessionStorage 없음 · 좌표 = 사업지 m · 오프셋 좌(+) · 우(-). + * ========================================================================== */ + +/** 횡단 설계선 · 지반 표본 한 점(`CrossDesignEdge` · `CrossGroundSample` 꼴). */ +export interface FootprintPoint { + offset_m?: number | null; + elevation_m?: number | null; + valid?: boolean; +} + +/** 측점 한 자리 — 중심 · 진행 방향 · 그 측점 횡단 설계 결과 · 지반 표본. */ +export interface FootprintStation { + chainage_m: number; + x: number; + y: number; + /** 진행 방향(크기 무관) — 좌측 법선 = (−ty, tx). */ + tx: number; + ty: number; + design: { + road_edges?: { + left?: FootprintPoint | null; + right?: FootprintPoint | null; + } | null; + design_line?: FootprintPoint[] | null; + } | null; + samples: FootprintPoint[]; +} + +/** `slope_pieces` 의 측점 줄. */ +export interface FootprintRecord { + chainage_m: number; + left_edge_m: number; + left_end_m: number; + right_edge_m: number; + right_end_m: number; + left_cut?: boolean; + right_cut?: boolean; + x?: number; + y?: number; + nx?: number; + ny?: number; +} + +type Line = number[][]; + +export interface PlanFootprint { + /** 도로 끝 [좌, 우]. */ + edge: Line[]; + /** 비탈 끝 — 쌓기(성토) 구간 · 깎기(절토) 구간. */ + fill: Line[]; + cut: Line[]; + /** 비탈 눈금 — 도로 끝에서 비탈 끝으로(긴 · 짧은 번갈아). */ + ticks: Line[]; +} + +/** 비탈 눈금 간격(1/1,200 실거리 m). 짝: `TICK_STEP_M`. */ +export const TICK_STEP_M = 2.0; + +type Side = "left" | "right"; +const SIDES: Side[] = ["left", "right"]; +const KEYS = ["left_edge_m", "left_end_m", "right_edge_m", "right_end_m"] as const; + +/** 파이썬 `round(v, n)` 대용 — 반올림 경계(…5)만 다를 수 있음(1e-n 안). */ +const roundTo = (value: number, digits: number): number => { + const scale = 10 ** digits; + return Math.round(value * scale) / scale; +}; + +function bisectLeft(values: number[], target: number): number { + let low = 0; + let high = values.length; + while (low < high) { + const middle = (low + high) >> 1; + if (values[middle] < target) low = middle + 1; + else high = middle; + } + return low; +} + +/** 노선 [누가거리, x, y] 위 그 자리와 좌측 법선 [x, y, nx, ny]. 짝: `route_frame`. */ +export function routeFrame(route: Array<[number, number, number]>, chainage: number): number[] { + const chainages = route.map((item) => item[0]); + const index = Math.min(Math.max(bisectLeft(chainages, chainage), 1), route.length - 1); + const [c0, x0, y0] = route[index - 1]; + const [c1, x1, y1] = route[index]; + const dx = x1 - x0; + const dy = y1 - y0; + const length = Math.hypot(dx, dy) || 1.0; + const ratio = c1 > c0 ? (chainage - c0) / (c1 - c0) : 0.0; + return [x0 + dx * ratio, y0 + dy * ratio, -dy / length, dx / length]; +} + +function interp(records: Array>, chainage: number, key: string): number { + const chainages = records.map((record) => record.chainage_m); + const index = bisectLeft(chainages, chainage); + if (index <= 0) return records[0][key]; + if (index >= records.length) return records[records.length - 1][key]; + const before = records[index - 1]; + const after = records[index]; + const span = after.chainage_m - before.chainage_m; + const ratio = span > 0 ? (chainage - before.chainage_m) / span : 0.0; + return before[key] + (after[key] - before[key]) * ratio; +} + +function groundOf(samples: FootprintPoint[]): Array<[number, number]> { + return samples + .filter( + (item) => + ("valid" in item ? Boolean(item.valid) : true) && typeof item.elevation_m === "number", + ) + .map((item): [number, number] => [Number(item.offset_m), Number(item.elevation_m)]) + .sort((a, b) => a[0] - b[0] || a[1] - b[1]); +} + +function groundAt(ground: Array<[number, number]>, offset: number): number { + const offsets = ground.map(([x]) => x); + const index = Math.min(Math.max(bisectLeft(offsets, offset), 1), ground.length - 1); + const [x0, z0] = ground[index - 1] ?? ground[ground.length - 1]; + const [x1, z1] = ground[index]; + return z0 + (z1 - z0) * (x1 > x0 ? (offset - x0) / (x1 - x0) : 0.0); +} + +/** 도로 끝에서 지반이 노면보다 높으면 그 쪽 비탈은 깎기. 짝: `side_is_cut`. */ +export function sideIsCut(samples: FootprintPoint[], edge: number, elevation: number): boolean { + const ground = groundOf(samples); + return ground.length >= 2 && groundAt(ground, edge) > elevation; +} + +/** 도로 끝에서 바깥으로 — 설계선이 원지반에 닿는 오프셋(비탈 끝). 짝: `slope_end`. */ +export function slopeEnd( + designLine: FootprintPoint[], + samples: FootprintPoint[], + edge: number, + side: Side, +): number { + const ground = groundOf(samples); + const points = designLine + .map((p): [number, number] => [Number(p.offset_m), Number(p.elevation_m)]) + .sort((a, b) => a[0] - b[0] || a[1] - b[1]); + const outward = + side === "left" + ? points.filter((p) => p[0] > edge) + : [...points].reverse().filter((p) => p[0] < edge); + if (!ground.length) return outward.length ? outward[outward.length - 1][0] : edge; + let end = edge; + let differs = false; + for (const [offset, elevation] of outward) { + if (Math.abs(elevation - groundAt(ground, offset)) <= 1e-3) return differs ? offset : end; + differs = true; + end = offset; + } + return end; +} + +/** 측점 한 자리 → `slope_pieces` 줄 · 도로 끝이 없으면 null. 짝: `slope_records` 한 줄. */ +export function footprintRecord(station: FootprintStation): FootprintRecord | null { + const edges = station.design?.road_edges; + const left = Number(edges?.left?.offset_m); + const right = Number(edges?.right?.offset_m); + if (edges?.left?.offset_m == null || edges?.right?.offset_m == null) return null; + if (!Number.isFinite(left) || !Number.isFinite(right)) return null; + const line = station.design?.design_line ?? []; + const samples = station.samples ?? []; + const record: FootprintRecord = { + chainage_m: station.chainage_m, + left_edge_m: left, + left_end_m: slopeEnd(line, samples, left, "left"), + right_edge_m: right, + right_end_m: slopeEnd(line, samples, right, "right"), + }; + for (const side of SIDES) { + const elevation = edges[side]?.elevation_m; + if (typeof elevation === "number") { + record[`${side}_cut`] = sideIsCut(samples, record[`${side}_edge_m`], elevation); + } + } + const length = Math.hypot(station.tx, station.ty) || 1.0; + record.x = station.x; + record.y = station.y; + record.nx = -station.ty / length; + record.ny = station.tx / length; + return record; +} + +/** 한 선을 참 · 거짓 구간으로 끊음 — 바뀌는 자리 점은 앞뒤가 나눠 가져 이어짐. */ +function splitRuns(points: Line, flags: boolean[]): [Line[], Line[]] { + const runs: { true: Line[]; false: Line[] } = { true: [], false: [] }; + let current: Line = []; + let previous: boolean | null = null; + points.forEach((point, index) => { + const flag = flags[index]; + if (flag !== previous) { + if (current.length) current.push(point); + current = [point]; + runs[`${flag}`].push(current); + previous = flag; + } else { + current.push(point); + } + }); + return [runs.true.filter((run) => run.length >= 2), runs.false.filter((run) => run.length >= 2)]; +} + +/** 측점 줄 → 도로 끝선 · 쌓기 · 깎기 비탈 끝선 · 눈금. 짝: `slope_pieces`. */ +export function slopePieces( + route: Array<[number, number, number]>, + input: FootprintRecord[], + startM: number, + endM: number, + stepM: number = TICK_STEP_M, +): PlanFootprint { + const records = input + .map((record) => ({ + ...record, + left_cut: Number(Boolean(record.left_cut)), + right_cut: Number(Boolean(record.right_cut)), + })) + .sort((a, b) => a.chainage_m - b.chainage_m) as unknown as Array>; + if (route.length < 2 || !records.length) return { edge: [], fill: [], cut: [], ticks: [] }; + const byChainage = new Map>(); + for (const record of records) byChainage.set(roundTo(record.chainage_m, 3), record); + const samples = new Set(); + for (const record of records) { + if (startM <= record.chainage_m && record.chainage_m <= endM) + samples.add(roundTo(record.chainage_m, 3)); + } + for (let step = startM; step <= endM; step += stepM) samples.add(roundTo(step, 3)); + const lines: Record = Object.fromEntries(KEYS.map((key) => [key, []])); + const cut: Record = { left: [], right: [] }; + const ticks: Line[] = []; + [...samples] + .sort((a, b) => a - b) + .forEach((chainage, index) => { + const record = byChainage.get(chainage); + const [x, y, nx, ny] = + record && ["x", "y", "nx", "ny"].every((k) => record[k] !== undefined) + ? [record.x, record.y, record.nx, record.ny] + : routeFrame(route, chainage); + const offsets = Object.fromEntries(KEYS.map((key) => [key, interp(records, chainage, key)])); + const points = Object.fromEntries( + KEYS.map((key) => [ + key, + [roundTo(x + nx * offsets[key], 4), roundTo(y + ny * offsets[key], 4)], + ]), + ); + for (const key of KEYS) lines[key].push(points[key]); + for (const side of SIDES) cut[side].push(interp(records, chainage, `${side}_cut`) > 0.5); + // 눈금은 도로 끝에서 비탈 끝으로 — 긴 것 · 짧은 것이 번갈아(납품 도면 관례). + for (const side of SIDES) { + const edge = offsets[`${side}_edge_m`]; + const end = offsets[`${side}_end_m`]; + if (Math.abs(end - edge) < 0.05) continue; + const reach = index % 2 === 0 ? end : edge + (end - edge) / 2.0; + ticks.push([points[`${side}_edge_m`], [x + nx * reach, y + ny * reach]]); + } + }); + const cutLines: Line[] = []; + const fillLines: Line[] = []; + for (const side of SIDES) { + const [cutRuns, fillRuns] = splitRuns(lines[`${side}_end_m`], cut[side]); + cutLines.push(...cutRuns); + fillLines.push(...fillRuns); + } + return { edge: [lines.left_edge_m, lines.right_edge_m], fill: fillLines, cut: cutLines, ticks }; +} + +/** 측점들 → 평면 발자국(도로 끝이 없는 측점은 뺌). */ +export function planFootprint( + route: Array<[number, number, number]>, + stations: FootprintStation[], + startM: number, + endM: number, + stepM: number = TICK_STEP_M, +): PlanFootprint { + const records = stations + .map(footprintRecord) + .filter((record): record is FootprintRecord => record !== null); + return slopePieces(route, records, startM, endM, stepM); +} diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanSlope.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanSlope.py index 3ef15e745..d5c327e7f 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanSlope.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanSlope.py @@ -1,6 +1,10 @@ """B07 계획평면도 비탈 · 노선 따라가기 기하 — 노견선 · 성토 · 절토 외곽선 · 비탈 눈금(60 · 67장). `_Engine_Cad_PlanKinds.py` 가 700줄을 넘어 떼어냄(141-1) — 파일 입출력 없음 · 좌표 = 사업지 m. + +⚠⚠ 짝: `B05_Profile/logic/B05_Profile_PlanFootprint.ts`(route_frame · side_is_cut · slope_end · +slope_pieces) — 종단 · 횡단 편집 중 평면 발자국 · 한쪽만 고치면 화면과 계획평면도가 갈림. +거울 시험: `resources/tester/test_346_2_plan_footprint_mirror.py` """ import math diff --git a/resources/tester/test_346_2_plan_footprint_mirror.py b/resources/tester/test_346_2_plan_footprint_mirror.py new file mode 100644 index 000000000..e1a26ef04 --- /dev/null +++ b/resources/tester/test_346_2_plan_footprint_mirror.py @@ -0,0 +1,184 @@ +"""346-2 평면 발자국 — TS(`B05_Profile/logic/B05_Profile_PlanFootprint.ts`) ↔ 파이썬 +(`B07_DesignDetail_Engine_Cad_PlanSlope.py` · `slope_records` 한 줄) 거울 시험. + + ./venv/Scripts/python.exe -m pytest resources/tester/test_346_2_plan_footprint_mirror.py -q +""" + +from __future__ import annotations + +import json +import math +import subprocess +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanSlope import ( # noqa: E402 + route_frame, + side_is_cut, + slope_end, + slope_pieces, +) + +TS_FILE = ROOT / "B05_Profile" / "logic" / "B05_Profile_PlanFootprint.ts" +TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc" +TOL = 2e-4 # 점 좌표 round(·, 4) — 반올림 경계만 1e-4 갈릴 수 있음 + +VERTICES = [(0.0, 0.0), (100.0, 0.0), (160.0, 60.0), (170.0, 140.0)] + + +def _route() -> list[tuple[float, float, float]]: + route, chain = [], 0.0 + for index, (x, y) in enumerate(VERTICES): + if index: + chain += math.hypot(x - VERTICES[index - 1][0], y - VERTICES[index - 1][1]) + route.append((chain, x, y)) + return route + + +def _section(chainage: float) -> tuple[dict, list[dict]]: + """측점 횡단 — 지반 기울기가 측점마다 바뀌어 깎기 · 쌓기가 뒤집힘 · 노면 ±2.5 m.""" + tilt = 0.4 * math.sin(chainage / 37.0) + road = 100.0 + 0.6 * math.cos(chainage / 23.0) + offsets = [-20.0 + 0.5 * i for i in range(81)] + + def ground(o: float) -> float: + return 100.0 + tilt * o + 0.3 * math.sin(o / 3.0) + + samples = [ + {"offset_m": o, "elevation_m": ground(o), "valid": abs(o) < 18.5 or chainage < 100} + for o in offsets + ] + line = [] + for o in offsets: + if abs(o) <= 2.5: + line.append({"offset_m": o, "elevation_m": road}) + continue + run = abs(o) - 2.5 + g = ground(o) + edge_g = ground(2.5 if o > 0 else -2.5) + z = min(g, road + run) if edge_g > road else max(g, road - run / 1.5) + line.append({"offset_m": o, "elevation_m": z}) + design = { + "road_edges": { + "left": {"offset_m": 2.5, "elevation_m": road}, + "right": {"offset_m": -2.5, "elevation_m": road}, + }, + "design_line": line, + } + return design, samples + + +def _stations() -> list[dict]: + route = _route() + total = route[-1][0] + stations = [] + chainages = [20.0 * i for i in range(int(total // 20) + 1)] + [total, 60.0] # 겹친 측점 하나 + for chainage in chainages: + x, y, nx, ny = route_frame(route, chainage) + design, samples = _section(chainage) + stations.append( + { + "chainage_m": chainage, + "x": x, + "y": y, + "tx": ny, + "ty": -nx, + "design": design, + "samples": samples, + } + ) + stations.append({**stations[3], "chainage_m": 70.0, "design": {"road_edges": None}}) + stations.append({**stations[4], "chainage_m": 90.0, "samples": []}) # 지반 없음 + return stations + + +def _py_record(station: dict) -> dict | None: + """`B07_DesignDetail_Router_Support_Plan.slope_records` 의 측점 한 줄(파일 읽기 뺌).""" + design = station["design"] or {} + edges = design.get("road_edges") or {} + try: + left_edge = float(edges["left"]["offset_m"]) + right_edge = float(edges["right"]["offset_m"]) + except (KeyError, TypeError, ValueError): + return None + line, samples = design.get("design_line") or [], station["samples"] or [] + record = { + "chainage_m": float(station["chainage_m"]), + "left_edge_m": left_edge, + "left_end_m": slope_end(line, samples, left_edge, "left"), + "right_edge_m": right_edge, + "right_end_m": slope_end(line, samples, right_edge, "right"), + } + for side in ("left", "right"): + elevation = edges[side].get("elevation_m") + if isinstance(elevation, (int, float)): + record[f"{side}_cut"] = side_is_cut(samples, record[f"{side}_edge_m"], elevation) + length = math.hypot(station["tx"], station["ty"]) or 1.0 + record.update( + x=station["x"], y=station["y"], nx=-station["ty"] / length, ny=station["tx"] / length + ) + return record + + +DRIVER = """ +import { readFileSync, writeFileSync } from "node:fs"; +import { planFootprint } from "./B05_Profile_PlanFootprint.js"; +const cases = JSON.parse(readFileSync(process.argv[2], "utf-8")); +writeFileSync(process.argv[3], JSON.stringify(cases.map((c) => + planFootprint(c.route, c.stations, c.start, c.end, c.step)))); +""" + +CASES = [(0.0, 400.0, 2.0), (35.0, 130.0, 2.0), (0.0, 400.0, 10.0)] + + +@pytest.fixture(scope="module") +def ts_result(tmp_path_factory: pytest.TempPathFactory) -> list[dict]: + out = tmp_path_factory.mktemp("ts_footprint") + subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(TSC), str(TS_FILE), "--outDir", str(out), "--module", "esnext", + "--target", "es2022", "--moduleResolution", "bundler", "--ignoreConfig"], + cwd=str(ROOT), check=True, capture_output=True, + ) # fmt: skip + (out / "driver.mjs").write_text(DRIVER, encoding="utf-8") + payload = [ + {"route": _route(), "stations": _stations(), "start": s, "end": e, "step": t} + for s, e, t in CASES + ] + (out / "in.json").write_text(json.dumps(payload), encoding="utf-8") + run = subprocess.run( # noqa: S603 + ["node", str(out / "driver.mjs"), str(out / "in.json"), str(out / "out.json")], + capture_output=True, text=True, cwd=str(out), + ) # fmt: skip + assert run.returncode == 0, run.stderr + return json.loads((out / "out.json").read_text(encoding="utf-8")) + + +def _same_lines(want: list, got: list, name: str) -> None: + assert len(want) == len(got), f"{name} 선 수" + for line_w, line_g in zip(want, got): + assert len(line_w) == len(line_g), f"{name} 점 수" + for pw, pg in zip(line_w, line_g): + assert abs(pw[0] - pg[0]) < TOL and abs(pw[1] - pg[1]) < TOL, f"{name} {pw} {pg}" + + +@pytest.mark.parametrize("index", range(len(CASES))) +def test_발자국이_두_쪽에서_같다(ts_result, index: int) -> None: + start, end, step = CASES[index] + records = [r for r in map(_py_record, _stations()) if r is not None] + edge, fill, cut, ticks = slope_pieces(_route(), records, start, end, step) + got = ts_result[index] + _same_lines(edge["lines"], got["edge"], "도로 끝") + _same_lines(fill["lines"], got["fill"], "쌓기") + _same_lines(cut["lines"], got["cut"], "깎기") + _same_lines(ticks["ticks"], got["ticks"], "눈금") + + +def test_깎기_쌓기가_함께_나옴(ts_result) -> None: + got = ts_result[0] + assert len(got["edge"]) == 2 and got["fill"] and got["cut"] and got["ticks"] From 1fcdf5aa4c827db6cb5f9c72a4f1d6fd3a634780 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 16 Oct 2026 11:10:13 +0900 Subject: [PATCH 12/18] =?UTF-8?q?perf(=EC=98=81=EA=B5=AC=EC=A0=80=EC=9E=A5?= =?UTF-8?q?):=20343-2=20=EB=8F=84=EC=97=BD=203D=20=EB=A9=B4=20=EC=97=B4?= =?UTF-8?q?=EC=87=A0=20=C2=B7=20=EC=A7=80=ED=91=9C=EB=A9=B4=20=EB=AA=A8?= =?UTF-8?q?=EB=8D=B8=20id=20=EC=9C=A0=EC=A7=80=20=C2=B7=20=EB=B9=84?= =?UTF-8?q?=EC=9A=A9=EB=A9=B4=20=EB=A9=94=EB=AA=A8=EB=A6=AC=20=EA=B8=B0?= =?UTF-8?q?=EC=96=B5=20=C2=B7=20=EC=B6=94=EC=B2=9C=20=EB=85=B8=EC=84=A0=20?= =?UTF-8?q?=EC=97=B4=EC=87=A0=20=C2=B7=20=ED=9A=A1=EB=8B=A8=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20ETag?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 도엽 3D 면 — 결과 옆 dtm_sheet_{방식}.key.json(등고선 이름:mtime:크기 · 노선 · CRS · 격자 · 여유 · 스무딩 · 화면 상자 · ALGO) 같으면 건너뜀 - 지표면 모델 — (방식 · 필터 · 파일) + 파일 지문 같으면 행 id 살려 고쳐 씀 · 이번 결과에 없는 옛 행만 지움 - 비용면 · 계류 메모리 기억(비용면 서명 · 계류 파일 지문 · 좌표계) · 추천 열쇠 = 서명(배열 통째 해시 없앰) - 추천 저장본 종류(갈래 · 지형)당 최근 2벌 · 꺼내면 최근으로 - 횡단 상세 GET — 측점 파일 안 지움(valid_cross_names) · 본문 해시 ETag 304 - 시험 test_343_2_persist_keys 13건 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL --- B04_PreProcess/B04_PreProcess_Engine.py | 11 + .../B04_PreProcess_Engine_SheetSurface.py | 94 ++++- B04_PreProcess/B04_PreProcess_Repository.py | 193 +++++++--- .../B05_Profile_Engine_RouteInitial.py | 68 +++- .../B05_Profile_Engine_RouteInitial_Search.py | 2 + .../B05_Profile_Engine_RouteInitial_Store.py | 34 +- B05_Profile/B05_Profile_Engine_Sections.py | 21 +- B06_Section/B06_Section_Router.py | 34 +- resources/tester/test_343_2_persist_keys.py | 341 ++++++++++++++++++ 9 files changed, 706 insertions(+), 92 deletions(-) create mode 100644 resources/tester/test_343_2_persist_keys.py diff --git a/B04_PreProcess/B04_PreProcess_Engine.py b/B04_PreProcess/B04_PreProcess_Engine.py index 8c806ee6b..adc1585fc 100644 --- a/B04_PreProcess/B04_PreProcess_Engine.py +++ b/B04_PreProcess/B04_PreProcess_Engine.py @@ -128,6 +128,15 @@ def _structured_stats(structured: Any, structured_path: Path) -> tuple[dict[str, return stats, xyz +def file_stamp(path: Path | None) -> str | None: + """결과 파일 지문 = 수정시각 ns : 크기. 없으면 None.""" + try: + stat = path.stat() if path is not None else None + except OSError: + return None + return f"{stat.st_mtime_ns}:{stat.st_size}" if stat else None + + def _key_matches(key_path: Path, key: Any) -> bool: """결과 옆 열쇠 파일이 지금 입력과 같은지.""" try: @@ -605,6 +614,8 @@ def _collect_analysis_result( "source_filter": filter_key, "representation": meta.get("representation"), "footprint_area_m2": meta.get("footprint_area_m2"), + # 파일 지문 — 같으면 DB 행 id 를 살린다(화면 주소 · 노선 연결 유지). + "file_stamp": file_stamp(model_path), }, "layers": layers, } diff --git a/B04_PreProcess/B04_PreProcess_Engine_SheetSurface.py b/B04_PreProcess/B04_PreProcess_Engine_SheetSurface.py index 566c2c33f..22ae99d4f 100644 --- a/B04_PreProcess/B04_PreProcess_Engine_SheetSurface.py +++ b/B04_PreProcess/B04_PreProcess_Engine_SheetSurface.py @@ -15,6 +15,7 @@ LAS 없는 설계(2026-08-30 사용자 확정)의 지형 원천이자, LAS가 격자에서 뽑는 1m 등고선이 곧 2D 보간선이며, 메시(glb)는 그 격자의 표현일 뿐이다. """ +import hashlib import json import logging import time @@ -24,6 +25,7 @@ from typing import Any import numpy as np from pyproj import Transformer +from B04_PreProcess.B04_PreProcess_Engine import file_stamp from B04_PreProcess.B04_PreProcess_Engine_ModelContext import ( atomic_npz, clip_and_compact_mesh, @@ -36,6 +38,7 @@ from B04_PreProcess.B04_PreProcess_Engine_SheetMethods import ( SHEET_METHOD_LABELS, ) from B04_PreProcess.B04_PreProcess_Engine_Watershed_Grid import grid_spec_from_bounds +from common_util.common_util_json import atomic_write_json from config.config_system import ( SHEET_SURFACE_GRID_M, SHEET_SURFACE_MARGIN_M, @@ -54,6 +57,9 @@ _CONTOUR_FILE = "도엽_등고선.geojson" # 산출 모델 식별자 — surface_models.generation_params.source_filter 및 파일 stem에 쓴다. SHEET_SOURCE_FILTER = "sheet" +# 계산 판 번호 — 만드는 방식이 바뀌면 올림 · 열쇠가 어긋나 옛 저장본을 다시 셈. +SHEET_SURFACE_ALGO = 1 + def _load_features_metric( processed_dir: Path, crs: str, filename: str = _CONTOUR_FILE @@ -378,6 +384,15 @@ def _write_method_model( vertices, faces = _preview_mesh(x_coords, y_coords, z_grid, valid_grid) write_glb(preview_path, vertices, faces, scene) _write_smoothed(models_dir, stem, x_coords, y_coords, z_grid, valid_grid, bounds, scene) + return _method_model_entry(project_root, models_dir, method_key) + + +def _method_model_entry(project_root: Path, models_dir: Path, method_key: str) -> dict[str, Any]: + """방식 하나의 등록용 dict — 저장본 그대로 쓸 때도 같은 모양.""" + source_filter = f"{SHEET_SOURCE_FILTER}_{method_key}" + stem = f"dtm_{source_filter}" + model_path = models_dir / f"{stem}.npz" + preview_path = models_dir / f"{stem}_preview.glb" return { "model_type": "dtm", "source_filter": source_filter, @@ -391,6 +406,7 @@ def _write_method_model( "interpolation": method_key, "interpolation_label": SHEET_METHOD_LABELS.get(method_key, method_key), "margin_m": SHEET_SURFACE_MARGIN_M, + "file_stamp": file_stamp(model_path), }, "layers": [ { @@ -403,6 +419,59 @@ def _write_method_model( } +def _sheet_key( + project_root: Path, processed_dir: Path, route_xy: np.ndarray, crs: str, method_key: str +) -> dict[str, Any] | None: + """도엽 3D 면 열쇠 — 등고선 파일 · 노선 · 좌표계 · 격자 · 여유 · 스무딩 · 화면 상자 · 판.""" + contour = processed_dir / _CONTOUR_FILE + try: + stat = contour.stat() + except OSError: + return None + scene = _scene_bounds(project_root, None) # type: ignore[arg-type] # 라이다 없으면 None + key = { + "algo": SHEET_SURFACE_ALGO, + "method": method_key, + "contour": f"{contour.name}:{stat.st_mtime_ns}:{stat.st_size}", + "route": hashlib.sha1(np.round(route_xy, 3).tobytes()).hexdigest(), + "crs": str(crs), + "grid_m": SHEET_SURFACE_GRID_M, + "margin_m": SHEET_SURFACE_MARGIN_M, + "smoothing": [ + SURFACE_SMOOTHING_DTM_SIGMA_M, + SURFACE_SMOOTHING_DTM_SPLINE_SMOOTH, + SURFACE_SMOOTHING_DTM_PREVIEW_RESOLUTION_M, + SURFACE_MAX_PREVIEW_VERTICES, + ], + "scene_bounds": None if scene is None else np.round(scene, 3).tolist(), + } + return json.loads(json.dumps(key, default=float)) + + +def _key_path(models_dir: Path, method_key: str) -> Path: + return models_dir / f"dtm_{SHEET_SOURCE_FILTER}_{method_key}.key.json" + + +def _cached_method_model( + project_root: Path, models_dir: Path, method_key: str, key: dict[str, Any] | None +) -> dict[str, Any] | None: + """열쇠가 같고 결과 파일이 있으면 등록용 dict — 아니면 None(다시 셈).""" + if key is None: + return None + entry = _method_model_entry(project_root, models_dir, method_key) + if not all((project_root / item).is_file() for item in _entry_files(entry)): + return None + try: + stored = json.loads(_key_path(models_dir, method_key).read_text(encoding="utf-8")) + except (OSError, ValueError): + return None + return entry if stored == key else None + + +def _entry_files(entry: dict[str, Any]) -> list[str]: + return [entry["model_file_path"], *(layer["file_path"] for layer in entry["layers"])] + + def build_sheet_surface_model( project_root: Path, processed_dir: Path, @@ -420,6 +489,16 @@ def build_sheet_surface_model( `route_xy`: (N, 2) 노선 정점 XY(사업지 CRS, m), `crs`: 그 좌표계(pyproj 입력 문자열). """ started = time.monotonic() + selected = methods or list(SHEET_SURFACE_METHODS) + # 열쇠가 같은 방식은 저장본 그대로 — 다시 셀 방식만 등고선을 읽고 굽는다. + keys = {key: _sheet_key(project_root, processed_dir, route_xy, crs, key) for key in selected} + cached = { + key: _cached_method_model(project_root, models_dir, key, keys[key]) for key in selected + } + pending = [key for key in selected if cached[key] is None] + if not pending: + logger.info("도엽 서피스: 열쇠 같음 — 저장본 그대로 (%s)", ", ".join(selected)) + return [cached[key] for key in selected] # type: ignore[misc] features = _load_features_metric(processed_dir, crs) if not features: return [] @@ -439,9 +518,8 @@ def build_sheet_surface_model( # 등고 간격(m) — 마루 연장 상한의 근거. 레벨이 하나뿐이면 5m(주곡선) 폴백. interval_m = float(np.diff(np.array(present)).min()) if len(present) > 1 else 5.0 - selected = methods or list(SHEET_SURFACE_METHODS) - models: list[dict[str, Any]] = [] - for method_key in selected: + built: dict[str, dict[str, Any]] = {} + for method_key in pending: builder = SHEET_METHOD_BUILDERS.get(method_key) if builder is None: logger.warning("도엽 서피스: 알 수 없는 보간 방식 %s — 건너뜁니다.", method_key) @@ -456,9 +534,13 @@ def build_sheet_surface_model( except Exception as exc: # noqa: BLE001 — 한 방식이 죽어도 나머지는 만든다 logger.warning("도엽 서피스(%s) 생성 실패: %s", method_key, exc) continue + # 쓰는 도중 끊기면 옛 열쇠가 새 파일 조각을 덮지 않게 먼저 지움. + _key_path(models_dir, method_key).unlink(missing_ok=True) model = _write_method_model(project_root, models_dir, spec, surface, method_key) if model is not None: - models.append(model) + built[method_key] = model + if keys[method_key] is not None: + atomic_write_json(_key_path(models_dir, method_key), keys[method_key]) logger.info("도엽 서피스(%s) 완료 (%.1fs)", method_key, time.monotonic() - step_started) logger.info( @@ -466,10 +548,10 @@ def build_sheet_surface_model( spec.n_rows, spec.n_cols, len(present), - len(models), + len(built), time.monotonic() - started, ) - return models + return [cached[key] or built[key] for key in selected if cached[key] or key in built] def build_sheet_surface_from_route( diff --git a/B04_PreProcess/B04_PreProcess_Repository.py b/B04_PreProcess/B04_PreProcess_Repository.py index 3e30cd680..7adf6f9a0 100644 --- a/B04_PreProcess/B04_PreProcess_Repository.py +++ b/B04_PreProcess/B04_PreProcess_Repository.py @@ -337,19 +337,114 @@ async def update_project_status( ) -async def delete_project_surface_models(connection: aiomysql.Connection, project_id: UUID) -> int: - """프로젝트의 기존 지표면 모델 행을 모두 제거한다. +def _params_dict(value: Any) -> dict[str, Any]: + if isinstance(value, (str, bytes)): + try: + value = json.loads(value) + except ValueError: + return {} + return value if isinstance(value, dict) else {} - 모든 분석 세대가 동일 파일 경로를 재사용하므로 재분석 시 구세대 행을 - 남기면 존재하지 않는 파일을 가리키게 된다. terrain_layers는 FK CASCADE, - routes.surface_model_id는 FK SET NULL로 함께 정리된다. - """ + +def _model_key(model_type: Any, params: dict[str, Any], model_file_path: Any) -> tuple: + """같은 모델인지 가리는 열쇠 — (방식, 필터, 파일).""" + return (str(model_type), str(params.get("source_filter")), str(model_file_path)) + + +async def _existing_models( + connection: aiomysql.Connection, project_id: UUID +) -> dict[tuple, tuple[int, Any]]: + """기존 지표면 행 — 열쇠 → (id, 파일 지문).""" async with connection.cursor() as cursor: await cursor.execute( - "DELETE FROM surface_models WHERE project_id = %s", + "SELECT id, model_type, model_file_path, generation_params " + "FROM surface_models WHERE project_id = %s ORDER BY id", (str(project_id),), ) - return int(cursor.rowcount) + rows = await cursor.fetchall() + existing: dict[tuple, tuple[int, Any]] = {} + for model_id, model_type, model_file_path, raw_params in rows: + params = _params_dict(raw_params) + existing[_model_key(model_type, params, model_file_path)] = ( + int(model_id), + params.get("file_stamp"), + ) + return existing + + +async def _upsert_surface_model( + connection: aiomysql.Connection, + existing: dict[tuple, tuple[int, Any]], + *, + project_id: UUID, + model: dict[str, Any], + source_file_id: int | None, + processed_cloud_id: int | None, + crs_epsg: int | None, +) -> int: + """파일 지문이 같은 옛 행이면 id 를 살려 고쳐 씀 — 화면 주소 · 노선 연결이 그대로. + + 지문이 다르거나 없으면 옛 행을 지우고 새로 넣는다(새 지표면 = 노선 연결도 끊김). + """ + params = model["generation_params"] or {} + hit = existing.pop(_model_key(model["model_type"], params, model["model_file_path"]), None) + stamp = params.get("file_stamp") + if hit is not None and stamp and hit[1] == stamp: + model_id = hit[0] + async with connection.cursor() as cursor: + await cursor.execute( + """ + UPDATE surface_models + SET source_file_id = %s, processed_cloud_id = %s, status = 'COMPLETE', + crs_epsg = %s, resolution_m = %s, generation_params = %s, + completed_at = CURRENT_TIMESTAMP + WHERE id = %s + """, + ( + source_file_id, + processed_cloud_id, + crs_epsg, + model["resolution_m"], + json.dumps(params, ensure_ascii=False), + model_id, + ), + ) + await cursor.execute( + "DELETE FROM terrain_layers WHERE surface_model_id = %s", (model_id,) + ) + else: + # 같은 파일을 가리키는 옛 행은 걷어 낸다 — 열쇠 모양이 옛것이어도 겹치지 않게. + async with connection.cursor() as cursor: + if model["model_file_path"]: + await cursor.execute( + "DELETE FROM surface_models WHERE project_id = %s AND model_file_path = %s", + (str(project_id), model["model_file_path"]), + ) + elif hit is not None: + await cursor.execute("DELETE FROM surface_models WHERE id = %s", (hit[0],)) + model_id = await create_surface_model( + connection, + project_id=project_id, + model_type=model["model_type"], + source_file_id=source_file_id, + processed_cloud_id=processed_cloud_id, + crs_epsg=crs_epsg, + resolution_m=model["resolution_m"], + model_file_path=model["model_file_path"], + generation_params=model["generation_params"], + ) + for layer in model.get("layers", []): + await create_terrain_layer( + connection, + surface_model_id=model_id, + layer_name=layer["layer_name"], + geometry_type=layer["geometry_type"], + layer_file_path=layer["file_path"], + file_format=layer["file_format"], + file_size_mb=None, + statistics=None, + ) + return model_id async def save_surface_analysis_to_db( @@ -362,11 +457,12 @@ async def save_surface_analysis_to_db( ) -> list[int]: """WF1 분석 결과를 DB에 저장한다 (기존 모델 행은 교체). + 파일 지문이 같은 모델은 id 를 살리고, 이번 결과에 없는 옛 행은 지운다. 이 함수는 트랜잭션을 시작하거나 종료하지 않는다. 호출자는 같은 커넥션에서 begin/commit/rollback을 한 번만 수행해야 한다. """ input_file = await get_input_file(connection, project_id, input_file_id) - await delete_project_surface_models(connection, project_id) + existing = await _existing_models(connection, project_id) processed = analysis_result["processed"] processed_cloud_id = await create_processed_point_cloud( connection, @@ -384,28 +480,26 @@ async def save_surface_analysis_to_db( ) surface_model_ids: list[int] = [] for model in analysis_result["models"]: - model_id = await create_surface_model( - connection, - project_id=project_id, - model_type=model["model_type"], - source_file_id=input_file_id, - processed_cloud_id=processed_cloud_id, - crs_epsg=input_file["crs_epsg"], - resolution_m=model["resolution_m"], - model_file_path=model["model_file_path"], - generation_params=model["generation_params"], - ) - surface_model_ids.append(model_id) - for layer in model["layers"]: - await create_terrain_layer( + surface_model_ids.append( + await _upsert_surface_model( connection, - surface_model_id=model_id, - layer_name=layer["layer_name"], - geometry_type=layer["geometry_type"], - layer_file_path=layer["file_path"], - file_format=layer["file_format"], - file_size_mb=None, - statistics=None, + existing, + project_id=project_id, + model=model, + source_file_id=input_file_id, + processed_cloud_id=processed_cloud_id, + crs_epsg=input_file["crs_epsg"], + ) + ) + # 이번 결과에 없는 옛 행 — terrain_layers 는 CASCADE · routes.surface_model_id 는 SET NULL. + stale_ids = [model_id for model_id, _ in existing.values()] + if stale_ids: + async with connection.cursor() as cursor: + await cursor.execute( + "DELETE FROM surface_models WHERE id IN (" + + ", ".join(["%s"] * len(stale_ids)) + + ")", + tuple(stale_ids), ) return surface_model_ids @@ -433,36 +527,19 @@ async def save_sheet_surface_models( row = await cursor.fetchone() source_file_id, processed_cloud_id, crs_epsg = row if row else (None, None, None) + # 같은 파일 · 같은 지문이면 id 를 살린다 — 다른 행은 건드리지 않는다. + existing = await _existing_models(connection, project_id) model_ids: list[int] = [] for model in models: - model_path = model.get("model_file_path") - if model_path: - async with connection.cursor() as cursor: - await cursor.execute( - "DELETE FROM surface_models WHERE project_id = %s AND model_file_path = %s", - (str(project_id), model_path), - ) - model_id = await create_surface_model( - connection, - project_id=project_id, - model_type=model["model_type"], - source_file_id=source_file_id, - processed_cloud_id=processed_cloud_id, - crs_epsg=crs_epsg, - resolution_m=model["resolution_m"], - model_file_path=model_path, - generation_params=model["generation_params"], - ) - model_ids.append(model_id) - for layer in model.get("layers", []): - await create_terrain_layer( + model_ids.append( + await _upsert_surface_model( connection, - surface_model_id=model_id, - layer_name=layer["layer_name"], - geometry_type=layer["geometry_type"], - layer_file_path=layer["file_path"], - file_format=layer["file_format"], - file_size_mb=None, - statistics=None, + existing, + project_id=project_id, + model=model, + source_file_id=source_file_id, + processed_cloud_id=processed_cloud_id, + crs_epsg=crs_epsg, ) + ) return model_ids diff --git a/B05_Profile/B05_Profile_Engine_RouteInitial.py b/B05_Profile/B05_Profile_Engine_RouteInitial.py index 13a1e0fd5..dff6e4f64 100644 --- a/B05_Profile/B05_Profile_Engine_RouteInitial.py +++ b/B05_Profile/B05_Profile_Engine_RouteInitial.py @@ -109,12 +109,19 @@ MODE_WEIGHTS = { # 후보 묶음은 갈래와 무관 — 같은 입력이면 다시 짜지 않음(최근 몇 벌만 · 프로세스 메모리) POOL_CACHE_SIZE = 4 _POOLS: dict[bytes, Pool] = {} +# 비용면 · 계류 기억(요청 사이) — 열쇠 = 프로젝트 · 비용면 서명 · 계류 파일 지문 · 좌표계 +TERRAIN_CACHE_SIZE = 4 +_TERRAINS: dict[str, tuple[Terrain, list[list[tuple[float, float]]]]] = {} def load_terrain_and_streams( project_root: Path, selection: dict[str, Any] ) -> tuple[Terrain, list[list[tuple[float, float]]]]: - """지반 격자(탐색 비용면 캐시 그대로)와 이격을 따질 계류(사업지 좌표계)를 읽는다.""" + """지반 격자(탐색 비용면 캐시 그대로)와 이격을 따질 계류(사업지 좌표계)를 읽는다. + + 같은 입력(비용면 서명 · 계류 파일 지문 · 좌표계)이면 메모리 기억을 그대로 돌려준다 — + 점 놓을 때마다 npz · geojson 을 다시 읽지 않는다. 돌려준 배열은 고치지 않는다. + """ from pyproj import Transformer from B04_PreProcess.B04_PreProcess_Router_Watershed import ( @@ -122,20 +129,49 @@ def load_terrain_and_streams( _load_features, _reproject_features, ) - from B05_Profile.B05_Profile_Engine_Solver import _MODELS_SUBDIR, _load_or_build_cost_surface + from B05_Profile.B05_Profile_Engine_Solver import ( + _MODELS_SUBDIR, + _cost_surface_signature, + _load_or_build_cost_surface, + ) from common_util.common_util_crs import resolve_project_crs - x, y, z, valid, *_ = _load_or_build_cost_surface( - project_root, - project_root / _MODELS_SUBDIR, - str(selection.get("source_filter")), - str(selection.get("method") or "dtm"), - bool(selection.get("smooth")), + filter_key = str(selection.get("source_filter")) + method = str(selection.get("method") or "dtm") + smooth = bool(selection.get("smooth")) + models_dir = project_root / _MODELS_SUBDIR + stream_path = project_root / "B04_PreProcess" / "processed" / STREAM_FILE + crs = resolve_project_crs(project_root) + try: + stat = stream_path.stat() + stream_stamp = f"{stat.st_mtime_ns}:{stat.st_size}" + except OSError: + stream_stamp = "-" + signature = "|".join( + ( + filter_key, + _cost_surface_signature(models_dir, filter_key, method, smooth), + stream_stamp, + str(crs), + ) ) - to_metric = Transformer.from_crs("EPSG:4326", resolve_project_crs(project_root), always_xy=True) - features = _load_features(project_root / "B04_PreProcess" / "processed", STREAM_FILE) + memo_key = f"{project_root}|{signature}" + if memo_key in _TERRAINS: + return _TERRAINS[memo_key] + + x, y, z, valid, *_ = _load_or_build_cost_surface( + project_root, models_dir, filter_key, method, smooth + ) + to_metric = Transformer.from_crs("EPSG:4326", crs, always_xy=True) + features = _load_features(stream_path.parent, STREAM_FILE) streams = stream_lines(_reproject_features(features, to_metric)) - return Terrain(np.asarray(x), np.asarray(y), np.asarray(z), np.asarray(valid)), streams + terrain = Terrain( + np.asarray(x), np.asarray(y), np.asarray(z), np.asarray(valid), signature=signature + ) + while len(_TERRAINS) >= TERRAIN_CACHE_SIZE: + _TERRAINS.pop(next(iter(_TERRAINS))) + _TERRAINS[memo_key] = (terrain, streams) + return terrain, streams def _choice_path(project_root: Path) -> Path: @@ -378,8 +414,16 @@ def _pool_key( streams: list[list[tuple[float, float]]], *numbers: float, ) -> bytes: - """후보 묶음을 가를 입력 지문 — 지형 · 예상노선 · 계류 · 제약 값.""" + """후보 묶음을 가를 입력 지문 — 지형 · 예상노선 · 계류 · 제약 값. + + 지형에 입력 지문(`signature` — 비용면 서명 + 계류 파일)이 있으면 배열 통째 해시 대신 씀. + """ digest = hashlib.blake2b(digest_size=16) + if terrain.signature: + digest.update(terrain.signature.encode()) + digest.update(np.ascontiguousarray(np.asarray(expected)).tobytes()) + digest.update(repr(numbers).encode()) + return digest.digest() for array in (terrain.x, terrain.y, terrain.z, terrain.valid, np.asarray(expected)): digest.update(np.ascontiguousarray(array).tobytes()) digest.update(repr((streams, numbers)).encode()) diff --git a/B05_Profile/B05_Profile_Engine_RouteInitial_Search.py b/B05_Profile/B05_Profile_Engine_RouteInitial_Search.py index cb97542c1..adfaeb6bc 100644 --- a/B05_Profile/B05_Profile_Engine_RouteInitial_Search.py +++ b/B05_Profile/B05_Profile_Engine_RouteInitial_Search.py @@ -68,6 +68,8 @@ class Terrain: y: np.ndarray # 행 좌표(오름차순) z: np.ndarray # [행, 열] 표고 valid: np.ndarray # [행, 열] 지형 있음 + # 지형 · 계류 입력 지문(비용면 서명 + 계류 파일) — 있으면 열쇠가 배열 통째 해시를 대신함 + signature: str | None = None class NoRoute(ValueError): diff --git a/B05_Profile/B05_Profile_Engine_RouteInitial_Store.py b/B05_Profile/B05_Profile_Engine_RouteInitial_Store.py index 976917ab1..f2c006e05 100644 --- a/B05_Profile/B05_Profile_Engine_RouteInitial_Store.py +++ b/B05_Profile/B05_Profile_Engine_RouteInitial_Store.py @@ -1,7 +1,7 @@ """계획노선 추천 결과 영구 저장(PLAN 327 · 요구 224-3). 같은 입력이면 같은 결과 — 셈한 추천을 입력 지문 키로 프로젝트 폴더에 남기고, 같은 입력이 오면 -셈하지 않고 꺼낸다. 바뀐 입력만 새로 셈해 더한다(덮어쓰기 없음 · 상한 없음). +셈하지 않고 꺼낸다. 바뀐 입력만 새로 셈해 더한다(덮어쓰기 없음 · 종류마다 최근 `KEEP_PER_KIND` 벌). 층 = 서버 계산 캐시(데이터 3층 밖 · 사용자 편집값 아님 — 자동저장 금지와 무관). 자리 `B05_Profile/route_recommend/<키>.json` — initial_snapshot 대상(`B05_Profile/route`) 밖이라 @@ -33,6 +33,8 @@ from common_util.common_util_json import atomic_write_json ALGO_VERSION = 2 STORE_DIR = "B05_Profile/route_recommend" +# 종류(갈래 · 지형) 마다 남길 벌 수 — 꺼낼 때 수정시각을 새로 해 최근에 쓴 것이 남음 +KEEP_PER_KIND = 2 # 업로드 체인 끝에 미리 셈할 기본 다섯 알약(화면 차례) · 추적은 묶음이 선 뒤라 `similar` 가 채워짐 DEFAULT_MODES = ("gradeline", "earthwork", "gentle", "weighted", "follow") @@ -85,12 +87,17 @@ def is_default( def read_stored(project_root: Path, key: str) -> dict[str, Any] | None: """저장본 한 벌 — 없거나 못 읽거나 판이 다르면 None.""" + path = store_dir(project_root) / f"{key}.json" try: - data = json.loads((store_dir(project_root) / f"{key}.json").read_text(encoding="utf-8")) + data = json.loads(path.read_text(encoding="utf-8")) except (OSError, ValueError): return None if not isinstance(data, dict) or data.get("version") != ALGO_VERSION: return None + try: + path.touch() # 최근에 쓴 것 = 정리 때 남김 + except OSError: + pass return data @@ -118,9 +125,32 @@ def write_stored( "result": result, }, ) + prune_stored(project_root, request) return True +def _kind(request: Any) -> tuple[Any, Any]: + request = request if isinstance(request, dict) else {} + return request.get("mode"), request.get("terrain") + + +def prune_stored(project_root: Path, request: dict[str, Any]) -> int: + """같은 종류(갈래 · 지형) 저장본을 최근 `KEEP_PER_KIND` 벌만 남기고 지운다. 지운 수.""" + kind = _kind(request) + same: list[tuple[int, Path]] = [] + for path in store_dir(project_root).glob("*.json"): + try: + data = json.loads(path.read_text(encoding="utf-8")) + if _kind(data.get("request")) == kind: + same.append((path.stat().st_mtime_ns, path)) + except (OSError, ValueError, AttributeError): + continue + same.sort(reverse=True) + for _, path in same[KEEP_PER_KIND:]: + path.unlink(missing_ok=True) + return max(0, len(same) - KEEP_PER_KIND) + + @asynccontextmanager async def pool_lock(pool: str) -> AsyncIterator[None]: # ponytail: 잠금은 지우지 않음 — 입력 묶음 수만큼(작음) · 다중 프로세스면 파일 잠금으로 diff --git a/B05_Profile/B05_Profile_Engine_Sections.py b/B05_Profile/B05_Profile_Engine_Sections.py index 5dc47a85a..335c8f503 100644 --- a/B05_Profile/B05_Profile_Engine_Sections.py +++ b/B05_Profile/B05_Profile_Engine_Sections.py @@ -141,18 +141,27 @@ def find_cross_file(cross_dir: Path, chainage_m: float) -> Path: return path if path.exists() or not legacy.exists() else legacy -def prune_stale_cross_files(cross_dir: Path, stations: list[Any]) -> set[str]: - """stations에 없는 잔재 cross_*.json을 삭제하고 유효 파일명 집합을 반환한다. +def valid_cross_names(cross_dir: Path, stations: list[Any]) -> set[str]: + """stations 에 맞는 cross_*.json 파일명 집합 — 읽기 전용(지우지 않음 · GET 이 씀). - 측점 정보가 비어 있으면 오삭제를 피하기 위해 아무것도 지우지 않고 - 빈 집합을 반환한다(호출부는 빈 집합이면 필터를 생략한다). + 측점 정보가 비면 빈 집합(호출부는 빈 집합이면 필터를 생략한다). """ - # 옛 프로젝트는 소수 측점 파일이 정수 m 이름이라 찾기 규칙(find_cross_file)으로 가려야 안 지워짐 - valid = { + # 옛 프로젝트는 소수 측점 파일이 정수 m 이름이라 찾기 규칙(find_cross_file)으로 가림 + return { find_cross_file(cross_dir, station["chainage_m"]).name for station in stations if isinstance(station, dict) and isinstance(station.get("chainage_m"), (int, float)) } + + +def prune_stale_cross_files(cross_dir: Path, stations: list[Any]) -> set[str]: + """stations에 없는 잔재 cross_*.json을 삭제하고 유효 파일명 집합을 반환한다. + + 쓰는 쪽(측점 만들기 · 측점 목록 고침)만 부른다 — 조회(GET)는 `valid_cross_names`. + 측점 정보가 비어 있으면 오삭제를 피하기 위해 아무것도 지우지 않고 + 빈 집합을 반환한다(호출부는 빈 집합이면 필터를 생략한다). + """ + valid = valid_cross_names(cross_dir, stations) if not valid: return valid for path in cross_dir.glob("cross_*.json"): diff --git a/B06_Section/B06_Section_Router.py b/B06_Section/B06_Section_Router.py index f80a2a120..f3645fb8a 100644 --- a/B06_Section/B06_Section_Router.py +++ b/B06_Section/B06_Section_Router.py @@ -1,6 +1,7 @@ """B06 종횡단 생성 FastAPI 라우터.""" import asyncio +import hashlib import json import logging from pathlib import Path @@ -8,15 +9,15 @@ from typing import Any from uuid import UUID import aiomysql -from fastapi import APIRouter, Depends -from fastapi.responses import JSONResponse +from fastapi import APIRouter, Depends, Request +from fastapi.responses import JSONResponse, Response from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path from B05_Profile.B05_Profile_Engine_Grade_Profile import rebuild_alignment_profile from B05_Profile.B05_Profile_Engine_Sections import ( - prune_stale_cross_files, relabel_pipe_stations, run_section_generation, + valid_cross_names, ) from B05_Profile.B05_Profile_Engine_Sections_Core import SectionGenerationOptions from B06_Section.B06_Section_Engine_Attach import attach_saved_shapes @@ -85,6 +86,7 @@ from B06_Section.B06_Section_Schema import ( ) from B06_Section.B06_Section_Server_Calc_Prebuild import conversion_factors_for, haul_limits_for from common_util.common_util_auth import verify_session +from common_util.common_util_http_cache import CACHE_CONTROL 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_workflow_state import get_workflow_state @@ -266,7 +268,8 @@ def _read_section_detail(project_root: Path, longitudinal_file_path: str) -> dic longitudinal = json.loads(longitudinal_path.read_text(encoding="utf-8")) stations = longitudinal.get("stations") if isinstance(longitudinal, dict) else None - valid_names = prune_stale_cross_files(cross_dir, stations if isinstance(stations, list) else []) + # 조회는 지우지 않음 — 잔재는 걸러 읽기만(지우기는 측점을 쓰는 쪽 몫) + valid_names = valid_cross_names(cross_dir, stations if isinstance(stations, list) else []) cross_sections = [ json.loads(path.read_text(encoding="utf-8")) for path in sorted(cross_dir.glob("cross_*.json")) @@ -282,6 +285,15 @@ def _read_section_detail(project_root: Path, longitudinal_file_path: str) -> dic return {"longitudinal": longitudinal, "cross_sections": cross_sections} +def _etag_json_response(request: Request, body: bytes) -> Response: + """본문 해시 ETag JSON 응답 — 브라우저가 같은 ETag 를 들고 오면 304 만.""" + etag = f'"{hashlib.blake2b(body, digest_size=16).hexdigest()}"' + headers = {"ETag": etag, "Cache-Control": CACHE_CONTROL} + if request.headers.get("if-none-match") == etag: + return Response(status_code=304, headers=headers) + return Response(content=body, media_type="application/json", headers=headers) + + def _read_balloon_offsets(row: dict[str, Any]) -> dict[str, list[float]] | None: """확정 시 저장해 둔 유토곡선 balloon 위치를 꺼낸다. 없거나 형태가 깨졌으면 None.""" data = row.get("data") @@ -307,9 +319,12 @@ def _read_balloon_offsets(row: dict[str, Any]) -> dict[str, list[float]] | None: @router.get("/{project_id}/sections/{route_id}/detail", response_model=SectionDetailResponse) async def get_section_detail( - project_id: UUID, route_id: int -) -> SectionDetailResponse | JSONResponse: - """경로의 SVG 렌더링용 종단·횡단 원시 샘플을 반환한다.""" + project_id: UUID, route_id: int, request: Request +) -> SectionDetailResponse | JSONResponse | Response: + """경로의 SVG 렌더링용 종단·횡단 원시 샘플을 반환한다. + + ETag = 응답 본문 해시 — 내용이 같으면 304(1.5 MB 다시 안 받음 · 브라우저 HTTP 캐시). + """ try: # 서로 기다릴 이유가 없는 읽기 셋 — 원격 DB 라 순차로 내면 왕복이 그대로 더해진다 # (질의 하나 약 12ms, 2026-09-06 실측). 같이 보내 가장 느린 하나의 시간만 쓴다. @@ -350,7 +365,10 @@ async def get_section_detail( project_root, standard, ) - return SectionDetailResponse(**detail, balloon_offsets=_read_balloon_offsets(longitudinal)) + body = SectionDetailResponse( + **detail, balloon_offsets=_read_balloon_offsets(longitudinal) + ).model_dump_json() + return _etag_json_response(request, body.encode("utf-8")) except FileNotFoundError as exc: return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)}) except (OSError, ValueError, json.JSONDecodeError) as exc: diff --git a/resources/tester/test_343_2_persist_keys.py b/resources/tester/test_343_2_persist_keys.py new file mode 100644 index 000000000..79bf23853 --- /dev/null +++ b/resources/tester/test_343_2_persist_keys.py @@ -0,0 +1,341 @@ +"""343-2 도엽 3D 면 열쇠 · 지표면 모델 id 유지 · 비용면 · 추천 노선 열쇠 · 횡단 상세 ETag. + +같은 입력 두 번째 = 다시 안 셈 · 열쇠 바뀌면 다시 셈 · 지울 때. +""" + +from __future__ import annotations + +import asyncio +import json +import os +import time +from pathlib import Path + +import numpy as np +import pytest +from pyproj import Transformer + +import B04_PreProcess.B04_PreProcess_Engine_SheetSurface as sheet +from B04_PreProcess.B04_PreProcess_Engine_SheetMethods import SHEET_METHOD_BUILDERS + +EPSG = 5186 +X0, Y0 = 200000.0, 500000.0 +ROUTE = np.array([[X0 - 50.0, Y0], [X0 + 50.0, Y0]], dtype=np.float64) + + +@pytest.fixture() +def sheet_project(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> dict: + processed = tmp_path / "B04_PreProcess" / "processed" + models = tmp_path / "B04_PreProcess" / "models" + processed.mkdir(parents=True) + models.mkdir(parents=True) + to_wgs84 = Transformer.from_crs(f"EPSG:{EPSG}", "EPSG:4326", always_xy=True) + features = [] + for half, elev in ((450.0, 100.0), (300.0, 110.0), (150.0, 120.0)): + ring = [ + list(to_wgs84.transform(x, y)) + for x, y in ( + (X0 - half, Y0 - half), + (X0 + half, Y0 - half), + (X0 + half, Y0 + half), + (X0 - half, Y0 + half), + (X0 - half, Y0 - half), + ) + ] + features.append( + { + "type": "Feature", + "properties": {"등고수치": elev}, + "geometry": {"type": "LineString", "coordinates": ring}, + } + ) + (processed / "도엽_등고선.geojson").write_text( + json.dumps({"type": "FeatureCollection", "features": features}, ensure_ascii=False), + encoding="utf-8", + ) + calls: list[str] = [] + real = SHEET_METHOD_BUILDERS["tin_sheet"] + + def counting(*args, **kwargs): # type: ignore[no-untyped-def] + calls.append("tin_sheet") + return real(*args, **kwargs) + + monkeypatch.setitem(SHEET_METHOD_BUILDERS, "tin_sheet", counting) + return {"root": tmp_path, "processed": processed, "models": models, "calls": calls} + + +def _build(project: dict, route: np.ndarray = ROUTE) -> list[dict]: + return sheet.build_sheet_surface_model( + project["root"], project["processed"], project["models"], route, EPSG, ["tin_sheet"] + ) + + +def test_도엽_3D_면_같은_입력_두번째는_안_셈(sheet_project: dict) -> None: + first = _build(sheet_project) + assert sheet_project["calls"] == ["tin_sheet"] + assert (sheet_project["models"] / "dtm_sheet_tin_sheet.key.json").is_file() + started = time.perf_counter() + second = _build(sheet_project) + assert time.perf_counter() - started < 0.5 + assert sheet_project["calls"] == ["tin_sheet"] + assert second == first + assert first[0]["generation_params"]["file_stamp"] + + +@pytest.mark.parametrize("change", ["route", "contour", "algo", "key_gone", "file_gone"]) +def test_도엽_3D_면_열쇠가_바뀌면_다시_셈( + sheet_project: dict, monkeypatch: pytest.MonkeyPatch, change: str +) -> None: + _build(sheet_project) + route = ROUTE + if change == "route": + route = ROUTE + np.array([5.0, 0.0]) + elif change == "contour": + path = sheet_project["processed"] / "도엽_등고선.geojson" + stat = path.stat() + os.utime(path, ns=(stat.st_atime_ns, stat.st_mtime_ns + 5_000_000_000)) + elif change == "algo": + monkeypatch.setattr(sheet, "SHEET_SURFACE_ALGO", sheet.SHEET_SURFACE_ALGO + 1) + elif change == "key_gone": + (sheet_project["models"] / "dtm_sheet_tin_sheet.key.json").unlink() + else: + (sheet_project["models"] / "dtm_sheet_tin_sheet_preview.glb").unlink() + _build(sheet_project, route) + assert sheet_project["calls"] == ["tin_sheet", "tin_sheet"] + + +# ── 지표면 모델 id 유지(R2) ───────────────────────────────────────────────────── + + +class _FakeCursor: + def __init__(self, db: "_FakeDb") -> None: + self.db = db + self.rowcount = 0 + self.lastrowid = 0 + self._rows: list[tuple] = [] + + async def __aenter__(self) -> "_FakeCursor": + return self + + async def __aexit__(self, *_: object) -> None: + return None + + async def execute(self, sql: str, params: tuple = ()) -> None: + text = " ".join(sql.split()) + self.db.log.append(text) + if text.startswith("SELECT id, model_type"): + self._rows = [ + (row["id"], row["model_type"], row["path"], json.dumps(row["params"])) + for row in self.db.models.values() + ] + elif text.startswith("INSERT INTO surface_models"): + self.db.next_id += 1 + self.lastrowid = self.db.next_id + self.db.models[self.lastrowid] = { + "id": self.lastrowid, + "model_type": params[1], + "path": params[7], + "params": json.loads(params[8]), + } + elif text.startswith("UPDATE surface_models"): + self.db.models[params[-1]]["params"] = json.loads(params[4]) + elif text.startswith("DELETE FROM surface_models WHERE id IN"): + for model_id in params: + self.db.models.pop(model_id, None) + elif text.startswith("DELETE FROM surface_models WHERE id ="): + self.db.models.pop(params[0], None) + elif text.startswith("DELETE FROM surface_models WHERE project_id"): + for model_id in [k for k, v in self.db.models.items() if v["path"] == params[1]]: + self.db.models.pop(model_id) + elif text.startswith("INSERT INTO terrain_layers"): + self.db.next_id += 1 + self.lastrowid = self.db.next_id + + async def fetchall(self) -> list[tuple]: + return self._rows + + async def fetchone(self) -> tuple | None: + return None + + +class _FakeDb: + def __init__(self) -> None: + self.log: list[str] = [] + self.models: dict[int, dict] = {} + self.next_id = 100 + + def cursor(self, *_: object) -> _FakeCursor: + return _FakeCursor(self) + + +def _model(path: str, stamp: str) -> dict: + return { + "model_type": "dtm", + "model_file_path": path, + "resolution_m": 1.0, + "generation_params": {"source_filter": "csf", "file_stamp": stamp}, + "layers": [ + { + "layer_name": "dtm_csf_preview", + "geometry_type": "MESH", + "file_path": "B04_PreProcess/models/dtm_csf_preview.glb", + "file_format": "glb", + } + ], + } + + +def _save(db: _FakeDb, models: list[dict]) -> list[int]: + from B04_PreProcess import B04_PreProcess_Repository as repo + + async def fake_input(*_a): # type: ignore[no-untyped-def] + return {"crs_epsg": 5186} + + async def fake_cloud(*_a, **_k): # type: ignore[no-untyped-def] + return 9 + + repo_input, repo_cloud = repo.get_input_file, repo.create_processed_point_cloud + repo.get_input_file, repo.create_processed_point_cloud = fake_input, fake_cloud + try: + return asyncio.run( + repo.save_surface_analysis_to_db( + db, # type: ignore[arg-type] + project_id="00000000-0000-0000-0000-000000000001", # type: ignore[arg-type] + input_file_id=1, + analysis_result={ + "processed": { + "processed_file_path": "B04_PreProcess/processed/structured.npz", + "converted_file_path": None, + "point_count": 1, + "bounds": {}, + "statistics": {}, + }, + "models": models, + }, + source_filters=["csf"], + ) + ) + finally: + repo.get_input_file, repo.create_processed_point_cloud = repo_input, repo_cloud + + +def test_파일_지문이_같으면_모델_id_유지() -> None: + db = _FakeDb() + path = "B04_PreProcess/models/dtm_csf.npz" + first = _save(db, [_model(path, "1:2"), _model("B04_PreProcess/models/tin_csf.npz", "3:4")]) + second = _save(db, [_model(path, "1:2")]) + assert second == [first[0]] + # 이번 결과에 없는 옛 행은 지움 · 같은 파일 행은 하나 + assert list(db.models) == [first[0]] + third = _save(db, [_model(path, "9:9")]) + assert third != second and list(db.models) == third + + +def test_도엽_한_방식_요청도_id_유지() -> None: + from B04_PreProcess import B04_PreProcess_Repository as repo + + db = _FakeDb() + path = "B04_PreProcess/models/dtm_sheet_tin_sheet.npz" + entry = {**_model(path, "5:6"), "generation_params": {"source_filter": "sheet_tin_sheet"}} + entry["generation_params"]["file_stamp"] = "5:6" + first = asyncio.run(repo.save_sheet_surface_models(db, "p", [entry])) # type: ignore[arg-type] + second = asyncio.run(repo.save_sheet_surface_models(db, "p", [entry])) # type: ignore[arg-type] + assert first == second and len(db.models) == 1 + + +# ── 비용면 · 계류 메모리 기억(R17) · 추천 노선 열쇠(R18) ────────────────────────── + + +def test_비용면_계류_메모리_기억(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + import B04_PreProcess.B04_PreProcess_Router_Watershed as watershed + import B05_Profile.B05_Profile_Engine_RouteInitial as engine + import B05_Profile.B05_Profile_Engine_Solver as solver + import common_util.common_util_crs as crs + + loads: list[int] = [] + signature = {"value": "sig-1"} + grid = np.arange(4.0) + + def fake_load(*_a): # type: ignore[no-untyped-def] + loads.append(1) + return grid, grid, np.zeros((4, 4)), np.ones((4, 4), bool), None, None, 1.0 + + monkeypatch.setattr(engine, "_TERRAINS", {}) + monkeypatch.setattr(solver, "_load_or_build_cost_surface", fake_load) + monkeypatch.setattr(solver, "_cost_surface_signature", lambda *_a: signature["value"]) + monkeypatch.setattr(watershed, "_load_features", lambda *_a: []) + monkeypatch.setattr(crs, "resolve_project_crs", lambda _root: "EPSG:5186") + selection = {"source_filter": "csf", "method": "dtm", "smooth": False} + terrain, _ = engine.load_terrain_and_streams(tmp_path, selection) + again, _ = engine.load_terrain_and_streams(tmp_path, selection) + assert loads == [1] and again is terrain and terrain.signature + signature["value"] = "sig-2" + changed, _ = engine.load_terrain_and_streams(tmp_path, selection) + assert loads == [1, 1] and changed.signature != terrain.signature + # 추천 열쇠 = 서명 — 배열 통째 해시 안 함 · 서명이 다르면 다른 열쇠 + key_a = engine._pool_key([(0.0, 0.0), (3.0, 3.0)], terrain, [], 0.14) + key_b = engine._pool_key([(0.0, 0.0), (3.0, 3.0)], changed, [], 0.14) + assert key_a != key_b + assert key_a == engine._pool_key([(0.0, 0.0), (3.0, 3.0)], terrain, [[(9.0, 9.0)]], 0.14) + + +def test_추천_저장본_종류당_최근_두_벌(tmp_path: Path) -> None: + from B05_Profile.B05_Profile_Engine_RouteInitial_Store import ( + KEEP_PER_KIND, + read_stored, + store_dir, + write_stored, + ) + + assert KEEP_PER_KIND == 2 + request = {"mode": "earthwork", "terrain": None} + write_stored(tmp_path, "a", "p", request, {"n": 1}, True) + time.sleep(0.02) + write_stored(tmp_path, "b", "p", request, {"n": 2}, False) + write_stored(tmp_path, "g", "p", {"mode": "gentle", "terrain": None}, {}, True) + time.sleep(0.02) + assert read_stored(tmp_path, "a") is not None # 꺼내면 최근으로 + time.sleep(0.02) + write_stored(tmp_path, "c", "p", request, {"n": 3}, False) + names = sorted(path.stem for path in store_dir(tmp_path).glob("*.json")) + assert names == ["a", "c", "g"] + + +# ── 횡단 상세 — GET 은 안 지움 · ETag(R26) ─────────────────────────────────────── + + +def test_횡단_상세_조회는_파일을_지우지_않음(tmp_path: Path) -> None: + from B05_Profile.B05_Profile_Engine_Sections import prune_stale_cross_files, valid_cross_names + + for name in ("cross_00000m.json", "cross_00020m.json", "cross_00040m.json"): + (tmp_path / name).write_text("{}", encoding="utf-8") + stations = [{"chainage_m": 0.0}, {"chainage_m": 20.0}] + names = valid_cross_names(tmp_path, stations) + assert len(names) == 2 and (tmp_path / "cross_00040m.json").is_file() + assert prune_stale_cross_files(tmp_path, stations) == names + assert not (tmp_path / "cross_00040m.json").exists() + + +def test_횡단_상세_ETag_같으면_304() -> None: + from starlette.requests import Request + + from B06_Section.B06_Section_Router import _etag_json_response + + def request(etag: str | None) -> Request: + headers = [] if etag is None else [(b"if-none-match", etag.encode())] + return Request({"type": "http", "headers": headers}) + + body = b'{"cross_sections": []}' + first = _etag_json_response(request(None), body) + assert first.status_code == 200 and first.body == body + etag = first.headers["etag"] + assert _etag_json_response(request(etag), body).status_code == 304 + assert _etag_json_response(request(etag), body + b" ").status_code == 200 + + +def test_횡단_상세_GET_소스에_지우기_없음() -> None: + source = Path("B06_Section/B06_Section_Router.py").read_text(encoding="utf-8") + start = source.index("def _read_section_detail(") + end = source.index("\ndef ", start + 10) + assert "prune_stale_cross_files" not in source[start:end] + assert "unlink" not in source[start:end] From e503189cb89566db6b0a9b4f4a37842f12eb1412 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 16 Oct 2026 11:10:49 +0900 Subject: [PATCH 13/18] =?UTF-8?q?feat(=EB=B0=B0=EC=88=98=EC=9C=A0=EC=97=AD?= =?UTF-8?q?):=20342-3=20=EB=85=B8=EC=84=A0=20=C2=B7=20=EA=B4=80=20?= =?UTF-8?q?=EC=9E=90=EB=A6=AC=20=EC=A7=80=EB=AC=B8=20=E2=80=94=20=EB=85=B8?= =?UTF-8?q?=EC=84=A0=EC=9D=B4=20=EB=B6=84=EC=84=9D=20=EB=95=8C=EC=99=80=20?= =?UTF-8?q?=EB=8B=A4=EB=A5=B4=EB=A9=B4=20=EC=84=B8=EB=B6=80=20=EC=9C=A0?= =?UTF-8?q?=EC=97=AD=20=EC=95=88=20=EB=82=98=EB=88=84=EA=B3=A0=20=E3=80=8C?= =?UTF-8?q?=EB=82=A1=EC=9D=8C=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 분석 응답 · 저장분(00_watershed_response.json)에 route_signature · pipe_signature - 세부 유역(GET · POST · DELETE · PUT pipe-points): 분석 지문 ≠ 지금 노선(옛 저장분 = 지문 없음) → 관 자리만 · basins 없음 · stale 까닭 글 · 관 목록도 없으면 409 - PUT 낡음 = 관 자리 · 속성만 정본 · 세부 유역 산출물(04_detailed_basins) 안 씀 - 응답에 지금 route_signature · pipe_signature · B05 배수유역 칸 = 낡음 글 표시(DetailBasinResponse 칸 셋) - 시험 test_342_3_drainage_stale Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XWh5wNNycMkbwMpACz1rte --- B04_PreProcess/B04_PreProcess_Api_Fetch.ts | 5 + .../B04_PreProcess_Router_Basins.py | 82 +++++++++++++---- .../B04_PreProcess_Router_Watershed.py | 4 + B05_Profile/B05_Profile_UI_Drainage_Panel.ts | 2 +- common_util/common_util_drainage_pipes.py | 33 +++++++ resources/tester/test_342_3_drainage_stale.py | 91 +++++++++++++++++++ 6 files changed, 197 insertions(+), 20 deletions(-) create mode 100644 resources/tester/test_342_3_drainage_stale.py diff --git a/B04_PreProcess/B04_PreProcess_Api_Fetch.ts b/B04_PreProcess/B04_PreProcess_Api_Fetch.ts index f8078b5a4..80e4a99cc 100644 --- a/B04_PreProcess/B04_PreProcess_Api_Fetch.ts +++ b/B04_PreProcess/B04_PreProcess_Api_Fetch.ts @@ -553,6 +553,11 @@ export interface DetailBasinResponse { upstream_lonlat: Array>; /** [저장] 때 못 만든 구조물 조합 사본 폴더(142-2 · `*` = 통째 실패). */ snapshot_failed?: string[]; + /** 지문(342-3) — 지금 노선 · 관 자리. */ + route_signature?: string; + pipe_signature?: string; + /** 분석이 낡음(노선이 분석 때와 다름) — 까닭 글 · 세부 유역 없음 · 관 자리만. */ + stale?: string | null; } let lastNote = { text: "", at: 0 }; diff --git a/B04_PreProcess/B04_PreProcess_Router_Basins.py b/B04_PreProcess/B04_PreProcess_Router_Basins.py index e6de81797..ff7705355 100644 --- a/B04_PreProcess/B04_PreProcess_Router_Basins.py +++ b/B04_PreProcess/B04_PreProcess_Router_Basins.py @@ -28,15 +28,21 @@ from B05_Profile.B05_Profile_Structures_Schema import structure_type_map from B06_Section.B06_Section_Engine_Culvert import pipe_wall_min_height_m from common_util.common_util_auth import get_optional_session from common_util.common_util_drainage_context import DrainageContext, load_drainage_context -from common_util.common_util_drainage_detail import DrainageDetail, build_detail +from common_util.common_util_drainage_detail import ( + DrainageDetail, + build_detail, + pipes_from_chainages, +) from common_util.common_util_drainage_pipes import ( PIPE_SOURCE_USER, PipePoint, + analysis_route_signature, carry_facility_attributes, carry_indices, clear_pipe_points, load_pipe_points, parse_pipe_points, + pipe_signature, route_signature, save_detail_basins, save_pipe_points, @@ -54,6 +60,10 @@ logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/projects", tags=["B04 Surface Drainage Basins"]) _NO_ANALYSIS = "배수유역 분석 결과가 없습니다. [유역 분석]을 먼저 실행하세요." +# 노선 지문이 분석 때와 다름(옛 저장분은 지문 없음) — 옛 격자로 나누면 틀린 유역이 나온다(342-3). +STALE_MESSAGE = ( + "배수유역 낡음 — 노선이 분석 때와 다릅니다. 유역을 다시 분석해야 세부 유역을 나눕니다." +) def _build( @@ -72,7 +82,7 @@ def _build( return None, [] if points is not None: _retag(detail.pipes, points) - rebuilt = [PipePoint(chainage_m=pipe.chainage_m, source=pipe.reason) for pipe in detail.pipes] + rebuilt = _rebuilt(detail) # 원본에 없던 자리 = 이번에 새로 놓인 관(자동 배치 · 간격 보충) — 등록부 기본값을 받을 관 fresh = [ pick is None @@ -90,6 +100,21 @@ def _build( return detail, carried +def _rebuilt(detail: DrainageDetail) -> list[PipePoint]: + return [PipePoint(chainage_m=pipe.chainage_m, source=pipe.reason) for pipe in detail.pipes] + + +def _build_stale(context: DrainageContext, points: list[PipePoint]) -> DrainageDetail: + """낡은 분석 — 세부 유역 없이 관 자리만(지금 노선 위) · 시설 속성은 넘겨받은 그대로.""" + to_lonlat = context.to_lonlat + detail = DrainageDetail( + route_lonlat=[list(to_lonlat(vertex.x, vertex.y)) for vertex in context.vertices], + pipes=pipes_from_chainages(context.vertices, [point.chainage_m for point in points]), + ) + _retag(detail.pipes, points) + return detail + + # 관 보호공 · 유입 집수정 높이 칸 — 새 관 기본 = 관경 + 0.5(139-1) · 유입 흙막이만 + 0.7(211-1) PIPE_HEIGHT_KEYS = ("inlet_revet_height_m", "outlet_revet_height_m", "inlet_basin_height_m") @@ -181,6 +206,7 @@ def _payload( detail: DrainageDetail, points: list[PipePoint], saved: bool, + stale: bool = False, ) -> dict[str, Any]: """관 목록과 세부유역을 화면 좌표(WGS84)로 정리한다. @@ -188,7 +214,12 @@ def _payload( 응답이 151KB → 100KB(압축까지 얹으면 22KB)가 된다(2026-09-06 실측). 화면이 그리는 값이고 다시 계산에 넣지 않으므로 줄여도 결과가 안 갈린다. """ - return round_floats(_raw_payload(project_id, context, detail, points, saved), LONLAT_DIGITS) + payload = _raw_payload(project_id, context, detail, points, saved) + # 지문(342-3) — 화면이 지금 노선 · 관 자리와 견줌 · 낡으면 까닭 글(세부 유역 없음) + payload["route_signature"] = route_signature(context.vertices) + payload["pipe_signature"] = pipe_signature([pipe.chainage_m for pipe in detail.pipes]) + payload["stale"] = STALE_MESSAGE if stale else None + return round_floats(payload, LONLAT_DIGITS) def _raw_payload( @@ -322,9 +353,12 @@ def _basin_features( async def _resolve( project_id: UUID, payload: dict[str, Any] | None, *, use_stored: bool -) -> tuple[DrainageContext, DrainageDetail, list[PipePoint], bool] | JSONResponse: +) -> tuple[DrainageContext, DrainageDetail, list[PipePoint], bool, bool] | JSONResponse: """요청 본문 → 저장분 → 자동 생성 순으로 관 목록을 정하고 세부유역까지 계산한다. + 분석의 노선 지문이 지금 노선과 다르면(옛 저장분 = 지문 없음) 나누지 않고 관 자리만 낡음으로 + 돌려준다 — 관 목록도 없으면(자동 생성 자리) 409(342-3). 마지막 값 = 낡음. + 빈 목록(`[]`)은 「관 0개」로 그대로 쓴다 — 자동 생성은 요청에 칸이 없고 저장분도 없을(None) 때만(312). """ context, reason = await load_drainage_context(project_id) @@ -341,12 +375,21 @@ async def _resolve( load_pipe_points(context.stored_path, signature, context.vertices) if use_stored else None ) points = requested if requested is not None else stored + saved = requested is None and stored is not None + + if analysis_route_signature(context.stored_path) != signature: + if points is None: + return JSONResponse( + status_code=409, content={"status": "stale", "message": STALE_MESSAGE} + ) + detail = _build_stale(context, points) + return context, detail, carry_facility_attributes(_rebuilt(detail), points), saved, True result = await asyncio.to_thread(_build, context.stored_path, context, points) detail, resolved = result if detail is None: return JSONResponse(status_code=404, content={"status": "error", "message": _NO_ANALYSIS}) - return context, detail, resolved, requested is None and stored is not None + return context, detail, resolved, saved, False @router.get("/{project_id}/drainage/pipe-points", response_model=None) @@ -355,8 +398,8 @@ async def get_pipe_points(project_id: UUID) -> dict[str, Any] | JSONResponse: resolved = await _resolve(project_id, None, use_stored=True) if isinstance(resolved, JSONResponse): return resolved - context, detail, points, saved = resolved - return _payload(project_id, context, detail, points, saved) + context, detail, points, saved, stale = resolved + return _payload(project_id, context, detail, points, saved, stale) @router.post("/{project_id}/drainage/detail-basins", response_model=None) @@ -371,8 +414,8 @@ async def post_detail_basins( resolved = await _resolve(project_id, payload, use_stored=False) if isinstance(resolved, JSONResponse): return resolved - context, detail, points, _ = resolved - return _payload(project_id, context, detail, points, saved=False) + context, detail, points, _, stale = resolved + return _payload(project_id, context, detail, points, False, stale) @router.delete("/{project_id}/drainage/pipe-points", response_model=None) @@ -390,8 +433,8 @@ async def delete_pipe_points(project_id: UUID) -> dict[str, Any] | JSONResponse: resolved = await _resolve(project_id, None, use_stored=False) if isinstance(resolved, JSONResponse): return resolved - context, detail, points, _ = resolved - return _payload(project_id, context, detail, points, saved=False) + context, detail, points, _, stale = resolved + return _payload(project_id, context, detail, points, False, stale) @router.put("/{project_id}/drainage/pipe-points", response_model=None) @@ -404,19 +447,20 @@ async def put_pipe_points( resolved = await _resolve(project_id, payload, use_stored=True) if isinstance(resolved, JSONResponse): return resolved - context, detail, points, _ = resolved + context, detail, points, _, stale = resolved signature = route_signature(context.vertices) # 좌표를 같이 남긴다 — 다른 선(B05 최적 경로)으로 읽어도 그 자리에 되놓는다. saved = await asyncio.to_thread( 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), - context.crs, - ) + if not stale: # 낡은 분석 — 관 자리 · 속성만 정본으로 · 세부 유역 산출물은 안 씀 + await asyncio.to_thread( + save_detail_basins, + context.stored_path, + _basin_features(context, detail, points), + context.crs, + ) # 지정한 집계 항목을 프로젝트에 복사 · 안 쓰는 복사본 지움(PLAN 21-3 · 실패해도 저장 유지) from common_util.common_util_storage import resolve_stored_project_path from M02_MasterTemplete.M02_Item_Snapshot import importer, safe_sync @@ -424,7 +468,7 @@ async def put_pipe_points( failed = await asyncio.to_thread( safe_sync, resolve_stored_project_path(context.stored_path), importer(session) ) - result = _payload(project_id, context, detail, points, saved=True) + result = _payload(project_id, context, detail, points, True, stale) result["saved_count"] = saved result["snapshot_failed"] = failed # 못 만든 구조물 조합 복사본(142-2) — [저장] 알림 return result diff --git a/B04_PreProcess/B04_PreProcess_Router_Watershed.py b/B04_PreProcess/B04_PreProcess_Router_Watershed.py index 9beacc380..1011a16b8 100644 --- a/B04_PreProcess/B04_PreProcess_Router_Watershed.py +++ b/B04_PreProcess/B04_PreProcess_Router_Watershed.py @@ -43,6 +43,7 @@ from B04_PreProcess.B04_PreProcess_Router_Watershed_Output import ( ) from B05_Profile.B05_Profile_Repository import get_surface_crs_epsg from common_util.common_util_crs import resolve_project_crs +from common_util.common_util_drainage_pipes import pipe_signature, route_signature from common_util.common_util_route_geometry import ( StructureCandidate, find_planned_route_file, @@ -419,6 +420,9 @@ async def get_primary_region( "schema_version": RESPONSE_SCHEMA_VERSION, "project_id": str(project_id), "route_source": prepared["route_source"], + # 지문(342-3) — 노선 · 기본 관 자리가 지금과 다르면 이 결과는 낡음(세부 유역 안 나눔). + "route_signature": route_signature(prepared["vertices"]), + "pipe_signature": pipe_signature([pipe.chainage_m for pipe in preview.pipes]), "radius_m": region.radius_m, # 채택된 상류 세류망 = 1차 영역의 기준선. "upstream_lines": [_line_lonlat(line, to_lonlat) for line in region.split.upstream], diff --git a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts index 5acb2fd98..6c6b6be19 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts @@ -387,7 +387,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra if (seq !== requestSeq) return; // 뒤에 보낸 요청이 있음 — 그 응답을 기다림 apply(response); status.hidden = basins.length > 0; - if (basins.length === 0) status.textContent = L("B05_Drainage_Status_NoBasin"); + if (!basins.length) status.textContent = response.stale ?? L("B05_Drainage_Status_NoBasin"); scheduleDraw(); } catch (error) { if (seq !== requestSeq) return; diff --git a/common_util/common_util_drainage_pipes.py b/common_util/common_util_drainage_pipes.py index 144ea2fef..af91e4507 100644 --- a/common_util/common_util_drainage_pipes.py +++ b/common_util/common_util_drainage_pipes.py @@ -45,6 +45,7 @@ from config.config_system import ( DRAINAGE_DETAIL_FILENAME, DRAINAGE_EDITS_DIRNAME, DRAINAGE_PIPE_POINTS_FILENAME, + DRAINAGE_RESPONSE_FILENAME, ) logger = logging.getLogger(__name__) @@ -165,6 +166,38 @@ def route_signature(vertices: list[RouteVertex]) -> str: return f"{len(vertices)}-{digest.hexdigest()[:16]}" +def pipe_signature(chainages: list[float]) -> str: + """관 자리 지문 — 누가거리를 0.01 m 로 끊어 차례대로 해시한다(342-3).""" + digest = hashlib.sha1(usedforsecurity=False) + for chainage in sorted(chainages): + digest.update(f"{chainage:.2f};".encode()) + return f"{len(chainages)}-{digest.hexdigest()[:16]}" + + +# 배수유역 분석 저장분의 노선 지문 — 파일 자국(수정시각 · 크기)이 같으면 다시 읽지 않는다. +_ANALYSIS_SIGNATURE: dict[str, tuple[tuple[float, int], str | None]] = {} + + +def analysis_route_signature(stored_path: str) -> str | None: + """배수유역 분석(`00_watershed_response.json`)의 노선 지문 — 없으면 None(옛 저장분 = 낡음).""" + path = drainage_dir(stored_path) / DRAINAGE_RESPONSE_FILENAME + try: + info = path.stat() + except OSError: + return None + stamp = (info.st_mtime, info.st_size) + cached = _ANALYSIS_SIGNATURE.get(str(path)) + if cached is not None and cached[0] == stamp: + return cached[1] + try: + signature = json.loads(path.read_text(encoding="utf-8")).get("route_signature") + except (OSError, ValueError): + signature = None + _ANALYSIS_SIGNATURE.clear() + _ANALYSIS_SIGNATURE[str(path)] = (stamp, signature) + return signature + + def fill_pipe_coordinates(points: list[PipePoint], vertices: list[RouteVertex]) -> list[PipePoint]: """좌표가 비어 있는 관에 그 누가거리의 노선 좌표를 채운다(제자리 수정).""" if not vertices: diff --git a/resources/tester/test_342_3_drainage_stale.py b/resources/tester/test_342_3_drainage_stale.py new file mode 100644 index 000000000..27fac0b7d --- /dev/null +++ b/resources/tester/test_342_3_drainage_stale.py @@ -0,0 +1,91 @@ +"""342-3 배수유역 지문 · 낡음 — 분석 노선 지문이 지금 노선과 다르면 세부 유역을 안 나눔. + +- 지문: 관 자리 지문은 차례와 무관 · 분석 저장분 지문은 파일이 바뀔 때만 다시 읽음 · 없으면 None. +- 낡음: 관 목록이 있으면 관 자리만(세부 유역 없음 · 시설 속성 그대로 · 까닭 글) · 없으면 409. +- 같으면 그대로 나눔(_build). +""" + +import asyncio +import json +import os +from pathlib import Path +from types import SimpleNamespace +from uuid import uuid4 + +from fastapi.responses import JSONResponse + +from B04_PreProcess import B04_PreProcess_Router_Basins as basins +from common_util import common_util_drainage_pipes as pipes_mod +from common_util.common_util_drainage_context import DrainageContext +from common_util.common_util_drainage_pipes import PipePoint, pipe_signature, route_signature +from common_util.common_util_route_geometry import RouteVertex + +VERTICES = [RouteVertex(0.0, 0.0, 100.0, 0.0), RouteVertex(300.0, 0.0, 90.0, 300.0)] + + +def _context() -> DrainageContext: + return DrainageContext(stored_path="x", project_root=Path("."), vertices=list(VERTICES)) + + +def test_pipe_signature_ignores_order_and_noise(): + assert pipe_signature([10.0, 120.0]) == pipe_signature([120.0, 10.001]) + assert pipe_signature([10.0, 120.0]) != pipe_signature([10.0, 121.0]) + + +def test_analysis_signature_reads_saved_and_follows_file(tmp_path, monkeypatch): + monkeypatch.setattr(pipes_mod, "drainage_dir", lambda stored: tmp_path) + assert pipes_mod.analysis_route_signature("x") is None # 저장분 없음 + path = tmp_path / "00_watershed_response.json" + path.write_text(json.dumps({"status": "success"}), "utf-8") + assert pipes_mod.analysis_route_signature("x") is None # 옛 저장분 = 지문 없음 + path.write_text(json.dumps({"route_signature": "2-abc"}), "utf-8") + os.utime(path, (1, 1)) + assert pipes_mod.analysis_route_signature("x") == "2-abc" + + +def _patch(monkeypatch, analysis_signature, stored): + async def load(project_id): + return _context(), "" + + monkeypatch.setattr(basins, "load_drainage_context", load) + monkeypatch.setattr(basins, "analysis_route_signature", lambda stored_path: analysis_signature) + monkeypatch.setattr(basins, "load_pipe_points", lambda *args: stored) + built = SimpleNamespace(called=False) + + def build(stored_path, context, points): + built.called = True + return SimpleNamespace(pipes=[], basins=["유역"]), points or [] + + monkeypatch.setattr(basins, "_build", build) + return built + + +def test_stale_keeps_pipes_without_basins(monkeypatch): + stored = [ + PipePoint(chainage_m=120.0, source="stream", facility="ford_bridge"), + PipePoint(chainage_m=40.0, source="user"), + ] + built = _patch(monkeypatch, None, stored) # 옛 저장분 + context, detail, points, saved, stale = asyncio.run( + basins._resolve(uuid4(), None, use_stored=True) + ) + assert stale and saved and not built.called + assert detail.basins == [] + assert [point.chainage_m for point in points] == [40.0, 120.0] + assert points[1].facility == "ford_bridge" + payload = basins._payload(uuid4(), context, detail, points, saved, stale) + assert payload["stale"] == basins.STALE_MESSAGE + assert payload["route_signature"] == route_signature(VERTICES) + assert payload["pipe_signature"] == pipe_signature([40.0, 120.0]) + + +def test_stale_without_pipes_is_409(monkeypatch): + _patch(monkeypatch, "다른-노선", None) + result = asyncio.run(basins._resolve(uuid4(), None, use_stored=True)) + assert isinstance(result, JSONResponse) and result.status_code == 409 + + +def test_same_signature_splits(monkeypatch): + built = _patch(monkeypatch, route_signature(VERTICES), None) + *_, stale = asyncio.run(basins._resolve(uuid4(), None, use_stored=True)) + assert built.called and not stale From f8fcb812d38d5164c38b53f52b80be92168b93c4 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 16 Oct 2026 11:11:10 +0900 Subject: [PATCH 14/18] =?UTF-8?q?=EF=BB=BFfeat(=EC=A2=85=EB=8B=A8=EB=B3=B4?= =?UTF-8?q?=EA=B8=B0):=20345-2=203D=20=E2=86=92=20=ED=81=B0=20=EB=AA=A8?= =?UTF-8?q?=EB=8B=AC=20=E2=80=94=20[3D]=20=EB=8B=A8=EC=B6=94=20=C2=B7=20?= =?UTF-8?q?=EC=B2=98=EC=9D=8C=20=EC=97=B4=20=EB=95=8C=20=EC=A7=80=ED=98=95?= =?UTF-8?q?=20=C2=B7=20=EB=8B=AB=EC=9C=BC=EB=A9=B4=20=EC=88=A8=EA=B8=B4=20?= =?UTF-8?q?=EC=9E=90=EB=A6=AC=EB=A1=9C=20=C2=B7=20=EA=B7=B8=EB=A6=AC?= =?UTF-8?q?=EA=B8=B0=20=EB=A9=88=EC=B6=A4=20=C2=B7=20=EB=96=A0=EB=82=A0=20?= =?UTF-8?q?=EB=95=8C=EB=A7=8C=20=EB=B2=84=EB=A6=BC=20=C2=B7=203D=20?= =?UTF-8?q?=EB=85=B8=EC=84=A0=20=EC=A0=90=20=EC=98=AE=EA=B9=80=20=C2=B7=20?= =?UTF-8?q?=EC=A7=80=EC=9B=80=20+=20=E3=80=8C=EC=84=A0=ED=83=9D=20?= =?UTF-8?q?=ED=8F=AC=EC=9D=B8=ED=8A=B8=20=EC=83=81=EC=84=B8=E3=80=8D=20?= =?UTF-8?q?=EC=B9=B8=20=EC=A7=80=EC=9B=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 design_view_profile_3d_modal.ts(공용 openModal wide · 보기 조작 줄 · [3D 업데이트] · 3D 고름이 같은 노드로 따라감) - 지형 올리기는 페이지 열기에서 빠짐 · 모달 처음 열 때(로딩 원) · 등고선 간격 바꿈도 그 값으로 - 시험: test_345_2_3d_modal 4 통과(닫힌 동안 그리기 0) · 칸 시험(parity · 311) 고쳐 씀 · typecheck 통과 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VmC34fsxxbweEG3RUjs3Mn --- A00_Common/design/design_view_profile.ts | 35 ++-- A00_Common/design/design_view_profile_3d.ts | 22 ++- .../design/design_view_profile_3d_modal.ts | 88 ++++++++++ B05_Profile/B05_Profile_UI_Panel.ts | 7 - B05_Profile/B05_Profile_UI_Panel_Route.ts | 38 +---- B05_Profile/B05_Profile_UI_Panel_Sections.ts | 22 +-- B05_Profile/B05_Profile_UI_Style.css | 19 +++ B05_Profile/B05_Profile_UI_Viewer.ts | 5 - .../B05_Profile_UI_Viewer_Marker_Input.ts | 18 +- resources/tester/helper_311_panel_folded.cjs | 2 - resources/tester/helper_345_2_3d_modal.cjs | 161 ++++++++++++++++++ .../tester/helper_design_view_parity.cjs | 4 - resources/tester/test_311_panel_folded.py | 1 - resources/tester/test_345_2_3d_modal.py | 69 ++++++++ resources/tester/test_design_view_parity.py | 5 +- 15 files changed, 387 insertions(+), 109 deletions(-) create mode 100644 A00_Common/design/design_view_profile_3d_modal.ts create mode 100644 resources/tester/helper_345_2_3d_modal.cjs create mode 100644 resources/tester/test_345_2_3d_modal.py diff --git a/A00_Common/design/design_view_profile.ts b/A00_Common/design/design_view_profile.ts index 232e1817d..9044fe2c1 100644 --- a/A00_Common/design/design_view_profile.ts +++ b/A00_Common/design/design_view_profile.ts @@ -1,6 +1,6 @@ /* ============================================================================= * design_view_profile.ts - * 한 페이지의 **종단 보기**(계획서 24-3b) — 가운데 위 칸 = 3D 화면. 좌측 패널 · 아래 단추 · + * 한 페이지의 **종단 보기**(계획서 24-3b) — 3D 화면은 [3D] 큰 모달(345-2). 좌측 패널 · 아래 단추 · * 종단도(유토곡선 · 배수유역도)는 한 벌(`design_shared`)을 횡단 보기와 같은 노드로 쓴다. * * 여기서는 종단 보기 몫만 건다 — 3D · 좌측 종단 칸 재료 · 구조물 다리 · 종단도 알림 · 저장 몫. @@ -284,9 +284,6 @@ export async function mountProfileView( }), onContourApply: (interval) => view3d.applyContours(interval), ...view3d.viewCallbacks, - onMovePoint: viewer.beginMoveSelected, - onDeletePoint: viewer.markers.deleteSelected, - onRadiusChange: (radius) => viewer.markers.updateSelected({ radius_m: radius }), onInputChange: markStale, onStationDisplayChange: (offset) => profilePanel.setStationDisplay(offset), onStructuresChange: (next) => bridge.applyStructures(next), @@ -336,7 +333,6 @@ export async function mountProfileView( } viewer.markers.onChange(markStale); - viewer.markers.onSelectionChange(panel.setSelected); viewer.markers.onStationSelectionChange((stationId) => { profilePanel.setSelectedStation(stationId); syncIrregularSelection(stationId); @@ -393,7 +389,13 @@ export async function mountProfileView( // 한 페이지 두 보기 — 숨김 · 보임 · 떠남(계획서 24-3a · 24-3b). bindProfileViewLife(host, { projectId: activeProjectId, - viewer, + // 3D 는 모달 — 숨으면 닫음(그리기 멈춤) · 보여도 모달을 열 때만 그림 · 떠나면 버림(345-2). + viewer: { + setActive: (active) => { + if (!active) view3d.modal.close(); + }, + dispose: view3d.modal.dispose, + }, ready: () => !state.restoring, // 다시 보이면 횡단 보기가 한 벌(폼 · 종단도)에 실어 둔 값을 종단 보기 값으로 되돌린다. reload: async () => { @@ -420,22 +422,24 @@ export async function mountProfileView( /* ── 진입 로딩 ───────────────────────────────────────────────────────── * 화면 틀을 먼저 띄우고 자료가 끝나는 순서대로 채운다(2026-08-01 사용자 지시). - * 3D 지형은 **보조 자료라 로딩에 넣지 않는다**(2026-09-04 사용자 지시). 서클은 3D 뷰포트 - * 정중앙에 둔다(하단 종단 패널에 가려지는 것은 무방). */ + * 3D 지형은 **보조 자료라 로딩에 넣지 않는다**(2026-09-04 사용자 지시) — [3D] 모달을 처음 열 때 + * 올린다(345-2). 서클은 가운데 위 칸 정중앙에 둔다(하단 종단 패널에 가려지는 것은 무방). */ const LOAD_STEP_COUNT = 3; + const mainContent = document.createElement("div"); + mainContent.className = "b05-route__main"; + const planTools = document.createElement("div"); + planTools.className = "b05-route__plan-tools"; + planTools.append(view3d.modal.button); + mainContent.append(planTools, view3d.modal.park); + center.append(mainContent); const progress = createProgressCircle({ label: "노선 정보를 불러오는 중…", overlay: true }); - viewer.root.append(progress.root); + mainContent.append(progress.root); let loadedSteps = 0; function advanceLoading(label: string): void { loadedSteps += 1; progress.set(Math.min(1, loadedSteps / LOAD_STEP_COUNT), label); } - const mainContent = document.createElement("div"); - mainContent.className = "b05-route__main"; - mainContent.append(viewer.root); - center.append(mainContent); - try { // ① 좌측 폼·노선 설정값 — 캐시가 있으면 그것으로 **먼저** 그리고 신선도는 뒤에서 확인한다 // (2026-09-06). 캐시가 없을 때만 DB를 기다린다. @@ -509,7 +513,4 @@ export async function mountProfileView( snapOld(); restorePick(); // 관·구조물 목록 중 늦게 오는 쪽이 있어 여기서 한 번 더. } - - // ④ 3D 지형 — 화면을 잡지 않고 뒤에서 올린다. 실패해도 나머지는 그대로 쓴다. - void view3d.loadTerrain(); } diff --git a/A00_Common/design/design_view_profile_3d.ts b/A00_Common/design/design_view_profile_3d.ts index 16a7dda32..96f999fe7 100644 --- a/A00_Common/design/design_view_profile_3d.ts +++ b/A00_Common/design/design_view_profile_3d.ts @@ -1,6 +1,7 @@ /* ============================================================================= * design_view_profile_3d.ts - * 종단 보기의 **3D 몫** — 보기 조작 줄 콜백 · [3D 업데이트] · 등고선 다시 적용 · 3D 지형 올리기. + * 종단 보기의 **3D 몫** — 보기 조작 줄 콜백 · [3D 업데이트] · 등고선 다시 적용 · 3D 지형 올리기 · + * 큰 모달(`design_view_profile_3d_modal` · 345-2 — 지형은 처음 열 때 올림). * `design_view_profile.ts` 가 700줄을 넘어 뗀 조각(345-1). 상태는 본체가 쥔 `ProfileViewState`. * ========================================================================== */ @@ -14,6 +15,7 @@ import type { createRouteProfilePanel } from "../../B05_Profile/B05_Profile_UI_P import type { RouteViewer } from "../../B05_Profile/B05_Profile_UI_Viewer"; import type { DesignPageHost } from "./design_page"; import type { createProfileSections, ProfileViewState } from "./design_view_profile_sections"; +import { createViewer3dModal } from "./design_view_profile_3d_modal"; export interface Profile3dDeps { projectId: string; @@ -70,8 +72,14 @@ export function createProfile3d(deps: Profile3dDeps) { async function applyContours(interval: number): Promise { // 화면 전체를 잠그지 않는다 — 진행 표시는 [재적용] 버튼이 맡는다(CLAUDE.md 5장). try { - await viewer.reloadContours(interval); + await viewer.reloadContours(interval); // 지형 전(모달 안 엶)이면 그냥 지나감 await updateContourInterval(projectId, interval); + // 처음 열 때 올릴 지형도 새 간격으로(지형은 모달을 열 때 올림 · 345-2). + if (state.latest) + state.latest = { + ...state.latest, + surface_params: { ...state.latest.surface_params, contour_interval_m: interval }, + }; // 세션 캐시에도 반영해 다음 진입 시 옛 등고선 간격으로 복원되지 않게 한다. const cached = deps.latestSource.read(); if (cached) { @@ -85,7 +93,7 @@ export function createProfile3d(deps: Profile3dDeps) { } } - /** ④ 3D 지형 — 화면을 잡지 않고 뒤에서 올린다. 실패해도 나머지는 그대로 쓴다. */ + /** ④ 3D 지형 — 모달을 처음 열 때 올린다(345-2). 실패해도 나머지는 그대로 쓴다. */ async function loadTerrain(): Promise { const surface = state.confirmedSurface; const current = state.latest; @@ -110,5 +118,11 @@ export function createProfile3d(deps: Profile3dDeps) { } } - return { viewCallbacks, applyContours, loadTerrain }; + const modal = createViewer3dModal({ + viewer, + loadTerrain, + visible: () => deps.host.visible(), + }); + + return { viewCallbacks, applyContours, modal }; } diff --git a/A00_Common/design/design_view_profile_3d_modal.ts b/A00_Common/design/design_view_profile_3d_modal.ts new file mode 100644 index 000000000..69af1a74d --- /dev/null +++ b/A00_Common/design/design_view_profile_3d_modal.ts @@ -0,0 +1,88 @@ +/* ============================================================================= + * design_view_profile_3d_modal.ts + * 종단 보기 3D 를 **큰 모달**로(345-2). 3D 화면은 숨긴 자리(`park`)에 살고 [3D] 를 누르면 공용 큰 + * 모달(`openModal({ wide })`) 몸으로 옮겨 붙는다 — 같은 노드라 보기 조작 줄 · [3D 업데이트] · + * 3D 안 고름(측점 · 구조물 · 우클릭 더함)이 그대로 따라간다. + * + * 처음 열 때 3D 지형을 올린다(아직이면 로딩 원). 닫으면 숨긴 자리로 되돌려 `setActive(false)` — + * 그리기 루프 · 포인터 듣기만 멈추고 WebGL 문맥 · 지형은 살린다. 버림은 페이지를 떠날 때만. + * ========================================================================== */ + +import { createButton } from "@ui/ui_template_elements"; +import { openModal } from "@ui/ui_template_modal"; +import { createProgressCircle } from "@ui/ui_template_progress"; + +export interface Viewer3dModalDeps { + viewer: { root: HTMLElement; setActive: (active: boolean) => void; dispose: () => void }; + /** 처음 열 때 한 번 — 3D 지형 올리기(실패해도 모달은 열림). */ + loadTerrain: () => Promise; + /** 종단 보기가 보이는가 — 숨은 보기에서는 안 연다. */ + visible: () => boolean; +} + +export function createViewer3dModal(deps: Viewer3dModalDeps) { + const { viewer } = deps; + const park = document.createElement("div"); + park.className = "b05-route__3d-park"; + park.hidden = true; + park.append(viewer.root); + viewer.setActive(false); // 닫힌 동안 그리기 0 + let modal: { close: () => void } | null = null; + let terrain: Promise | null = null; + + const putBack = (): void => { + modal = null; + park.append(viewer.root); + viewer.setActive(false); + }; + + function open(): void { + if (modal || !deps.visible()) return; + modal = openModal({ + title: "3D 보기", + closeLabel: "닫기", + wide: true, + dialogClass: "b05-route__3d-modal", + mount: (body) => { + body.classList.add("b05-route__3d-body"); + body.append(viewer.root); + }, + // 닫기 단추 · Esc · 바깥 누름 — 묻지 않고 숨긴 자리로 되돌린다. + beforeClose: () => { + putBack(); + return true; + }, + }); + viewer.setActive(true); + if (terrain) return; + const progress = createProgressCircle({ label: "3D 지형을 불러오는 중…", overlay: true }); + progress.set(null); + viewer.root.append(progress.root); + terrain = deps.loadTerrain().finally(() => progress.remove()); + } + + /** 열려 있으면 닫는다 — 보기를 떠날 때 · 페이지를 떠날 때. */ + function close(): void { + if (!modal) return; + const shown = modal; + putBack(); + shown.close(); + } + + const button = createButton({ label: "3D", variant: "ghost", onClick: open }); + button.classList.add("b05-route__3d-open"); + button.title = "3D 화면을 큰 창으로 엽니다 — 닫으면 그리기를 멈춥니다"; + + return { + park, + button, + open, + close, + isOpen: () => modal !== null, + dispose(): void { + close(); + viewer.dispose(); + park.remove(); + }, + }; +} diff --git a/B05_Profile/B05_Profile_UI_Panel.ts b/B05_Profile/B05_Profile_UI_Panel.ts index 8f748552f..b8a70d0a0 100644 --- a/B05_Profile/B05_Profile_UI_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Panel.ts @@ -8,7 +8,6 @@ * 여기 남는다. 아래 고정 영역(단추 넷) · 「구조물 배치」 폼은 한 벌이 두 보기에 같은 노드로 준다. * ========================================================================== */ -import type { PlacedRoutePoint } from "./B05_Profile_UI_Markers"; import type { StructureInstance } from "./B05_Profile_Api_Structures"; import type { FacilityAttributes } from "./B05_Profile_UI_Drainage_Facility"; import type { StructuresSection } from "./B05_Profile_UI_Structures_Panel"; @@ -51,9 +50,6 @@ interface PanelCallbacks { /** [3D 업데이트] — 계획선 편집을 3D 예상형상·측점선에 한 번에 반영(2026-09-01 사용자 * 지시). 편집마다 따라오던 자동 갱신을 없애고 이 버튼으로만 돌린다. */ onCorridorRefresh: () => void | Promise; - onMovePoint: () => void; - onDeletePoint: () => void; - onRadiusChange: (radius: number) => void; onInputChange: () => void; /** 구조물 목록(구조물군 B~G)이 바뀔 때 — 정본 저장·그래프 반영은 Page가 맡는다. */ onStructuresChange: (structures: StructureInstance[]) => void; @@ -183,9 +179,6 @@ export function createRoutePanel(callbacks: PanelCallbacks, shared: DesignPageSh enforcePipeClearance.checked = values.enforcePipeClearance; settings.syncCriteria(); }, - setSelected(point: PlacedRoutePoint | null) { - route.setSelected(point); - }, }; } diff --git a/B05_Profile/B05_Profile_UI_Panel_Route.ts b/B05_Profile/B05_Profile_UI_Panel_Route.ts index a21185ec8..95482457e 100644 --- a/B05_Profile/B05_Profile_UI_Panel_Route.ts +++ b/B05_Profile/B05_Profile_UI_Panel_Route.ts @@ -1,19 +1,16 @@ /* ============================================================================= * B05_Profile_UI_Panel_Route.ts - * 좌측 패널의 **노선 쪽 칸 재료** — 계획노선 편집 단추 · 선택 포인트 상세. 칸 틀은 + * 좌측 패널의 **노선 쪽 칸 재료** — 계획노선 편집 단추. 칸 틀은 * `B05_Profile_UI_Panel_Sections.ts` 가 씌운다(등록부). 「경로 계산 설정」은 `Old/` 로 옮김(54-24). + * 「선택 포인트 상세」(3D 노선 점 옮김 · 지움)는 지움(345-2). * `B05_Profile_UI_Panel.ts` 에서 떼어냈다(계획서 24-2b). 본문 로직과 수치는 그대로다. * ========================================================================== */ -import type { PlacedRoutePoint } from "./B05_Profile_UI_Markers"; -import { button, numberField } from "./B05_Profile_UI_Panel_Fields"; +import { button } from "./B05_Profile_UI_Panel_Fields"; export interface RoutePartCallbacks { /** [계획노선 편집] — 큰 모달을 열어 노선을 고친다(계산은 모달 [확인]에서만 돈다). */ onEditPlannedRoute: () => void; - onMovePoint: () => void; - onDeletePoint: () => void; - onRadiusChange: (radius: number) => void; } export function createRoutePart(callbacks: RoutePartCallbacks) { @@ -23,34 +20,7 @@ export function createRoutePart(callbacks: RoutePartCallbacks) { plannedRouteBtn.classList.add("b05-route__btn"); plannedRouteBtn.title = "예상노선 위에서 계획노선을 고칩니다. [확인] 때만 다시 계산합니다."; - const selectedName = document.createElement("strong"); - const radius = numberField("회피/금지 반경 (m)", "25"); - radius.addEventListener("change", () => callbacks.onRadiusChange(Number(radius.value) || 1)); - const selectedActions = document.createElement("div"); - selectedActions.className = "b05-route__actions"; - selectedActions.append( - button("위치 이동", callbacks.onMovePoint), - button("삭제", callbacks.onDeletePoint, "danger"), - ); - // 칸 뿌리는 등록부가 만든 뒤 알려 준다 — 평소 숨김(포인트를 고를 때만 보인다). - let selectedRoot: HTMLElement | null = null; - - return { - plannedRouteButton: plannedRouteBtn, - /** 「선택 포인트 상세 설정」 본문 — 화면 순서대로. */ - selectedNodes: [selectedName, radius.wrapper, selectedActions] as HTMLElement[], - bindSelectedRoot(root: HTMLElement): void { - selectedRoot = root; - root.hidden = true; - }, - setSelected(point: PlacedRoutePoint | null): void { - if (selectedRoot) selectedRoot.hidden = !point; - if (!point) return; - selectedName.textContent = `${point.type.toUpperCase()} (${point.x.toFixed(2)}, ${point.y.toFixed(2)})`; - radius.wrapper.hidden = point.type !== "ap" && point.type !== "fp"; - radius.value = String(point.radius_m ?? 25); - }, - }; + return { plannedRouteButton: plannedRouteBtn }; } export type RoutePart = ReturnType; diff --git a/B05_Profile/B05_Profile_UI_Panel_Sections.ts b/B05_Profile/B05_Profile_UI_Panel_Sections.ts index 2fc33bfd4..417e064da 100644 --- a/B05_Profile/B05_Profile_UI_Panel_Sections.ts +++ b/B05_Profile/B05_Profile_UI_Panel_Sections.ts @@ -1,10 +1,10 @@ /* ============================================================================= * B05_Profile_UI_Panel_Sections.ts - * 종단 보기 좌측 패널의 **칸 정의**(등록부 `registerPanelSection`) — 페이지 설정 · 계획노선 · - * 선택 포인트 상세. 「구조물 배치」는 두 보기 공용이라 `design_sections.ts` 에 있다. + * 종단 보기 좌측 패널의 **칸 정의**(등록부 `registerPanelSection`) — 페이지 설정 · 계획노선. + * 「구조물 배치」는 두 보기 공용이라 `design_sections.ts` 에 있다. * - * 칸 차례(2026-08-18 사용자 확정): 구조물 배치 > 페이지 설정 > 계획노선 - * > 선택 포인트(평소 숨김) > 아래 고정 영역. 「경로 계산 설정」은 `Old/` 로 옮김(54-24). 재료는 `_Panel_Settings` · `_Panel_Route` 가 만들어 + * 칸 차례(2026-08-18 사용자 확정): 구조물 배치 > 페이지 설정 > 계획노선 > 아래 고정 영역. + * 「선택 포인트 상세 설정」(3D 노선 점 옮김 · 지움 · 저장 안 되는 길)은 지움(345-2). 「경로 계산 설정」은 `Old/` 로 옮김(54-24). 재료는 `_Panel_Settings` · `_Panel_Route` 가 만들어 * `ctx.parts` 로 건넨다. * ========================================================================== */ @@ -36,17 +36,3 @@ registerPanelSection({ return frame.root; }, }); - -registerPanelSection({ - id: "selected-point", - title: "선택 포인트 상세 설정", - views: ["profile"], - order: 50, - build: (ctx, view) => { - const frame = createSectionFrame(view, "선택 포인트 상세 설정"); - const route = part(ctx, "route"); - frame.body.append(...route.selectedNodes); - route.bindSelectedRoot(frame.root); - return frame.root; - }, -}); diff --git a/B05_Profile/B05_Profile_UI_Style.css b/B05_Profile/B05_Profile_UI_Style.css index 97fa3b1c1..3c974f2b8 100644 --- a/B05_Profile/B05_Profile_UI_Style.css +++ b/B05_Profile/B05_Profile_UI_Style.css @@ -628,3 +628,22 @@ html[data-size="compact"] .b05-route__view-controls { html[data-size="compact"] .b05-route__view-group { flex-wrap: wrap; } + +/* 3D 큰 모달(345-2) — 3D 화면이 모달 몸을 꽉 채움 · 몸 스크롤 없음(캔버스 크기는 ResizeObserver 가 따라감). */ +.ui-modal--wide > .ui-modal__body.b05-route__3d-body { + overflow: hidden; +} + +.b05-route__3d-body > .b05-route__viewport { + flex: 1 1 auto; + min-height: 0; + border-radius: var(--radius-md); +} + +/* 가운데 위 [3D] 단추 자리 — 노선 편집 본문이 서기 전 임시 칸. */ +.b05-route__plan-tools { + display: flex; + flex: none; + gap: var(--spacing-8); + padding: var(--spacing-8) var(--spacing-16); +} diff --git a/B05_Profile/B05_Profile_UI_Viewer.ts b/B05_Profile/B05_Profile_UI_Viewer.ts index bd13562a0..78f276413 100644 --- a/B05_Profile/B05_Profile_UI_Viewer.ts +++ b/B05_Profile/B05_Profile_UI_Viewer.ts @@ -87,7 +87,6 @@ export interface RouteViewer { setView: (view: "iso" | "top" | "front" | "side") => void; /** 직교/원근 전환(2026-09-04) — 보이는 크기를 유지한 채 카메라만 갈아 끼운다. */ setProjection: (kind: ProjectionKind) => void; - beginMoveSelected: () => void; /** 화면(client) 좌표 아래 지형의 모델 좌표 — 3D 우클릭 구조물 배치용(2026-08-19). */ modelPointAt: (clientX: number, clientY: number) => { x: number; y: number; z: number } | null; /** 코리도 구조물 개별 선택(2026-09-04) — 클릭 알림·강조 되살리기 창구. */ @@ -512,10 +511,6 @@ export function createRouteViewer(): RouteViewer { renderStationLines: markers.renderStationLines, setView: fit, setProjection: (kind) => cameraRig.setKind(kind, controls), - beginMoveSelected() { - markerInput.beginMoveSelected(); - status.textContent = "선택한 포인트를 이동할 지형 위치를 클릭하세요."; - }, modelPointAt: markerInput.terrainPointAt, setActive(active) { structurePick.setListening(active); diff --git a/B05_Profile/B05_Profile_UI_Viewer_Marker_Input.ts b/B05_Profile/B05_Profile_UI_Viewer_Marker_Input.ts index 7ab032e7e..4ee4c6d51 100644 --- a/B05_Profile/B05_Profile_UI_Viewer_Marker_Input.ts +++ b/B05_Profile/B05_Profile_UI_Viewer_Marker_Input.ts @@ -20,9 +20,7 @@ import { export interface MarkerPointerControls { /** 화면 좌표 아래 지형 지점(모델 좌표). 지형·bounds가 없으면 null. */ terrainPointAt: (clientX: number, clientY: number) => { x: number; y: number; z: number } | null; - /** 다음 클릭으로 선택 포인트를 옮기는 모드로 들어간다. */ - beginMoveSelected: () => void; - /** 마커를 잡고 있거나 이동 대기 중인가 — 회전 중심 유틸이 회전을 넘길 판단에 쓴다. */ + /** 마커를 잡고 있는가 — 회전 중심 유틸이 회전을 넘길 판단에 쓴다. */ blocked: () => boolean; /** 마지막 누름이 마커 · 측점 띠에 맞았나 — 3D 구조물 고름이 그 누름을 「빈 곳」으로 읽지 않게(232-1). */ pressedMarker: () => boolean; @@ -41,7 +39,6 @@ export function bindMarkerPointerControls(options: { }): MarkerPointerControls { const { canvas, camera, controls, markers, getTerrain, getBounds } = options; - let movingSelected = false; let dragCandidate: { id: string; pointerId: number; x: number; y: number } | null = null; let draggingMarker = false; let lastDragPoint: { x: number; y: number; z: number } | null = null; @@ -136,13 +133,7 @@ export function bindMarkerPointerControls(options: { event.stopPropagation(); return; } - if (movingSelected) { - const point = terrainPoint(event); - if (point) markers.moveSelected(point); - movingSelected = false; - } else { - markers.selectObject(undefined); - } + markers.selectObject(undefined); } function handlePointerMove(event: PointerEvent): void { @@ -191,10 +182,7 @@ export function bindMarkerPointerControls(options: { return { terrainPointAt, - beginMoveSelected: () => { - movingSelected = true; - }, - blocked: () => dragCandidate !== null || draggingMarker || movingSelected, + blocked: () => dragCandidate !== null || draggingMarker, pressedMarker: () => downOnMarker, dispose: () => { canvas.removeEventListener("dragover", handleDragOver); diff --git a/resources/tester/helper_311_panel_folded.cjs b/resources/tester/helper_311_panel_folded.cjs index 4b997e9bb..51d2252bc 100644 --- a/resources/tester/helper_311_panel_folded.cjs +++ b/resources/tester/helper_311_panel_folded.cjs @@ -178,8 +178,6 @@ const panel = design.createDesignPanel( settings: { nodes: [text("a")] }, route: { plannedRouteButton: text("plan"), - selectedNodes: [text("sel")], - bindSelectedRoot: () => undefined, }, crossView: { row: text("cross-row") }, standardSlot: text("std"), diff --git a/resources/tester/helper_345_2_3d_modal.cjs b/resources/tester/helper_345_2_3d_modal.cjs new file mode 100644 index 000000000..83466b52a --- /dev/null +++ b/resources/tester/helper_345_2_3d_modal.cjs @@ -0,0 +1,161 @@ +/* 345-2 3D 큰 모달 헬퍼 — `design_view_profile_3d_modal.ts` 를 트랜스파일해 가짜 DOM · 가짜 뷰어로 + * 닫힌 동안 그리기 0 · 열면 그림 · 닫으면 숨긴 자리로 되돌림 · 지형은 처음 열 때 한 번 · 버림은 떠날 때만을 본다. + * 결과는 JSON 한 벌(표준출력). + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); + +class FakeNode { + constructor(tag) { + this.tagName = tag; + this.children = []; + this.parent = null; + this.hidden = false; + this.title = ""; + this.listeners = {}; + const classes = new Set(); + this.classList = { + add: (...n) => n.forEach((x) => classes.add(x)), + contains: (n) => classes.has(n), + }; + this._classes = classes; + } + set className(v) { + this._classes.clear(); + String(v) + .split(/\s+/) + .filter(Boolean) + .forEach((x) => this._classes.add(x)); + } + append(...kids) { + kids.forEach((kid) => { + if (kid.parent) kid.parent.children = kid.parent.children.filter((c) => c !== kid); + kid.parent = this; + this.children.push(kid); + }); + } + remove() { + if (this.parent) this.parent.children = this.parent.children.filter((c) => c !== this); + this.parent = null; + } + addEventListener(type, fn) { + (this.listeners[type] ??= []).push(fn); + } + click() { + (this.listeners.click ?? []).forEach((fn) => fn({})); + } +} +global.document = { createElement: (tag) => new FakeNode(tag) }; + +const modals = []; +const STUBS = { + "@ui/ui_template_elements": { + createButton: ({ label, onClick }) => { + const button = new FakeNode("button"); + button.label = label; + if (onClick) button.addEventListener("click", onClick); + return button; + }, + }, + "@ui/ui_template_modal": { + openModal: (opts) => { + const body = new FakeNode("div"); + const record = { opts, body, open: true }; + opts.mount(body, () => (record.open = false), new FakeNode("div")); + record.close = () => (record.open = false); + modals.push(record); + return { close: record.close }; + }, + }, + "@ui/ui_template_progress": { + createProgressCircle: () => { + const root = new FakeNode("div"); + return { root, set: () => undefined, remove: () => root.remove() }; + }, + }, +}; + +function loadTs(file) { + const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + const localRequire = (request) => STUBS[request] ?? require(request); + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + return box.exports; +} + +const { createViewer3dModal } = loadTs( + path.join(ROOT, "A00_Common", "design", "design_view_profile_3d_modal.ts"), +); + +// 가짜 뷰어 — 만들면 그리기 루프가 돎(진짜와 같음) · setActive 가 루프를 켜고 끔. +const viewer = { + root: new FakeNode("div"), + running: true, + draws: 0, + disposed: false, + setActive(active) { + this.running = active; + }, + dispose() { + this.disposed = true; + }, +}; +const frames = (n) => { + for (let i = 0; i < n; i += 1) if (viewer.running) viewer.draws += 1; +}; + +let terrainLoads = 0; +let resolveTerrain; +const view3d = createViewer3dModal({ + viewer, + loadTerrain: () => { + terrainLoads += 1; + return new Promise((resolve) => (resolveTerrain = resolve)); + }, + visible: () => true, +}); + +(async () => { + const out = {}; + frames(10); + out.closedDraws = viewer.draws; + out.inPark = viewer.root.parent === view3d.park && view3d.park.hidden; + out.terrainBeforeOpen = terrainLoads; + + view3d.button.click(); + const first = modals[0]; + out.wide = first.opts.wide === true; + out.inModal = viewer.root.parent === first.body; + out.spinnerWhileLoading = viewer.root.children.length === 1; + frames(3); + out.openDraws = viewer.draws; + resolveTerrain(); + await new Promise((r) => setImmediate(r)); + out.spinnerGone = viewer.root.children.length === 0; + + // 닫기 단추 · Esc · 바깥 누름 = beforeClose 뒤 닫힘(공용 모달 규칙) + out.beforeCloseOk = (await first.opts.beforeClose()) === true; + first.close(); + frames(10); + out.afterCloseDraws = viewer.draws; + out.backInPark = viewer.root.parent === view3d.park; + out.disposedOnClose = viewer.disposed; + + view3d.open(); + view3d.open(); // 열린 동안 또 눌러도 하나 + out.modalCount = modals.length; + out.terrainLoads = terrainLoads; + frames(2); + out.reopenDraws = viewer.draws; + + view3d.dispose(); // 페이지 떠남 — 열려 있으면 닫고 버림 + out.closedOnDispose = modals[1].open === false; + out.disposed = viewer.disposed; + out.runningAfterDispose = viewer.running; + console.log(JSON.stringify(out)); +})(); diff --git a/resources/tester/helper_design_view_parity.cjs b/resources/tester/helper_design_view_parity.cjs index e94b6161e..e08b68822 100644 --- a/resources/tester/helper_design_view_parity.cjs +++ b/resources/tester/helper_design_view_parity.cjs @@ -199,10 +199,6 @@ const built = panel.createDesignPanel( settings: { nodes: [text("a"), text("b")] }, route: { plannedRouteButton: text("plan"), - selectedNodes: [text("sel")], - bindSelectedRoot: (root) => { - root.hidden = true; - }, }, crossView: { row: text("cross-row") }, standardSlot: text("std"), diff --git a/resources/tester/test_311_panel_folded.py b/resources/tester/test_311_panel_folded.py index 66aeb65fd..60880ce05 100644 --- a/resources/tester/test_311_panel_folded.py +++ b/resources/tester/test_311_panel_folded.py @@ -43,7 +43,6 @@ def test_B05_B06_첫_그림_접힘(out): assert out["profileFirst"] == { "페이지 설정": True, "계획노선": True, - "선택 포인트 상세 설정": False, } assert out["crossFirst"]["B06_View_Title"] is True assert out["crossFirst"]["B06_Std_Title"] is True # 옛 기본 접힘 그대로 diff --git a/resources/tester/test_345_2_3d_modal.py b/resources/tester/test_345_2_3d_modal.py new file mode 100644 index 000000000..2ce035a62 --- /dev/null +++ b/resources/tester/test_345_2_3d_modal.py @@ -0,0 +1,69 @@ +"""345-2 3D → 큰 모달 — 닫힌 동안 그리기 0 · 처음 열 때 지형 · 닫으면 숨긴 자리 · 버림은 떠날 때만. + +Node 헬퍼 `helper_345_2_3d_modal.cjs` 가 모달 조각을 트랜스파일해 가짜 뷰어로 돌림 · 배선은 글로 봄. +""" + +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] + + +def _read(rel: str) -> str: + return (ROOT / rel).read_text(encoding="utf-8") + + +@pytest.fixture(scope="module") +def out() -> dict: + run = subprocess.run( + ["node", str(HERE / "helper_345_2_3d_modal.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + check=True, + ) + return json.loads(run.stdout) + + +def test_닫힌_동안_그리기_0(out): + assert out["closedDraws"] == 0 + assert out["inPark"] is True + assert out["afterCloseDraws"] == out["openDraws"] == 3 + assert out["backInPark"] is True + assert out["disposedOnClose"] is False # 닫기는 멈춤만 · 문맥 살림 + + +def test_처음_열_때_지형_한_번_로딩_원(out): + assert out["terrainBeforeOpen"] == 0 + assert out["wide"] is True and out["inModal"] is True + assert out["spinnerWhileLoading"] is True and out["spinnerGone"] is True + assert out["terrainLoads"] == 1 # 다시 열어도 다시 안 올림 + assert out["modalCount"] == 2 # 열린 동안 또 눌러도 하나 + assert out["reopenDraws"] == 5 + + +def test_떠날_때만_버림(out): + assert out["beforeCloseOk"] is True + assert out["closedOnDispose"] is True + assert out["disposed"] is True + assert out["runningAfterDispose"] is False + + +def test_배선_3D_상주_없음_죽은_길_지움(): + view = _read("A00_Common/design/design_view_profile.ts") + assert "mainContent.append(viewer.root)" not in view + assert "view3d.modal.park" in view and "view3d.modal.button" in view + assert "dispose: view3d.modal.dispose" in view + assert "loadTerrain" not in view # 지형은 모달을 열 때 + for gone in ("beginMoveSelected", "deleteSelected", "updateSelected", "panel.setSelected"): + assert gone not in view + assert "selected-point" not in _read("B05_Profile/B05_Profile_UI_Panel_Sections.ts") + assert "selectedNodes" not in _read("B05_Profile/B05_Profile_UI_Panel_Route.ts") + assert "beginMoveSelected" not in _read("B05_Profile/B05_Profile_UI_Viewer.ts") + assert len(view.splitlines()) <= 700 diff --git a/resources/tester/test_design_view_parity.py b/resources/tester/test_design_view_parity.py index 98906a2bb..cc7e3cb83 100644 --- a/resources/tester/test_design_view_parity.py +++ b/resources/tester/test_design_view_parity.py @@ -59,7 +59,9 @@ def results(): def test_보기를_오가도_좌측_패널_단추_구조물_배치는_같은_노드(results): assert results["sameNodes"] == {"cross": True, "back": True} # 보기마다 새로 만들지 않는다 — 칸은 처음 보일 때 한 번만 서고 뒤로는 숨김 · 보임만. - assert results["childCount"] == 1 + 4 + 2 # 아래 고정 영역 + 종단 칸 넷 + 횡단 칸 둘 + assert ( + results["childCount"] == 1 + 3 + 2 + ) # 아래 고정 영역 + 종단 칸 셋 + 횡단 칸 둘(345-2 선택 포인트 지움) def test_구조물_배치는_두_보기_모두_맨_위_같은_DOM(results): @@ -80,7 +82,6 @@ def test_보기마다_제_칸만_보인다(results): "구조물 배치", "페이지 설정", "계획노선", - "선택 포인트 상세 설정", ] assert views["cross"]["shownTitles"] == ["구조물 배치", "횡단 보기 설정", "표준 횡단면 설정"] assert views["back"]["shownTitles"] == views["profile"]["shownTitles"] From fba1eb8a5db8be39878394f9f61a34b0a2a134fe Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 16 Oct 2026 11:13:24 +0900 Subject: [PATCH 15/18] =?UTF-8?q?perf(=EB=B0=B0=EC=88=98=EC=9C=A0=EC=97=AD?= =?UTF-8?q?):=20342-3=20=EC=84=B8=EB=B6=80=20=EC=9C=A0=EC=97=AD=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=EB=B3=B8=20=E2=80=94=20=EC=97=B4=EC=87=A0?= =?UTF-8?q?=EA=B0=80=20=EA=B0=99=EC=9C=BC=EB=A9=B4=20=EB=8B=A4=EC=8B=9C=20?= =?UTF-8?q?=EB=82=98=EB=88=84=EC=A7=80=20=EC=95=8A=EC=9D=8C(=EB=B6=80?= =?UTF-8?q?=EB=A1=9D=204=20R15)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 열쇠 = 관 목록 · 노선(평면 · 종단 Z) · 분석 산출물(03 road_routing · 01 primary_region) · 강우표 · 구조물 정의 자국 - GET · POST · DELETE pipe-points = 메모리 최근 4벌 + 파일 한 벌(drainage/04_detail_cache.pkl · [저장] 때 GET 열쇠로 씀) - [저장](PUT)은 늘 정본으로 다시 셈 · 관 목록은 사본으로 돌려줌 - 시험 test_342_3_detail_cache(같은 열쇠 재사용 · 입력 바뀌면 다시 셈 · 다시 켜도 파일로 읽힘) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XWh5wNNycMkbwMpACz1rte --- .../B04_PreProcess_Router_Basins.py | 99 ++++++++++++++++++- resources/tester/test_342_3_detail_cache.py | 75 ++++++++++++++ resources/tester/test_342_3_drainage_stale.py | 3 + 3 files changed, 172 insertions(+), 5 deletions(-) create mode 100644 resources/tester/test_342_3_detail_cache.py diff --git a/B04_PreProcess/B04_PreProcess_Router_Basins.py b/B04_PreProcess/B04_PreProcess_Router_Basins.py index ff7705355..867b3dd55 100644 --- a/B04_PreProcess/B04_PreProcess_Router_Basins.py +++ b/B04_PreProcess/B04_PreProcess_Router_Basins.py @@ -15,16 +15,21 @@ from __future__ import annotations import asyncio +import copy +import hashlib +import json import logging import math +import os +import pickle from typing import Any from uuid import UUID from fastapi import APIRouter, Depends from fastapi.responses import JSONResponse -from B04_PreProcess.B04_PreProcess_Engine_Watershed_Export import drainage_dir -from B05_Profile.B05_Profile_Structures_Schema import structure_type_map +from B04_PreProcess.B04_PreProcess_Engine_Watershed_Export import STAGES, drainage_dir +from B05_Profile.B05_Profile_Structures_Schema import DEFINITION_PATH, structure_type_map from B06_Section.B06_Section_Engine_Culvert import pipe_wall_min_height_m from common_util.common_util_auth import get_optional_session from common_util.common_util_drainage_context import DrainageContext, load_drainage_context @@ -52,6 +57,7 @@ from common_util.common_util_route_geometry import StructureCandidate from config.config_system import ( DRAINAGE_PIPE_MAX_SPACING_M, DRAINAGE_PIPE_MIN_SPACING_M, + DRAINAGE_RAINFALL_FILENAME, FORD_MANNING_N, FORD_SLOPE, ) @@ -115,6 +121,83 @@ def _build_stale(context: DrainageContext, points: list[PipePoint]) -> DrainageD return detail +# 세부 유역 저장본(342-3 · 부록 4 R15) — 열쇠 = 관 목록 · 노선(평면 · 종단 Z) · 분석 산출물 · +# 강우표 · 구조물 정의 자국. 같으면 다시 나누지 않는다 — 메모리 최근 몇 벌 + [저장] 때 파일 한 벌. +_DETAIL_CACHE: dict[str, tuple[DrainageDetail, list[PipePoint]]] = {} +_DETAIL_CACHE_SIZE = 4 +DETAIL_CACHE_FILENAME = "04_detail_cache.pkl" + + +def _detail_key(context: DrainageContext, points: list[PipePoint] | None) -> str: + directory = drainage_dir(context.stored_path) + digest = hashlib.sha1(usedforsecurity=False) + listed = None if points is None else [point.as_dict() for point in points] + digest.update(json.dumps(listed, sort_keys=True, ensure_ascii=False).encode()) + for vertex in context.vertices: + digest.update(f"{vertex.x:.3f},{vertex.y:.3f},{vertex.z:.3f};".encode()) + routing = STAGES["road_routing"] + for path in ( + directory / f"{routing}_road_routing.npz", + directory / f"{routing}_road_routing.geojson", + directory / f"{STAGES['primary_region']}_primary_region.geojson", + directory / DRAINAGE_RAINFALL_FILENAME, + DEFINITION_PATH, + ): + try: + info = os.stat(path) + digest.update(f"{info.st_mtime_ns}:{info.st_size};".encode()) + except OSError: + digest.update(b"-;") + return digest.hexdigest() + + +def _cache_path(stored_path: str): + return drainage_dir(stored_path) / DETAIL_CACHE_FILENAME + + +def _remember(key: str, value: tuple[DrainageDetail, list[PipePoint]]) -> None: + _DETAIL_CACHE.pop(key, None) + _DETAIL_CACHE[key] = value + while len(_DETAIL_CACHE) > _DETAIL_CACHE_SIZE: + _DETAIL_CACHE.pop(next(iter(_DETAIL_CACHE))) + + +def _build_cached( + context: DrainageContext, points: list[PipePoint] | None +) -> tuple[DrainageDetail | None, list[PipePoint]]: + """`_build` + 저장본 — 관 목록은 사본으로 돌려준다(저장이 좌표를 채워도 저장본은 그대로).""" + key = _detail_key(context, points) + hit = _DETAIL_CACHE.get(key) + if hit is None: + try: + with _cache_path(context.stored_path).open("rb") as file: + saved = pickle.load(file) + hit = saved["value"] if saved.get("key") == key else None + except (OSError, pickle.PickleError, EOFError, AttributeError, KeyError, TypeError): + hit = None + if hit is None: + detail, carried = _build(context.stored_path, context, points) + if detail is None: + return None, [] + hit = (detail, carried) + _remember(key, hit) + return hit[0], copy.deepcopy(hit[1]) + + +def _save_detail_cache( + context: DrainageContext, points: list[PipePoint] | None, detail: DrainageDetail, carried +) -> None: + """[저장] 뒤 — 다음 GET 이 읽을 관 목록(저장분) 열쇠로 파일 한 벌을 남긴다.""" + key = _detail_key(context, points) + value = (detail, copy.deepcopy(carried)) + _remember(key, value) + try: + with _cache_path(context.stored_path).open("wb") as file: + pickle.dump({"key": key, "value": value}, file) + except OSError: + logger.warning("배수유역: 세부 유역 저장본을 남기지 못했습니다(%s).", context.stored_path) + + # 관 보호공 · 유입 집수정 높이 칸 — 새 관 기본 = 관경 + 0.5(139-1) · 유입 흙막이만 + 0.7(211-1) PIPE_HEIGHT_KEYS = ("inlet_revet_height_m", "outlet_revet_height_m", "inlet_basin_height_m") @@ -352,7 +435,7 @@ def _basin_features( async def _resolve( - project_id: UUID, payload: dict[str, Any] | None, *, use_stored: bool + project_id: UUID, payload: dict[str, Any] | None, *, use_stored: bool, fresh: bool = False ) -> tuple[DrainageContext, DrainageDetail, list[PipePoint], bool, bool] | JSONResponse: """요청 본문 → 저장분 → 자동 생성 순으로 관 목록을 정하고 세부유역까지 계산한다. @@ -385,7 +468,11 @@ async def _resolve( detail = _build_stale(context, points) return context, detail, carry_facility_attributes(_rebuilt(detail), points), saved, True - result = await asyncio.to_thread(_build, context.stored_path, context, points) + # [저장]은 정본으로 다시 셈 — 저장본은 GET · POST 만 + if fresh: + result = await asyncio.to_thread(_build, context.stored_path, context, points) + else: + result = await asyncio.to_thread(_build_cached, context, points) detail, resolved = result if detail is None: return JSONResponse(status_code=404, content={"status": "error", "message": _NO_ANALYSIS}) @@ -444,7 +531,7 @@ async def put_pipe_points( session: dict[str, Any] | None = Depends(get_optional_session), # 가져온이(142-2) ) -> dict[str, Any] | JSONResponse: """관 지점을 정본으로 확정하고 세부유역 산출물까지 함께 남긴다(모델 확정 시점).""" - resolved = await _resolve(project_id, payload, use_stored=True) + resolved = await _resolve(project_id, payload, use_stored=True, fresh=True) if isinstance(resolved, JSONResponse): return resolved context, detail, points, _, stale = resolved @@ -461,6 +548,8 @@ async def put_pipe_points( _basin_features(context, detail, points), context.crs, ) + stored = load_pipe_points(context.stored_path, signature, context.vertices) + await asyncio.to_thread(_save_detail_cache, context, stored, detail, points) # 지정한 집계 항목을 프로젝트에 복사 · 안 쓰는 복사본 지움(PLAN 21-3 · 실패해도 저장 유지) from common_util.common_util_storage import resolve_stored_project_path from M02_MasterTemplete.M02_Item_Snapshot import importer, safe_sync diff --git a/resources/tester/test_342_3_detail_cache.py b/resources/tester/test_342_3_detail_cache.py new file mode 100644 index 000000000..8ea80935b --- /dev/null +++ b/resources/tester/test_342_3_detail_cache.py @@ -0,0 +1,75 @@ +"""342-3 세부 유역 저장본(부록 4 R15) — 열쇠가 같으면 다시 나누지 않음. + +- 열쇠 = 관 목록 · 노선(평면 · 종단 Z) · 분석 산출물 · 강우표 · 구조물 정의 자국. +- 같은 열쇠 = _build 안 부름 · 관 목록은 사본 · 무엇이든 바뀌면 다시 셈. +- [저장] 뒤 파일 한 벌 — 서버를 다시 켜도(메모리 비움) GET 열쇠로 읽힘. +""" + +import os +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from B04_PreProcess import B04_PreProcess_Router_Basins as basins +from common_util.common_util_drainage_context import DrainageContext +from common_util.common_util_drainage_detail import DrainageDetail +from common_util.common_util_drainage_pipes import PipePoint +from common_util.common_util_route_geometry import RouteVertex + + +@pytest.fixture +def setup(tmp_path, monkeypatch): + monkeypatch.setattr(basins, "drainage_dir", lambda stored: tmp_path) + basins._DETAIL_CACHE.clear() + calls = SimpleNamespace(count=0) + + def build(stored_path, context, points): + calls.count += 1 + chainages = [p.chainage_m for p in points] if points else [50.0] + return DrainageDetail(), [PipePoint(chainage_m=c, source="stream") for c in chainages] + + monkeypatch.setattr(basins, "_build", build) + (tmp_path / "03_road_routing.npz").write_bytes(b"grid") + context = DrainageContext( + stored_path="x", + project_root=Path("."), + vertices=[RouteVertex(0.0, 0.0, 100.0, 0.0), RouteVertex(300.0, 0.0, 90.0, 300.0)], + ) + return tmp_path, context, calls + + +def test_same_key_reuses_and_copies(setup): + _, context, calls = setup + points = [PipePoint(chainage_m=120.0, source="user")] + _, first = basins._build_cached(context, points) + first[0].x = 999.0 # 저장이 좌표를 채워도 저장본은 그대로 + _, second = basins._build_cached(context, points) + assert calls.count == 1 + assert second[0].x is None + + +def test_any_input_change_recomputes(setup): + tmp_path, context, calls = setup + points = [PipePoint(chainage_m=120.0, source="user")] + basins._build_cached(context, points) + basins._build_cached(context, [PipePoint(chainage_m=121.0, source="user")]) # 관 자리 + context.vertices[1].z = 91.0 # 계획고(종단 Z) → 관 깊이 + basins._build_cached(context, points) + routing = tmp_path / "03_road_routing.npz" + routing.write_bytes(b"grid-2") # 분석 다시 돎 + os.utime(routing, ns=(1, 1)) + basins._build_cached(context, points) + assert calls.count == 4 + + +def test_saved_file_survives_restart(setup): + tmp_path, context, calls = setup + stored = [PipePoint(chainage_m=120.0, source="stream")] + detail, carried = basins._build(context.stored_path, context, stored) + basins._save_detail_cache(context, stored, detail, carried) + assert (tmp_path / basins.DETAIL_CACHE_FILENAME).exists() + basins._DETAIL_CACHE.clear() # 서버 다시 켬 + _, again = basins._build_cached(context, stored) + assert calls.count == 1 + assert [p.chainage_m for p in again] == [120.0] diff --git a/resources/tester/test_342_3_drainage_stale.py b/resources/tester/test_342_3_drainage_stale.py index 27fac0b7d..18d096861 100644 --- a/resources/tester/test_342_3_drainage_stale.py +++ b/resources/tester/test_342_3_drainage_stale.py @@ -57,6 +57,9 @@ def _patch(monkeypatch, analysis_signature, stored): return SimpleNamespace(pipes=[], basins=["유역"]), points or [] monkeypatch.setattr(basins, "_build", build) + monkeypatch.setattr( + basins, "_build_cached", lambda context, points: build("x", context, points) + ) return built From 48ddca36a29d42cc73ea04afc538cf26f2b8f05f Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 16 Oct 2026 11:15:22 +0900 Subject: [PATCH 16/18] =?UTF-8?q?fix(=EC=A2=85=ED=9A=A1=EB=8B=A8):=20342-1?= =?UTF-8?q?=20=EA=B3=84=ED=9A=8D=EC=84=A0=20=ED=95=9C=20=EB=B2=8C=20=C2=B7?= =?UTF-8?q?=20=EB=82=A8=EC=9D=80=20=EB=82=A1=EC=9D=8C=20=ED=91=9C=EC=8B=9C?= =?UTF-8?q?=20=C2=B7=20=ED=9D=99=20=EB=B0=B0=EB=B6=84=20=EC=97=B4=EC=87=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 횡단 다시 셈이 화면이 쥔 선(측점 한 벌 · 관 지점 · 옮긴 꼭짓점)을 받음 — 종단도가 선을 맡기고 B06 진입 정합 · 3D [업데이트] 앞 다시 셈도 같은 선 · 낡음 판정도 그 선 기준(staleAgainstScreen) - 다시 셈 뒤에도 낡은 측점이 남으면 유토곡선은 그리고 그 측점만 표시 · 콘솔 경고 한 줄 · 흙 배분도 건너뛰지 않음(09-03 「새 값만 보여주기」 바꿈 · 사용자 확정 10-16) - 흙 배분: 실패한 열쇠 다시 보냄 · 받은 열쇠(누가토량 결과 전체)는 다시 안 물음 · 서버도 같은 열쇠 기억 · 묶음 실패는 500 - 옛 시험 셋 새 동작으로 맞춤 · 서버 열쇠 시험 더함 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QfHKQs7J3hQELKMWQMUJEy --- A00_Common/design/design_profile.ts | 8 +- B05_Profile/B05_Profile_Api_HaulPlan.ts | 34 +++- .../B05_Profile_UI_Profile_MassHaul.ts | 12 +- B05_Profile/B05_Profile_UI_Profile_Preview.ts | 41 +++- B05_Profile/B05_Profile_UI_Profile_Render.ts | 11 +- B06_Section/B06_Section_Cross_Refresh.ts | 177 +++++++++++------- B06_Section/B06_Section_Router_HaulPlan.py | 34 +++- .../B06_Section_UI_Page_Design_Sync.ts | 6 +- B06_Section/B06_Section_UI_Section_Common.ts | 5 + resources/tester/helper_317_stale_preview.cjs | 2 + resources/tester/test_317_3d_update.py | 5 +- resources/tester/test_342_1_haul_plan_key.py | 60 ++++++ .../tester/test_b05_stale_design_sync.py | 3 +- 13 files changed, 290 insertions(+), 108 deletions(-) create mode 100644 resources/tester/test_342_1_haul_plan_key.py diff --git a/A00_Common/design/design_profile.ts b/A00_Common/design/design_profile.ts index 432d57736..84fe62bc7 100644 --- a/A00_Common/design/design_profile.ts +++ b/A00_Common/design/design_profile.ts @@ -342,9 +342,8 @@ export function createDesignProfile(ctx: DesignProfileContext) { history.record(); } - /** 계획선 측점 줄 = 측점 한 벌(서버 일반 측점 + 지금 구조물 측점 · 272) — 표 · ▲▼ 가 옛 자리 - * 구조물 측점(옮긴 관 · 서버가 다시 맞추기 전)을 안 봄 · 그래프 · 3D 와 같은 목록. 사라진 구조물 - * 측점의 꼭짓점은 [저장] 때 서버와 같게 옮기거나 지움(`followMovedNodes`). 늘 저장분에서 셈. */ + /** 계획선 측점 줄 = 측점 한 벌(서버 일반 + 지금 구조물 측점 · 272) — 표 · ▲▼ · 그래프 · 3D · 횡단 다시 셈(342-1)이 + * 같은 목록 · 사라진 구조물 측점의 꼭짓점은 서버와 같게 옮기거나 지움(`followMovedNodes`) · 늘 저장분에서 셈. */ function listed(): AlignmentBase | null { if (!storedBase || !detail) return storedBase; const server = detail.longitudinal.stations; @@ -393,10 +392,11 @@ export function createDesignProfile(ctx: DesignProfileContext) { detail: () => detail, routeId: () => routeId, edits: () => store.edits(), + alignment: () => alignment, debounceMs: () => (root.dataset.view === "cross" ? 120 : CROSS_PREVIEW_DEBOUNCE_MS), onApplied: (updated) => { draw(); - callbacks?.onCrossDesignsUpdated?.(updated); + if (updated.length) callbacks?.onCrossDesignsUpdated?.(updated); }, }); const scheduleCrossPreview = (): void => crossPreview.schedule(); diff --git a/B05_Profile/B05_Profile_Api_HaulPlan.ts b/B05_Profile/B05_Profile_Api_HaulPlan.ts index 60a15c8ef..16595b035 100644 --- a/B05_Profile/B05_Profile_Api_HaulPlan.ts +++ b/B05_Profile/B05_Profile_Api_HaulPlan.ts @@ -21,6 +21,8 @@ export type HaulPlanPayload = HaulPlan | null; const SETTLE_MS = 400; /** 배분 계산 대기 상한 — Node 실행 200ms 대라 넉넉히 잡는다. */ const TIMEOUT_MS = 20000; +/** 열쇠별로 받아 둔 배분 — 되돌리기 · 보기 오감처럼 같은 누가토량이 다시 오면 묻지 않음(342-1). */ +const KEPT_PLANS = 8; export interface HaulPlanPrefetch { /** 새 누가토량 결과가 나왔음을 알린다 — 잠잠해지면 서버에 물어본다. */ @@ -48,23 +50,28 @@ export function createHaulPlanPrefetch( let sentKey: string | null = null; /** 받은 값을 화면에 반영하려고 다시 그리는 중 — 그 그리기가 거는 요청은 무시한다. */ let applying = false; + /** 받은 배분 — 열쇠(누가토량 결과 전체) → 배분 · 오래된 것부터 버림. */ + const kept = new Map(); /** - * 입력이 달라졌는지 가리는 짧은 표식. 응답이 오면 곡선을 다시 그리고, 그리기가 다시 + * 입력이 달라졌는지 가리는 표식. 응답이 오면 곡선을 다시 그리고, 그리기가 다시 * `schedule` 을 부르므로 **표식이 없으면 요청이 끝없이 되돌아온다**(2026-09-06 실측). - * 누가토량이 바뀌면 마지막 누계와 측점 수 중 하나는 반드시 달라진다. + * 누가토량 결과 전체 — 끝 · 가운데 누계만 보던 옛 표식은 사이 측점이 바뀌어도 같았다(342-1). */ function keyOf(result: unknown): string { - const points = (result as { points?: Array<{ cumulative_volume_m3?: number }> })?.points; + const points = (result as { points?: unknown[] })?.points; if (!Array.isArray(points) || !points.length) return "empty"; - const last = points[points.length - 1]?.cumulative_volume_m3 ?? 0; - const mid = points[Math.floor(points.length / 2)]?.cumulative_volume_m3 ?? 0; - return `${points.length}:${last.toFixed(3)}:${mid.toFixed(3)}`; + return JSON.stringify(points); } - async function send(result: unknown, seq: number): Promise { + /** 못 받은 열쇠는 잊는다 — 다음 그리기가 같은 값으로 다시 보냄(342-1). */ + function forget(seq: number): void { + if (seq === sequence) sentKey = null; + } + + async function send(result: unknown, seq: number, key: string): Promise { const route = routeId(); - if (!route) return; + if (!route) return forget(seq); const controller = new AbortController(); const abort = window.setTimeout(() => controller.abort(), TIMEOUT_MS); try { @@ -78,11 +85,14 @@ export function createHaulPlanPrefetch( signal: controller.signal, }, ); - if (!response.ok) return; + if (!response.ok) return forget(seq); const payload = (await response.json()) as { haul_plan?: HaulPlanPayload }; // 늦게 온 응답은 버린다 — 그 사이 사용자가 계획고를 더 만졌을 수 있다. if (seq !== sequence) return; plan = payload.haul_plan ?? null; + kept.delete(key); + kept.set(key, plan); + if (kept.size > KEPT_PLANS) kept.delete(kept.keys().next().value as string); // 다시 그리기가 `schedule` 을 되부르므로 그 한 바퀴를 막는다. 표식(`sentKey`)만으로는 // 부족했다 — 반영 뒤 누계가 아주 조금 달라지는 경로가 있어 2~3바퀴가 더 돌았다 // (2026-09-06 보조 창 실측: 편집이 멈춘 뒤 2.8~6.3초에 걸쳐 3건). @@ -94,6 +104,7 @@ export function createHaulPlanPrefetch( } } catch { // 배분을 못 받아도 곡선 자체는 그대로 보인다 — 화면을 막지 않는다. + forget(seq); } finally { window.clearTimeout(abort); } @@ -109,7 +120,10 @@ export function createHaulPlanPrefetch( sequence += 1; const seq = sequence; window.clearTimeout(timer); - timer = window.setTimeout(() => void send(pending, seq), SETTLE_MS); + // 받아 둔 열쇠면 묻지 않고 그대로 — 그리는 쪽은 이 뒤에 `current()` 를 읽는다. + const known = kept.get(key); + if (known !== undefined) return void (plan = known); + timer = window.setTimeout(() => void send(pending, seq, key), SETTLE_MS); }, current: () => plan, dispose() { diff --git a/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts b/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts index d219230b3..d04fbde36 100644 --- a/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts +++ b/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts @@ -141,6 +141,8 @@ export interface RouteMassHaulDrawParams { * 「새 값만 보여주기」). Panel이 `hasStaleDesigns`로 판정해 넘긴다. */ pendingRecalc?: boolean; + /** 다시 셈 뒤에도 남은 낡은 측점 — 곡선은 그리고 이 측점만 표시(342-1 · 사용자 확정 10-16). */ + staleChainages?: number[]; } export interface RouteMassHaulPanel { @@ -379,11 +381,19 @@ export function createRouteMassHaulPanel( // 안에서 끝나 수십 ms면 끝나므로, 그동안 **직전 곡선을 그대로 두고** 상태줄로만 // 알린다. 아직 한 번도 못 그렸을 때만 문구로 자리를 채운다. if (params.pendingRecalc) { + pendingChip.textContent = "다시 계산 중…"; if (drawnOnce) pendingChip.hidden = false; else note("횡단을 지금 계획선에 맞춰 다시 계산하는 중입니다."); return; } - pendingChip.hidden = true; + const stale = params.staleChainages ?? []; + pendingChip.hidden = !stale.length; + pendingChip.textContent = stale.length + ? `낡은 측점 ${stale.length}곳 · ${stale + .slice(0, 5) + .map((chainage) => `${chainage.toFixed(2)} m`) + .join(" · ")}${stale.length > 5 ? " …" : ""}` + : "다시 계산 중…"; // B06과 같은 계산 — 횡단 기준(정식)과 종단 기준(개략 비교)을 함께 낸다. // 결과를 아껴 두지 **않는다** — 횡단 설계가 제자리에서 갈리므로 객체 동일성으로는 // 바뀐 것을 못 잰다(2026-09-04 캐시를 넣었다가 곡선이 안 따라와 되돌림). diff --git a/B05_Profile/B05_Profile_UI_Profile_Preview.ts b/B05_Profile/B05_Profile_UI_Profile_Preview.ts index b1e9db86a..4ed712a3b 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Preview.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Preview.ts @@ -9,8 +9,9 @@ * 끌기 중에는 계속 호출되므로 디바운스하고, 늦게 온 응답은 seq 비교로 버린다. * ========================================================================== */ -import type { AlignmentEdits } from "./B05_Profile_UI_Profile_Alignment"; -import { refreshCrossDesigns } from "../B06_Section/B06_Section_Cross_Refresh"; +import type { AlignmentEdits, ProfileAlignment } from "./B05_Profile_UI_Profile_Alignment"; +import { holdScreenAlignment, refreshCrossDesigns } from "../B06_Section/B06_Section_Cross_Refresh"; +import { staleChainages } from "../B06_Section/B06_Section_UI_Section_Common"; import type { SectionDetailResponse } from "../B06_Section/B06_Section_Api_Fetch"; export interface CrossPreviewContext { @@ -18,6 +19,8 @@ export interface CrossPreviewContext { detail: () => SectionDetailResponse | null; routeId: () => number | null; edits: () => AlignmentEdits; + /** 화면이 쥔 계획선(측점 한 벌) — 다시 셈 · B06 · 3D 가 같은 선을 받음(342-1). */ + alignment: () => ProfileAlignment | null; /** 부를 때마다 읽음 — 보기마다 다름. */ debounceMs: () => number; /** 반영이 끝난 뒤 다시 그린다 — 다시 계산한 측점 누가거리를 넘긴다(횡단 카드 갱신용). */ @@ -27,8 +30,12 @@ export interface CrossPreviewContext { export interface CrossPreview { /** 편집이 있을 때마다 부른다 — 마지막 값만 서버로 나간다. */ schedule: () => void; - /** 그리기가 잰 낡음 지문 — 새 지문일 때만 `schedule`(같은 낡음 되풀이 · 서버 폴백 반복 막음 · 317-1). */ - scheduleIfStale: (key: string) => void; + /** + * 그리기가 잰 낡음 지문 — 새 지문일 때만 `schedule`(같은 낡음 되풀이 · 서버 폴백 반복 막음 · 317-1). + * 돌려주는 값 = 다시 셈을 기다리는 중인가. 다시 셈 뒤에도 남은 낡음은 false — 곡선은 그리고 + * 그 측점만 표시(342-1 · 09-03 「새 값만 보여주기」를 바꿈 · 사용자 확정 10-16). + */ + scheduleIfStale: (key: string) => boolean; /** 패널을 걷을 때 대기 중인 요청 타이머를 끈다. */ dispose: () => void; } @@ -37,19 +44,34 @@ export function createCrossPreview(ctx: CrossPreviewContext): CrossPreview { let timer = 0; let seq = 0; let lastStale = ""; + /** 다시 셈 예약 · 진행 중 — 이 동안의 낡음은 「다시 계산 중」. */ + let busy = false; + let warned = ""; + const release = holdScreenAlignment((routeId) => + routeId === ctx.routeId() ? ctx.alignment() : null, + ); const preview: CrossPreview = { scheduleIfStale(key) { if (key && key !== lastStale) preview.schedule(); + else if (key && !busy && key !== warned) { + warned = key; + console.warn( + `[횡단] 다시 셈 뒤에도 낡은 측점 ${staleChainages(key).length}곳 — 곡선은 그리고 그 측점만 표시`, + staleChainages(key), + ); + } lastStale = key; + return key !== "" && busy; }, schedule() { if (!ctx.detail() || ctx.routeId() === null) return; + busy = true; window.clearTimeout(timer); timer = window.setTimeout(() => { const detail = ctx.detail(); const routeId = ctx.routeId(); - if (!detail || routeId === null) return; + if (!detail || routeId === null) return void (busy = false); const current = (seq += 1); // 재계산은 B06과 **같은 창구**를 쓴다 — 표준 단면값·암 경계 오프셋이 빠지면 서버가 // 다른 설계를 그려 같은 데이터가 두 화면에서 다른 값이 된다(2026-09-03 일원화). @@ -60,19 +82,26 @@ export function createCrossPreview(ctx: CrossPreviewContext): CrossPreview { routeId, detail, edits: ctx.edits(), + alignment: ctx.alignment(), // 늦게 온 옛 응답이 새 설계를 덮지 않게 반영 직전에 한 번 더 확인한다. shouldApply: isCurrent, }) .then((updated) => { - if (updated.length) ctx.onApplied(updated); + if (current === seq) busy = false; + // 남은 낡음이 있으면 바뀐 것이 없어도 다시 그림 — 「다시 계산 중」에 멈추지 않게(342-1) + if (updated.length || lastStale) ctx.onApplied(updated); }) .catch(() => { /* 프리뷰 실패는 무시 — 화면의 계획선은 그대로 두고 다음 편집에서 다시 시도한다. */ + if (current !== seq) return; + busy = false; + if (lastStale) ctx.onApplied([]); }); }, ctx.debounceMs()); }, dispose() { window.clearTimeout(timer); + release(); }, }; return preview; diff --git a/B05_Profile/B05_Profile_UI_Profile_Render.ts b/B05_Profile/B05_Profile_UI_Profile_Render.ts index af784b046..60bca4a84 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Render.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Render.ts @@ -15,6 +15,7 @@ import { } from "../B06_Section/B06_Section_UI_Longitudinal"; import { LONG_PAD, + staleChainages, staleKey, windowElevationRange, } from "../B06_Section/B06_Section_UI_Section_Common"; @@ -59,8 +60,9 @@ import type { RouteProfilePanelCallbacks } from "./B05_Profile_UI_Profile_Panel" /** 본체가 들고 있는 상태를 읽고, 그리는 도중 바뀌는 것만 되돌려 주는 통로. */ export interface ProfileRenderContext { body: HTMLElement; - /** 그릴 때 잰 낡은 횡단 지문("" = 모두 맞음) — 패널이 새 지문일 때만 다시 셈(317-1). */ - onStaleDesigns?: (key: string) => void; + /** 그릴 때 잰 낡은 횡단 지문("" = 모두 맞음) — 패널이 새 지문일 때만 다시 셈(317-1). + * 돌려주는 값 = 다시 셈을 기다리는 중(false 면 남은 낡음 — 곡선은 그리고 그 측점만 표시 · 342-1). */ + onStaleDesigns?: (key: string) => boolean; callbacks: RouteProfilePanelCallbacks | undefined; massHaul: { draw: (params: RouteMassHaulDrawParams) => void }; tableOverlay: { @@ -230,7 +232,7 @@ export function renderProfile(ctx: ProfileRenderContext): void { longitudinal: { design_profiles: designProfiles }, cross_sections: detail.cross_sections, }); - ctx.onStaleDesigns?.(stale); + const pending = ctx.onStaleDesigns?.(stale) ?? stale !== ""; // 그래프에는 비정규 측점을 일반 측점처럼(세로선+라벨) 섞어 넣는다. const graphData = normalizedLongitudinal(longitudinal); // 측점 한 벌(272) — 서버의 구조물 측점은 사이드바 목록(관을 옮기면 따라옴)이 갈음하고, 같은 m @@ -424,7 +426,8 @@ export function renderProfile(ctx: ProfileRenderContext): void { // 비교 대상은 **편집이 반영된 계획선**(`designProfiles`)이다. 저장분 // (`detail.longitudinal.design_profiles`)과 견주면 편집 중에는 영원히 어긋난 것으로 // 나와 곡선이 계속 빈 화면이 된다(2026-09-03 사용자 보고). - pendingRecalc: stale !== "", + pendingRecalc: pending, + staleChainages: pending ? [] : staleChainages(stale), axis: { maxChainageM: maxChainageOf(longitudinal), // 종단 그래프의 `chainageMapper`와 정확히 같은 매핑이 되도록 반 칸 들여쓰기를 diff --git a/B06_Section/B06_Section_Cross_Refresh.ts b/B06_Section/B06_Section_Cross_Refresh.ts index dfe0e8ad5..e58b1b3e0 100644 --- a/B06_Section/B06_Section_Cross_Refresh.ts +++ b/B06_Section/B06_Section_Cross_Refresh.ts @@ -25,7 +25,7 @@ * ========================================================================== */ import { computeCrossDesign } from "@util/common_util_cross_design"; -import type { StandardCrossSectionSpec } from "@util/common_util_cross_design"; +import type { CrossDesignOptions, StandardCrossSectionSpec } from "@util/common_util_cross_design"; import { previewCrossDesigns } from "./B06_Section_Api_Fetch"; import type { CrossSection, SectionDetailResponse } from "./B06_Section_Api_Fetch"; import { bermSpecAt, readBermSpans, readRockBoundarySession } from "./B06_Section_UI_Page_Persist"; @@ -46,6 +46,7 @@ import { planElevationAt, toAlignmentBase, } from "../B05_Profile/B05_Profile_UI_Profile_Alignment"; +import type { ProfileAlignment } from "../B05_Profile/B05_Profile_UI_Profile_Alignment"; import { readAlignment, toDesignProfile } from "../B05_Profile/B05_Profile_UI_Profile_Data"; import { readState } from "../A00_Common/b_page_state"; import { readAlignmentDraft } from "../B05_Profile/B05_Profile_UI_Profile_Edit"; @@ -64,6 +65,8 @@ export interface CrossRefreshInput { /** 제자리 갱신 대상 — 공유 캐시가 들고 있는 그 객체여야 두 화면이 같이 따라온다. */ detail: SectionDetailResponse; edits: CrossRefreshEdits; + /** 화면이 쥔 계획선(측점 한 벌 · 342-1) — 없으면 종단도가 맡긴 선 → 저장분 + `edits`. */ + alignment?: ProfileAlignment | null; /** * 결과를 **아직 써도 되는지** 묻는다(false면 반영하지 않는다). 로컬 계산은 즉시 끝나 * 늦은 응답이 없지만, 옛 데이터 폴백(서버 프리뷰)에서는 여전히 문지기가 필요하다. @@ -149,10 +152,33 @@ function preserveUserFields( } /** - * 전 측점 횡단을 현재 계획선으로 다시 계산해 `detail.cross_sections[].design`을 제자리 교체한다. - * 돌려주는 값은 실제로 바뀐 측점의 누가거리 목록 — 호출한 쪽이 그 카드만 다시 그리면 된다. - * 계획선 ▲ 한 번에 값이 바뀌는 측점은 몇 개뿐 — 전부 돌려주면 카드를 다 갈아 끼워 멈춤(54-22). + * 계획선 한 벌(342-1) — 종단도가 화면에 그린 선(서버 측점 대신 관 지점 · 옮긴 꼭짓점)을 맡겨 두는 자리. + * 다시 셈이 저장분 선으로 계획고를 적으면 그리기 판정(화면 선)과 mm 단위로 갈려 늘 낡음이 됐다 + * (유토곡선 「다시 계산하는 중」 머묾 · 341 부록 2 4장). B05 · B06 · 3D 가 모두 이 선을 받는다. */ +type ScreenLine = (routeId: number) => ProfileAlignment | null; +let screenLine: ScreenLine | null = null; + +/** 종단도가 자기 선을 맡김 — 돌려준 함수로 거둠(자기 것일 때만). */ +export function holdScreenAlignment(line: ScreenLine): () => void { + screenLine = line; + return () => { + if (screenLine === line) screenLine = null; + }; +} + +/** 낡음 판정을 화면 선 기준으로 — 저장분 선끼리 견주면 화면과 반대 판정이 났다(341 부록 2 4장). */ +export function staleAgainstScreen(detail: SectionDetailResponse, routeId: number): boolean { + const screen = screenLine?.(routeId); + if (!screen) return hasStaleDesigns(detail); + return hasStaleDesigns({ + longitudinal: { + design_profiles: [toDesignProfile(screen, detail.longitudinal.design_profiles?.[0])], + }, + cross_sections: detail.cross_sections, + }); +} + /** * 낡은 설계만 다시 셈 — B05 [3D 업데이트]가 예상형상을 짓기 전에 부름(317-1). * 편집 = 세션 계획선 초안 → 없으면 저장분 · 초안이 있으면 늘 맞춤(B06 `reconcileStaleDesigns` 와 같은 규칙). @@ -161,7 +187,7 @@ export async function refreshStaleDesigns( input: Omit, ): Promise { const draft = readAlignmentDraft(input.routeId); - if (!draft && !hasStaleDesigns(input.detail)) return []; + if (!draft && !staleAgainstScreen(input.detail, input.routeId)) return []; const saved = ( input.detail.longitudinal.profile_alignment as | { @@ -179,6 +205,11 @@ export async function refreshStaleDesigns( return refreshCrossDesigns({ ...input, edits }); } +/** + * 전 측점 횡단을 현재 계획선으로 다시 계산해 `detail.cross_sections[].design`을 제자리 교체한다. + * 돌려주는 값은 실제로 바뀐 측점의 누가거리 목록 — 호출한 쪽이 그 카드만 다시 그리면 된다. + * 계획선 ▲ 한 번에 값이 바뀌는 측점은 몇 개뿐 — 전부 돌려주면 카드를 다 갈아 끼워 멈춤(54-22). + */ export async function refreshCrossDesigns(input: CrossRefreshInput): Promise { const sections = input.detail.cross_sections; const before = new Map(sections.map((section) => [section, JSON.stringify(section.design)])); @@ -290,14 +321,19 @@ function readCutSlopeRatios(projectId: string, routeId: number): Map 0 ? Number(section.ford?.overflow_depth_m) : 0, + // 곡선부 확폭 입력 — 측점 기록에 실려 온다(서버 엔진과 같은 값, 2026-09-06). + planRadiusM: section.plan_radius_m ?? null, + curveOuterSide: + section.curve_outer_side === "left" || section.curve_outer_side === "right" + ? section.curve_outer_side + : null, + curveWideningM: section.curve_widening_m ?? null, + shoulderExtendLeftM: extendLeft, + shoulderExtendRightM: extendRight, + // 사토장 — **저장분 폭을 그대로 잇는다**. 폭은 구간 용량에서 서버가 정한 값이라 + // 브라우저가 다시 풀지 않는다(다시 풀면 그 측점만 폭이 달라져 작업장 모양이 깨진다). + // 이어 붙이지 않으면 계획선을 만질 때마다 사토장이 그림에서 사라진다. + spoilFill: + typeof design.spoil_fill_width_m === "number" && + design.spoil_fill_width_m > 0 && + (design.spoil_fill_side === "left" || design.spoil_fill_side === "right") + ? { + side: design.spoil_fill_side, + widthM: design.spoil_fill_width_m, + slopeRatioN: null, + } + : null, + }; let next; try { - next = computeCrossDesign( - section.samples ?? [], - planElevationAt(alignment, section.chainage_m), - { - groundType: - choice?.ground_type ?? - (typeof design.ground_type === "string" ? design.ground_type : "ripping_rock"), - sectionMode: - choice?.section_mode ?? - (typeof design.section_mode === "string" ? design.section_mode : "left_cut"), - ditchSide: - choice?.ditch_side ?? - (typeof design.ditch_side === "string" ? design.ditch_side : null), - // 저장분은 측구가 없으면 `ditch_type: null` 이다 — 서버와 같이 기본형으로 되돌린다. - ditchType: - choice?.ditch_type ?? - (typeof design.ditch_type === "string" ? design.ditch_type : "standard"), - paved: pavedBySpecs(section), - // 물넘이 자리 = 측구 없음 · 지대 낮은 쪽 한 경사(286-2 · 287-4 · 서버 `tilt_ranges` 짝 = 얹힌 제원) - tilted: Boolean(section.ford_pavement || section.underdrain), - tiltPct: section.ford_pavement?.slope_pct ?? section.underdrain?.slope_pct ?? null, - standard, - berm: bermAt(section.chainage_m, design), - rockBoundaryOffsetM, - cutSlopeRatio: cutSlopeAt(section.chainage_m, design), - twoStageSlope: choice?.two_stage_slope ?? Boolean(design.two_stage_slope), - // ⚠ **선택과 결과를 갈라 넘긴다**(2026-09-09). 선택이 있으면 그것을 따르고, - // 없으면 옛 저장분의 결과를 **자동값과 다를 때만** 선택으로 살린다. - ditchChoice: choice - ? (choice.ditch_choice ?? null) - : typeof design.ditch_choice === "boolean" - ? design.ditch_choice - : null, - ditchEnabled: typeof design.ditch_enabled === "boolean" ? design.ditch_enabled : null, - // 세월교 월류 하강 = 그 측점에 얹힌 세월교 제원(초안 · 저장분 같은 자리 · 서버 `ford_drop_at` 짝 · 314) - surfaceDropM: - Number(section.ford?.overflow_depth_m) > 0 ? Number(section.ford?.overflow_depth_m) : 0, - // 곡선부 확폭 입력 — 측점 기록에 실려 온다(서버 엔진과 같은 값, 2026-09-06). - planRadiusM: section.plan_radius_m ?? null, - curveOuterSide: - section.curve_outer_side === "left" || section.curve_outer_side === "right" - ? section.curve_outer_side - : null, - curveWideningM: section.curve_widening_m ?? null, - shoulderExtendLeftM: extendLeft, - shoulderExtendRightM: extendRight, - // 사토장 — **저장분 폭을 그대로 잇는다**. 폭은 구간 용량에서 서버가 정한 값이라 - // 브라우저가 다시 풀지 않는다(다시 풀면 그 측점만 폭이 달라져 작업장 모양이 깨진다). - // 이어 붙이지 않으면 계획선을 만질 때마다 사토장이 그림에서 사라진다. - spoilFill: - typeof design.spoil_fill_width_m === "number" && - design.spoil_fill_width_m > 0 && - (design.spoil_fill_side === "left" || design.spoil_fill_side === "right") - ? { - side: design.spoil_fill_side, - widthM: design.spoil_fill_width_m, - slopeRatioN: null, - } - : null, - }, - ); + next = computeCrossDesign(section.samples ?? [], elevation, options); } catch { continue; // 샘플 부족·값 손상 측점은 건너뛴다(서버 엔진과 같은 태도). } diff --git a/B06_Section/B06_Section_Router_HaulPlan.py b/B06_Section/B06_Section_Router_HaulPlan.py index fcb3c5854..b3643bad6 100644 --- a/B06_Section/B06_Section_Router_HaulPlan.py +++ b/B06_Section/B06_Section_Router_HaulPlan.py @@ -15,7 +15,10 @@ from __future__ import annotations import asyncio +import hashlib +import json import logging +from collections import OrderedDict from typing import Any from uuid import UUID @@ -37,6 +40,16 @@ router = APIRouter(prefix="/api/projects", tags=["B06 Profile Cross"]) _NPM_SCRIPT = "build:server-calc" # 측점 수 상한 — 정상 노선은 수백 곳이다. 그보다 크면 비정상 요청으로 본다. _MAX_POINTS = 5000 +# 열쇠(누가토량 결과 + 계수 · 장비 경계) → 배분 — 같은 열쇠면 Node 를 다시 안 띄움 +# (342-1 · 부록 4 R25). ponytail: 프로세스 메모리 · 최근 몇 벌만 — 재시작 뒤 첫 열기는 +# 다시 셈(_derived 저장본은 343 틀 뒤). +_KEPT: OrderedDict[str, Any] = OrderedDict() +_KEPT_MAX = 32 + + +def _plan_key(result: dict[str, Any], context: Any) -> str: + body = json.dumps({"result": result, "context": context}, sort_keys=True, default=str) + return hashlib.sha1(body.encode("utf-8")).hexdigest() @router.post("/{project_id}/sections/{route_id}/haul-plan", response_model=None) @@ -69,15 +82,17 @@ async def compute_haul_plan( factors = await conversion_factors_for(project_id) # 장비 거리 경계도 프로젝트 값으로 — 도쟈 한계거리를 고쳤으면 배분이 그 값으로 선다. limits = await haul_limits_for(project_id) + context = _mass_haul_context(haul_inputs, factors, limits) + key = _plan_key(result, context) + if key in _KEPT: + _KEPT.move_to_end(key) + return JSONResponse(content={"status": "success", "haul_plan": _KEPT[key]}) try: output = await asyncio.to_thread( run_bundle_json, BUNDLE, _NPM_SCRIPT, - { - "haul_plan_for": result, - "context": _mass_haul_context(haul_inputs, factors, limits), - }, + {"haul_plan_for": result, "context": context}, ) except Exception: logger.exception("유토 배분 계산 실패: project_id=%s route_id=%s", project_id, route_id) @@ -85,5 +100,14 @@ async def compute_haul_plan( status_code=500, content={"status": "error", "message": "유토 배분 계산에 실패했습니다."}, ) - plan = output.get("haul_plan") if isinstance(output, dict) else None + # 묶음 실패(None)는 실패로 — 성공 + 빈 배분으로 주면 화면이 그 열쇠를 다시 안 물었다(342-1). + if not isinstance(output, dict): + return JSONResponse( + status_code=500, + content={"status": "error", "message": "유토 배분 계산에 실패했습니다."}, + ) + plan = output.get("haul_plan") + _KEPT[key] = plan + if len(_KEPT) > _KEPT_MAX: + _KEPT.popitem(last=False) return JSONResponse(content={"status": "success", "haul_plan": plan}) diff --git a/B06_Section/B06_Section_UI_Page_Design_Sync.ts b/B06_Section/B06_Section_UI_Page_Design_Sync.ts index f1836b9c0..b406d57c9 100644 --- a/B06_Section/B06_Section_UI_Page_Design_Sync.ts +++ b/B06_Section/B06_Section_UI_Page_Design_Sync.ts @@ -15,14 +15,13 @@ import { writeShoulderExtend, } from "./B06_Section_Cross_Design_Session"; import { showToast } from "@ui/ui_template_elements"; -import { refreshCrossDesigns } from "./B06_Section_Cross_Refresh"; +import { refreshCrossDesigns, staleAgainstScreen } from "./B06_Section_Cross_Refresh"; import { bermSpansFromStructures, readBermSpans, writeBermSpans, } from "./B06_Section_UI_Page_Persist"; import { readAlignmentDraft } from "../B05_Profile/B05_Profile_UI_Profile_Edit"; -import { hasStaleDesigns } from "./B06_Section_UI_Section_Common"; import { L } from "./B06_Section_UI_Page_Common"; import type { SectionDetailResponse } from "./B06_Section_Api_Fetch"; import type { CrossDesignChange, SectionViewController } from "./B06_Section_UI_Section_View"; @@ -167,7 +166,8 @@ export function createDesignSync(ctx: DesignSyncContext): DesignSyncController { // 저장분끼리는 늘 일치해 「낡지 않음」으로 나오는데, B05가 세션에 남긴 편집은 그 안에 // 없어 B06이 재계산을 통째로 건너뛰었다 — 같은 시점에 B05 절토 4,774.1㎥ ↔ B06 // 4,515.0㎥ 로 갈렸다(2026-09-03 실측). 재계산은 브라우저 안에서 끝나 값싸다. - if (!options?.force && !draft && !hasStaleDesigns(sectionDetail)) return; + // 판정 · 다시 셈 모두 종단도가 쥔 선(측점 한 벌) 기준 — 저장분 선끼리 견주면 화면과 갈림(342-1). + if (!options?.force && !draft && !staleAgainstScreen(sectionDetail, currentRouteId)) return; // 진입 정합은 화면을 잠그지 않는다 — 카드가 도착하는 대로 조용히 갱신된다 // (CLAUDE.md 5장). try { diff --git a/B06_Section/B06_Section_UI_Section_Common.ts b/B06_Section/B06_Section_UI_Section_Common.ts index 9fc75d028..3d8a3a94e 100644 --- a/B06_Section/B06_Section_UI_Section_Common.ts +++ b/B06_Section/B06_Section_UI_Section_Common.ts @@ -133,6 +133,11 @@ export function staleKey( .join("|"); } +/** 낡음 지문(`staleKey`)에 든 측점 누가거리 — 다시 셈 뒤에도 남은 측점을 표시할 때(342-1). */ +export function staleChainages(key: string): number[] { + return key ? key.split("|").map((entry) => Number(entry.split(":")[0])) : []; +} + interface StaleCandidate { chainage_m: number; // 측점에 얹힌 구조물 제원 — 저장분 설계값(포장 · 기울기 · 노면 하강)과 맞대 본다(314) diff --git a/resources/tester/helper_317_stale_preview.cjs b/resources/tester/helper_317_stale_preview.cjs index 2e2fe9ab6..3a8f936c9 100644 --- a/resources/tester/helper_317_stale_preview.cjs +++ b/resources/tester/helper_317_stale_preview.cjs @@ -15,6 +15,7 @@ const STUBS = { refreshCalls += 1; return []; }, + holdScreenAlignment: () => () => {}, // 화면 선 맡김(342-1) — 이 시험 몫 아님 }, }; globalThis.window = { setTimeout: (fn) => (fn(), 1), clearTimeout: () => {} }; @@ -63,6 +64,7 @@ const fordFresh = { ...fordStale, design: { ...fordStale.design, tilted: true } detail: () => detailOf([fordStale]), routeId: () => 1, edits: () => ({ station_offsets: {}, curve_radii: {} }), + alignment: () => null, debounceMs: () => 0, onApplied: () => {}, }); diff --git a/resources/tester/test_317_3d_update.py b/resources/tester/test_317_3d_update.py index c7d59d199..d4c20a1f8 100644 --- a/resources/tester/test_317_3d_update.py +++ b/resources/tester/test_317_3d_update.py @@ -56,8 +56,9 @@ def test_draw_and_button_wiring() -> None: panel = _read("A00_Common", "design", "design_profile.ts") assert "onStaleDesigns: crossPreview.scheduleIfStale" in panel render = _read("B05_Profile", "B05_Profile_UI_Profile_Render.ts") - assert "ctx.onStaleDesigns?.(stale);" in render - assert 'pendingRecalc: stale !== ""' in render + # 다시 셈 뒤에도 남은 낡음은 기다림 끝 — 곡선은 그리고 그 측점만 표시(342-1) + assert 'const pending = ctx.onStaleDesigns?.(stale) ?? stale !== "";' in render + assert "pendingRecalc: pending," in render corridor = _read("B05_Profile", "B05_Profile_UI_Corridor.ts") body = corridor.split("export async function refreshCorridor", 1)[1].split("\n}\n", 1)[0] assert "await refreshStaleDesigns({ projectId, routeId, detail })" in body diff --git a/resources/tester/test_342_1_haul_plan_key.py b/resources/tester/test_342_1_haul_plan_key.py new file mode 100644 index 000000000..9a916d7e1 --- /dev/null +++ b/resources/tester/test_342_1_haul_plan_key.py @@ -0,0 +1,60 @@ +# -*- coding: utf-8 -*- +"""342-1 흙 배분 서버 — 같은 열쇠(누가토량 결과 + 계수 · 장비 경계)면 Node 를 다시 안 띄움. + +묶음 실패는 500 — 성공 + 빈 배분으로 주면 화면이 그 열쇠를 다시 안 물었음. +""" + +import asyncio +import json +import sys +from pathlib import Path +from uuid import uuid4 + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from B06_Section import B06_Section_Router_HaulPlan as router # noqa: E402 + + +def _patch(monkeypatch, outputs: list) -> list: + calls: list = [] + + async def _none(_project_id): + return {} + + def _run(_bundle, _script, payload): + calls.append(payload) + return outputs.pop(0) + + monkeypatch.setattr(router, "haul_inputs_for", _none) + monkeypatch.setattr(router, "conversion_factors_for", _none) + monkeypatch.setattr(router, "haul_limits_for", _none) + monkeypatch.setattr(router, "_mass_haul_context", lambda *_: {"limits": []}) + monkeypatch.setattr(router, "run_bundle_json", _run) + router._KEPT.clear() + return calls + + +def _post(result: dict): + response = asyncio.run(router.compute_haul_plan(uuid4(), 7, {"result": result})) + return response.status_code, json.loads(response.body) + + +def test_같은_열쇠는_Node_한_번(monkeypatch): + calls = _patch(monkeypatch, [{"haul_plan": {"tag": "a"}}, {"haul_plan": {"tag": "b"}}]) + a = {"points": [{"cumulative_volume_m3": 1.0}]} + b = {"points": [{"cumulative_volume_m3": 2.0}]} + assert _post(a) == (200, {"status": "success", "haul_plan": {"tag": "a"}}) + assert _post(a)[1]["haul_plan"] == {"tag": "a"} + assert len(calls) == 1 + assert _post(b)[1]["haul_plan"] == {"tag": "b"} + assert len(calls) == 2 + + +def test_묶음_실패는_500_기억_안_함(monkeypatch): + calls = _patch(monkeypatch, [None, {"haul_plan": None}]) + a = {"points": [{"cumulative_volume_m3": 1.0}]} + assert _post(a)[0] == 500 + assert _post(a) == (200, {"status": "success", "haul_plan": None}) + assert len(calls) == 2 diff --git a/resources/tester/test_b05_stale_design_sync.py b/resources/tester/test_b05_stale_design_sync.py index 6e8532e29..d9527e41b 100644 --- a/resources/tester/test_b05_stale_design_sync.py +++ b/resources/tester/test_b05_stale_design_sync.py @@ -99,6 +99,7 @@ def test_source_shared_rule_and_b05_entry_recompute(): assert "scheduleCrossPreview();" in B05_PANEL # B06: 같은 규칙 하나만 쓴다 — 옛 암 2단계 필드 누락 조건도 공용 유틸로 옮겼다 # (2026-09-03 일원화: 조건이 갈리면 같은 데이터가 두 화면에서 다른 값이 된다). - assert "hasStaleDesigns(sectionDetail)" in B06_PAGE + # 판정은 종단도가 쥔 화면 선 기준(342-1 · 공용 규칙 hasStaleDesigns 를 그 선으로 부름) + assert "staleAgainstScreen(sectionDetail, currentRouteId)" in B06_PAGE assert "two_stage_slope === undefined" in COMMON assert "two_stage_slope === undefined" not in B06_PAGE From eac37456e21ee3d4af641d91e87492d6456d84db Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 16 Oct 2026 11:16:00 +0900 Subject: [PATCH 17/18] =?UTF-8?q?chore(=EC=A7=80=EC=B9=A8):=20342-4=20?= =?UTF-8?q?=ED=8F=B4=EB=8D=94=EB=B3=84=20CLAUDE.md=20=EB=84=B7=20=E2=80=94?= =?UTF-8?q?=20B04=5FPreProcess=20=C2=B7=20B05=5FProfile=20=C2=B7=20B06=5FS?= =?UTF-8?q?ection=20=C2=B7=20A00=5FCommon/design?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 갈래 지도 · ① 짝 목록(시험) · 서버 Node 묶음 입구 · 끌려가는 파일 · 남의 페이지가 부르는 파일 · logic/ 규칙 · 금지 - 40줄 안 · 뿌리 CLAUDE.md 안 고침 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013QyNXY2TUKXCFMwAca2APG --- A00_Common/design/CLAUDE.md | 38 +++++++++++++++++++++++++++++++++++ B04_PreProcess/CLAUDE.md | 35 ++++++++++++++++++++++++++++++++ B05_Profile/CLAUDE.md | 40 +++++++++++++++++++++++++++++++++++++ B06_Section/CLAUDE.md | 39 ++++++++++++++++++++++++++++++++++++ 4 files changed, 152 insertions(+) create mode 100644 A00_Common/design/CLAUDE.md create mode 100644 B04_PreProcess/CLAUDE.md create mode 100644 B05_Profile/CLAUDE.md create mode 100644 B06_Section/CLAUDE.md diff --git a/A00_Common/design/CLAUDE.md b/A00_Common/design/CLAUDE.md new file mode 100644 index 000000000..8d96894a0 --- /dev/null +++ b/A00_Common/design/CLAUDE.md @@ -0,0 +1,38 @@ +# A00_Common/design 지침 + +B05 · B06 한 페이지 뼈대 — 주소 둘(`#/b05-profile` · `#/b06-section`)이 `design_page` 하나를 엶. + +갈래 지도(앞머리 → 하는 일): + +- `design_page` 페이지 틀 · 보기 전환(mountCenter) · `design_shared` · `design_panel` · `design_dock` 공용 틀 · 좌측 칸 · 도크 +- `design_view_profile` 종단 보기 · `design_view_cross` 횡단 보기 +- `design_profile*` 가운데 아래 종단도 판(자리 · 겹침 · 떼어 보기 · 자유 보기) +- `design_store` 가게 · `design_selection` · `design_pick_*` 선택 · 집기 · `design_pipe_ids` 관 id · `design_sections` 측점 +- `design_save` [저장] · `design_reset` [초기화] — B05 · B06 캐시 한 번에 +- `design_structure_*` 구조물 명세 · `_calc` 명세 계산 + +① 짝(ts ↔ py · 거울 시험): + +- `design_structure_ford_calc.ts` fordHeightM ↔ `B04_PreProcess_Router_Basins.ford_height_m` · `test_65_ford_defaults.py` +- 같은 파일 ford_depth ↔ `B08_Quantity_Engine_Hydro.ford_depth` · `test_73_11_b08_hydro_box_ford.py` +- `design_structure_pipe_calc.ts` 관 길이 = B06 `computeStoredLayouts`(② 서버 Node) 그대로 · 새로 셈하지 않음 + +서버 Node 묶음: + +- 입구 없음 · 끌려감 = `design_pick_match` · `design_pipe_ids` · `design_selection`(corridor · server-calc · b07-extent) · `design_store` · `design_structure_{box,ford,ford_pavement,guard,pipe,road,spec}`(b07-extent · b07-note) · 상위 `A00_Common/b_page_state` +- 끌려가는 파일 모듈 맨 위에 DOM · window · sessionStorage 금지 · 고치면 해당 build 다시 지어 시험 + +남의 페이지가 부르는 파일(고치면 부르는 쪽 시험도): + +- B05 · B06 둘 다 — `design_panel` · `design_pick_match` · `design_pipe_ids` · `design_profile` · `design_sections` · `design_selection`(B04 도) · `design_shared` · `design_structure_pipe_calc` · `design_structure_status` +- M02 — `design_structure_form` · `design_structure_pipe` · `design_structure_spec` + +logic/ 규칙: + +- 페이지 전용 계산은 각 페이지 `logic/` · B05 · B06 공용 계산은 `common_util` +- design 은 화면 · 가게 자리 — 새 순수 계산을 여기 두지 않음 + +금지: + +- B05 · B06 캐시를 따로 저장하지 않음 — [저장] 한 번(`design_save`) +- 자동저장(디바운스 PUT) 만들지 않음 diff --git a/B04_PreProcess/CLAUDE.md b/B04_PreProcess/CLAUDE.md new file mode 100644 index 000000000..3ec981c67 --- /dev/null +++ b/B04_PreProcess/CLAUDE.md @@ -0,0 +1,35 @@ +# B04_PreProcess 지침 + +갈래 지도(앞머리 → 하는 일): + +- `UI_Map*` 2D 지도(그리기 · 겹침 · 층 · 보기) · `UI_TerrainViewer*` 3D 지형 · 등고선 · `UI_Viewer` · `UI_Camera` +- `UI_Flow*` · `UI_Basins` · `UI_Watershed` 배수 흐름 · 유역 · `UI_RouteSamples` 노선 표본 · `UI_Page` 페이지 · `Api_Fetch` 서버 요청 +- `Engine_Filter_*` 지면 필터 · `Engine_Ground` · `Engine_ModelBuild` · `Engine_ModelContext` · `Engine_Smooth` 지표면 모델 +- `Engine_Sheet*` · `Engine_MapSheet` 수치지형도 · `Engine_Contour` 등고선 · `Engine_GisVector` · `Engine_VWorld` 국가 GIS +- `Engine_Watershed_*` 배수유역 · `Engine_Pipeline` · `Engine` 전처리 차례 · `Router_*` · `Repository` · `Service` · `Schema` +- `logic/` 순수 계산 TS + +① 짝(ts ↔ py · 거울 시험): + +- `Router_Basins.py` ford_height_m ↔ `A00_Common/design/design_structure_ford_calc.ts` fordHeightM · `test_65_ford_defaults.py` +- 배수유역 · 지표면 모델 엔진 = 서버 단독(③) · 지형 표본 = `common_util_surface_sampler` 하나 + +서버 Node 묶음: + +- 입구 없음 · `Api_Fetch.ts` 가 `build:b07-extent` 에 끌려감 — 모듈 맨 위에 DOM · window · sessionStorage 금지 + +남의 페이지가 부르는 파일(고치면 부르는 쪽 시험도): + +- ts — `Api_Fetch`(A00 · B05 · B06 · B08 · B11) · `UI_MapRender` · `UI_MapRender_Prepare` · `UI_MapOverlays` · `UI_FlowRamp` · `UI_FlowArrows` · `UI_Camera` · `UI_RouteSamples`(B05) · `UI_Page`(A00) +- py — `Router_Watershed` · `Router_Basins` · `Router_GIS` · `Router` · `Service` · `Repository` · `Engine_Watershed_*` · `Engine_VWorld` · `Engine_Extent` · `Engine_SheetSurface` · `Engine_Structurize` + +logic/ 규칙: + +- DOM · three · fetch · sessionStorage · window 금지 · 순수 함수만 +- 화면 파일이 logic 을 부름 · logic 은 화면 파일을 안 부름(logic · common_util 만) +- 이름 앞머리 그대로(`logic/B04_PreProcess_Xxx.ts`) · 파이썬 짝이면 두 파일 머리에 짝 표시 + 거울 시험 + +금지: + +- 파이썬 파일을 하위 폴더로 옮기지 않음(절대 import · `Engine_GisVector.py` 뿌리 경로) +- 실제 파일 다시 올리기 · 전처리 다시 돌리기로 시험하지 않음 — 서버 함수 시험으로 diff --git a/B05_Profile/CLAUDE.md b/B05_Profile/CLAUDE.md new file mode 100644 index 000000000..99b95a30f --- /dev/null +++ b/B05_Profile/CLAUDE.md @@ -0,0 +1,40 @@ +# B05_Profile 지침 + +갈래 지도(앞머리 → 하는 일): + +- `UI_RouteEdit_*` 노선 편집(노드 · 곡선 · 측점 틀 · 등고선 띠) · `Api_Replan` 그 서버 요청 +- `UI_Corridor_*` 3D 길 모형 · `UI_Viewer*` 3D 화면 +- `UI_Profile_*` 종단도(계획선 · 끌기 · 표 · 유토곡선) · `UI_Structures_*` · `Structures_*.py` 구조물 +- `UI_Drainage_*` 배수유역 칸 · 관 · 시설 · `UI_Page_*` · `UI_Panel_*` 페이지 틀 · 좌측 칸 · `Api_*` 서버 요청 +- `Engine_Grade*` 종단 계획선 · `Engine_Sections*` 측점 · 종횡단 생성 · `Engine_RouteInitial*` 노선 추천 · `Engine_AsPlanned` 노선 그대로 · `Router_*` API +- `logic/` 순수 계산 TS + +① 짝(ts ↔ py · 거울 시험): + +- `UI_RouteEdit_Curve.ts` ↔ `common_util_route_polyline.py` · `test_route_polyline_browser_mirror.py` +- `UI_RouteEdit_Frame.ts` ↔ `Engine_Sections_Core.py`(측점 틀 · 스냅) · `test_b05_routeedit_frame_mirror.py` · `test_224_2_station_snap.py` +- `UI_Drainage_Facility.ts` carryIndices ↔ `common_util_drainage_pipes.py` · `test_b05_facility_carry.py` +- `logic/B05_Profile_PlanFootprint.ts` ↔ `B07_DesignDetail_Engine_Cad_PlanSlope.py` · `test_346_2_plan_footprint_mirror.py` +- 지형 표본 = `common_util_surface_sampler.build_surface_sampler` 하나 · 2 m 비용면은 노선 추천 점수만 + +서버 Node 묶음: + +- 입구 `B05_Profile_Corridor_Node.ts`(package.json `build:corridor`) · 끌어감 = `UI_Corridor_*`(Build · Envelope · Frames · Station · Structures) · `UI_IrregularStations` · B06 `UI_Cross_*` 구조물 · A00 `b_page_state` · `design_pick_match` · `design_pipe_ids` · `design_selection` +- `build:b07-extent` · `build:b07-note` 가 `Api_Structures` · `Api_Pipes_Draft` · `UI_Structures_Balloon` · `UI_Structures_Define` 를 끌어감 +- 묶음이 끄는 TS 를 고치면 그 build 다시 지어 시험 · 그 파일 모듈 맨 위에 DOM · window · sessionStorage 금지 + +남의 페이지가 부르는 파일(고치면 부르는 쪽 시험도): + +- ts — `Api_Fetch` · `Api_Structures` · `Api_Pipes_Draft`(B06 · B07 · B08) · `UI_Structures_Balloon` · `_Define` · `_Fields` · `_Snap` · `_Panel_Types`(B06 · B07 · M02) · `UI_Profile_Alignment` · `_Edit` · `_Data` · `_Structures` · `UI_Drainage_Ford_Fill` · `_Facility_Merge` · `_Facility_Fields` · `UI_IrregularStations`(B06 · M02) · A00 design 이 부르는 `UI_Page_*` · `UI_Profile_*` +- py — `Structures_Schema` · `Structures_Repository` · `Engine_Sections` · `Engine_Sections_Core` · `Repository` · `Router_Confirm` · `Engine_Grade*` · `Router_RouteInitial` + +logic/ 규칙: + +- DOM · three · fetch · sessionStorage · window 금지 · 순수 함수만 +- 화면 파일이 logic 을 부름 · logic 은 화면 파일을 안 부름(logic · common_util 만) +- 이름 앞머리 그대로(`logic/B05_Profile_Xxx.ts`) · 파이썬 짝이면 두 파일 머리에 짝 표시 + 거울 시험 + +금지: + +- 파이썬 파일을 하위 폴더로 옮기지 않음(절대 import · `Corridor_Prebuild.py` 뿌리 경로 `parents[1]`) +- 묶음 입구 파일 옮기지 않음 · 보관 폴더(`Old/`) 만들지 않음 — 지움 diff --git a/B06_Section/CLAUDE.md b/B06_Section/CLAUDE.md new file mode 100644 index 000000000..62b92b79a --- /dev/null +++ b/B06_Section/CLAUDE.md @@ -0,0 +1,39 @@ +# B06_Section 지침 + +갈래 지도(앞머리 → 하는 일): + +- `UI_Cross_*` 횡단 카드 · 설계선 · 구조물 그림(Culvert · Box · Ford · Revetment · Wall · Underdrain · Lean) +- `UI_Cross_View_*` 횡단 보기 · `UI_Cross_Detail_*` 상세 창 · `UI_Cross_Design*` 설계선 편집 +- `UI_Page_*` 페이지 틀 · 저장 · 구조물 칸 · `UI_Section_*` 측점 · 횡단 공용 +- `Section_Store` · `Cross_Refresh` · `Api_*` 횡단 상세 가게 · 서버 요청 +- `Engine_Design*` 횡단 설계 · `Engine_Areas` 면적 · `Engine_Culvert` 관 · `Engine_SampleExtend` 지반 덧붙임 · `Engine_SpoilFill` · `Engine_Structures_Wall` · `Engine_Tilt` · `Router_*` · `Repository*` +- `logic/` 순수 계산 TS + +① 짝(ts ↔ py · 거울 시험): + +- `Engine_Design.py` ↔ `common_util_cross_design.ts` · `Engine_Areas.py` ↔ `common_util_cross_design_areas.ts` · `test_b06_cross_design_mirror.py` +- `Engine_Culvert.py` ↔ `common_util_culvert_sets.ts` · `UI_Cross_Culvert_Const.ts` · `test_b06_culvert_sets_mirror.py` · `test_b06_pipe_wall_height_mirror.py` +- `Engine_Structures_Wall.py` ↔ `common_util_structure_walls.ts` · `test_b06_structure_walls_mirror.py` +- 지형 표본 = `common_util_surface_sampler` 하나(`Engine_SampleExtend` 도) + +서버 Node 묶음: + +- 입구 `B06_Section_Server_Calc_Node.ts`(package.json `build:server-calc`) · 끌어감 = `UI_Cross_Culvert*` · `_Box_Geom` · `_Ford_Geom` · `_Wall*` · `_Revetment` · `_Lean` · `_Excavation` · `_Structure_Cut` · `_Body_Guard` · `Structure_Layouts` · `UI_Section_Common` +- `build:corridor` · `build:b07-extent` 도 `UI_Cross_*` 구조물 그림을 끌어감 — 고치면 해당 build 다시 지어 시험 +- 입구 경로를 시험이 씀(`test_b06_ground_unlock.py` · `test_b06_height_unlock.py` 등) · 묶음이 끄는 파일 모듈 맨 위에 DOM · window · sessionStorage 금지 + +남의 페이지가 부르는 파일(고치면 부르는 쪽 시험도): + +- ts — `Api_Fetch` · `Section_Store`(B03 · B05 · B07 · B09) · `UI_Cross_Culvert*` · `_Box` · `_Ford*` · `_Revetment` · `_Wall_Hatch` · `_Underdrain` · `_Design` · `_Body_Guard` · `_Fit` · `_Panel_Base`(B05 · B07) · `UI_Cross_Culvert_Const`(M02) · `UI_Section_Common` · `UI_Longitudinal` · `Cross_Refresh` · `Api_Culvert_Options` · `Structure_Layouts` · A00 design 이 부르는 `UI_Page_*` +- py — `Repository` · `Repository_Bulk` · `Router` · `Router_Confirm` · `Router_Design` · `Engine_Design` · `Engine_Culvert` · `Schema` + +logic/ 규칙: + +- DOM · three · fetch · sessionStorage · window 금지 · 순수 함수만 +- 화면 파일이 logic 을 부름 · logic 은 화면 파일을 안 부름(logic · common_util 만) +- 이름 앞머리 그대로(`logic/B06_Section_Xxx.ts`) · 파이썬 짝이면 두 파일 머리에 짝 표시 + 거울 시험 + +금지: + +- 파이썬 파일을 하위 폴더로 옮기지 않음(절대 import · `Server_Calc_Prebuild.py` 뿌리 경로 `parents[1]`) +- 묶음 입구 파일 옮기지 않음 From ca8c88f8100f17e481c66f53fb29542489e53403 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 16 Oct 2026 11:16:19 +0900 Subject: [PATCH 18/18] =?UTF-8?q?perf(=ED=9A=A1=EB=8B=A8):=20346-2=20?= =?UTF-8?q?=EA=B5=AD=EB=B6=80=20=EC=97=B0=EC=82=B0=20=C2=B7=20[=EC=A0=84?= =?UTF-8?q?=EC=B2=B4=20=EC=B8=A1=EC=A0=90=20=EB=B0=98=EC=98=81]=20?= =?UTF-8?q?=ED=95=9C=20=EB=B2=88=20=C2=B7=20=EB=B3=B4=EC=9D=B4=EB=8A=94=20?= =?UTF-8?q?=EC=B9=B4=EB=93=9C=20=EB=A8=BC=EC=A0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 횡단 다시 셈이 측점별 입력 지문(지반 표본 · 계획고 · 횡단 입력 · 구조물 맥락 · 표준값)을 기억 — 같으면 앞 결과 그대로 · 계획고 ▲ 한 번 = 10 측점 중 2 측점만 다시 셈 · 결과는 통째 셈과 같음 - [전체 측점 반영] = 다시 셈 한 번(측점마다 66 × 66 돌던 것) · 지금 값을 「사용자 선택」으로 박는 동작 없앰(암 경계 다시 셈 포함 · 사용자 확정 10-16) - 횡단 카드: 보이는 카드만 바로 갈아 끼움 · 화면 밖은 쉬는 틈에 몇 장씩(경고칩 · 면적 합은 계산값이라 그대로) - 머묾 재현 · 국부 연산 · 흙 배분 열쇠 · 전체 반영 · 카드 시험(vite ssr 로 진짜 모듈 묶음) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QfHKQs7J3hQELKMWQMUJEy --- B06_Section/B06_Section_Cross_Refresh.ts | 22 +- .../B06_Section_UI_Page_Design_Sync.ts | 44 +-- B06_Section/B06_Section_UI_Section_Common.ts | 22 ++ B06_Section/B06_Section_UI_Section_View.ts | 40 +- resources/tester/helper_342_1_entry.ts | 28 ++ .../tester/helper_342_1_station_line.mjs | 347 ++++++++++++++++++ resources/tester/test_316_defaults.py | 5 +- resources/tester/test_342_1_station_line.py | 126 +++++++ 8 files changed, 588 insertions(+), 46 deletions(-) create mode 100644 resources/tester/helper_342_1_entry.ts create mode 100644 resources/tester/helper_342_1_station_line.mjs create mode 100644 resources/tester/test_342_1_station_line.py diff --git a/B06_Section/B06_Section_Cross_Refresh.ts b/B06_Section/B06_Section_Cross_Refresh.ts index e58b1b3e0..c4b1da7a5 100644 --- a/B06_Section/B06_Section_Cross_Refresh.ts +++ b/B06_Section/B06_Section_Cross_Refresh.ts @@ -318,6 +318,12 @@ function readCutSlopeRatios(projectId: string, routeId: number): Map(); + function refreshLocally(input: CrossRefreshInput): number[] | null { const { projectId, detail, edits } = input; const stored = readAlignment(detail.longitudinal); @@ -334,6 +340,7 @@ function refreshLocally(input: CrossRefreshInput): number[] | null { if (!alignment) return null; // 선형 저장분이 없는 옛 데이터 — 서버가 풀어 준다. const standard = effectiveStandardCross(projectId) as StandardCrossSectionSpec | null; if (!standard) return null; // 컨텍스트를 아직 못 받음 — 이번만 서버로. + const standardKey = JSON.stringify(standard); // 방금 푼 계획선을 **공유 캐시에도 얹는다**. 여기서 만드는 횡단 설계는 편집이 반영된 // 계획고 기준인데 `design_profiles` 만 저장분으로 남으면 두 값의 기준이 어긋나, 낡음 // 판정이 영원히 참이 되어 유토곡선이 빈 채로 남는다(2026-09-03 사용자 보고: B05 편집 중 @@ -460,9 +467,21 @@ function refreshLocally(input: CrossRefreshInput): number[] | null { } : null, }; + // 국부 연산(346-2) — 입력 지문(지반 표본 · 계획고 · 횡단 입력 · 구조물 맥락 · 표준값)이 같고 + // 설계가 그때 낸 그 객체면 앞 결과 그대로. 계획고 ▲ 한 번에 다시 세는 측점은 곡선 둘레 몇 곳뿐. + const samples = section.samples ?? []; + const fingerprint = `${standardKey}|${elevation}|${samples.length}|${JSON.stringify({ ...options, standard: null })}`; + const memo = memos.get(section); + if ( + memo?.samples === section.samples && + memo.fingerprint === fingerprint && + memo.design === previous + ) { + continue; + } let next; try { - next = computeCrossDesign(section.samples ?? [], elevation, options); + next = computeCrossDesign(samples, elevation, options); } catch { continue; // 샘플 부족·값 손상 측점은 건너뛴다(서버 엔진과 같은 태도). } @@ -477,6 +496,7 @@ function refreshLocally(input: CrossRefreshInput): number[] | null { const own = next[`berm_depth_${side}_m`]; if (typeof own === "number") section.design[`berm_depth_${side}_m`] = own; } + memos.set(section, { samples: section.samples, fingerprint, design: section.design }); updated.push(section.chainage_m); } return updated; diff --git a/B06_Section/B06_Section_UI_Page_Design_Sync.ts b/B06_Section/B06_Section_UI_Page_Design_Sync.ts index b406d57c9..804307026 100644 --- a/B06_Section/B06_Section_UI_Page_Design_Sync.ts +++ b/B06_Section/B06_Section_UI_Page_Design_Sync.ts @@ -117,28 +117,13 @@ export function createDesignSync(ctx: DesignSyncContext): DesignSyncController { await reconcileStaleDesigns({ force: true }); } - /** 현재 design 값에서 재계산용 change를 복원한다(암 경계 오프셋 변경 시 재계산 트리거). */ - function changeFromDesign(chainageM: number): CrossDesignChange | null { - const target = ctx - .detail() - ?.cross_sections.find((section) => Math.abs(section.chainage_m - chainageM) < 0.01); - const design = target?.design; - if (!design) return null; - return { - ground_type: design.ground_type, - section_mode: design.section_mode, - ditch_side: design.ditch_side ?? null, - ditch_type: design.ditch_type ?? "standard", - paved: design.paved, - two_stage_slope: design.two_stage_slope ?? false, - ditch_choice: design.ditch_choice ?? null, - }; - } - - /** 암 경계 오프셋 변경 후 암 지반이면 2단계 무릎·단면적을 서버 재계산한다. */ + /** 암 경계 오프셋 변경 후 암 지반이면 2단계 무릎·단면적을 다시 셈 — 지금 값을 「사용자 선택」으로 + * 박지 않음(자동 판정이 굳으면 노선이 바뀌어도 재판정 안 됨 · 346-2). 암 경계는 세션에서 읽음. */ function recomputeIfRock(chainageM: number): void { - const change = changeFromDesign(chainageM); - if (change && change.ground_type !== "soil") void handleDesignChange(chainageM, change); + const design = ctx + .detail() + ?.cross_sections.find((section) => Math.abs(section.chainage_m - chainageM) < 0.01)?.design; + if (design && design.ground_type !== "soil") void reconcileStaleDesigns({ force: true }); } /** @@ -215,23 +200,18 @@ export function createDesignSync(ctx: DesignSyncContext): DesignSyncController { void reconcileStaleDesigns({ force: true }); } - /** 패널 [전체 반영](N-2-1): design 보유 전 측점을 패널 최신값으로 순차 재계산한다. - * handleDesignChange가 standardPanel.getValues()를 실어 보내므로 표준단면 수치만 - * 갱신되고 측점별 버튼 선택값은 보존된다. 순차 await로 동시 API 호출 수를 제한한다. */ + /** 패널 [전체 반영](N-2-1 · 346-2): 표준값으로 전 측점을 **한 번** 다시 셈 — 측점마다 돌면 + * 66 × 66 = 4.1 s 였음. 표준값은 다시 셈이 `effectiveStandardCross` 로 읽고, 측점별 선택은 + * 세션에 있는 것만 씀(지금 값을 「사용자 선택」으로 박지 않음 · 사용자 확정 10-16). */ let applyingAll = false; async function applyPanelToAll(): Promise { const sectionDetail = ctx.detail(); if (applyingAll || !sectionDetail || !projectId || ctx.routeId() === null) return; - const targets = sectionDetail.cross_sections.filter((section) => section.design); - if (!targets.length) return; - // 화면을 잠그지 않는다 — 카드가 하나씩 갱신되는 것이 곧 진행 표시다(CLAUDE.md 5장). - // 대신 도는 동안 다시 누르는 것만 막는다. + if (!sectionDetail.cross_sections.some((section) => section.design)) return; + // 화면을 잠그지 않는다(CLAUDE.md 5장) — 도는 동안 다시 누르는 것만 막는다. applyingAll = true; try { - for (const section of targets) { - const change = changeFromDesign(section.chainage_m); - if (change) await handleDesignChange(section.chainage_m, change); - } + await reconcileStaleDesigns({ force: true }); showToast(L("B06_Std_ApplyAll_Success"), "success"); } finally { applyingAll = false; diff --git a/B06_Section/B06_Section_UI_Section_Common.ts b/B06_Section/B06_Section_UI_Section_Common.ts index 3d8a3a94e..529736949 100644 --- a/B06_Section/B06_Section_UI_Section_Common.ts +++ b/B06_Section/B06_Section_UI_Section_Common.ts @@ -133,6 +133,28 @@ export function staleKey( .join("|"); } +/** + * 쉬는 틈 일거리 줄(346-2) — 화면 밖 횡단 카드를 몇 장씩 갈아 끼움(카드 하나 ≈ 8 ms · 0.54 s / 66). + * 줄에 든 측점은 `run` 이 꺼내 그림 · 통째로 다시 그리면 `queue.clear()`. + */ +export function createIdleQueue(run: (chainageM: number) => void, batch = 6) { + const queue = new Set(); + let waiting = 0; + const drain = (): void => { + if (waiting || !queue.size) return; + const idle = window.requestIdleCallback ?? ((next: () => void) => window.setTimeout(next, 16)); + waiting = idle(() => { + waiting = 0; + for (const chainageM of [...queue].slice(0, batch)) { + queue.delete(chainageM); + run(chainageM); + } + drain(); + }); + }; + return { queue, drain }; +} + /** 낡음 지문(`staleKey`)에 든 측점 누가거리 — 다시 셈 뒤에도 남은 측점을 표시할 때(342-1). */ export function staleChainages(key: string): number[] { return key ? key.split("|").map((entry) => Number(entry.split(":")[0])) : []; diff --git a/B06_Section/B06_Section_UI_Section_View.ts b/B06_Section/B06_Section_UI_Section_View.ts index 88a6567b7..32fd338c1 100644 --- a/B06_Section/B06_Section_UI_Section_View.ts +++ b/B06_Section/B06_Section_UI_Section_View.ts @@ -43,7 +43,7 @@ import { naturalHeightAtWidth, } from "./B06_Section_UI_Cross_View_Metrics"; import { cardZoomStates } from "./B06_Section_UI_Cross_View_Zoom"; -import { CROSS_GRID_GAP, CROSS_HEIGHT } from "./B06_Section_UI_Section_Common"; +import { CROSS_GRID_GAP, CROSS_HEIGHT, createIdleQueue } from "./B06_Section_UI_Section_Common"; import type { SectionStructureEdit } from "./B06_Section_UI_Section_View_Menu"; import { culvertLinkFor as culvertLink } from "./B06_Section_UI_Cross_Culvert_Wire"; import type { CulvertLink } from "./B06_Section_UI_Cross_Culvert_Wire"; @@ -415,8 +415,11 @@ export function createSectionView( }); } + /** 화면 밖에서 아직 옛 그림인 카드 — 쉬는 틈에 몇 장씩(346-2). */ + const offscreen = createIdleQueue((chainageM) => rebuildCard(chainageM)); const draw = (): void => { if (!currentDetail || !Number.isFinite(renderWidth) || renderWidth <= 0) return; + offscreen.queue.clear(); // 통째로 다시 그림 — 밀린 카드도 새 그림 const detail = currentDetail; cachedStationInterval = currentStationInterval ?? inferStationInterval(detail.longitudinal.stations); @@ -488,17 +491,23 @@ export function createSectionView( syncProfile(); }; - /** 카드 한 장만 갈아 끼운다(종단도는 안 건드린다). 못 찾으면 false. */ - const rebuildCard = (chainageM: number): boolean => { - if (!currentDetail) return false; - const section = currentDetail.cross_sections.find( + /** 측점의 자료 · 카드 — 측점이 없으면 null. */ + const cardAt = (chainageM: number) => { + const section = currentDetail?.cross_sections.find( (candidate) => Math.abs(candidate.chainage_m - chainageM) < 0.01, ); - if (!section) return false; - const existing = document.getElementById(`cross-${section.station_id}`); + return section && { section, el: document.getElementById(`cross-${section.station_id}`) }; + }; + + /** 카드 한 장만 갈아 끼운다(종단도는 안 건드린다). 못 찾으면 false. */ + const rebuildCard = (chainageM: number): boolean => { + const card = cardAt(chainageM); + if (!card) return false; + offscreen.queue.delete(chainageM); // 단건 갱신은 draw에서 정해둔 행 높이를 재사용해 같은 행 카드와 높이를 유지한다. - if (existing) - existing.replaceWith(buildCrossCard(section, cachedRowHeight.get(section.station_id))); + card.el?.replaceWith( + buildCrossCard(card.section, cachedRowHeight.get(card.section.station_id)), + ); return true; }; @@ -515,7 +524,18 @@ export function createSectionView( */ const refreshCards = (chainages: ReadonlyArray, redraw = true): void => { let touched = false; - for (const chainageM of chainages) if (rebuildCard(chainageM)) touched = true; + // 보이는 카드 먼저(346-2) — 화면 밖 카드는 쉬는 틈에 나눠 그림 · 경고칩 · 면적 합은 계산값이라 그대로 + const view = cards.getBoundingClientRect(); + for (const chainageM of chainages) { + const card = cardAt(chainageM); + if (!card) continue; + touched = true; + const rect = card.el?.getBoundingClientRect(); + if (rect && rect.height > 0 && rect.bottom >= view.top && rect.top <= view.bottom) + rebuildCard(chainageM); + else offscreen.queue.add(chainageM); + } + offscreen.drain(); if (!touched || !currentDetail) return; if (redraw) redrawProfile(); else fillSlopeNotice.update(currentDetail.cross_sections, cachedStationInterval); diff --git a/resources/tester/helper_342_1_entry.ts b/resources/tester/helper_342_1_entry.ts new file mode 100644 index 000000000..c9ec78270 --- /dev/null +++ b/resources/tester/helper_342_1_entry.ts @@ -0,0 +1,28 @@ +/* 342-1 · 346-2 시험 묶음 진입점 — 진짜 모듈을 vite ssr 로 한 벌 묶어 Node 헬퍼가 부름. + * 화면 · 서버 없이 계획선 한 벌 · 낡음 머묾 · 국부 연산 · 흙 배분 열쇠 · [전체 측점 반영] 을 잰다. + */ +export { + holdScreenAlignment, + refreshCrossDesigns, + staleAgainstScreen, +} from "../../B06_Section/B06_Section_Cross_Refresh"; +export { + createIdleQueue, + staleChainages, + staleKey, +} from "../../B06_Section/B06_Section_UI_Section_Common"; +export { + buildAlignment, + emptyEdits, + toAlignmentBase, +} from "../../B05_Profile/B05_Profile_UI_Profile_Alignment"; +export { toDesignProfile } from "../../B05_Profile/B05_Profile_UI_Profile_Data"; +export { followMovedNodes, oneStationList } from "../../common_util/common_util_structure_station"; +export { + irregularGraphStations, + maxChainageOf, +} from "../../B05_Profile/B05_Profile_UI_Profile_Layout"; +export { createCrossPreview } from "../../B05_Profile/B05_Profile_UI_Profile_Preview"; +export { createHaulPlanPrefetch } from "../../B05_Profile/B05_Profile_Api_HaulPlan"; +export { createDesignSync } from "../../B06_Section/B06_Section_UI_Page_Design_Sync"; +export { writeState } from "../../A00_Common/b_page_state"; diff --git a/resources/tester/helper_342_1_station_line.mjs b/resources/tester/helper_342_1_station_line.mjs new file mode 100644 index 000000000..e336a91a2 --- /dev/null +++ b/resources/tester/helper_342_1_station_line.mjs @@ -0,0 +1,347 @@ +/* 342-1 · 346-2 헬퍼 — vite ssr 로 묶은 진짜 모듈(helper_342_1_entry.ts)을 부르고 결과를 JSON 한 벌로. + * node helper_342_1_station_line.mjs <묶음.mjs> <입력.json> + * 입력 = { standard: 파이썬 config 표준단면, policy: 종단 정책 } · 화면 선 = design_profile.ts 의 진짜 + * `listed()` 본문(소스에서 떼어 트랜스파일 · helper_177_3 방식). + */ +import { readFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { dirname, join } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const ROOT = join(HERE, "..", ".."); +const require = createRequire(import.meta.url); +const ts = require(join(ROOT, "config", "node_modules", "typescript")); + +const store = () => { + const m = new Map(); + return { + getItem: (k) => (m.has(k) ? m.get(k) : null), + setItem: (k, v) => m.set(k, String(v)), + removeItem: (k) => m.delete(k), + key: (i) => [...m.keys()][i] ?? null, + get length() { + return m.size; + }, + clear: () => m.clear(), + keys: () => [...m.keys()], + }; +}; +globalThis.window = globalThis; +globalThis.sessionStorage = store(); +globalThis.localStorage = store(); +// 토스트 등 DOM 을 건드리는 자리 — 무엇을 불러도 받아 주는 빈 객체 +const any = new Proxy(function () {}, { + get: (_t, k) => (k === Symbol.toPrimitive ? () => 0 : any), + apply: () => any, + construct: () => any, +}); +globalThis.document = any; +globalThis.requestAnimationFrame = (run) => setTimeout(run, 0); +const warns = []; +console.warn = (...args) => warns.push(args.map(String).join(" ")); + +const [bundlePath, inputPath] = process.argv.slice(2); +const m = await import(pathToFileURL(bundlePath).href); +const input = JSON.parse(readFileSync(inputPath, "utf8")); +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); +const transpile = (code) => + ts.transpileModule(code, { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + +const PID = "p342"; +const RID = 7; +m.writeState("std-cross", input.standard, PID); + +/* ── 견본 노선 0~160 m — 서버 비정규 측점 85.054(0.001 m) · 관 지점 85.05(0.01 m) · 종단곡선 안 ── */ +const SERVER_IRR = 85.054; +const PIPE = 85.05; +const regular = [0, 20, 40, 60, 80, 100, 120, 140, 160]; +const serverStations = [...regular, SERVER_IRR] + .sort((a, b) => a - b) + .map((c) => ({ + station_id: `s${c}`, + chainage_m: c, + kind: c === SERVER_IRR ? "irregular" : c === 0 ? "bp" : "regular", + label: String(c), + ...(c === SERVER_IRR ? { structure: "파형강관 D800" } : {}), + })); +const groundAt = (c) => 100 + 0.08 * c - 6 * Math.sin(c / 23); +const chainage = Array.from({ length: 161 }, (_, i) => i); +const basePvi = serverStations.map((s, i) => ({ + chainage_m: s.chainage_m, + elevation_m: groundAt(s.chainage_m) + (i % 2 ? 1.6 : -1.3), +})); +const base = { + policy: input.policy, + onlyExplicitCurves: false, + basePvi, + chainage, + ground: chainage.map(groundAt), + stations: serverStations.map((s) => ({ station_id: s.station_id, chainage_m: s.chainage_m })), +}; +const stored = m.buildAlignment(base, m.emptyEdits()); +const crossSamples = (c) => + Array.from({ length: 49 }, (_, i) => { + const offset = -12 + i * 0.5; + return { offset_m: offset, elevation_m: groundAt(c) + 0.3 * offset, valid: true }; + }); +const freshDesign = () => ({ + design_elevation_m: 0, + ground_type: "soil", + section_mode: "left_cut", + ditch_side: "left", + ditch_type: "standard", + two_stage_slope: false, +}); +const makeDetail = () => ({ + longitudinal: { + length_m: 160, + stations: serverStations.map((s) => ({ ...s })), + samples: chainage.map((c) => ({ chainage_m: c, elevation_m: groundAt(c), valid: true })), + profile_alignment: JSON.parse(JSON.stringify(stored)), + design_profiles: [m.toDesignProfile(stored, undefined)], + }, + cross_sections: serverStations.map((s) => ({ + station_id: s.station_id, + chainage_m: s.chainage_m, + samples: crossSamples(s.chainage_m), + design: freshDesign(), + })), +}); +const irregularStations = [ + { id: "pipe-1", station: 4, remainder: 5.05, chainage_m: PIPE, structure: "파형강관 D800" }, +]; + +/* 화면 선 = design_profile.ts 의 진짜 listed() */ +const dpSrc = readFileSync(join(ROOT, "A00_Common", "design", "design_profile.ts"), "utf8"); +const listedSrc = dpSrc.match(/\r?\n function listed\(\)[\s\S]*?\r?\n \}\r?\n/)[0]; +const listedOf = new Function( + "storedBase", + "detail", + "irregularStations", + "oneStationList", + "irregularGraphStations", + "maxChainageOf", + "followMovedNodes", + `${transpile(listedSrc)}\nreturn listed();`, +); +const screenBase = (detail) => + listedOf( + m.toAlignmentBase(stored), + detail, + irregularStations, + m.oneStationList, + m.irregularGraphStations, + m.maxChainageOf, + m.followMovedNodes, + ); +const staleOf = (detail, line) => + m.staleKey({ + longitudinal: { design_profiles: [m.toDesignProfile(line, undefined)] }, + cross_sections: detail.cross_sections, + }); +const refresh = (detail, extra = {}) => + m.refreshCrossDesigns({ + projectId: PID, + routeId: RID, + detail, + edits: m.emptyEdits(), + ...extra, + }); + +const out = {}; + +/* 1. 머묾 재현 — 고치기 전 길(저장분 선) 대 화면 선 */ +{ + const detail = makeDetail(); + await refresh(detail, { alignment: stored }); // 저장본 = 서버가 저장분 선으로 낸 설계 + const screen = m.buildAlignment(screenBase(detail), m.emptyEdits()); + out.screenStations = screen.stations.map((s) => s.chainage_m); + out.staleBefore = m.staleChainages(staleOf(detail, screen)); + await refresh(detail); // 옛 길 — 맡긴 선 없음 → 저장분 선으로 다시 셈 + out.staleOldPath = m.staleChainages(staleOf(detail, screen)); + const release = m.holdScreenAlignment((id) => (id === RID ? screen : null)); + out.staleAgainstScreenBefore = m.staleAgainstScreen(detail, RID); + await refresh(detail); // 새 길 — 종단도가 맡긴 화면 선(B06 · 3D 와 같은 길) + out.staleNewPath = m.staleChainages(staleOf(detail, screen)); + out.staleAgainstScreenAfter = m.staleAgainstScreen(detail, RID); + // B06 판정(다시 셈이 얹은 design_profiles)과 B05 판정(화면 선)이 같은가 + out.staleB06View = m.staleChainages( + m.staleKey({ + longitudinal: detail.longitudinal, + cross_sections: detail.cross_sections, + }), + ); + release(); +} + +/* 2. 국부 연산 — 같은 입력 다시 셈 0 · 계획고 ▲ 한 번 = 몇 측점만 · 결과는 통째 셈과 같음 */ +{ + const detail = makeDetail(); + const screen = m.buildAlignment(screenBase(detail), m.emptyEdits()); + await refresh(detail, { alignment: screen }); + await refresh(detail, { alignment: screen }); // 첫 셈이 결과 칸(측구 유무 등)을 채운 뒤 한 번 더 — 저장본 상태 + const ids = () => detail.cross_sections.map((s) => s.design); + let before = ids(); + await refresh(detail, { alignment: screen }); + out.recomputedSame = ids().filter((d, i) => d !== before[i]).length; + const edited = m.buildAlignment(screenBase(detail), { + station_offsets: { "140.000": 0.3 }, + curve_radii: {}, + }); + before = ids(); + const changed = await refresh(detail, { alignment: edited }); + out.recomputedEdit = ids().filter((d, i) => d !== before[i]).length; + out.changedEdit = changed.length; + out.total = detail.cross_sections.length; + // 통째 셈(새 측점 객체 = 기억 없음)과 결과 같음 + const whole = makeDetail(); + whole.cross_sections.forEach((s, i) => (s.design = { ...detail.cross_sections[i].design })); + await refresh(whole, { alignment: stored }); + await refresh(whole, { alignment: edited }); + out.sameAsWhole = + JSON.stringify(whole.cross_sections.map((s) => s.design)) === + JSON.stringify(detail.cross_sections.map((s) => s.design)); +} + +/* 3. 다시 셈 뒤에도 남은 낡음 — 기다림(true) → 남음(false) · 콘솔 경고 · 다시 그림 */ +{ + const detail = makeDetail(); + const screen = m.buildAlignment(screenBase(detail), m.emptyEdits()); + await refresh(detail, { alignment: screen }); + const broken = detail.cross_sections[3]; + broken.samples = []; // 다시 셈이 못 고치는 측점 + broken.design.design_elevation_m += 1; + const applied = []; + const preview = m.createCrossPreview({ + projectId: PID, + detail: () => detail, + routeId: () => RID, + edits: () => m.emptyEdits(), + alignment: () => screen, + debounceMs: () => 0, + onApplied: (updated) => applied.push(updated.length), + }); + const key = staleOf(detail, screen); + out.settleFirst = preview.scheduleIfStale(key); + await sleep(50); + const again = staleOf(detail, screen); + out.settleSameKey = again === key; + out.settleAfter = preview.scheduleIfStale(again); + out.settleWarns = warns.filter((w) => w.includes("낡은 측점")).length; + out.settleRedraws = applied.length; + out.settleClear = preview.scheduleIfStale(""); + preview.dispose(); +} + +/* 4. 흙 배분 — 실패한 열쇠 다시 보냄 · 받은 열쇠는 다시 안 물음 */ +{ + const replies = [false, true, true]; + const posts = []; + globalThis.fetch = async (_url, init) => { + posts.push(JSON.parse(init.body).result.points.length); + const ok = replies.shift() ?? true; + return { + ok, + json: async () => ({ haul_plan: { tag: JSON.parse(init.body).result.points.length } }), + }; + }; + let ready = 0; + const prefetch = m.createHaulPlanPrefetch( + PID, + () => RID, + () => (ready += 1), + ); + const A = { points: [{ cumulative_volume_m3: 1 }, { cumulative_volume_m3: 2 }] }; + const B = { + points: [{ cumulative_volume_m3: 1 }, { cumulative_volume_m3: 2 }, { cumulative_volume_m3: 3 }], + }; + prefetch.schedule(A); + await sleep(450); + out.haulAfterFail = posts.length; // 1 — 실패 + prefetch.schedule(A); // 같은 열쇠 — 다시 보냄 + await sleep(450); + out.haulRetry = posts.length; // 2 + prefetch.schedule(B); + await sleep(450); + out.haulB = posts.length; // 3 + prefetch.schedule(A); // 받아 둔 열쇠 — 안 물음 + await sleep(450); + out.haulKept = posts.length; // 3 + out.haulKeptPlan = prefetch.current()?.tag; // 2 (A 의 점 2개) + out.haulReady = ready; + prefetch.dispose(); +} + +/* 5. [전체 측점 반영] — 다시 셈 한 번 · 측점 선택을 세션에 안 박음 */ +{ + const detail = makeDetail(); + const screen = m.buildAlignment(screenBase(detail), m.emptyEdits()); + const release = m.holdScreenAlignment((id) => (id === RID ? screen : null)); + const cardsCalls = []; + const sync = m.createDesignSync({ + projectId: PID, + routeId: () => RID, + detail: () => detail, + view: () => ({ + refreshCards: (updated) => cardsCalls.push(updated.length), + refreshCard: () => cardsCalls.push(-1), + detailChart: () => null, + }), + }); + const t0 = performance.now(); + await sync.applyPanelToAll(); + out.applyAllMs = performance.now() - t0; + out.applyAllCalls = cardsCalls; + out.applyAllChoiceKeys = sessionStorage.keys().filter((k) => k.includes("crossdesign")).length; + release(); +} + +/* 6. 횡단 카드 보이는 것 먼저 — B06_Section_UI_Section_View.ts 의 진짜 refreshCards 본문 */ +{ + const viewSrc = readFileSync(join(ROOT, "B06_Section", "B06_Section_UI_Section_View.ts"), "utf8"); + const body = viewSrc.match(/\r?\n const refreshCards = [\s\S]*?\r?\n \};\r?\n/)[0]; + const rebuilt = []; + let redraws = 0; + const top = { 0: 0, 20: 150, 40: 300, 60: 450, 80: 600 }; // 카드 위치(px) · 화면 = 0~320 + const factory = new Function( + "cards", + "cardAt", + "rebuildCard", + "offscreen", + "currentDetail", + "redrawProfile", + "fillSlopeNotice", + "cachedStationInterval", + `${transpile(body)}\nreturn refreshCards;`, + ); + let offscreen; + const rebuildCard = (c) => { + offscreen.queue.delete(c); + rebuilt.push(c); + return true; + }; + offscreen = m.createIdleQueue(rebuildCard, 2); + const refreshCards = factory( + { getBoundingClientRect: () => ({ top: 0, bottom: 320 }) }, + (c) => ({ + section: {}, + el: { getBoundingClientRect: () => ({ top: top[c], bottom: top[c] + 140, height: 140 }) }, + }), + rebuildCard, + offscreen, + { cross_sections: [] }, + () => (redraws += 1), + { update: () => undefined }, + 20, + ); + refreshCards([80, 0, 60, 20, 40]); + out.cardsNow = [...rebuilt]; + out.cardsRedraws = redraws; + await sleep(120); + out.cardsAll = [...rebuilt]; +} + +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_316_defaults.py b/resources/tester/test_316_defaults.py index 086d0d9b5..6c0175b12 100644 --- a/resources/tester/test_316_defaults.py +++ b/resources/tester/test_316_defaults.py @@ -80,9 +80,8 @@ def test_ts_defaults_off_and_stored_kept(): assert "twoStageSlope: choice?.two_stage_slope ?? Boolean(design.two_stage_slope)," in _read( "B06_Section/B06_Section_Cross_Refresh.ts" ) - assert "two_stage_slope: design.two_stage_slope ?? false," in _read( - "B06_Section/B06_Section_UI_Page_Design_Sync.ts" - ) + # 지금 값을 「사용자 선택」으로 박는 길은 없앰(346-2 · 사용자 확정 10-16) — 선택은 세션 것만 + assert "changeFromDesign" not in _read("B06_Section/B06_Section_UI_Page_Design_Sync.ts") def test_3d_first_view_perspective(): diff --git a/resources/tester/test_342_1_station_line.py b/resources/tester/test_342_1_station_line.py new file mode 100644 index 000000000..1dfd11def --- /dev/null +++ b/resources/tester/test_342_1_station_line.py @@ -0,0 +1,126 @@ +# -*- coding: utf-8 -*- +"""342-1 · 346-2 — 계획선 한 벌 · 낡음 머묾 · 국부 연산 · 흙 배분 열쇠 · [전체 측점 반영] · 카드. + +못박는 것 + - 머묾 재현: 서버 측점 85.054(0.001 m) · 관 지점 85.05(0.01 m) · 종단곡선 안 — 저장분 선으로 + 다시 세면 화면 선 기준 낡음이 남음(옛 길) · 화면 선을 받으면 낡음 0 · B05 · B06 판정 같음. + - 국부 연산: 같은 입력 다시 셈 0 측점 · 계획고 ▲ 한 번 = 곡선 둘레 몇 측점 · 결과 = 통째 셈. + - 다시 셈 뒤에도 남은 낡음 = 기다림 끝(false) · 콘솔 경고 한 줄 · 다시 그림(곡선은 그림). + - 흙 배분: 실패한 열쇠 다시 보냄 · 받아 둔 열쇠는 다시 안 물음. + - [전체 측점 반영] = 다시 셈 한 번 · 측점 선택을 세션에 안 박음. + - 횡단 카드: 보이는 카드만 바로 · 나머지는 쉬는 틈에. + +Node 헬퍼 `helper_342_1_station_line.mjs` 가 vite ssr 로 묶은 진짜 모듈(`helper_342_1_entry.ts`)을 +부름. +""" + +import json +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from config.config_system import STANDARD_CROSS_SECTION # noqa: E402 + +FIXTURE = HERE / "fixtures" / "b05_192_1_moved_pipe_longitudinal.json" + + +@pytest.fixture(scope="module") +def out(tmp_path_factory): + work = tmp_path_factory.mktemp("b342") + build = subprocess.run( + [ + "node", + str(ROOT / "config" / "node_modules" / "vite" / "bin" / "vite.js"), + "build", + "--configLoader", + "runner", + "--ssr", + "../resources/tester/helper_342_1_entry.ts", + "--outDir", + str(work), + "--logLevel", + "error", + ], + cwd=ROOT, + capture_output=True, + text=True, + encoding="utf-8", + timeout=300, + ) + assert build.returncode == 0, build.stderr + bundle = work / "bundle.mjs" + shutil.copy(work / "helper_342_1_entry.js", bundle) + policy = json.loads(FIXTURE.read_text(encoding="utf-8"))["profile_alignment"]["policy"] + source = work / "input.json" + source.write_text( + json.dumps({"standard": STANDARD_CROSS_SECTION, "policy": policy}, default=str), + encoding="utf-8", + ) + proc = subprocess.run( + ["node", str(HERE / "helper_342_1_station_line.mjs"), str(bundle), str(source)], + cwd=ROOT, + capture_output=True, + text=True, + encoding="utf-8", + timeout=120, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_화면_선은_관_지점으로_갈음(out): + assert 85.05 in out["screenStations"] and 85.054 not in out["screenStations"] + + +def test_머묾_재현_옛_길은_낡음이_남음(out): + assert out["staleBefore"] == [85.054] + assert out["staleOldPath"] == [85.054] + assert out["staleAgainstScreenBefore"] is True + + +def test_화면_선을_받으면_낡음_0_두_판정_같음(out): + assert out["staleNewPath"] == [] + assert out["staleAgainstScreenAfter"] is False + assert out["staleB06View"] == [] + + +def test_국부_연산_바뀐_측점만(out): + assert out["recomputedSame"] == 0 + assert 0 < out["recomputedEdit"] < out["total"] + assert out["sameAsWhole"] is True + + +def test_남은_낡음은_곡선을_그리고_경고(out): + assert out["settleFirst"] is True # 다시 셈 기다림 + assert out["settleSameKey"] is True # 다시 셈이 못 고침 + assert out["settleAfter"] is False # 기다림 끝 — 곡선 그림 + assert out["settleWarns"] == 1 + assert out["settleRedraws"] >= 1 # 바뀐 것 없어도 다시 그림 + assert out["settleClear"] is False + + +def test_흙_배분_실패_열쇠_다시_보냄_받은_열쇠는_안_물음(out): + assert out["haulAfterFail"] == 1 + assert out["haulRetry"] == 2 + assert out["haulB"] == 3 + assert out["haulKept"] == 3 + assert out["haulKeptPlan"] == 2 + + +def test_전체_측점_반영은_한_번_선택_안_박음(out): + assert out["applyAllCalls"] == [out["total"]] + assert out["applyAllChoiceKeys"] == 0 + + +def test_횡단_카드_보이는_것_먼저(out): + assert out["cardsNow"] == [0, 20, 40] + assert out["cardsRedraws"] == 1 + assert sorted(out["cardsAll"]) == [0, 20, 40, 60, 80]