diff --git a/B04_PreProcess/B04_PreProcess_UI_TerrainViewer.ts b/B04_PreProcess/B04_PreProcess_UI_TerrainViewer.ts index 39819486..215afe8b 100644 --- a/B04_PreProcess/B04_PreProcess_UI_TerrainViewer.ts +++ b/B04_PreProcess/B04_PreProcess_UI_TerrainViewer.ts @@ -432,13 +432,37 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { return { center, span }; }; + /** 화면맞춤 기준 범위 — 지표면 범위에 계획노선까지 담는다. + * + * 카메라 타깃은 늘 지표면 중심(0,0,0)이라 범위를 한쪽으로만 늘려서는 소용이 없다. + * 중심에서 가장 먼 노선 정점까지를 반폭으로 잡아 **대칭으로** 넓힌다. 그러지 않으면 + * 라이다가 노선의 일부만 덮을 때 나머지가 화면 밖으로 잘린다(2026-09-02 용화 실측: + * 노선 2,136m 중 라이다는 1,400m만 덮어 오른쪽이 캔버스 밖으로 나갔다). */ + const fitBounds = (): SurfaceBounds | null => { + if (!referenceBounds || routePoints.length < 2) return referenceBounds; + const cx = (referenceBounds.x_min + referenceBounds.x_max) / 2; + const cy = (referenceBounds.y_min + referenceBounds.y_max) / 2; + let halfX = (referenceBounds.x_max - referenceBounds.x_min) / 2; + let halfY = (referenceBounds.y_max - referenceBounds.y_min) / 2; + for (const point of routePoints) { + halfX = Math.max(halfX, Math.abs(point.x - cx)); + halfY = Math.max(halfY, Math.abs(point.y - cy)); + } + return { + ...referenceBounds, + x_min: cx - halfX, + x_max: cx + halfX, + y_min: cy - halfY, + y_max: cy + halfY, + }; + }; + const fitCamera = (object: THREE.Object3D) => { const { span } = getFitParams(object); const aspect = viewerArea.clientWidth / Math.max(viewerArea.clientHeight, 1); - const distance = referenceBounds - ? getTopFitDistance(referenceBounds, aspect) - : span * 1.2; + const bounds = fitBounds(); + const distance = bounds ? getTopFitDistance(bounds, aspect) : span * 1.2; controls.target.set(0, 0, 0); // 정확히 수직이면 lookAt이 화면 방향을 못 정해 첫 드래그에 화면이 뒤집힌다. camera.position.set(0, distance, distance * TOP_VIEW_TILT); diff --git a/B05_Profile/B05_Profile_Engine_Grade_Profile.py b/B05_Profile/B05_Profile_Engine_Grade_Profile.py index b8db30eb..87cdae81 100644 --- a/B05_Profile/B05_Profile_Engine_Grade_Profile.py +++ b/B05_Profile/B05_Profile_Engine_Grade_Profile.py @@ -44,6 +44,10 @@ from B05_Profile.B05_Profile_Engine_Grade_Solver import ( from config.config_system import FOREST_ROAD_PROFILE_ALIGNMENT ALIGNMENT_PROFILE_ID = "design_grade_line" +# 계획선이 어느 진입점에서 나왔는지 저장본만 보고 가릴 수 있게 값을 나눈다. 1차·2차가 +# 같은 `_profile_entry()` 를 쓰다 보니 둘 다 `station_alignment` 로 나가, 사고 조사 때 +# 폴백으로 떨어진 것을 저장본에서 확인하지 못했다(2026-09-02). +PIPE_ANCHORED_BASIS = "pipe_anchored" ALIGNMENT_BASIS = "station_alignment" @@ -68,6 +72,7 @@ def _profile_entry( direction: str, balanced: bool, warnings: list[str], + basis: str = ALIGNMENT_BASIS, ) -> dict[str, Any]: """`design_profiles` 배열 계약(기존 스키마)에 맞춰 계획선 한 벌을 만든다. @@ -80,7 +85,7 @@ def _profile_entry( "schema_version": ALIGNMENT_SCHEMA_VERSION, "id": ALIGNMENT_PROFILE_ID, "name": "계획선", - "basis": ALIGNMENT_BASIS, + "basis": basis, "criteria": {**options.as_dict(), "resolved_main_direction": direction}, # 구 스키마 호환: 변화점 목록을 pvis 이름으로도 노출한다. "pvis": alignment["pvi"], @@ -254,7 +259,9 @@ def design_pipe_anchored_profile( ) warnings.extend(alignment["warnings"]) balanced = bool(alignment["balance"]["within_tolerance"]) - return alignment, _profile_entry(alignment, options, direction, balanced, warnings) + return alignment, _profile_entry( + alignment, options, direction, balanced, warnings, PIPE_ANCHORED_BASIS + ) def design_alignment_profile( @@ -389,4 +396,6 @@ def rebuild_alignment_profile( direction, alignment["balance"]["within_tolerance"], alignment["warnings"], + # 재구성은 저장된 자동 선형을 그대로 쓰므로 출처도 그대로 물려받는다. + str(previous.get("basis") or ALIGNMENT_BASIS), )