feat(B05): 진입 로딩 3D 후순위 · 도넛 회전 분리 · 직교/원근 전환 단추

사용자 지시(2026-09-04) — 「3D 는 보조라 후순위. 조용히 뒤에서 뜨고 다 되면
팝업으로 알릴 것. 로딩 도넛은 진행률과 무관하게 계속 돌고 숫자만 갱신」,
그리고 「직교/원근 전환 단추는 넣음」.

진입 로딩
- 단계 5 → 4. ④ 종·횡단 자료까지 끝나면 로딩 표시를 걷어 화면을 바로 쓰게 함.
- 3D 지형은 finally 뒤 배경 작업으로 돌리고 끝나면 토스트 「3D 지형 준비 완료」.
  실패해도 나머지 화면은 그대로 씀. [초기화]는 재진입이라 같은 규칙이 걸림.
- 「확정 지표면 없음」 판정을 ③ 뒤로 앞당김.

진행 서클(공용)
- 회전 껍데기(`__spin`)를 한 겹 두고 CSS 회전을 거기에만 검. 껍데기는 늘 돌고
  안쪽 svg 는 12시 고정이라 호·숫자가 제자리에서 갱신됨. `is-indeterminate` 제거.

직교/원근 전환
- 카메라 두 벌을 `B05_Profile_UI_Viewer_Camera.ts` 가 쥐고 갈아 끼움. 위치·시선·
  근평면·먼평면과 보이는 크기를 옮겨 화면이 튀지 않음.
- 마커 입력·구조물 클릭·커서 피벗 유틸이 카메라를 함수로 받게 바꿈(공용 유틸은
  값도 그대로 받아 B04 호출부는 무변경).

자체검증(공용 브라우저 5173, 용화_LAS) — 진행률 25→50→75%, 회전 애니메이션
running 유지(변환행렬 표본 전부 다름), 로딩 걷힌 순간 그래프선 2·좌측단추 61·
3D 메쉬 2, 1.8초 뒤 메쉬 489 + 토스트. 초기화 재진입도 같음. 전환 단추는
Ortho ↔ Perspective 왕복 후 값 완전 일치, B04 페이지 오류 0건.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-04 19:13:46 +09:00
co-authored by Claude Opus 5
parent 84042fe010
commit 5203191751
9 changed files with 193 additions and 64 deletions
+13 -3
View File
@@ -120,7 +120,11 @@ function pivotReticleTexture(): THREE.CanvasTexture {
}
export interface CursorPivotOptions {
camera: THREE.PerspectiveCamera | THREE.OrthographicCamera;
/** 카메라. **바뀔 수 있으면 함수로** 준다 — B05는 직교/원근을 갈아 끼운다(2026-09-04). */
camera:
| THREE.PerspectiveCamera
| THREE.OrthographicCamera
| (() => THREE.PerspectiveCamera | THREE.OrthographicCamera);
controls: OrbitControls;
/** 포인터 이벤트를 받는 캔버스. */
element: HTMLElement;
@@ -134,7 +138,9 @@ export interface CursorPivotOptions {
/** 커서 기준 회전·줌을 붙이고, 해제 함수를 돌려준다. */
export function bindCursorPivotControls(options: CursorPivotOptions): () => void {
const { camera, controls, element } = options;
const { controls, element } = options;
const getCamera = (): THREE.PerspectiveCamera | THREE.OrthographicCamera =>
typeof options.camera === "function" ? options.camera() : options.camera;
// 회전·줌 모두 여기서 직접 처리한다(OrbitControls에는 휠 방향을 뒤집는 설정이 없다).
controls.enableRotate = false;
controls.enableZoom = false;
@@ -165,6 +171,7 @@ export function bindCursorPivotControls(options: CursorPivotOptions): () => void
/** 조준점을 현재 축 위치·크기로 맞춘다. 스프라이트 scale = 쿼드의 월드 폭. */
function syncPivotMarker(): void {
const camera = getCamera();
if (!pivotMarker || !pivotMarker.visible) return;
pivotMarker.position.copy(pivot);
const distance = camera.position.distanceTo(pivot);
@@ -190,6 +197,7 @@ export function bindCursorPivotControls(options: CursorPivotOptions): () => void
* 지형을 맞히면 그 점을 쓰고, 하늘·구멍이라 못 맞히면 시선에 수직이고 현재 target을
* 지나는 평면과 광선을 만나게 해 **커서 방향**의 점을 쓴다(화면 중앙으로 돌아가지 않는다). */
function pickPivot(event: { clientX: number; clientY: number }): void {
const camera = getCamera();
pivot.copy(controls.target);
const rect = element.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) return;
@@ -219,7 +227,7 @@ export function bindCursorPivotControls(options: CursorPivotOptions): () => void
// 그랩 팬 시작 — 커서 아래 지형점을 잡고, 시선 수직 평면 위에서 따라오게 한다.
pickPivot(event);
panAnchor.copy(pivot);
camera.getWorldDirection(viewDirection);
getCamera().getWorldDirection(viewDirection);
panPlane.setFromNormalAndCoplanarPoint(viewDirection, panAnchor);
panPointerId = event.pointerId;
element.setPointerCapture?.(event.pointerId);
@@ -243,6 +251,7 @@ export function bindCursorPivotControls(options: CursorPivotOptions): () => void
/** 휠 줌 — 커서가 가리키는 지점을 축으로 삼아 그 점이 화면에 고정된 채 멀어지고 가까워진다.
* 휠을 위로 올리면 멀어진다(사용자 지시). */
function onWheel(event: WheelEvent): void {
const camera = getCamera();
if (!controls.enabled || options.blocked?.()) return;
event.preventDefault();
pickPivot(event);
@@ -265,6 +274,7 @@ export function bindCursorPivotControls(options: CursorPivotOptions): () => void
}
function onPointerMove(event: PointerEvent): void {
const camera = getCamera();
if (panPointerId === event.pointerId) {
// 그랩 팬 — 잡은 점이 커서 아래에 계속 오도록 카메라·target을 평행 이동한다.
const rect = element.getBoundingClientRect();