From 0a5c485b5c16ce6dd44faaa6f9745de77801e9f0 Mon Sep 17 00:00:00 2001 From: eunseo-com Date: Mon, 10 Aug 2026 21:33:19 +0900 Subject: [PATCH 01/22] Design C3 world to RealityKit escape flow --- docs/WORK_LOG.md | 7 +- .../2026-08-10-ch1-reality-escape-design.md | 122 ++++++++++++++++++ 2 files changed, 126 insertions(+), 3 deletions(-) create mode 100644 docs/superpowers/specs/2026-08-10-ch1-reality-escape-design.md diff --git a/docs/WORK_LOG.md b/docs/WORK_LOG.md index 1118ee5..cf2c70b 100644 --- a/docs/WORK_LOG.md +++ b/docs/WORK_LOG.md @@ -4,11 +4,11 @@ ## 현재 인수인계 -- 상태: Chapter 1 구현·DocC 카탈로그 완료, Draft PR의 최종 보완 대기 -- 진행 중 범위: Chapter 1 `ClosedWorld` 구현과 DocC 카탈로그(Task 1~8)를 완료했다. 최종 검토에서 확인한 보완 항목을 별도 커밋으로 처리한 뒤 병합 준비를 마친다. +- 상태: C3 월드 기반 SceneKit → RealityKit 재설계 승인, 구현 시작 전 +- 진행 중 범위: Draft PR의 Chapter 1 결과물을 기준선으로 두고, C3_Piggy의 3D 월드와 실제 공간 숨기 흐름을 결합하는 별도 브랜치 설계를 시작했다. - 마지막 완료 범위: Task 8 — Chapter 1 DocC 튜토리얼 카탈로그, 5개 현재 소스 스니펫, Tuist 타깃 인식 설정. - 마지막 검증: `tuist generate --no-open` 성공. `xcodebuild docbuild -project PiggyEscape/PiggyEscape.xcodeproj -scheme PiggyEscape -destination 'platform=iOS Simulator,name=iPhone 17 Pro' -derivedDataPath /tmp/piggyescape-docbuild`가 `.doccarchive`를 만들고 `** BUILD DOCUMENTATION SUCCEEDED **`로 완료했다. -- 다음 시작점: 가짜 소파 바닥 정렬, 실제 장면의 NodeInspector 실행 예시, DocC의 가짜 소파 코드·화면 연결 설명, 공통 인수인계 문서 통합을 보완하고 재검토한다. +- 다음 시작점: `docs/superpowers/specs/2026-08-10-ch1-reality-escape-design.md`를 검토한 뒤 구현 계획을 작성한다. - 차단 요소: 없음. ### Task 7에서 해결한 항목 @@ -21,6 +21,7 @@ | 날짜 | 작업 범위 | 결과 | 검증 | 다음 시작점 | | --- | --- | --- | --- | --- | +| 2026-08-10 | Chapter 1 C3 월드·현실 숨기 재설계 | C3 SceneKit 월드에서 나무 뒤로 숨었다가 카메라 회전에 들키고, RealityKit의 실제 메쉬 뒤로 이동하는 흐름을 설계함. 금융·저장·Watch 범위는 제외 | 설계 문서 검토 대기 | 구현 계획 작성 | | 2026-08-10 | Chapter 1 최종 검토 | 가짜 소파 바닥 정렬, 실제 노드 구조 탐구 실행, DocC 단계 완결성, 공통 인수인계 문서 통합의 보완 필요를 확인 | 전체 변경·문서·검증 근거 검토 | 보완 커밋과 재검토 | | 2026-08-10 | Chapter 1 Task 8 | Chapter 1 DocC 튜토리얼 카탈로그와 현재 구현을 반영한 5개 코드 스니펫을 추가하고 앱 타깃에 등록함 | `tuist generate --no-open` 성공, `xcodebuild docbuild`가 `.doccarchive`를 생성하고 성공으로 완료 | Chapter 1 결과물 검토 또는 다음 챕터 설계 | | 2026-08-10 | Chapter 1 Task 7 최종 재검토 | 구현·변환·카메라·인터랙션 회귀 검토를 마침. 생성물 제외 규칙과 테스트 추적 상태를 보완함 | 26/26 테스트 통과 결과와 변경 내용을 재검토 | Task 8 | diff --git a/docs/superpowers/specs/2026-08-10-ch1-reality-escape-design.md b/docs/superpowers/specs/2026-08-10-ch1-reality-escape-design.md new file mode 100644 index 0000000..fc4650d --- /dev/null +++ b/docs/superpowers/specs/2026-08-10-ch1-reality-escape-design.md @@ -0,0 +1,122 @@ +# Chapter 1 — C3 월드에서 현실 세계로 탈출하기 설계 + +## 배경 + +기존 Chapter 1의 단순 방·가짜 소파 장면은 Draft PR의 기준선으로 남긴다. 이 설계는 그 장면을 C3_Piggy의 SceneKit 월드로 교체하고, 가짜 나무 뒤에 숨은 돼지가 카메라 회전에 의해 드러난 뒤 RealityKit의 실제 공간으로 넘어가는 체험을 정의한다. + +## 목표 + +사용자는 먼저 코드로 구성된 C3_Piggy 월드를 회전 카메라로 살핀다. 돼지가 기존 나무 뒤로 걸어가 숨으려 해도, 사용자가 카메라를 돌리면 즉시 드러난다. 돼지는 현실 세계로 넘어가고, 사용자가 직접 지정한 실제 물체의 반대편으로 이동한다. LiDAR가 재구성한 실제 메쉬가 돼지를 가리므로, 사용자는 실제로 다른 위치로 움직여야 돼지를 다시 찾을 수 있다. + +## 범위와 제외 범위 + +### 포함 + +- C3_Piggy의 SceneKit 3D 월드 표현: 섬, 기존 나무, 돼지 모델, 카메라 회전·줌, 조명, 에셋 로딩과 걷기 애니메이션 +- 돼지 나레이션, 탭으로 시작하는 가짜 숨기, 카메라 회전으로 발견되는 장면 +- 카메라 권한 요청을 포함한 SceneKit → RealityKit 자동 전환 +- iOS LiDAR 실기기의 ARKit 평면·메쉬 재구성과 RealityKit 오클루전 +- 사용자가 실제 물체의 세로 면을 탭해 숨을 곳을 지정하는 인터랙션 +- Chapter 1 DocC·프로젝트 컨텍스트·인수인계 갱신 + +### 제외 + +- C3_Piggy의 금융 로직, SwiftData 저장소, 용돈 관리, WatchConnectivity, Watch 앱과 관련 화면 +- 자동으로 물체의 의미를 판별하는 분류 UX. 사용자가 실제 물체를 직접 탭한다. +- LiDAR가 없는 기기에서 실제 물체 오클루전을 흉내 내는 대체 기능 +- Chapter 2 이후의 상세 구현과 문서 배포 + +## 경험 흐름 + +```text +openingNarration + → readyForPigTap + → walkingBehindTree + → hiddenInClosedWorld + → discoveredByCamera + → cameraPermission + → scanningReality + → waitingForRealTarget + → walkingBehindRealObject + → hiddenInReality +``` + +1. C3 월드가 나타나면 돼지가 “아, 나 좀 그만 쳐다보지. 나 숨고 싶어…”라고 말한다. +2. 나레이션이 끝난 뒤에만 돼지를 탭할 수 있다. +3. 탭하면 `Piggy_running.usdz`의 걷기 애니메이션을 재생하며, 기존 `Cylinder_Tree` 에셋의 카메라 반대편으로 이동한다. +4. 돼지가 나무 뒤에 도착한 뒤 사용자가 C3의 궤도 카메라를 충분히 회전하면, 실제 렌더링에서도 돼지가 드러나는 각도를 확인하고 “아, 들켰네… 제대로 숨고 싶은데.” 나레이션을 표시한다. +5. 짧은 페이드 전환 뒤 시스템 카메라 권한 요청을 시작한다. 권한을 허용하면 RealityKit 화면을 자동으로 연다. +6. RealityKit 화면은 먼저 실제 공간을 스캔하고 “숨을 물체의 옆면을 탭해줘.”라고 안내한다. +7. 사용자가 실제 물체의 세로 메쉬 면을 탭하면, 돼지는 그 면에서 현재 카메라와 반대가 되는 쪽의 바닥 위치로 걷는다. +8. 실제 메쉬가 초기 시점에서 돼지를 가린다. 사용자가 카메라 위치를 옮겨 반대편을 보면 돼지가 다시 보인다. + +## SceneKit 월드 + +### C3 재사용 경계 + +`PiggyWorldScene`, `IslandBuilder`, `PigController`, `SceneContainerView`, `AssetLoader`에서 월드를 구성하는 부분을 기준으로 재사용한다. 카메라 궤도·조명·섬 타일·장식물·돼지 모델의 좌표와 애니메이션 동작은 보존한다. SwiftData 모델, 잔액에 따른 크기 변화, 작은 돼지, 이체·입금·Watch 연동 경로는 가져오지 않는다. + +월드 코드에는 금융 상태가 없는 작은 어댑터를 둔다. 이 어댑터는 다음 책임만 갖는다. + +- C3 섬과 기존 `Cylinder_Tree`를 이름이 있는 씬 노드로 생성한다. +- 돼지의 정지 모델과 걷기 모델을 전환한다. +- 카메라 yaw·zoom을 C3와 같은 제스처로 갱신한다. +- 선택한 나무의 경계와 현재 카메라 방향으로 숨는 위치를 계산한다. + +돼지의 바깥 노드는 위치·걷기 액션을 맡고, 안쪽 모델 노드는 원본 축 보정·표준 크기를 유지한다. 따라서 모델 교체나 이동 액션이 원본 에셋의 변환을 덮어쓰지 않는다. + +### 가짜 숨기와 발견 + +숨는 좌표는 나무의 월드 경계를 기준으로 현재 카메라에서 멀어지는 방향에 배치한다. 돼지는 나무를 통과하지 않도록 나무의 발자국 바깥에 서고, 화면 중심의 초기 시점에서는 나무가 돼지를 가린다. + +발견 상태는 카메라 yaw가 숨기 시작 시점과 충분히 달라지고 돼지가 뷰 프러스텀에 들어온 뒤에만 시작한다. 이 조건과 실제 Simulator 장면 검증을 함께 사용해, 단순한 타이머가 아니라 카메라 회전의 결과로 들켰다는 서사를 유지한다. + +## 나레이션과 전환 디자인 + +새 폰트나 이미지 에셋은 추가하지 않는다. C3의 네이티브 UI 규칙을 따른다. + +- SwiftUI 안내·권한 전환 문구: `.system(..., design: .rounded)`의 bold/heavy, 흰색, 얕은 검은 그림자 +- SceneKit 위 나레이션: C3의 `overlaySKScene` 방식과 `AvenirNext-Bold` 레이블을 사용하며, 한글은 시스템 폴백을 허용한다. +- 컨테이너: 반투명 어두운 표면 또는 `.ultraThinMaterial`, 둥근 모서리, 짧은 fade/scale 전환 +- 색: C3의 하늘·복셀 월드를 가리지 않는 흰색 본문과 낮은 불투명도 검은 그림자. 돼지 핑크와 노란 강조색은 상태 안내에만 사용한다. + +SceneKit의 복셀 월드가 페이드아웃하고 시스템 권한 문구가 나타나는 순간을 전환점으로 삼는다. 권한 거부 시에는 이유와 설정으로 가는 방법을 같은 스타일의 안내로 표시하고, RealityKit 세션을 시작하지 않는다. + +## RealityKit 실제 숨기 + +iOS 17을 유지하기 위해 `ARView`를 SwiftUI에 연결한다. 세션 시작 전 `ARWorldTrackingConfiguration.supportsSceneReconstruction(.meshWithClassification)`을 확인한다. 지원 기기에서는 아래 설정을 사용한다. + +- 수평·수직 평면 감지 +- `sceneReconstruction = .meshWithClassification` +- RealityKit scene understanding의 `.occlusion`, `.collision`, `.physics`, `.receivesLighting` + +사용자 탭은 RealityKit 충돌 raycast로 실제 재구성 메쉬를 맞춘다. 선택한 면의 법선이 수직에 가까운 경우만 숨기 대상으로 허용한다. 탁자 윗면처럼 수평인 면, 바닥을 찾지 못하는 위치, 또는 카메라와 너무 가까운 위치는 안내를 표시하고 다시 선택하게 한다. + +유효한 탭에서는 표면의 월드 좌표에서 카메라 반대 방향으로 짧게 이동한 후보점을 만든 뒤, 근처의 검증된 수평 평면으로 투영해 돼지의 바닥 위치를 정한다. 돼지는 `Piggy_running` 상태로 후보점까지 이동하고 `Piggy` 정지 모델로 바뀐다. 실제 메쉬는 깊이 버퍼에만 기록되어, 현재 시점의 돼지는 실제 물체 뒤에서 보이지 않는다. 사용자가 물체 옆으로 이동하면 돼지는 다시 렌더링된다. + +LiDAR 메쉬 재구성을 지원하지 않는 기기에서는 실제 숨기 단계를 시작하지 않고 지원 기기가 필요하다고 안내한다. + +## 검증 기준 + +### 자동 검증 + +- C3 월드 어댑터가 기존 나무·돼지·카메라를 이름과 올바른 계층으로 생성한다. +- 나무 뒤 숨기 좌표가 나무의 카메라 반대편이며 돼지의 발이 바닥에 닿는다. +- 경험 상태는 나레이션 완료 전 돼지 탭을 무시하고, 정의된 순서로만 전환한다. +- 카메라 yaw 변화와 프러스텀 조건이 충족되기 전에는 발견 상태가 되지 않는다. +- RealityKit 숨기 좌표 계산은 세로 면·수평 바닥·카메라 방향 입력을 사용하며, 수평 선택과 가까운 선택을 거부한다. +- 기존 C3 원본에서 가져온 에셋은 바이트 단위로 같은지 확인한다. + +### 수동 실기기 검증 + +- LiDAR 지원 기기에서 카메라 권한 요청, 평면·메쉬 스캔, 실제 물체 세로 면 탭을 확인한다. +- 선택한 실제 물체의 현재 카메라 반대편에서 돼지가 가려지는지 확인한다. +- 사용자가 실제로 옆으로 이동했을 때 돼지가 다시 보이는지 확인한다. +- 권한 거부·LiDAR 미지원·수평면 탭·추적 부족의 안내와 복구 경로를 확인한다. + +## 문서와 협업 + +- C3_Piggy는 로컬 참고 원본이다. 재사용한 구조와 에셋 출처, 금융·Watch 기능을 제외한 이유를 저장소 문서에 요약한다. +- `docs/WORK_LOG.md`와 `docs/PROJECT_CONTEXT.md`는 새 흐름과 검증 상태를 갱신한다. +- Draft PR의 후속 보완 항목은 새 설계의 대응 범위에서 정리하고, 구현이 검증되기 전에는 완료로 표시하지 않는다. +- 커밋과 PR에는 작업자·도구·모델 표기를 남기지 않는다. From 88b9eff4f79bd180635a5ddd2dea2cd4bd1e7723 Mon Sep 17 00:00:00 2001 From: eunseo-com Date: Mon, 10 Aug 2026 21:39:52 +0900 Subject: [PATCH 02/22] Refine discovered pig narration design --- docs/WORK_LOG.md | 2 +- .../specs/2026-08-10-ch1-reality-escape-design.md | 9 ++++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/docs/WORK_LOG.md b/docs/WORK_LOG.md index cf2c70b..1dbc0f1 100644 --- a/docs/WORK_LOG.md +++ b/docs/WORK_LOG.md @@ -21,7 +21,7 @@ | 날짜 | 작업 범위 | 결과 | 검증 | 다음 시작점 | | --- | --- | --- | --- | --- | -| 2026-08-10 | Chapter 1 C3 월드·현실 숨기 재설계 | C3 SceneKit 월드에서 나무 뒤로 숨었다가 카메라 회전에 들키고, RealityKit의 실제 메쉬 뒤로 이동하는 흐름을 설계함. 금융·저장·Watch 범위는 제외 | 설계 문서 검토 대기 | 구현 계획 작성 | +| 2026-08-10 | Chapter 1 C3 월드·현실 숨기 재설계 | C3 SceneKit 월드에서 나무 뒤로 숨었다가 카메라 회전에 들키면 놀란 돼지 모델·자막으로 반응하고, RealityKit의 실제 메쉬 뒤로 이동하는 흐름을 설계함. 금융·저장·Watch 범위는 제외 | 설계 문서 검토 대기 | 구현 계획 작성 | | 2026-08-10 | Chapter 1 최종 검토 | 가짜 소파 바닥 정렬, 실제 노드 구조 탐구 실행, DocC 단계 완결성, 공통 인수인계 문서 통합의 보완 필요를 확인 | 전체 변경·문서·검증 근거 검토 | 보완 커밋과 재검토 | | 2026-08-10 | Chapter 1 Task 8 | Chapter 1 DocC 튜토리얼 카탈로그와 현재 구현을 반영한 5개 코드 스니펫을 추가하고 앱 타깃에 등록함 | `tuist generate --no-open` 성공, `xcodebuild docbuild`가 `.doccarchive`를 생성하고 성공으로 완료 | Chapter 1 결과물 검토 또는 다음 챕터 설계 | | 2026-08-10 | Chapter 1 Task 7 최종 재검토 | 구현·변환·카메라·인터랙션 회귀 검토를 마침. 생성물 제외 규칙과 테스트 추적 상태를 보완함 | 26/26 테스트 통과 결과와 변경 내용을 재검토 | Task 8 | diff --git a/docs/superpowers/specs/2026-08-10-ch1-reality-escape-design.md b/docs/superpowers/specs/2026-08-10-ch1-reality-escape-design.md index fc4650d..92b7473 100644 --- a/docs/superpowers/specs/2026-08-10-ch1-reality-escape-design.md +++ b/docs/superpowers/specs/2026-08-10-ch1-reality-escape-design.md @@ -12,7 +12,7 @@ ### 포함 -- C3_Piggy의 SceneKit 3D 월드 표현: 섬, 기존 나무, 돼지 모델, 카메라 회전·줌, 조명, 에셋 로딩과 걷기 애니메이션 +- C3_Piggy의 SceneKit 3D 월드 표현: 섬, 기존 나무, 돼지의 정지·걷기·놀람 모델, 카메라 회전·줌, 조명, 에셋 로딩과 걷기 애니메이션 - 돼지 나레이션, 탭으로 시작하는 가짜 숨기, 카메라 회전으로 발견되는 장면 - 카메라 권한 요청을 포함한 SceneKit → RealityKit 자동 전환 - iOS LiDAR 실기기의 ARKit 평면·메쉬 재구성과 RealityKit 오클루전 @@ -44,7 +44,7 @@ openingNarration 1. C3 월드가 나타나면 돼지가 “아, 나 좀 그만 쳐다보지. 나 숨고 싶어…”라고 말한다. 2. 나레이션이 끝난 뒤에만 돼지를 탭할 수 있다. 3. 탭하면 `Piggy_running.usdz`의 걷기 애니메이션을 재생하며, 기존 `Cylinder_Tree` 에셋의 카메라 반대편으로 이동한다. -4. 돼지가 나무 뒤에 도착한 뒤 사용자가 C3의 궤도 카메라를 충분히 회전하면, 실제 렌더링에서도 돼지가 드러나는 각도를 확인하고 “아, 들켰네… 제대로 숨고 싶은데.” 나레이션을 표시한다. +4. 돼지가 나무 뒤에 도착한 뒤 사용자가 C3의 궤도 카메라를 충분히 회전하면, 실제 렌더링에서도 돼지가 드러나는 각도를 확인한다. 이 순간 돼지 모델을 `Piggy_surprised.usdz`로 전환하고 “아, 들켰네… 제대로 숨고 싶은데.” 자막을 동시에 표시한다. 5. 짧은 페이드 전환 뒤 시스템 카메라 권한 요청을 시작한다. 권한을 허용하면 RealityKit 화면을 자동으로 연다. 6. RealityKit 화면은 먼저 실제 공간을 스캔하고 “숨을 물체의 옆면을 탭해줘.”라고 안내한다. 7. 사용자가 실제 물체의 세로 메쉬 면을 탭하면, 돼지는 그 면에서 현재 카메라와 반대가 되는 쪽의 바닥 위치로 걷는다. @@ -59,7 +59,7 @@ openingNarration 월드 코드에는 금융 상태가 없는 작은 어댑터를 둔다. 이 어댑터는 다음 책임만 갖는다. - C3 섬과 기존 `Cylinder_Tree`를 이름이 있는 씬 노드로 생성한다. -- 돼지의 정지 모델과 걷기 모델을 전환한다. +- 돼지의 정지 모델, 걷기 모델, 놀란 모델을 전환한다. - 카메라 yaw·zoom을 C3와 같은 제스처로 갱신한다. - 선택한 나무의 경계와 현재 카메라 방향으로 숨는 위치를 계산한다. @@ -71,6 +71,8 @@ openingNarration 발견 상태는 카메라 yaw가 숨기 시작 시점과 충분히 달라지고 돼지가 뷰 프러스텀에 들어온 뒤에만 시작한다. 이 조건과 실제 Simulator 장면 검증을 함께 사용해, 단순한 타이머가 아니라 카메라 회전의 결과로 들켰다는 서사를 유지한다. +발견 순간에는 걷기 모델을 멈추고 `Piggy_surprised.usdz`를 같은 바깥 노드 안에서 보여 준다. 나레이션 오버레이는 모델 전환과 같은 프레임에 나타나며, 페이드 전환이 끝날 때까지 놀란 상태를 유지한다. + ## 나레이션과 전환 디자인 새 폰트나 이미지 에셋은 추가하지 않는다. C3의 네이티브 UI 규칙을 따른다. @@ -104,6 +106,7 @@ LiDAR 메쉬 재구성을 지원하지 않는 기기에서는 실제 숨기 단 - 나무 뒤 숨기 좌표가 나무의 카메라 반대편이며 돼지의 발이 바닥에 닿는다. - 경험 상태는 나레이션 완료 전 돼지 탭을 무시하고, 정의된 순서로만 전환한다. - 카메라 yaw 변화와 프러스텀 조건이 충족되기 전에는 발견 상태가 되지 않는다. +- 발견 상태는 `Piggy_surprised.usdz` 표현과 “아, 들켰네… 제대로 숨고 싶은데.” 자막을 함께 시작한다. - RealityKit 숨기 좌표 계산은 세로 면·수평 바닥·카메라 방향 입력을 사용하며, 수평 선택과 가까운 선택을 거부한다. - 기존 C3 원본에서 가져온 에셋은 바이트 단위로 같은지 확인한다. From cc28116924bf1e7b38f870a75b3c437e5f8e5451 Mon Sep 17 00:00:00 2001 From: eunseo-com Date: Mon, 10 Aug 2026 21:52:37 +0900 Subject: [PATCH 03/22] Add surprise zoom reactions to escape design --- docs/WORK_LOG.md | 2 +- .../specs/2026-08-10-ch1-reality-escape-design.md | 13 ++++++++++--- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/docs/WORK_LOG.md b/docs/WORK_LOG.md index 1dbc0f1..46180e0 100644 --- a/docs/WORK_LOG.md +++ b/docs/WORK_LOG.md @@ -21,7 +21,7 @@ | 날짜 | 작업 범위 | 결과 | 검증 | 다음 시작점 | | --- | --- | --- | --- | --- | -| 2026-08-10 | Chapter 1 C3 월드·현실 숨기 재설계 | C3 SceneKit 월드에서 나무 뒤로 숨었다가 카메라 회전에 들키면 놀란 돼지 모델·자막으로 반응하고, RealityKit의 실제 메쉬 뒤로 이동하는 흐름을 설계함. 금융·저장·Watch 범위는 제외 | 설계 문서 검토 대기 | 구현 계획 작성 | +| 2026-08-10 | Chapter 1 C3 월드·현실 숨기 재설계 | C3 SceneKit 월드에서 나무 뒤로 숨었다가 카메라 회전에 들키면 놀란 돼지 모델·자막·강한 확대 반응을 보이고, RealityKit의 실제 메쉬 뒤에서 다시 들킬 때는 같은 반응과 화면 확대를 보이는 흐름을 설계함. 금융·저장·Watch 범위는 제외 | 설계 문서 검토 대기 | 구현 계획 작성 | | 2026-08-10 | Chapter 1 최종 검토 | 가짜 소파 바닥 정렬, 실제 노드 구조 탐구 실행, DocC 단계 완결성, 공통 인수인계 문서 통합의 보완 필요를 확인 | 전체 변경·문서·검증 근거 검토 | 보완 커밋과 재검토 | | 2026-08-10 | Chapter 1 Task 8 | Chapter 1 DocC 튜토리얼 카탈로그와 현재 구현을 반영한 5개 코드 스니펫을 추가하고 앱 타깃에 등록함 | `tuist generate --no-open` 성공, `xcodebuild docbuild`가 `.doccarchive`를 생성하고 성공으로 완료 | Chapter 1 결과물 검토 또는 다음 챕터 설계 | | 2026-08-10 | Chapter 1 Task 7 최종 재검토 | 구현·변환·카메라·인터랙션 회귀 검토를 마침. 생성물 제외 규칙과 테스트 추적 상태를 보완함 | 26/26 테스트 통과 결과와 변경 내용을 재검토 | Task 8 | diff --git a/docs/superpowers/specs/2026-08-10-ch1-reality-escape-design.md b/docs/superpowers/specs/2026-08-10-ch1-reality-escape-design.md index 92b7473..347763a 100644 --- a/docs/superpowers/specs/2026-08-10-ch1-reality-escape-design.md +++ b/docs/superpowers/specs/2026-08-10-ch1-reality-escape-design.md @@ -39,6 +39,7 @@ openingNarration → waitingForRealTarget → walkingBehindRealObject → hiddenInReality + → discoveredInReality ``` 1. C3 월드가 나타나면 돼지가 “아, 나 좀 그만 쳐다보지. 나 숨고 싶어…”라고 말한다. @@ -48,7 +49,7 @@ openingNarration 5. 짧은 페이드 전환 뒤 시스템 카메라 권한 요청을 시작한다. 권한을 허용하면 RealityKit 화면을 자동으로 연다. 6. RealityKit 화면은 먼저 실제 공간을 스캔하고 “숨을 물체의 옆면을 탭해줘.”라고 안내한다. 7. 사용자가 실제 물체의 세로 메쉬 면을 탭하면, 돼지는 그 면에서 현재 카메라와 반대가 되는 쪽의 바닥 위치로 걷는다. -8. 실제 메쉬가 초기 시점에서 돼지를 가린다. 사용자가 카메라 위치를 옮겨 반대편을 보면 돼지가 다시 보인다. +8. 실제 메쉬가 초기 시점에서 돼지를 가린다. 사용자가 카메라 위치를 옮겨 반대편을 보면 돼지가 다시 보인다. 이 발견 순간에도 놀란 모델·자막·1.5배 돼지 확대를 실행하고, 전체 카메라 화면을 1.12배로 짧게 디지털 확대했다가 원래 크기로 되돌린다. ## SceneKit 월드 @@ -71,7 +72,7 @@ openingNarration 발견 상태는 카메라 yaw가 숨기 시작 시점과 충분히 달라지고 돼지가 뷰 프러스텀에 들어온 뒤에만 시작한다. 이 조건과 실제 Simulator 장면 검증을 함께 사용해, 단순한 타이머가 아니라 카메라 회전의 결과로 들켰다는 서사를 유지한다. -발견 순간에는 걷기 모델을 멈추고 `Piggy_surprised.usdz`를 같은 바깥 노드 안에서 보여 준다. 나레이션 오버레이는 모델 전환과 같은 프레임에 나타나며, 페이드 전환이 끝날 때까지 놀란 상태를 유지한다. +발견 순간에는 걷기 모델을 멈추고 `Piggy_surprised.usdz`를 같은 바깥 노드 안에서 보여 준다. 나레이션 오버레이는 모델 전환과 같은 프레임에 나타나며, 페이드 전환이 끝날 때까지 놀란 상태를 유지한다. 바깥 노드는 0.16초 동안 1.5배로 커지고 0.34초 동안 표준 크기로 돌아와, 내부 모델의 축 보정·표준 크기를 보존하면서 강한 놀람을 표현한다. ## 나레이션과 전환 디자인 @@ -82,6 +83,8 @@ openingNarration - 컨테이너: 반투명 어두운 표면 또는 `.ultraThinMaterial`, 둥근 모서리, 짧은 fade/scale 전환 - 색: C3의 하늘·복셀 월드를 가리지 않는 흰색 본문과 낮은 불투명도 검은 그림자. 돼지 핑크와 노란 강조색은 상태 안내에만 사용한다. +RealityKit에서 실제로 다시 들킨 순간에는 돼지 바깥 엔티티에 같은 1.5배 확대·복귀를 적용한다. 실제 렌즈 화각을 변경하지 않고, `ARView`를 담는 화면 컨테이너를 1.12배로 짧게 확대했다가 되돌려 카메라가 움찔 다가오는 연출을 만든다. 이 디지털 확대는 AR 세션의 추적 좌표나 실제 메쉬 오클루전에 영향을 주지 않는다. + SceneKit의 복셀 월드가 페이드아웃하고 시스템 권한 문구가 나타나는 순간을 전환점으로 삼는다. 권한 거부 시에는 이유와 설정으로 가는 방법을 같은 스타일의 안내로 표시하고, RealityKit 세션을 시작하지 않는다. ## RealityKit 실제 숨기 @@ -96,6 +99,8 @@ iOS 17을 유지하기 위해 `ARView`를 SwiftUI에 연결한다. 세션 시작 유효한 탭에서는 표면의 월드 좌표에서 카메라 반대 방향으로 짧게 이동한 후보점을 만든 뒤, 근처의 검증된 수평 평면으로 투영해 돼지의 바닥 위치를 정한다. 돼지는 `Piggy_running` 상태로 후보점까지 이동하고 `Piggy` 정지 모델로 바뀐다. 실제 메쉬는 깊이 버퍼에만 기록되어, 현재 시점의 돼지는 실제 물체 뒤에서 보이지 않는다. 사용자가 물체 옆으로 이동하면 돼지는 다시 렌더링된다. +RealityKit의 발견은 AR 카메라에서 돼지까지 쏜 충돌 raycast보다 앞선 실제 메쉬 충돌이 더 이상 없을 때 한 번만 시작한다. 이때 돼지를 `Piggy_surprised`로 전환하고, 1.5배 확대·복귀와 “아, 들켰네… 제대로 숨고 싶은데.” 자막, 1.12배 화면 디지털 확대·복귀를 동시에 실행한다. + LiDAR 메쉬 재구성을 지원하지 않는 기기에서는 실제 숨기 단계를 시작하지 않고 지원 기기가 필요하다고 안내한다. ## 검증 기준 @@ -106,8 +111,9 @@ LiDAR 메쉬 재구성을 지원하지 않는 기기에서는 실제 숨기 단 - 나무 뒤 숨기 좌표가 나무의 카메라 반대편이며 돼지의 발이 바닥에 닿는다. - 경험 상태는 나레이션 완료 전 돼지 탭을 무시하고, 정의된 순서로만 전환한다. - 카메라 yaw 변화와 프러스텀 조건이 충족되기 전에는 발견 상태가 되지 않는다. -- 발견 상태는 `Piggy_surprised.usdz` 표현과 “아, 들켰네… 제대로 숨고 싶은데.” 자막을 함께 시작한다. +- SceneKit 발견 상태는 `Piggy_surprised.usdz` 표현·자막·1.5배 확대와 1.0배 복귀를 함께 시작한다. - RealityKit 숨기 좌표 계산은 세로 면·수평 바닥·카메라 방향 입력을 사용하며, 수평 선택과 가까운 선택을 거부한다. +- RealityKit 발견 판정은 실제 메쉬가 가린 선행 충돌이 사라질 때 한 번만 실행하고, 놀란 모델·자막·1.5배 확대·1.12배 화면 확대를 함께 시작한다. - 기존 C3 원본에서 가져온 에셋은 바이트 단위로 같은지 확인한다. ### 수동 실기기 검증 @@ -115,6 +121,7 @@ LiDAR 메쉬 재구성을 지원하지 않는 기기에서는 실제 숨기 단 - LiDAR 지원 기기에서 카메라 권한 요청, 평면·메쉬 스캔, 실제 물체 세로 면 탭을 확인한다. - 선택한 실제 물체의 현재 카메라 반대편에서 돼지가 가려지는지 확인한다. - 사용자가 실제로 옆으로 이동했을 때 돼지가 다시 보이는지 확인한다. +- 실제로 다시 보이는 순간 놀란 돼지·자막·돼지 확대·짧은 화면 확대가 한 번만 실행되고 원래 크기로 복귀하는지 확인한다. - 권한 거부·LiDAR 미지원·수평면 탭·추적 부족의 안내와 복구 경로를 확인한다. ## 문서와 협업 From 7d51da084e966131cb16e3172c46f9016e5cc411 Mon Sep 17 00:00:00 2001 From: eunseo-com Date: Mon, 10 Aug 2026 22:11:19 +0900 Subject: [PATCH 04/22] Plan C3 to RealityKit escape implementation --- docs/WORK_LOG.md | 5 +- ...08-10-ch1-reality-escape-implementation.md | 1167 +++++++++++++++++ 2 files changed, 1170 insertions(+), 2 deletions(-) create mode 100644 docs/superpowers/plans/2026-08-10-ch1-reality-escape-implementation.md diff --git a/docs/WORK_LOG.md b/docs/WORK_LOG.md index 46180e0..5c5f6b8 100644 --- a/docs/WORK_LOG.md +++ b/docs/WORK_LOG.md @@ -4,11 +4,11 @@ ## 현재 인수인계 -- 상태: C3 월드 기반 SceneKit → RealityKit 재설계 승인, 구현 시작 전 +- 상태: C3 월드 기반 SceneKit → RealityKit 재설계 승인, 구현 계획 작성 완료 - 진행 중 범위: Draft PR의 Chapter 1 결과물을 기준선으로 두고, C3_Piggy의 3D 월드와 실제 공간 숨기 흐름을 결합하는 별도 브랜치 설계를 시작했다. - 마지막 완료 범위: Task 8 — Chapter 1 DocC 튜토리얼 카탈로그, 5개 현재 소스 스니펫, Tuist 타깃 인식 설정. - 마지막 검증: `tuist generate --no-open` 성공. `xcodebuild docbuild -project PiggyEscape/PiggyEscape.xcodeproj -scheme PiggyEscape -destination 'platform=iOS Simulator,name=iPhone 17 Pro' -derivedDataPath /tmp/piggyescape-docbuild`가 `.doccarchive`를 만들고 `** BUILD DOCUMENTATION SUCCEEDED **`로 완료했다. -- 다음 시작점: `docs/superpowers/specs/2026-08-10-ch1-reality-escape-design.md`를 검토한 뒤 구현 계획을 작성한다. +- 다음 시작점: `docs/superpowers/plans/2026-08-10-ch1-reality-escape-implementation.md`의 Task 1부터 태스크별 테스트·검토·커밋 순서로 구현한다. - 차단 요소: 없음. ### Task 7에서 해결한 항목 @@ -21,6 +21,7 @@ | 날짜 | 작업 범위 | 결과 | 검증 | 다음 시작점 | | --- | --- | --- | --- | --- | +| 2026-08-10 | Chapter 1 C3 월드→RealityKit 구현 계획 | C3 에셋·상태 기계·섬 어댑터·가짜 숨기·LiDAR 실제 숨기·자동 전환·DocC/실기기 검증을 8개 독립 태스크로 분해함 | 설계 명세·C3 원본 코드·현재 프로젝트/테스트 구조·Apple ARKit/RealityKit 공식 API 대조 | 실행 방식 선택 후 Task 1 | | 2026-08-10 | Chapter 1 C3 월드·현실 숨기 재설계 | C3 SceneKit 월드에서 나무 뒤로 숨었다가 카메라 회전에 들키면 놀란 돼지 모델·자막·강한 확대 반응을 보이고, RealityKit의 실제 메쉬 뒤에서 다시 들킬 때는 같은 반응과 화면 확대를 보이는 흐름을 설계함. 금융·저장·Watch 범위는 제외 | 설계 문서 검토 대기 | 구현 계획 작성 | | 2026-08-10 | Chapter 1 최종 검토 | 가짜 소파 바닥 정렬, 실제 노드 구조 탐구 실행, DocC 단계 완결성, 공통 인수인계 문서 통합의 보완 필요를 확인 | 전체 변경·문서·검증 근거 검토 | 보완 커밋과 재검토 | | 2026-08-10 | Chapter 1 Task 8 | Chapter 1 DocC 튜토리얼 카탈로그와 현재 구현을 반영한 5개 코드 스니펫을 추가하고 앱 타깃에 등록함 | `tuist generate --no-open` 성공, `xcodebuild docbuild`가 `.doccarchive`를 생성하고 성공으로 완료 | Chapter 1 결과물 검토 또는 다음 챕터 설계 | diff --git a/docs/superpowers/plans/2026-08-10-ch1-reality-escape-implementation.md b/docs/superpowers/plans/2026-08-10-ch1-reality-escape-implementation.md new file mode 100644 index 0000000..550ce52 --- /dev/null +++ b/docs/superpowers/plans/2026-08-10-ch1-reality-escape-implementation.md @@ -0,0 +1,1167 @@ +# Chapter 1 — C3 월드에서 현실 세계로 탈출하기 구현 계획 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** C3_Piggy의 SceneKit 섬에서 돼지가 나무 뒤로 숨었다가 카메라 회전에 들키고, LiDAR로 재구성한 실제 물체 뒤로 다시 숨는 iOS 17 체험을 만든다. + +**Architecture:** 기존 Draft PR의 방·가짜 소파 코드는 기준선으로 보존하되, 앱의 진입 화면은 새 `EscapeRootView`로 교체한다. 순수 상태 전이·숨기 좌표·발견 판정은 UIKit/SceneKit/RealityKit에서 분리해 XCTest로 검증하고, C3 월드 어댑터와 `ARView` 코디네이터는 그 결과를 실제 노드·세션·제스처에 적용한다. SceneKit과 RealityKit은 동일한 돼지 포즈/놀람 반응 계약을 공유하지만, 실제 카메라 화각은 변경하지 않고 AR 화면 컨테이너만 1.12배 확대한다. + +**Tech Stack:** Swift 6, SwiftUI, SceneKit, SpriteKit, ARKit, RealityKit, AVFoundation, XCTest, Tuist 4, DocC, iOS 17.0. + +## Global Constraints + +- 작업 브랜치·작업 트리: `ch1-reality-escape`, `/Users/yang-eunseo/Downloads/SpatialComputing_TechMap/.worktrees/ch1-reality-escape`. +- 배포 타깃은 iOS 17.0으로 유지한다. AR UI는 iOS 17에서도 사용할 수 있는 `ARView` + `UIViewRepresentable`로 구현한다. +- C3 참고 원본은 `/Users/yang-eunseo/Downloads/C3_Piggy/C3_Piggy`다. 아래 C3 에셋은 바이트 변경 없이 앱 리소스로 복사한다: `Ground_Color.usdc`, `Piggy.usdc`, `Piggy_running.usdz`, `Piggy_surprised.usdz`, `Cylinder_Tree1_Color.usdc`, `Cylinder_Tree2_Color.usdc`, `Manger_Color.usdc`, `Stone_Color.usdc`, `Coin_Color.usdc`, `Warehouse_Color.usdc`, `Wood_Color.usdc`. +- C3의 카메라 궤도, 조명, 섬 타일·장식물 배치, 돼지 USD 축 보정·걷기 애니메이션 패턴만 재사용한다. SwiftData, 금융 상태, 용돈·작은 돼지, WatchConnectivity, Watch 앱과 그 UI는 가져오지 않는다. +- 시스템 카메라 권한 문구는 `피기가 현실의 물체 뒤에 숨을 수 있도록 카메라를 사용합니다.`로 고정한다. +- SceneKit 나레이션은 `AvenirNext-Bold`(한글 시스템 폴백 허용), SwiftUI 안내는 `.system(..., design: .rounded)`의 bold/heavy·흰색·얕은 검은 그림자를 쓴다. 새 폰트/이미지 에셋을 추가하지 않는다. +- SceneKit 발견과 RealityKit 재발견은 `Piggy_surprised` 전환·"아, 들켰네… 제대로 숨고 싶은데." 자막·바깥 돼지 노드 1.5배 확대(0.16초)·1.0배 복귀(0.34초)를 동시에 시작한다. RealityKit은 추가로 `ARView` 컨테이너를 1.12배 확대 후 복귀한다. +- 실제 숨기는 `ARWorldTrackingConfiguration.supportsSceneReconstruction(.meshWithClassification)`이 참인 LiDAR 지원 실기기에서만 시작한다. 시뮬레이터의 빌드/단위 테스트는 ARKit 실제 오클루전 검증을 대체하지 않는다. +- `ARView.environment.sceneUnderstanding.options`는 `[.occlusion, .collision, .physics, .receivesLighting]`로 설정한다. `.occlusion`은 실제 재구성 메쉬 뒤의 가상 돼지를 렌더링하지 않게 하고, `.collision`은 실제 메쉬 탭·가림 판정 raycast에 사용한다. [Apple 문서](https://developer.apple.com/documentation/realitykit/arview/environment-swift.struct/sceneunderstanding-swift.struct/options-swift.struct) +- 생성된 `.xcodeproj`, `.build`, DerivedData, `.claude/` 활동 로그는 추적하지 않는다. 기존 또는 새로 발견한 추적되지 않은 파일은 명시 요청 없이 이동·삭제·스테이징하지 않는다. +- 각 태스크는 해당 XCTest/빌드 증거와 `docs/WORK_LOG.md` 갱신을 같은 커밋에 포함한다. 커밋·PR에는 작업자·도구·모델 표기와 `Co-Authored-By` 트레일러를 넣지 않는다. + +## File Structure + +| 경로 | 책임 | +| --- | --- | +| `PiggyEscape/Project.swift` | 카메라 권한 Info.plist와 앱/테스트 타깃 리소스 등록 | +| `PiggyEscape/PiggyEscape/Sources/Escape/EscapeExperienceState.swift` | 전체 경험 상태·합법 이벤트·나레이션 문자열 | +| `PiggyEscape/PiggyEscape/Sources/Escape/HidePlanning.swift` | 나무·실제 물체의 카메라 반대편 좌표와 발견 판정 | +| `PiggyEscape/PiggyEscape/Sources/C3World/C3IslandBuilder.swift` | 금융 기능이 제거된 C3 섬 타일·장식물·이름 있는 나무 구성 | +| `PiggyEscape/PiggyEscape/Sources/C3World/C3PigModelFactory.swift` | idle/running/surprised USD 모델, 축 보정, 표준 높이, 바닥 정렬 | +| `PiggyEscape/PiggyEscape/Sources/C3World/C3ClosedWorld.swift` | C3 궤도 카메라·조명·섬·돼지·숨기 실행을 소유 | +| `PiggyEscape/PiggyEscape/Sources/C3World/C3ClosedWorldSceneView.swift` | `SCNView`, 탭/팬/핀치 제스처와 SceneKit 콜백 연결 | +| `PiggyEscape/PiggyEscape/Sources/C3World/NarrationOverlayScene.swift` | C3 스타일의 SpriteKit 나레이션/자막 레이블 | +| `PiggyEscape/PiggyEscape/Sources/Reality/RealityHidePlanner.swift` | 실제 세로 면·바닥·카메라 입력의 유효성·숨기 위치·재발견 판정 | +| `PiggyEscape/PiggyEscape/Sources/Reality/RealityPigVisualController.swift` | RealityKit 돼지 포즈 교체·이동·1.5배 놀람 반응 | +| `PiggyEscape/PiggyEscape/Sources/Reality/RealityHideARView.swift` | `ARView` 세션·메쉬·평면·사용자 탭·실제 오클루전·재발견 감시 | +| `PiggyEscape/PiggyEscape/Sources/Escape/EscapeRootView.swift` | 장면 전환, 권한 요청, C3 스타일 안내 오버레이, AR 화면 확대 | +| `PiggyEscape/PiggyEscape/Sources/ContentView.swift` | 루트 경험 화면 진입점 | +| `PiggyEscape/PiggyEscapeTests/*` | 순수 상태/수학, C3 노드, AR 계획·코디네이터 계약 회귀 테스트 | +| `PiggyEscape/PiggyEscape/Tutorials/SceneKitToRealityKit.docc/*` | C3 월드→실제 오클루전 흐름을 설명하는 Chapter 1 튜토리얼 | +| `docs/PROJECT_CONTEXT.md`, `docs/WORK_LOG.md`, `docs/TROUBLESHOOTING.md` | 공통 인수인계, 실제 검증 결과, ARKit 제한과 출처 | + +--- + +### Task 1: C3 에셋과 카메라 권한을 앱 타깃에 등록한다 + +**Files:** +- Modify: `PiggyEscape/Project.swift` +- Modify: `PiggyEscape/PiggyEscape/Resources/Ground_Color.usdc` +- Modify: `PiggyEscape/PiggyEscape/Resources/Piggy.usdc` +- Modify: `PiggyEscape/PiggyEscape/Resources/Wood_Color.usdc` +- Create: `PiggyEscape/PiggyEscape/Resources/Piggy_running.usdz` +- Create: `PiggyEscape/PiggyEscape/Resources/Piggy_surprised.usdz` +- Create: `PiggyEscape/PiggyEscape/Resources/Cylinder_Tree1_Color.usdc` +- Create: `PiggyEscape/PiggyEscape/Resources/Cylinder_Tree2_Color.usdc` +- Create: `PiggyEscape/PiggyEscape/Resources/Manger_Color.usdc` +- Create: `PiggyEscape/PiggyEscape/Resources/Stone_Color.usdc` +- Create: `PiggyEscape/PiggyEscape/Resources/Coin_Color.usdc` +- Create: `PiggyEscape/PiggyEscape/Resources/Warehouse_Color.usdc` +- Modify: `PiggyEscape/PiggyEscapeTests/AssetLoaderTests.swift` +- Modify: `docs/WORK_LOG.md` + +**Interfaces:** +- Produces: `Bundle.main`에서 `Piggy`, `Piggy_running`, `Piggy_surprised`, 두 `Cylinder_Tree`와 섬 장식 에셋을 확장자 없이 찾을 수 있는 리소스 집합. +- Produces: 앱 빌드 산출물 Info.plist의 `NSCameraUsageDescription` 문자열. + +- [ ] **Step 1: 실패하는 C3 모델 로드 테스트를 추가한다.** + +`PiggyEscape/PiggyEscapeTests/AssetLoaderTests.swift`에 다음 테스트를 추가한다. + +```swift +@MainActor +func test_c3EscapeAssets_loadFromTheAppBundle() { + let names = [ + "Ground_Color", "Piggy", "Piggy_running", "Piggy_surprised", + "Cylinder_Tree1_Color", "Cylinder_Tree2_Color", "Manger_Color", + "Stone_Color", "Coin_Color", "Warehouse_Color", "Wood_Color" + ] + + for name in names { + let node = AssetLoader.object(named: name) + XCTAssertNotNil(node, "expected bundled C3 asset \(name)") + XCTAssertFalse(node?.childNodes.isEmpty ?? true, "expected geometry hierarchy for \(name)") + } +} +``` + +- [ ] **Step 2: 테스트가 새 에셋 누락으로 실패하는지 확인한다.** + +Run: + +```bash +cd PiggyEscape +tuist generate --no-open +xcodebuild -project PiggyEscape.xcodeproj -scheme PiggyEscape \ + -destination 'platform=iOS Simulator,name=iPhone 17 Pro' \ + -only-testing:PiggyEscapeTests/AssetLoaderTests/test_c3EscapeAssets_loadFromTheAppBundle test +``` + +Expected: `Piggy_running`, `Piggy_surprised` 또는 C3 장식물 이름의 bundle lookup assertion이 실패한다. + +- [ ] **Step 3: C3 원본을 바이트 변경 없이 복사하고 복사 결과를 검증한다.** + +Run: + +```bash +for asset in Ground_Color.usdc Piggy.usdc Wood_Color.usdc Piggy_running.usdz Piggy_surprised.usdz Cylinder_Tree1_Color.usdc Cylinder_Tree2_Color.usdc Manger_Color.usdc Stone_Color.usdc Coin_Color.usdc Warehouse_Color.usdc; do + cmp -s "/Users/yang-eunseo/Downloads/C3_Piggy/C3_Piggy/$asset" "PiggyEscape/PiggyEscape/Resources/$asset" || cp "/Users/yang-eunseo/Downloads/C3_Piggy/C3_Piggy/$asset" "PiggyEscape/PiggyEscape/Resources/$asset" + cmp -s "/Users/yang-eunseo/Downloads/C3_Piggy/C3_Piggy/$asset" "PiggyEscape/PiggyEscape/Resources/$asset" +done +``` + +Expected: every final `cmp` exits with status 0; the existing three files are only overwritten if their bytes differed from the source. + +- [ ] **Step 4: Tuist Info.plist 설정에 카메라 사용 목적을 추가한다.** + +`PiggyEscape/Project.swift`의 앱 타깃 `infoPlist`를 아래와 같이 변경한다. + +```swift +infoPlist: .extendingDefault(with: [ + "NSCameraUsageDescription": "피기가 현실의 물체 뒤에 숨을 수 있도록 카메라를 사용합니다." +]), +``` + +- [ ] **Step 5: 리소스와 생성된 Info.plist를 검증한다.** + +Run: + +```bash +cd PiggyEscape +tuist generate --no-open +xcodebuild -project PiggyEscape.xcodeproj -scheme PiggyEscape \ + -destination 'platform=iOS Simulator,name=iPhone 17 Pro' \ + -derivedDataPath /tmp/piggyescape-build \ + -only-testing:PiggyEscapeTests/AssetLoaderTests test +plutil -extract NSCameraUsageDescription raw -o - \ + /tmp/piggyescape-build/Build/Products/Debug-iphonesimulator/PiggyEscape.app/Info.plist +``` + +Expected: `AssetLoaderTests` pass and `plutil` prints `피기가 현실의 물체 뒤에 숨을 수 있도록 카메라를 사용합니다.`. + +- [ ] **Step 6: 인수인계를 갱신하고 커밋한다.** + +`docs/WORK_LOG.md`의 현재 인수인계에 C3 에셋·카메라 권한 등록 완료, 실행한 테스트, 다음 Task 2를 기록한다. + +```bash +git add PiggyEscape/Project.swift PiggyEscape/PiggyEscape/Resources \ + PiggyEscape/PiggyEscapeTests/AssetLoaderTests.swift docs/WORK_LOG.md +git commit -m "Add C3 escape assets and camera permission" +``` + +--- + +### Task 2: 경험 상태와 숨기 좌표를 순수 로직으로 만든다 + +**Files:** +- Create: `PiggyEscape/PiggyEscape/Sources/Escape/EscapeExperienceState.swift` +- Create: `PiggyEscape/PiggyEscape/Sources/Escape/HidePlanning.swift` +- Create: `PiggyEscape/PiggyEscapeTests/EscapeExperienceStateTests.swift` +- Create: `PiggyEscape/PiggyEscapeTests/HidePlanningTests.swift` +- Modify: `docs/WORK_LOG.md` + +**Interfaces:** +- Produces: `EscapeExperienceState`, `EscapeExperienceEvent`, `EscapeExperienceMachine.send(_:) -> Bool`. +- Produces: `TreeHidePlanner.destination(treeCenter:treeRadius:cameraPosition:pigRadius:floorY:) -> SIMD3`. +- Produces: a complete state machine without importing UIKit, SceneKit, ARKit, or RealityKit; actual physical-object planning is introduced in Task 5. + +- [ ] **Step 1: 상태 전이와 좌표 계산의 실패 테스트를 작성한다.** + +`PiggyEscape/PiggyEscapeTests/EscapeExperienceStateTests.swift`: + +```swift +import XCTest +@testable import PiggyEscape + +final class EscapeExperienceStateTests: XCTestCase { + func test_pigTapIsIgnoredUntilOpeningNarrationCompletes() { + var machine = EscapeExperienceMachine() + XCTAssertFalse(machine.send(.pigTapped)) + XCTAssertEqual(machine.state, .openingNarration) + + XCTAssertTrue(machine.send(.narrationFinished)) + XCTAssertTrue(machine.send(.pigTapped)) + XCTAssertEqual(machine.state, .walkingBehindTree) + } + + func test_discoverySequenceCanReachRealityOnlyInOrder() { + var machine = EscapeExperienceMachine(state: .hiddenInClosedWorld) + XCTAssertTrue(machine.send(.closedWorldPigDiscovered)) + XCTAssertEqual(machine.state, .discoveredByCamera) + XCTAssertTrue(machine.send(.closedWorldFadeFinished)) + XCTAssertEqual(machine.state, .requestingCameraPermission) + XCTAssertTrue(machine.send(.cameraAuthorized)) + XCTAssertEqual(machine.state, .scanningReality) + } +} +``` + +`PiggyEscape/PiggyEscapeTests/HidePlanningTests.swift`: + +```swift +import XCTest +import simd +@testable import PiggyEscape + +final class HidePlanningTests: XCTestCase { + func test_treeDestinationIsOnTheCameraOppositeSideAndOnTheFloor() { + let target = TreeHidePlanner.destination( + treeCenter: SIMD3(0, 0, 0), treeRadius: 0.5, + cameraPosition: SIMD3(0, 2, 4), pigRadius: 0.25, floorY: 0 + ) + XCTAssertLessThan(target.z, 0) + XCTAssertEqual(target.y, 0, accuracy: 0.0001) + } + +} +``` + +- [ ] **Step 2: 새 타입 부재로 테스트가 컴파일 실패하는지 확인한다.** + +Run: + +```bash +xcodebuild -project PiggyEscape/PiggyEscape.xcodeproj -scheme PiggyEscape \ + -destination 'platform=iOS Simulator,name=iPhone 17 Pro' \ + -only-testing:PiggyEscapeTests/EscapeExperienceStateTests \ + -only-testing:PiggyEscapeTests/HidePlanningTests test +``` + +Expected: `EscapeExperienceMachine`과 `TreeHidePlanner`를 찾지 못해 컴파일 실패한다. + +- [ ] **Step 3: 합법 전이만 허용하는 상태 기계를 구현한다.** + +`EscapeExperienceState.swift`에 아래 계약을 구현한다. `.cameraDenied`와 `.lidarUnavailable`은 종료 상태이며, `reset`만 `.openingNarration`으로 돌린다. + +```swift +enum EscapeExperienceState: Equatable { + case openingNarration, readyForPigTap, walkingBehindTree, hiddenInClosedWorld + case discoveredByCamera, requestingCameraPermission, scanningReality + case waitingForRealTarget, walkingBehindRealObject, hiddenInReality, discoveredInReality + case cameraDenied, lidarUnavailable +} + +enum EscapeExperienceEvent { + case narrationFinished, pigTapped, pigReachedTree, closedWorldPigDiscovered + case closedWorldFadeFinished, cameraAuthorized, cameraDenied, meshSupported + case meshUnsupported, realTargetAccepted, pigReachedRealObject, realityPigDiscovered, reset +} + +struct EscapeExperienceMachine { + private(set) var state: EscapeExperienceState + init(state: EscapeExperienceState = .openingNarration) { self.state = state } + mutating func send(_ event: EscapeExperienceEvent) -> Bool { + switch (state, event) { + case (_, .reset): state = .openingNarration + case (.openingNarration, .narrationFinished): state = .readyForPigTap + case (.readyForPigTap, .pigTapped): state = .walkingBehindTree + case (.walkingBehindTree, .pigReachedTree): state = .hiddenInClosedWorld + case (.hiddenInClosedWorld, .closedWorldPigDiscovered): state = .discoveredByCamera + case (.discoveredByCamera, .closedWorldFadeFinished): state = .requestingCameraPermission + case (.requestingCameraPermission, .cameraAuthorized): state = .scanningReality + case (.requestingCameraPermission, .cameraDenied): state = .cameraDenied + case (.scanningReality, .meshSupported): state = .waitingForRealTarget + case (.scanningReality, .meshUnsupported): state = .lidarUnavailable + case (.waitingForRealTarget, .realTargetAccepted): state = .walkingBehindRealObject + case (.walkingBehindRealObject, .pigReachedRealObject): state = .hiddenInReality + case (.hiddenInReality, .realityPigDiscovered): state = .discoveredInReality + default: return false + } + return true + } +} +``` + +The implementation must map `cameraAuthorized` to `.scanningReality`, then `meshSupported` to `.waitingForRealTarget`; `realityPigDiscovered` is accepted only from `.hiddenInReality` and changes state to `.discoveredInReality`. + +- [ ] **Step 4: 나무 뒤 숨기 계산을 결정론적으로 구현한다.** + +`HidePlanning.swift`에 `TreeHidePlanner`를 구현한다. 카메라에서 나무 중심으로 향하는 XZ 방향을 정규화한 뒤 반대편에 돼지를 배치하고, 중심 거리는 `treeRadius + pigRadius + 0.08`, Y는 정확히 `floorY`를 쓴다. 카메라와 나무의 XZ가 같아 방향을 정할 수 없으면 기본 방향 `(0, 0, -1)`을 쓴다. + +```swift +enum TreeHidePlanner { + static func destination(treeCenter: SIMD3, treeRadius: Float, + cameraPosition: SIMD3, pigRadius: Float, + floorY: Float) -> SIMD3 { + let towardCamera = SIMD3(cameraPosition.x - treeCenter.x, 0, cameraPosition.z - treeCenter.z) + let direction = simd_length_squared(towardCamera) > 0.0001 + ? simd_normalize(towardCamera) + : SIMD3(0, 0, 1) + let distance = treeRadius + pigRadius + 0.08 + return SIMD3(treeCenter.x - direction.x * distance, floorY, treeCenter.z - direction.z * distance) + } +} +``` + +- [ ] **Step 5: 순수 로직 테스트를 통과시킨다.** + +Run: + +```bash +xcodebuild -project PiggyEscape/PiggyEscape.xcodeproj -scheme PiggyEscape \ + -destination 'platform=iOS Simulator,name=iPhone 17 Pro' \ + -only-testing:PiggyEscapeTests/EscapeExperienceStateTests \ + -only-testing:PiggyEscapeTests/HidePlanningTests test +``` + +Expected: 두 테스트 클래스의 모든 테스트가 통과한다. + +- [ ] **Step 6: 인수인계를 갱신하고 커밋한다.** + +```bash +git add PiggyEscape/PiggyEscape/Sources/Escape/EscapeExperienceState.swift \ + PiggyEscape/PiggyEscape/Sources/Escape/HidePlanning.swift \ + PiggyEscape/PiggyEscapeTests/EscapeExperienceStateTests.swift \ + PiggyEscape/PiggyEscapeTests/HidePlanningTests.swift docs/WORK_LOG.md +git commit -m "Add escape state and hide planning" +``` + +--- + +### Task 3: 금융 기능 없이 C3 섬·돼지·궤도 카메라를 이식한다 + +**Files:** +- Create: `PiggyEscape/PiggyEscape/Sources/C3World/C3IslandBuilder.swift` +- Create: `PiggyEscape/PiggyEscape/Sources/C3World/C3PigModelFactory.swift` +- Create: `PiggyEscape/PiggyEscape/Sources/C3World/C3ClosedWorld.swift` +- Create: `PiggyEscape/PiggyEscapeTests/C3IslandBuilderTests.swift` +- Create: `PiggyEscape/PiggyEscapeTests/C3PigModelFactoryTests.swift` +- Create: `PiggyEscape/PiggyEscapeTests/C3ClosedWorldTests.swift` +- Modify: `docs/WORK_LOG.md` + +**Interfaces:** +- Produces: `C3IslandBuilder.build() -> SCNNode`, with root `"C3Island"`, a selectable `"HideTree"`, and `"BigPigSpawn"`. +- Produces: `C3PigModelFactory.makeContainer(pose:) -> SCNNode`, `C3PigPose.idle`, `.running`, `.surprised`, and `C3PigModelFactory.setPose(_:on:)`. +- Produces: `@MainActor final class C3ClosedWorld` with `scene`, `pigContainer`, `hideTree`, `cameraYaw`, `rotateCamera(byYaw:)`, and `zoom(by:)`. + +- [ ] **Step 1: C3 월드 계층과 모델 교체의 실패 테스트를 작성한다.** + +```swift +@MainActor +func test_buildCreatesNamedExistingTreeAndBigPigSpawn() { + let island = C3IslandBuilder.build() + XCTAssertEqual(island.name, "C3Island") + XCTAssertNotNil(island.childNode(withName: "HideTree", recursively: true)) + XCTAssertNotNil(island.childNode(withName: "BigPigSpawn", recursively: true)) +} + +@MainActor +func test_pigContainerKeepsInnerModelCorrectionWhenPoseChanges() { + let pig = C3PigModelFactory.makeContainer(pose: .idle) + let originalScale = pig.scale + C3PigModelFactory.setPose(.surprised, on: pig) + XCTAssertEqual(pig.name, "EscapePig") + XCTAssertEqual(pig.scale.x, originalScale.x, accuracy: 0.0001) + XCTAssertEqual(pig.scale.y, originalScale.y, accuracy: 0.0001) + XCTAssertEqual(pig.scale.z, originalScale.z, accuracy: 0.0001) + XCTAssertNotNil(pig.childNode(withName: "PigModel_surprised", recursively: false)) +} + +@MainActor +func test_worldUsesOrthographicC3OrbitCamera() { + let world = C3ClosedWorld() + XCTAssertTrue(world.cameraNode.camera?.usesOrthographicProjection ?? false) + XCTAssertEqual(world.cameraNode.camera?.orthographicScale ?? 0, 6.0, accuracy: 0.001) +} +``` + +- [ ] **Step 2: 구현 전 테스트가 타입 부재로 실패하는지 확인한다.** + +Run: + +```bash +xcodebuild -project PiggyEscape/PiggyEscape.xcodeproj -scheme PiggyEscape \ + -destination 'platform=iOS Simulator,name=iPhone 17 Pro' \ + -only-testing:PiggyEscapeTests/C3IslandBuilderTests \ + -only-testing:PiggyEscapeTests/C3PigModelFactoryTests \ + -only-testing:PiggyEscapeTests/C3ClosedWorldTests test +``` + +Expected: C3 world types are not found and the test target fails to compile. + +- [ ] **Step 3: C3 섬 빌더의 비금융 부분만 이식한다.** + +`C3IslandBuilder`는 C3의 `IslandBuilder.buildFlatGroundIsland`, `buildFlatGroundDecorations`, `makeFlatGroundDecoration`, `flatGroundMetrics`, 변환을 반영한 `geometryBounds`를 이식한다. 아래 이름·배치 규칙을 고정한다. + +```swift +enum C3IslandBuilder { + static func build() -> SCNNode { + let island = SCNNode() + island.name = "C3Island" + // Ground_Color 중앙 타일 + 6개 외곽 타일, C3와 같은 X축 -π/2 회전과 √2 가로 스케일 + // Cylinder_Tree1_Color 장식 컨테이너의 이름은 "HideTree"로 지정 + // BigPigSpawn은 중앙 타일의 실제 윗면 중앙에 배치 + return island + } +} +``` + +Keep C3 decoration positions for the two trees, manger, stones, coin, warehouse, and wood. Do not copy `SmallPigSpawn_*`, allowance-pig placement, coin emission, balance scaling, or SwiftData imports. + +- [ ] **Step 4: USD 축 보정과 세 가지 돼지 포즈 팩토리를 구현한다.** + +```swift +enum C3PigPose: String { case idle, running, surprised } + +enum C3PigModelFactory { + static func makeContainer(pose: C3PigPose) -> SCNNode { + let container = SCNNode() + container.name = "EscapePig" + setPose(pose, on: container) + return container + } + + static func setPose(_ pose: C3PigPose, on container: SCNNode) { + container.childNodes.forEach { $0.removeFromParentNode() } + let model = loadNormalizedModel(named: pose == .idle ? "Piggy" : "Piggy_\(pose.rawValue)") + model.name = "PigModel_\(pose.rawValue)" + container.addChildNode(model) + if pose == .running { playEmbeddedAnimations(on: model) } + } +} +``` + +`loadNormalizedModel` must wrap the source art in an inner node, apply C3's `SCNVector3(.pi / 2, 0, .pi)` axis correction, normalize the inner model to 1.5m height, place its lowest transformed vertex at `y == 0`, and apply the C3 outer facing yaw `3 * .pi / 4`. The outer `EscapePig` node must never carry the normalization scale. + +- [ ] **Step 5: C3 궤도 카메라·조명·중앙 돼지 월드를 구현한다.** + +```swift +@MainActor +final class C3ClosedWorld { + let scene = SCNScene() + let cameraNode = SCNNode() + let pigContainer: SCNNode + let hideTree: SCNNode + private(set) var cameraYaw: Float = .pi / 4 + private let orbitRadius: Float = 17 + private let orbitHeight: Float = 12 + private var orthographicScale: CGFloat = 6 + private let directionalLightNode: SCNNode + + func rotateCamera(byYaw delta: Float) { + cameraYaw += delta + cameraNode.position = SCNVector3( + orbitRadius * sin(cameraYaw), orbitHeight, orbitRadius * cos(cameraYaw) + ) + directionalLightNode.eulerAngles = SCNVector3(-.pi / 3, cameraYaw - .pi / 4, 0) + } + + func zoom(by factor: Float) { + guard factor > 0 else { return } + orthographicScale = min(12, max(3, orthographicScale / CGFloat(factor))) + cameraNode.camera?.orthographicScale = orthographicScale + } +} +``` + +Use the C3 gradient background, ambient intensity 800, directional intensity 520, deferred soft shadow settings, radius 17, height 12, and orthographic scale 6. Add the island and the idle `EscapePig` at `BigPigSpawn`; expose the actual `HideTree` node for Task 4. + +- [ ] **Step 6: C3 world tests를 통과시킨다.** + +Run: + +```bash +xcodebuild -project PiggyEscape/PiggyEscape.xcodeproj -scheme PiggyEscape \ + -destination 'platform=iOS Simulator,name=iPhone 17 Pro' \ + -only-testing:PiggyEscapeTests/C3IslandBuilderTests \ + -only-testing:PiggyEscapeTests/C3PigModelFactoryTests \ + -only-testing:PiggyEscapeTests/C3ClosedWorldTests test +``` + +Expected: named island/tree/spawn, outer-vs-inner model transform, and orthographic camera tests pass. + +- [ ] **Step 7: 인수인계를 갱신하고 커밋한다.** + +```bash +git add PiggyEscape/PiggyEscape/Sources/C3World \ + PiggyEscape/PiggyEscapeTests/C3IslandBuilderTests.swift \ + PiggyEscape/PiggyEscapeTests/C3PigModelFactoryTests.swift \ + PiggyEscape/PiggyEscapeTests/C3ClosedWorldTests.swift docs/WORK_LOG.md +git commit -m "Add C3 closed world adapter" +``` + +--- + +### Task 4: SceneKit의 나레이션·탭·가짜 숨기·카메라 발견을 연결한다 + +**Files:** +- Create: `PiggyEscape/PiggyEscape/Sources/C3World/NarrationOverlayScene.swift` +- Create: `PiggyEscape/PiggyEscape/Sources/C3World/C3ClosedWorldSceneView.swift` +- Modify: `PiggyEscape/PiggyEscape/Sources/C3World/C3ClosedWorld.swift` +- Create: `PiggyEscape/PiggyEscapeTests/ClosedWorldEscapeTests.swift` +- Create: `PiggyEscape/PiggyEscapeTests/NarrationOverlaySceneTests.swift` +- Modify: `docs/WORK_LOG.md` + +**Interfaces:** +- Produces: `C3ClosedWorldSceneView(onNarrationFinished:onDiscovered:)`. +- Produces: `C3ClosedWorld.tapPig() -> Bool`, `completeOpeningNarration()`, `finishTreeHideForTesting()`, `isDiscoveredAfterCameraRotation() -> Bool`, and `performSurpriseReaction()`; its test-visible read-only state is `currentPose`, `lastCaption`, and `surprisePeakScale`. +- Produces: `NarrationOverlayScene.show(_:)`, `showSurpriseCaption()`, and `captionText` for deterministic verification. + +- [ ] **Step 1: 내레이션 게이트·발견 조건·놀람 반응의 실패 테스트를 작성한다.** + +```swift +@MainActor +func test_tapIsIgnoredBeforeNarrationAndStartsRunningAfterIt() { + let world = C3ClosedWorld() + XCTAssertFalse(world.tapPig()) + world.completeOpeningNarration() + XCTAssertTrue(world.tapPig()) + XCTAssertEqual(world.currentPose, .running) +} + +@MainActor +func test_cameraDiscoveryNeedsYawChangeAndVisiblePig() { + let world = C3ClosedWorld() + world.completeOpeningNarration() + _ = world.tapPig() + world.finishTreeHideForTesting() + XCTAssertFalse(world.isDiscoveredAfterCameraRotation()) + world.rotateCamera(byYaw: .pi / 2) + XCTAssertTrue(world.isDiscoveredAfterCameraRotation()) +} + +@MainActor +func test_surpriseCaptionAndScaleReactionStartTogether() { + let world = C3ClosedWorld() + world.performSurpriseReaction() + XCTAssertEqual(world.currentPose, .surprised) + XCTAssertEqual(world.lastCaption, "아, 들켰네… 제대로 숨고 싶은데.") + XCTAssertEqual(world.surprisePeakScale, 1.5, accuracy: 0.0001) +} +``` + +- [ ] **Step 2: 테스트가 새 API 부재로 실패하는지 확인한다.** + +Run: + +```bash +xcodebuild -project PiggyEscape/PiggyEscape.xcodeproj -scheme PiggyEscape \ + -destination 'platform=iOS Simulator,name=iPhone 17 Pro' \ + -only-testing:PiggyEscapeTests/ClosedWorldEscapeTests \ + -only-testing:PiggyEscapeTests/NarrationOverlaySceneTests test +``` + +Expected: state, overlay, and surprise API lookup errors cause a compile failure. + +- [ ] **Step 3: SpriteKit 나레이션 오버레이를 구현한다.** + +```swift +final class NarrationOverlayScene: SKScene { + private(set) var captionText = "" + private let label = SKLabelNode(fontNamed: "AvenirNext-Bold") + + func showOpeningNarration() { + show("아, 나 좀 그만 쳐다보지. 나 숨고 싶어…") + } + + func showSurpriseCaption() { + show("아, 들켰네… 제대로 숨고 싶은데.") + } +} +``` + +Set white text, black 0.18-alpha shadow, a 12-point rounded dark backing panel, and a short fade/scale action. `show(_:)` must set `captionText` synchronously before starting animations so tests do not depend on an `SKView` frame clock. + +- [ ] **Step 4: 가짜 숨기와 발견 반응을 C3 월드에 구현한다.** + +On an accepted pig tap, change to `.running`, calculate `TreeHidePlanner.destination` from the actual transformed `HideTree` bounds and current C3 camera position, rotate the outer `EscapePig` toward the destination, and run `SCNAction.move(to:duration:)` at 1.1 world units/second. After reaching the destination, change to `.idle`, record the current yaw, and wait for the next eligible camera rotation. + +Treat a discovery as eligible only when all conditions hold: the pig has reached the tree, absolute wrapped yaw difference is at least `0.70` radians, the pig is inside the camera frustum (`pigContainer.isInsideFrustum(of: cameraNode) == true`), and discovery has not run before. Then set `.surprised`, call the overlay callback in the same method, and run the following outer-node action under key `"escapePig.surpriseScale"`. + +```swift +let grow = SCNAction.scale(to: 1.5, duration: 0.16) +grow.timingMode = .easeOut +let restore = SCNAction.scale(to: 1.0, duration: 0.34) +restore.timingMode = .easeInEaseOut +pigContainer.runAction(.sequence([grow, restore]), forKey: "escapePig.surpriseScale") +``` + +- [ ] **Step 5: C3-style SceneKit view and gestures를 구현한다.** + +`C3ClosedWorldSceneView` must create an `SCNView` with `allowsCameraControl = false`, C3 custom lighting, `overlaySKScene`, a one-tap recognizer that first `hitTest`s the touch and only passes hits that are descendants of `EscapePig` to `tapPig()`, a pan recognizer that calls `rotateCamera(byYaw:)`, and a two-finger pinch that calls `zoom(by:)`. Use a C3-equivalent mapping of full screen width to `π` yaw and reset the gesture translation each callback. + +- [ ] **Step 6: SceneKit interaction tests를 통과시키고 Simulator에서 확인한다.** + +Run: + +```bash +xcodebuild -project PiggyEscape/PiggyEscape.xcodeproj -scheme PiggyEscape \ + -destination 'platform=iOS Simulator,name=iPhone 17 Pro' \ + -only-testing:PiggyEscapeTests/ClosedWorldEscapeTests \ + -only-testing:PiggyEscapeTests/NarrationOverlaySceneTests test +xcodebuild -project PiggyEscape/PiggyEscape.xcodeproj -scheme PiggyEscape \ + -destination 'platform=iOS Simulator,name=iPhone 17 Pro' build +``` + +Expected: tests pass and the Simulator build shows the C3 island, initial narration, tap-gated running pig, and discovery caption when the camera is panned. + +- [ ] **Step 7: 인수인계를 갱신하고 커밋한다.** + +```bash +git add PiggyEscape/PiggyEscape/Sources/C3World \ + PiggyEscape/PiggyEscapeTests/ClosedWorldEscapeTests.swift \ + PiggyEscape/PiggyEscapeTests/NarrationOverlaySceneTests.swift docs/WORK_LOG.md +git commit -m "Add C3 pig hide and discovery flow" +``` + +--- + +### Task 5: 실제 물체 숨기 계획과 LiDAR 지원 판정을 구현한다 + +**Files:** +- Create: `PiggyEscape/PiggyEscape/Sources/Reality/RealityHidePlanner.swift` +- Create: `PiggyEscape/PiggyEscape/Sources/Reality/RealityCapability.swift` +- Create: `PiggyEscape/PiggyEscapeTests/RealityHidePlannerTests.swift` +- Create: `PiggyEscape/PiggyEscapeTests/RealityCapabilityTests.swift` +- Modify: `docs/WORK_LOG.md` + +**Interfaces:** +- Produces: `RealitySurfaceHit(point:normal:)`, `RealityFloor(point:)`, `RealityHidePlanner.plan(hit:cameraPosition:floor:) -> RealityHidePlanResult`. +- Produces: `protocol RealityMeshSupporting { var supportsMeshWithClassification: Bool { get } }` and `SystemRealityMeshSupport`. +- Produces: `RealityRevealMonitor` that records initial mesh blocking and emits one `true` result only after the physical mesh no longer precedes the pig. + +- [ ] **Step 1: 실제 면 선택과 한 번만 재발견하는 실패 테스트를 작성한다.** + +```swift +func test_verticalSurfacePlacesPigOnCameraOppositeSideOfObject() { + let result = RealityHidePlanner.plan( + hit: RealitySurfaceHit(point: SIMD3(1, 0.9, 0), normal: SIMD3(1, 0, 0)), + cameraPosition: SIMD3(3, 1.5, 0), floor: RealityFloor(point: SIMD3(1, 0, 0)) + ) + guard case let .accepted(position) = result else { return XCTFail("expected accepted target") } + XCTAssertLessThan(position.x, 1) + XCTAssertEqual(position.y, 0, accuracy: 0.0001) +} + +func test_horizontalSurfaceAndMissingFloorAreRejected() { + XCTAssertEqual( + RealityHidePlanner.plan( + hit: RealitySurfaceHit(point: SIMD3(0, 0.8, 0), normal: SIMD3(0, 1, 0)), + cameraPosition: SIMD3(0, 1.5, 1), floor: RealityFloor(point: SIMD3(0, 0, 0)) + ), + .rejected(.selectVerticalSide) + ) + XCTAssertEqual( + RealityHidePlanner.plan( + hit: RealitySurfaceHit(point: SIMD3(1, 0.8, 0), normal: SIMD3(1, 0, 0)), + cameraPosition: SIMD3(3, 1.5, 0), floor: nil + ), + .rejected(.findFloor) + ) +} + +func test_floorTooFarFromTheObjectIsRejected() { + XCTAssertEqual( + RealityHidePlanner.plan( + hit: RealitySurfaceHit(point: SIMD3(0, 0.8, 0), normal: SIMD3(0, 0, 1)), + cameraPosition: SIMD3(0, 1.5, 2), floor: RealityFloor(point: SIMD3(2, 0, 0)) + ), + .rejected(.findFloor) + ) +} + +func test_revealMonitorFiresOnlyAfterFirstBlockedFrameThenVisibleFrame() { + var monitor = RealityRevealMonitor() + XCTAssertFalse(monitor.update(meshDistance: nil, pigDistance: 2)) + XCTAssertFalse(monitor.update(meshDistance: 1, pigDistance: 2)) + XCTAssertTrue(monitor.update(meshDistance: nil, pigDistance: 2)) + XCTAssertFalse(monitor.update(meshDistance: nil, pigDistance: 2)) +} +``` + +- [ ] **Step 2: 테스트가 Reality types 부재로 실패하는지 확인한다.** + +Run: + +```bash +xcodebuild -project PiggyEscape/PiggyEscape.xcodeproj -scheme PiggyEscape \ + -destination 'platform=iOS Simulator,name=iPhone 17 Pro' \ + -only-testing:PiggyEscapeTests/RealityHidePlannerTests \ + -only-testing:PiggyEscapeTests/RealityCapabilityTests test +``` + +Expected: Reality planning and capability types cannot be found. + +- [ ] **Step 3: ARKit-independent 입력 타입과 재발견 모니터를 구현한다.** + +```swift +import simd + +struct RealitySurfaceHit: Equatable { let point: SIMD3; let normal: SIMD3 } +struct RealityFloor: Equatable { let point: SIMD3 } + +struct RealityRevealMonitor { + private var hasObservedBlockingMesh = false + private var hasReportedReveal = false + + mutating func update(meshDistance: Float?, pigDistance: Float) -> Bool { + let blocked = meshDistance.map { $0 + 0.03 < pigDistance } ?? false + hasObservedBlockingMesh = hasObservedBlockingMesh || blocked + guard hasObservedBlockingMesh, !blocked, !hasReportedReveal else { return false } + hasReportedReveal = true + return true + } +} + +enum RealityHideRejection: Equatable { case selectVerticalSide, moveFartherAway, findFloor } +enum RealityHidePlanResult: Equatable { case accepted(SIMD3), rejected(RealityHideRejection) } + +enum RealityHidePlanner { + static let verticalNormalMaximumY: Float = 0.35 + static let minimumCameraDistance: Float = 0.45 + static let objectClearance: Float = 0.28 + static let maximumFloorDistance: Float = 1.2 + + static func plan(hit: RealitySurfaceHit, cameraPosition: SIMD3, + floor: RealityFloor?) -> RealityHidePlanResult { + guard abs(hit.normal.y) <= verticalNormalMaximumY else { return .rejected(.selectVerticalSide) } + guard simd_distance(hit.point, cameraPosition) >= minimumCameraDistance else { return .rejected(.moveFartherAway) } + guard let floor else { return .rejected(.findFloor) } + let floorOffset = SIMD2(hit.point.x - floor.point.x, hit.point.z - floor.point.z) + guard simd_length(floorOffset) <= maximumFloorDistance else { return .rejected(.findFloor) } + guard simd_length_squared(hit.normal) > 0.0001 else { return .rejected(.selectVerticalSide) } + let normal = simd_normalize(hit.normal) + let towardCamera = simd_dot(normal, cameraPosition - hit.point) >= 0 ? normal : -normal + let hiddenPoint = hit.point - towardCamera * objectClearance + return .accepted(SIMD3(hiddenPoint.x, floor.point.y, hiddenPoint.z)) + } +} +``` + +- [ ] **Step 4: 시스템 지원 판정과 안내 문자열을 구현한다.** + +```swift +import ARKit + +protocol RealityMeshSupporting { + var supportsMeshWithClassification: Bool { get } +} + +struct SystemRealityMeshSupport: RealityMeshSupporting { + var supportsMeshWithClassification: Bool { + ARWorldTrackingConfiguration.supportsSceneReconstruction(.meshWithClassification) + } +} + +enum RealityAvailabilityMessage { + static let unavailable = "이 기능은 LiDAR로 공간을 읽을 수 있는 기기에서 사용할 수 있어." + static let scanFirst = "주변 바닥과 숨을 물체를 조금 더 스캔해줘." + static let selectVerticalSide = "숨을 물체의 옆면을 탭해줘." + static let moveFartherAway = "조금 떨어진 물체의 옆면을 탭해줘." +} +``` + +The test fake must set `supportsMeshWithClassification` to both `true` and `false`; it must not call ARKit on Simulator to simulate LiDAR support. + +```swift +private struct FakeRealityMeshSupport: RealityMeshSupporting { + let supportsMeshWithClassification: Bool +} + +func test_injectedCapabilityDoesNotDependOnTheSimulator() { + XCTAssertTrue(FakeRealityMeshSupport(supportsMeshWithClassification: true).supportsMeshWithClassification) + XCTAssertFalse(FakeRealityMeshSupport(supportsMeshWithClassification: false).supportsMeshWithClassification) +} +``` + +- [ ] **Step 5: Reality planning tests를 통과시킨다.** + +Run: + +```bash +xcodebuild -project PiggyEscape/PiggyEscape.xcodeproj -scheme PiggyEscape \ + -destination 'platform=iOS Simulator,name=iPhone 17 Pro' \ + -only-testing:PiggyEscapeTests/RealityHidePlannerTests \ + -only-testing:PiggyEscapeTests/RealityCapabilityTests test +``` + +Expected: vertical/horizontal/distance/floor rejection, camera-opposite target, capability fake, and one-time reveal monitor tests all pass. + +- [ ] **Step 6: 인수인계를 갱신하고 커밋한다.** + +```bash +git add PiggyEscape/PiggyEscape/Sources/Reality/RealityHidePlanner.swift \ + PiggyEscape/PiggyEscape/Sources/Reality/RealityCapability.swift \ + PiggyEscape/PiggyEscapeTests/RealityHidePlannerTests.swift \ + PiggyEscape/PiggyEscapeTests/RealityCapabilityTests.swift docs/WORK_LOG.md +git commit -m "Add reality hide planning" +``` + +--- + +### Task 6: RealityKit 돼지 포즈와 실제 메쉬 뒤 숨기를 구현한다 + +**Files:** +- Create: `PiggyEscape/PiggyEscape/Sources/Reality/RealityPigVisualController.swift` +- Create: `PiggyEscape/PiggyEscape/Sources/Reality/RealityHideARView.swift` +- Create: `PiggyEscape/PiggyEscapeTests/RealityPigVisualControllerTests.swift` +- Create: `PiggyEscape/PiggyEscapeTests/RealityHideARViewCoordinatorTests.swift` +- Modify: `docs/WORK_LOG.md` + +**Interfaces:** +- Produces: `@MainActor final class RealityPigVisualController` with `loadIdlePig()`, `walk(to:completion:)`, `showSurprised()`, `playSurpriseScale()`, `worldPosition`, `currentPose`, `surprisePeakScale`, `surpriseRestoreScale`, and `makeForTesting()`. +- Produces: `RealityHideARView(onScanningReady:onTargetAccepted:onRevealed:onUnavailable:onMessage:)`. +- Consumes: Task 5 `RealityHidePlanner`, `RealityRevealMonitor`, `RealityMeshSupporting`. + +- [ ] **Step 1: RealityKit visual state와 AR coordinator 의존성의 실패 테스트를 작성한다.** + +```swift +@MainActor +func test_surpriseVisualUsesSamePeakAndRestoreScaleAsSceneKit() { + let controller = RealityPigVisualController.makeForTesting() + controller.showSurprised() + controller.playSurpriseScale() + XCTAssertEqual(controller.currentPose, .surprised) + XCTAssertEqual(controller.surprisePeakScale, 1.5, accuracy: 0.0001) + XCTAssertEqual(controller.surpriseRestoreScale, 1.0, accuracy: 0.0001) +} + +private struct FakeRealityMeshSupport: RealityMeshSupporting { + let supportsMeshWithClassification: Bool +} + +func test_coordinatorReportsUnavailableWithoutStartingARSession() { + let support = FakeRealityMeshSupport(supportsMeshWithClassification: false) + let coordinator = RealityHideARView.Coordinator(meshSupport: support) + XCTAssertFalse(coordinator.canStartMeshSession) +} +``` + +- [ ] **Step 2: 테스트가 RealityKit visual/controller 부재로 실패하는지 확인한다.** + +Run: + +```bash +xcodebuild -project PiggyEscape/PiggyEscape.xcodeproj -scheme PiggyEscape \ + -destination 'platform=iOS Simulator,name=iPhone 17 Pro' \ + -only-testing:PiggyEscapeTests/RealityPigVisualControllerTests \ + -only-testing:PiggyEscapeTests/RealityHideARViewCoordinatorTests test +``` + +Expected: the new visual controller and AR view coordinator are absent, causing a compile failure. + +- [ ] **Step 3: RealityKit 돼지 모델과 동일한 놀람 반응을 구현한다.** + +Use a stable outer `Entity` named `"RealityEscapePig"`; remove and replace only its `"RealityPigModel_"` child. Load `Piggy.usdc`, `Piggy_running.usdz`, and `Piggy_surprised.usdz` from the app bundle with RealityKit's asynchronous entity loader, retain the C3-facing yaw and normalized height from Task 3, and call `generateCollisionShapes(recursive: true)` after a model loads. + +```swift +func playSurpriseScale() { + surprisePeakScale = 1.5 + surpriseRestoreScale = 1.0 + outerEntity.move(to: Transform(scale: SIMD3(repeating: 1.5), + rotation: outerEntity.transform.rotation, + translation: outerEntity.transform.translation), + relativeTo: outerEntity.parent, duration: 0.16, timingFunction: .easeOut) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.16) { [weak self] in + self?.outerEntity.move(to: Transform(scale: SIMD3(repeating: 1), + rotation: self?.outerEntity.transform.rotation ?? simd_quatf(), + translation: self?.outerEntity.transform.translation ?? .zero), + relativeTo: self?.outerEntity.parent, duration: 0.34, timingFunction: .easeInOut) + } +} +``` + +The production implementation must not change `ARView.cameraTransform`, `ARSession`, or the real camera lens during this reaction. + +- [ ] **Step 4: ARView의 LiDAR mesh session과 실제 면 탭을 구현한다.** + +Create `ARView(frame: .zero, cameraMode: .ar, automaticallyConfigureSession: false)`. Before `session.run`, guard `meshSupport.supportsMeshWithClassification`; otherwise call `onUnavailable` and do not run a session. For supported devices configure exactly: + +```swift +let configuration = ARWorldTrackingConfiguration() +configuration.planeDetection = [.horizontal, .vertical] +configuration.sceneReconstruction = .meshWithClassification +arView.environment.sceneUnderstanding.options = [.occlusion, .collision, .physics, .receivesLighting] +arView.session.run(configuration, options: [.resetTracking, .removeExistingAnchors]) +``` + +On the user's one-finger tap, call `arView.hitTest(point, query: .nearest, mask: .sceneUnderstanding)`. Convert the nearest `CollisionCastHit` position and surface normal to `RealitySurfaceHit`; obtain a nearby horizontal floor from `ARPlaneAnchor` values in the current frame; then call Task 5's planner. Present `RealityAvailabilityMessage` for every rejection. On acceptance, walk the running pig to the result, change it to idle after arrival, and begin the reveal monitor. + +- [ ] **Step 5: 실제 메쉬 가림이 사라졌을 때 한 번만 재발견을 실행한다.** + +Subscribe to `SceneEvents.Update` only while the pig is hidden. Each frame, project the pig's world position into the `ARView`, query the real mesh at that screen point, and compare the mesh collision distance to the current AR camera-to-pig distance: + +```swift +guard let point = arView.project(pigWorldPosition), + let cameraTransform = arView.session.currentFrame?.camera.transform else { return } +let meshDistance = arView.hitTest(point, query: .nearest, mask: .sceneUnderstanding).first.map { + simd_distance(SIMD3(cameraTransform.columns.3.x, cameraTransform.columns.3.y, cameraTransform.columns.3.z), $0.position) +} +let pigDistance = simd_distance(SIMD3(cameraTransform.columns.3.x, cameraTransform.columns.3.y, cameraTransform.columns.3.z), pigWorldPosition) +if revealMonitor.update(meshDistance: meshDistance, pigDistance: pigDistance) { + visualController.showSurprised() + visualController.playSurpriseScale() + onRevealed() +} +``` + +Cancel the update subscription after the monitor emits `true`; later frames must not recreate the surprise. + +- [ ] **Step 6: unit tests를 통과시키고 실제 기기 검증 준비 상태를 확인한다.** + +Run: + +```bash +xcodebuild -project PiggyEscape/PiggyEscape.xcodeproj -scheme PiggyEscape \ + -destination 'platform=iOS Simulator,name=iPhone 17 Pro' \ + -only-testing:PiggyEscapeTests/RealityPigVisualControllerTests \ + -only-testing:PiggyEscapeTests/RealityHideARViewCoordinatorTests test +xcodebuild -project PiggyEscape/PiggyEscape.xcodeproj -scheme PiggyEscape \ + -destination 'platform=iOS Simulator,name=iPhone 17 Pro' build +``` + +Expected: deterministic controller/coordinator tests pass and the iOS build succeeds. Record that actual mesh occlusion still requires the LiDAR device test in Task 8. + +- [ ] **Step 7: 인수인계를 갱신하고 커밋한다.** + +```bash +git add PiggyEscape/PiggyEscape/Sources/Reality \ + PiggyEscape/PiggyEscapeTests/RealityPigVisualControllerTests.swift \ + PiggyEscape/PiggyEscapeTests/RealityHideARViewCoordinatorTests.swift docs/WORK_LOG.md +git commit -m "Add RealityKit hide behind real objects" +``` + +--- + +### Task 7: 자동 SceneKit→RealityKit 전환과 C3 스타일 안내를 만든다 + +**Files:** +- Create: `PiggyEscape/PiggyEscape/Sources/Escape/EscapeRootView.swift` +- Modify: `PiggyEscape/PiggyEscape/Sources/ContentView.swift` +- Create: `PiggyEscape/PiggyEscapeTests/EscapeRootCoordinatorTests.swift` +- Modify: `docs/WORK_LOG.md` + +**Interfaces:** +- Produces: `EscapeRootView` as the only app-visible flow. +- Produces: `CameraAuthorizing.requestVideoAccess(_:)`, `SystemCameraAuthorizer`, and `EscapeRootCoordinator` with explicit callbacks for C3 discovery, fade completion, camera result, mesh support, and RealityKit discovery. +- Consumes: Task 2 state machine, Task 4 SceneKit callbacks, Task 6 AR callbacks. + +- [ ] **Step 1: 전환 순서와 권한 거부의 실패 테스트를 작성한다.** + +```swift +private final class FakeCameraAuthorizer: CameraAuthorizing { + let result: Bool + private(set) var requestCount = 0 + init(result: Bool) { self.result = result } + func requestVideoAccess(_ completion: @escaping (Bool) -> Void) { + requestCount += 1 + completion(result) + } +} + +func test_closedWorldDiscoveryWaitsForFadeBeforeCameraPrompt() { + let authorizer = FakeCameraAuthorizer(result: true) + var coordinator = EscapeRootCoordinator(cameraAuthorizer: authorizer) + coordinator.closedWorldDiscoveryDidOccur() + XCTAssertEqual(coordinator.machine.state, .discoveredByCamera) + XCTAssertEqual(authorizer.requestCount, 0) + coordinator.closedWorldFadeDidFinish() + XCTAssertEqual(authorizer.requestCount, 1) +} + +func test_deniedCameraShowsRecoveryInsteadOfStartingRealitySession() { + let authorizer = FakeCameraAuthorizer(result: false) + var coordinator = EscapeRootCoordinator(cameraAuthorizer: authorizer) + coordinator.beginCameraRequestAfterFade() + XCTAssertEqual(coordinator.machine.state, .cameraDenied) + XCTAssertFalse(coordinator.showsRealityView) +} +``` + +- [ ] **Step 2: 테스트가 루트 coordinator 부재로 실패하는지 확인한다.** + +Run: + +```bash +xcodebuild -project PiggyEscape/PiggyEscape.xcodeproj -scheme PiggyEscape \ + -destination 'platform=iOS Simulator,name=iPhone 17 Pro' \ + -only-testing:PiggyEscapeTests/EscapeRootCoordinatorTests test +``` + +Expected: `EscapeRootCoordinator` and `CameraAuthorizing` cannot be found. + +- [ ] **Step 3: 권한 주입 경계와 상태 기반 루트 coordinator를 구현한다.** + +```swift +protocol CameraAuthorizing { + func requestVideoAccess(_ completion: @escaping (Bool) -> Void) +} + +struct SystemCameraAuthorizer: CameraAuthorizing { + func requestVideoAccess(_ completion: @escaping (Bool) -> Void) { + AVCaptureDevice.requestAccess(for: .video, completionHandler: completion) + } +} +``` + +`closedWorldDiscoveryDidOccur()` must set `.discoveredByCamera` and show the surprise caption immediately. It must wait `0.70` seconds for the C3 world fade before calling `requestVideoAccess`. On a true callback it sends `.cameraAuthorized`, shows the AR scanning view, then accepts Task 6's supported/unavailable callback; on false it sends `.cameraDenied` and displays a Settings recovery button using `UIApplication.openSettingsURLString`. + +- [ ] **Step 4: C3 느낌의 SwiftUI 오버레이와 RealityKit 화면 확대를 구현한다.** + +```swift +@State private var realityScreenScale: CGFloat = 1 + +private func performRealitySurprise() { + realityScreenScale = 1.12 + withAnimation(.easeOut(duration: 0.16)) { realityScreenScale = 1.12 } + withAnimation(.easeInOut(duration: 0.34).delay(0.16)) { realityScreenScale = 1 } +} +``` + +Render `RealityHideARView` under `.scaleEffect(realityScreenScale)` and clip it to its parent. The caption panel must use white `.system(size: 20, weight: .heavy, design: .rounded)` text, black 0.18-alpha shadow, `.ultraThinMaterial`, 24-point rounded corners, and short opacity/scale transitions. It must not mutate ARView's camera transform or AR session while the SwiftUI effect runs. + +- [ ] **Step 5: ContentView의 기준 화면을 새 경험 루트로 교체한다.** + +```swift +import SwiftUI + +struct ContentView: View { + var body: some View { + EscapeRootView() + .ignoresSafeArea() + } +} +``` + +The old `ClosedWorldSceneView` remains compiled as Draft PR reference code but must not be instantiated by `ContentView`. + +- [ ] **Step 6: 루트 전환 테스트·앱 빌드를 검증한다.** + +Run: + +```bash +xcodebuild -project PiggyEscape/PiggyEscape.xcodeproj -scheme PiggyEscape \ + -destination 'platform=iOS Simulator,name=iPhone 17 Pro' \ + -only-testing:PiggyEscapeTests/EscapeRootCoordinatorTests test +xcodebuild -project PiggyEscape/PiggyEscape.xcodeproj -scheme PiggyEscape \ + -destination 'platform=iOS Simulator,name=iPhone 17 Pro' build +``` + +Expected: coordinator tests pass and the Simulator build begins in the C3 island rather than the old room. + +- [ ] **Step 7: 인수인계를 갱신하고 커밋한다.** + +```bash +git add PiggyEscape/PiggyEscape/Sources/Escape/EscapeRootView.swift \ + PiggyEscape/PiggyEscape/Sources/ContentView.swift \ + PiggyEscape/PiggyEscapeTests/EscapeRootCoordinatorTests.swift docs/WORK_LOG.md +git commit -m "Connect C3 world to RealityKit escape" +``` + +--- + +### Task 8: 전체 회귀·실기기 검증과 DocC/인수인계를 마무리한다 + +**Files:** +- Modify: `PiggyEscape/PiggyEscape/Tutorials/SceneKitToRealityKit.docc/SceneKitToRealityKit.tutorial` +- Modify: `PiggyEscape/PiggyEscape/Tutorials/SceneKitToRealityKit.docc/Tutorials/01-ClosedWorld.tutorial` +- Modify: `PiggyEscape/PiggyEscape/Tutorials/SceneKitToRealityKit.docc/Tutorials/Resources/01-ClosedWorld-01-01.swift` +- Modify: `PiggyEscape/PiggyEscape/Tutorials/SceneKitToRealityKit.docc/Tutorials/Resources/01-ClosedWorld-02-01.swift` +- Modify: `PiggyEscape/PiggyEscape/Tutorials/SceneKitToRealityKit.docc/Tutorials/Resources/01-ClosedWorld-03-01.swift` +- Modify: `PiggyEscape/PiggyEscape/Tutorials/SceneKitToRealityKit.docc/Tutorials/Resources/01-ClosedWorld-04-01.swift` +- Modify: `PiggyEscape/PiggyEscape/Tutorials/SceneKitToRealityKit.docc/Tutorials/Resources/01-ClosedWorld-05-01.swift` +- Create: `docs/PROJECT_CONTEXT.md` +- Modify: `docs/WORK_LOG.md` +- Modify: `docs/TROUBLESHOOTING.md` +- Modify: `docs/superpowers/plans/2026-08-10-ch1-reality-escape-implementation.md` + +**Interfaces:** +- Produces: Chapter 1 DocC that teaches the C3 closed world, camera-driven failure to hide, camera-permission transition, LiDAR mesh occlusion, and physical rediscovery. +- Produces: Git-tracked shared context with actual automated and manual verification evidence, without claimed real-device results until they were observed. + +- [ ] **Step 1: DocC snippets가 새 코드 계약을 설명하도록 실패 검증을 준비한다.** + +Replace each existing room/fake-sofa snippet with a compilable focused excerpt: state gate, C3 named tree and model container, tree hide calculation/discovery reaction, `ARWorldTrackingConfiguration` + scene understanding options, and real mesh reveal monitor. Each snippet must define all names it uses inside the snippet or import them from the adjacent snippet's declared context. + +Run: + +```bash +cd PiggyEscape +tuist generate --no-open +xcodebuild docbuild -project PiggyEscape.xcodeproj -scheme PiggyEscape \ + -destination 'platform=iOS Simulator,name=iPhone 17 Pro' \ + -derivedDataPath /tmp/piggyescape-reality-docc +``` + +Expected before updating snippets: DocC content still describes room/fake sofa and no longer matches the running app, even if `docbuild` technically succeeds. + +- [ ] **Step 2: 튜토리얼을 다섯 장면으로 다시 작성한다.** + +Use this exact tutorial progression: + +```text +1. C3가 이미 만든 섬과 궤도 카메라를 SceneKit에 놓는다. +2. 돼지는 나무 뒤로 가지만 사용자가 카메라를 돌리면 다시 보인다. +3. 놀란 돼지와 자막 뒤 시스템 카메라 권한이 전환점이 된다. +4. RealityKit은 LiDAR 메쉬와 수직 면 탭으로 실제 물체의 반대편을 계산한다. +5. 실제 깊이 오클루전이 돼지를 가리고, 사용자가 몸을 움직여 다시 찾아낸다. +``` + +Explain that `sceneReconstruction = .meshWithClassification` is guarded by `supportsSceneReconstruction`, and explain `.occlusion` as depth-only use of reconstructed geometry. Link the primary Apple documentation URLs already listed in the design spec; do not copy third-party descriptions. + +- [ ] **Step 3: shared Markdown을 실제 상태로 갱신한다.** + +Create `docs/PROJECT_CONTEXT.md` by reconciling the root workspace's collaboration rules with this branch's newer approved `reality-escape` design: the current approved scope is this C3→RealityKit Chapter 1 experience, the Draft PR remains the baseline, and Task 8 manual LiDAR verification is not completed until observed. In `docs/TROUBLESHOOTING.md`, record only observed limitations: ARKit cannot perform physical mesh occlusion in Simulator; camera permission can only be fully checked on device; and `ARView` screen scaling is deliberately not a lens/FOV change. In `docs/WORK_LOG.md`, state exact test commands and the remaining manual checks. + +- [ ] **Step 4: 전체 자동 검증과 DocC 빌드를 실행한다.** + +Run: + +```bash +cd PiggyEscape +tuist generate --no-open +xcodebuild -project PiggyEscape.xcodeproj -scheme PiggyEscape \ + -destination 'platform=iOS Simulator,name=iPhone 17 Pro' \ + -derivedDataPath /tmp/piggyescape-reality-tests test +xcodebuild -project PiggyEscape.xcodeproj -scheme PiggyEscape \ + -destination 'platform=iOS Simulator,name=iPhone 17 Pro' \ + -derivedDataPath /tmp/piggyescape-reality-build build +xcodebuild docbuild -project PiggyEscape.xcodeproj -scheme PiggyEscape \ + -destination 'platform=iOS Simulator,name=iPhone 17 Pro' \ + -derivedDataPath /tmp/piggyescape-reality-docc +``` + +Expected: all XCTest cases pass, build succeeds, and DocC produces `SceneKitToRealityKit.doccarchive` under `/tmp/piggyescape-reality-docc`. + +- [ ] **Step 5: LiDAR 실기기에서 수동 수용 검증을 수행한다.** + +Use the physical LiDAR-capable iPhone to verify this exact checklist, recording pass/fail and device/OS in `docs/WORK_LOG.md` only after observation. + +```text +[ ] 앱 시작 시 C3 섬·기존 나무·초기 나레이션이 보인다. +[ ] 초기 나레이션이 끝나기 전 돼지 탭은 무시된다. +[ ] 탭 후 걷는 돼지가 나무의 현재 카메라 반대편으로 이동한다. +[ ] 카메라를 0.70 rad 이상 돌려 돼지가 보일 때 놀란 모델·자막·1.5배 확대 후 복귀가 한 번 실행된다. +[ ] 페이드 뒤 시스템 카메라 권한 문구가 표시되고, 허용 뒤 AR 스캔 안내가 열린다. +[ ] 실제 물체의 수직 옆면을 탭하면 돼지가 반대편 바닥으로 걸어간다. +[ ] 초기 시점에서 실제 물체의 LiDAR 메쉬가 돼지를 가린다. +[ ] 사용자가 실제로 옆으로 이동해 돼지를 다시 볼 때 놀란 모델·자막·1.5배 확대·1.12배 화면 확대가 한 번 실행되고 복귀한다. +[ ] 권한 거부, LiDAR 미지원, 수평면 탭, 너무 가까운 탭, 바닥 추적 부족은 각각 안내와 복구 경로를 보인다. +``` + +- [ ] **Step 6: 최종 인수인계를 기록하고 커밋한다.** + +Only stage source-controlled Markdown and tutorial source files; never stage `/tmp` outputs or `.claude/`. + +```bash +git add PiggyEscape/PiggyEscape/Tutorials docs/PROJECT_CONTEXT.md docs/WORK_LOG.md \ + docs/TROUBLESHOOTING.md docs/superpowers/plans/2026-08-10-ch1-reality-escape-implementation.md +git commit -m "Document C3 to RealityKit escape tutorial" +``` + +--- + +## Final Review Checklist + +- [ ] `ContentView` starts only `EscapeRootView`; it does not instantiate the old room/fake-sofa view. +- [ ] Every C3 asset in the Global Constraints list matches the local C3 source with `cmp`. +- [ ] No imported source references `SwiftData`, `WatchConnectivity`, `Allowance`, `Ledger`, `balance`, or `GoldenPiggy`. +- [ ] SceneKit discovery requires real camera yaw/frustum conditions, not a timer. +- [ ] Both discovery scenes use the same surprised model, caption, 1.5 peak scale, and 1.0 restore scale. +- [ ] RealityKit uses actual mesh reconstruction with `.occlusion` and `.collision`, not a colored proxy wall. +- [ ] RealityKit rediscovery waits for at least one blocked mesh observation and fires once only. +- [ ] Full XCTest, build, DocC build, and manual LiDAR results are recorded with their actual outcomes. +- [ ] Every task has an updated `docs/WORK_LOG.md` entry and a commit without authorship markers. From 652c756ff83e7bbaef39814ca8597c2f4e1d2da4 Mon Sep 17 00:00:00 2001 From: eunseo-com Date: Mon, 10 Aug 2026 22:15:39 +0900 Subject: [PATCH 05/22] Record implementation failures as learning --- docs/LEARNING_LOG.md | 31 +++++++++++++++++++ docs/WORK_LOG.md | 2 ++ ...08-10-ch1-reality-escape-implementation.md | 9 ++++-- 3 files changed, 40 insertions(+), 2 deletions(-) create mode 100644 docs/LEARNING_LOG.md diff --git a/docs/LEARNING_LOG.md b/docs/LEARNING_LOG.md new file mode 100644 index 0000000..b20a50c --- /dev/null +++ b/docs/LEARNING_LOG.md @@ -0,0 +1,31 @@ +# 실패와 학습 기록 + +> 구현 중 발생한 실패·예상 밖 동작·검증 한계는 해결 여부와 관계없이 이 문서에 남긴다. 같은 증상을 다시 만났을 때 대화 기록이 아니라 재현 가능한 근거부터 확인하기 위한 기록이다. 사람·도구·모델 이름과 비밀 정보는 쓰지 않는다. + +## 기록 규칙 + +- 실패한 테스트, 빌드, 실행, API/에셋 실험, 실기기 검증은 원인 확인 또는 중단 결정 전에 한 항목으로 기록한다. +- 항목에는 실패를 재현한 명령 또는 사용자 동작, 실제 관찰값, 영향 범위, 확인한 원인 또는 가설, 해결/보류 결정, 재발 방지 검증을 적는다. +- 가설은 가설로 표시한다. 확인하지 않은 내용을 원인으로 단정하지 않는다. +- 해결된 항목도 삭제하지 않는다. 후속 변경으로 재발하면 기존 항목 번호를 참조해 새 항목을 추가한다. +- 사용자 기기·권한·LiDAR처럼 이 환경에서 실행할 수 없는 검증은 `실기기 대기`로 기록하며 자동 테스트 성공으로 대체하지 않는다. + +## 항목 형식 + +```markdown +### L-YYYYMMDD-순번 — 짧은 증상 제목 + +- 상태: 조사 중 | 해결 | 보류 | 실기기 대기 +- 발생 태스크: Task N — 이름 +- 재현: 실행한 명령 또는 사용자 동작 +- 관찰: 실제 오류·출력·화면 결과 +- 영향: 사용자 경험·빌드·테스트·문서 중 영향 범위 +- 원인/가설: 확인 근거와 함께 작성 +- 조치: 적용한 수정 또는 보류 이유 +- 검증: 수정 후 실행한 명령·결과, 또는 남은 수동 검증 +- 배운 점: 다음 작업에서 먼저 확인할 조건 +``` + +## 항목 + +아직 기록된 실패가 없다. 첫 실패 또는 검증 한계가 확인되면 위 형식으로 추가한다. diff --git a/docs/WORK_LOG.md b/docs/WORK_LOG.md index 5c5f6b8..b20b51b 100644 --- a/docs/WORK_LOG.md +++ b/docs/WORK_LOG.md @@ -6,6 +6,7 @@ - 상태: C3 월드 기반 SceneKit → RealityKit 재설계 승인, 구현 계획 작성 완료 - 진행 중 범위: Draft PR의 Chapter 1 결과물을 기준선으로 두고, C3_Piggy의 3D 월드와 실제 공간 숨기 흐름을 결합하는 별도 브랜치 설계를 시작했다. +- 실패 기록: `docs/LEARNING_LOG.md`에 실패·검증 한계의 재현 조건·원인/가설·조치·재발 방지 근거를 기록한다. - 마지막 완료 범위: Task 8 — Chapter 1 DocC 튜토리얼 카탈로그, 5개 현재 소스 스니펫, Tuist 타깃 인식 설정. - 마지막 검증: `tuist generate --no-open` 성공. `xcodebuild docbuild -project PiggyEscape/PiggyEscape.xcodeproj -scheme PiggyEscape -destination 'platform=iOS Simulator,name=iPhone 17 Pro' -derivedDataPath /tmp/piggyescape-docbuild`가 `.doccarchive`를 만들고 `** BUILD DOCUMENTATION SUCCEEDED **`로 완료했다. - 다음 시작점: `docs/superpowers/plans/2026-08-10-ch1-reality-escape-implementation.md`의 Task 1부터 태스크별 테스트·검토·커밋 순서로 구현한다. @@ -21,6 +22,7 @@ | 날짜 | 작업 범위 | 결과 | 검증 | 다음 시작점 | | --- | --- | --- | --- | --- | +| 2026-08-10 | 실패·학습 기록 운영 | 실패한 테스트·빌드·실행·실기기 검증 한계를 재현 가능한 항목으로 남기는 별도 기록을 추가함 | 기록 형식·상태·필수 근거를 설계 문서와 대조 | Task 1 실행 | | 2026-08-10 | Chapter 1 C3 월드→RealityKit 구현 계획 | C3 에셋·상태 기계·섬 어댑터·가짜 숨기·LiDAR 실제 숨기·자동 전환·DocC/실기기 검증을 8개 독립 태스크로 분해함 | 설계 명세·C3 원본 코드·현재 프로젝트/테스트 구조·Apple ARKit/RealityKit 공식 API 대조 | 실행 방식 선택 후 Task 1 | | 2026-08-10 | Chapter 1 C3 월드·현실 숨기 재설계 | C3 SceneKit 월드에서 나무 뒤로 숨었다가 카메라 회전에 들키면 놀란 돼지 모델·자막·강한 확대 반응을 보이고, RealityKit의 실제 메쉬 뒤에서 다시 들킬 때는 같은 반응과 화면 확대를 보이는 흐름을 설계함. 금융·저장·Watch 범위는 제외 | 설계 문서 검토 대기 | 구현 계획 작성 | | 2026-08-10 | Chapter 1 최종 검토 | 가짜 소파 바닥 정렬, 실제 노드 구조 탐구 실행, DocC 단계 완결성, 공통 인수인계 문서 통합의 보완 필요를 확인 | 전체 변경·문서·검증 근거 검토 | 보완 커밋과 재검토 | diff --git a/docs/superpowers/plans/2026-08-10-ch1-reality-escape-implementation.md b/docs/superpowers/plans/2026-08-10-ch1-reality-escape-implementation.md index 550ce52..68db76d 100644 --- a/docs/superpowers/plans/2026-08-10-ch1-reality-escape-implementation.md +++ b/docs/superpowers/plans/2026-08-10-ch1-reality-escape-implementation.md @@ -20,6 +20,7 @@ - 실제 숨기는 `ARWorldTrackingConfiguration.supportsSceneReconstruction(.meshWithClassification)`이 참인 LiDAR 지원 실기기에서만 시작한다. 시뮬레이터의 빌드/단위 테스트는 ARKit 실제 오클루전 검증을 대체하지 않는다. - `ARView.environment.sceneUnderstanding.options`는 `[.occlusion, .collision, .physics, .receivesLighting]`로 설정한다. `.occlusion`은 실제 재구성 메쉬 뒤의 가상 돼지를 렌더링하지 않게 하고, `.collision`은 실제 메쉬 탭·가림 판정 raycast에 사용한다. [Apple 문서](https://developer.apple.com/documentation/realitykit/arview/environment-swift.struct/sceneunderstanding-swift.struct/options-swift.struct) - 생성된 `.xcodeproj`, `.build`, DerivedData, `.claude/` 활동 로그는 추적하지 않는다. 기존 또는 새로 발견한 추적되지 않은 파일은 명시 요청 없이 이동·삭제·스테이징하지 않는다. +- 실패한 테스트·빌드·실행·API/에셋 실험·실기기 검증은 재시도 또는 보류 결정 전에 `docs/LEARNING_LOG.md`에 재현 조건, 실제 관찰값, 원인/가설, 조치, 검증, 배운 점을 기록한다. 자동 테스트가 대신할 수 없는 실기기 확인은 `실기기 대기`로 남긴다. - 각 태스크는 해당 XCTest/빌드 증거와 `docs/WORK_LOG.md` 갱신을 같은 커밋에 포함한다. 커밋·PR에는 작업자·도구·모델 표기와 `Co-Authored-By` 트레일러를 넣지 않는다. ## File Structure @@ -42,6 +43,7 @@ | `PiggyEscape/PiggyEscapeTests/*` | 순수 상태/수학, C3 노드, AR 계획·코디네이터 계약 회귀 테스트 | | `PiggyEscape/PiggyEscape/Tutorials/SceneKitToRealityKit.docc/*` | C3 월드→실제 오클루전 흐름을 설명하는 Chapter 1 튜토리얼 | | `docs/PROJECT_CONTEXT.md`, `docs/WORK_LOG.md`, `docs/TROUBLESHOOTING.md` | 공통 인수인계, 실제 검증 결과, ARKit 제한과 출처 | +| `docs/LEARNING_LOG.md` | 실패·검증 한계의 재현 조건, 원인/가설, 조치, 재발 방지 근거 | --- @@ -61,13 +63,16 @@ - Create: `PiggyEscape/PiggyEscape/Resources/Coin_Color.usdc` - Create: `PiggyEscape/PiggyEscape/Resources/Warehouse_Color.usdc` - Modify: `PiggyEscape/PiggyEscapeTests/AssetLoaderTests.swift` +- Create: `docs/LEARNING_LOG.md` - Modify: `docs/WORK_LOG.md` **Interfaces:** - Produces: `Bundle.main`에서 `Piggy`, `Piggy_running`, `Piggy_surprised`, 두 `Cylinder_Tree`와 섬 장식 에셋을 확장자 없이 찾을 수 있는 리소스 집합. - Produces: 앱 빌드 산출물 Info.plist의 `NSCameraUsageDescription` 문자열. -- [ ] **Step 1: 실패하는 C3 모델 로드 테스트를 추가한다.** +- [ ] **Step 1: 실패·학습 기록을 만들고 실패 테스트를 추가한다.** + +Create `docs/LEARNING_LOG.md` with the repository's fixed entry format before running the first intentional failing test. Every failure in this task must add an entry before the next retry. `PiggyEscape/PiggyEscapeTests/AssetLoaderTests.swift`에 다음 테스트를 추가한다. @@ -148,7 +153,7 @@ Expected: `AssetLoaderTests` pass and `plutil` prints `피기가 현실의 물 ```bash git add PiggyEscape/Project.swift PiggyEscape/PiggyEscape/Resources \ - PiggyEscape/PiggyEscapeTests/AssetLoaderTests.swift docs/WORK_LOG.md + PiggyEscape/PiggyEscapeTests/AssetLoaderTests.swift docs/LEARNING_LOG.md docs/WORK_LOG.md git commit -m "Add C3 escape assets and camera permission" ``` From 105519d1a31cdd7318358a6c903637311ee6d700 Mon Sep 17 00:00:00 2001 From: eunseo-com Date: Mon, 10 Aug 2026 22:20:35 +0900 Subject: [PATCH 06/22] Add C3 escape assets and camera permission --- .../PiggyEscape/Resources/Coin_Color.usdc | Bin 0 -> 17430 bytes .../Resources/Cylinder_Tree1_Color.usdc | Bin 0 -> 12900 bytes .../Resources/Cylinder_Tree2_Color.usdc | Bin 0 -> 9868 bytes .../PiggyEscape/Resources/Manger_Color.usdc | Bin 0 -> 21364 bytes .../PiggyEscape/Resources/Piggy_running.usdz | Bin 0 -> 132418 bytes .../Resources/Piggy_surprised.usdz | Bin 0 -> 146162 bytes .../PiggyEscape/Resources/Stone_Color.usdc | Bin 0 -> 7292 bytes .../Resources/Warehouse_Color.usdc | Bin 0 -> 72908 bytes .../PiggyEscapeTests/AssetLoaderTests.swift | 15 ++++++ PiggyEscape/Project.swift | 4 +- docs/LEARNING_LOG.md | 48 +++++++++++++++++- docs/WORK_LOG.md | 13 ++--- 12 files changed, 72 insertions(+), 8 deletions(-) create mode 100644 PiggyEscape/PiggyEscape/Resources/Coin_Color.usdc create mode 100644 PiggyEscape/PiggyEscape/Resources/Cylinder_Tree1_Color.usdc create mode 100644 PiggyEscape/PiggyEscape/Resources/Cylinder_Tree2_Color.usdc create mode 100644 PiggyEscape/PiggyEscape/Resources/Manger_Color.usdc create mode 100644 PiggyEscape/PiggyEscape/Resources/Piggy_running.usdz create mode 100644 PiggyEscape/PiggyEscape/Resources/Piggy_surprised.usdz create mode 100644 PiggyEscape/PiggyEscape/Resources/Stone_Color.usdc create mode 100644 PiggyEscape/PiggyEscape/Resources/Warehouse_Color.usdc diff --git a/PiggyEscape/PiggyEscape/Resources/Coin_Color.usdc b/PiggyEscape/PiggyEscape/Resources/Coin_Color.usdc new file mode 100644 index 0000000000000000000000000000000000000000..17dad4ddadf1425a21edd4aa2441ded79544d3cd GIT binary patch literal 17430 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 literal 0 HcmV?d00001 diff --git a/PiggyEscape/PiggyEscape/Resources/Cylinder_Tree1_Color.usdc b/PiggyEscape/PiggyEscape/Resources/Cylinder_Tree1_Color.usdc new file mode 100644 index 0000000000000000000000000000000000000000..d648a93df9857e13489e598db97cf52f2b2ff025 GIT binary patch literal 12900 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 literal 0 HcmV?d00001 diff --git a/PiggyEscape/PiggyEscape/Resources/Cylinder_Tree2_Color.usdc b/PiggyEscape/PiggyEscape/Resources/Cylinder_Tree2_Color.usdc new file mode 100644 index 0000000000000000000000000000000000000000..ac87fc0b9c8758cc15d24d7ed94f0b947eef15c6 GIT binary patch literal 9868 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 Av;Y7A literal 0 HcmV?d00001 diff --git a/PiggyEscape/PiggyEscape/Resources/Manger_Color.usdc b/PiggyEscape/PiggyEscape/Resources/Manger_Color.usdc new file mode 100644 index 0000000000000000000000000000000000000000..b141846e7c876068682b5441bc45ddd07a8213ac GIT binary patch literal 21364 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 literal 0 HcmV?d00001 diff --git a/PiggyEscape/PiggyEscape/Resources/Piggy_running.usdz b/PiggyEscape/PiggyEscape/Resources/Piggy_running.usdz new file mode 100644 index 0000000000000000000000000000000000000000..b66e85354d0e4171aac4526008e6fc6f69cd3af2 GIT binary patch literal 132418 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 z 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 P4a2|J0bhUxA`$vO?hjhT literal 0 HcmV?d00001 diff --git a/PiggyEscape/PiggyEscape/Resources/Piggy_surprised.usdz b/PiggyEscape/PiggyEscape/Resources/Piggy_surprised.usdz new file mode 100644 index 0000000000000000000000000000000000000000..325123f6028985a6c54a2cc90f3e77f70fe8e7db GIT binary patch literal 146162 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 literal 0 HcmV?d00001 diff --git a/PiggyEscape/PiggyEscape/Resources/Stone_Color.usdc b/PiggyEscape/PiggyEscape/Resources/Stone_Color.usdc new file mode 100644 index 0000000000000000000000000000000000000000..6c76654f6f4e9e8f58a001dff3b35171a619d72c GIT binary patch literal 7292 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 RUQ&F!93t-)_udr4{{gg7m6HGf literal 0 HcmV?d00001 diff --git a/PiggyEscape/PiggyEscape/Resources/Warehouse_Color.usdc b/PiggyEscape/PiggyEscape/Resources/Warehouse_Color.usdc new file mode 100644 index 0000000000000000000000000000000000000000..6508c0f83046b46531b405ee39121572104c76df GIT binary patch literal 72908 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∈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 Date: Mon, 10 Aug 2026 22:28:24 +0900 Subject: [PATCH 07/22] Add escape state and hide planning --- .../Escape/EscapeExperienceState.swift | 40 ++++++++++++++++ .../Sources/Escape/HidePlanning.swift | 27 +++++++++++ .../EscapeExperienceStateTests.swift | 24 ++++++++++ .../PiggyEscapeTests/HidePlanningTests.swift | 14 ++++++ docs/LEARNING_LOG.md | 48 +++++++++++++++++++ docs/WORK_LOG.md | 11 +++-- 6 files changed, 159 insertions(+), 5 deletions(-) create mode 100644 PiggyEscape/PiggyEscape/Sources/Escape/EscapeExperienceState.swift create mode 100644 PiggyEscape/PiggyEscape/Sources/Escape/HidePlanning.swift create mode 100644 PiggyEscape/PiggyEscapeTests/EscapeExperienceStateTests.swift create mode 100644 PiggyEscape/PiggyEscapeTests/HidePlanningTests.swift diff --git a/PiggyEscape/PiggyEscape/Sources/Escape/EscapeExperienceState.swift b/PiggyEscape/PiggyEscape/Sources/Escape/EscapeExperienceState.swift new file mode 100644 index 0000000..7b2bc09 --- /dev/null +++ b/PiggyEscape/PiggyEscape/Sources/Escape/EscapeExperienceState.swift @@ -0,0 +1,40 @@ +enum EscapeExperienceState: Equatable { + case openingNarration, readyForPigTap, walkingBehindTree, hiddenInClosedWorld + case discoveredByCamera, requestingCameraPermission, scanningReality + case waitingForRealTarget, walkingBehindRealObject, hiddenInReality, discoveredInReality + case cameraDenied, lidarUnavailable +} + +enum EscapeExperienceEvent { + case narrationFinished, pigTapped, pigReachedTree, closedWorldPigDiscovered + case closedWorldFadeFinished, cameraAuthorized, cameraDenied, meshSupported + case meshUnsupported, realTargetAccepted, pigReachedRealObject, realityPigDiscovered, reset +} + +struct EscapeExperienceMachine { + private(set) var state: EscapeExperienceState + + init(state: EscapeExperienceState = .openingNarration) { + self.state = state + } + + mutating func send(_ event: EscapeExperienceEvent) -> Bool { + switch (state, event) { + case (_, .reset): state = .openingNarration + case (.openingNarration, .narrationFinished): state = .readyForPigTap + case (.readyForPigTap, .pigTapped): state = .walkingBehindTree + case (.walkingBehindTree, .pigReachedTree): state = .hiddenInClosedWorld + case (.hiddenInClosedWorld, .closedWorldPigDiscovered): state = .discoveredByCamera + case (.discoveredByCamera, .closedWorldFadeFinished): state = .requestingCameraPermission + case (.requestingCameraPermission, .cameraAuthorized): state = .scanningReality + case (.requestingCameraPermission, .cameraDenied): state = .cameraDenied + case (.scanningReality, .meshSupported): state = .waitingForRealTarget + case (.scanningReality, .meshUnsupported): state = .lidarUnavailable + case (.waitingForRealTarget, .realTargetAccepted): state = .walkingBehindRealObject + case (.walkingBehindRealObject, .pigReachedRealObject): state = .hiddenInReality + case (.hiddenInReality, .realityPigDiscovered): state = .discoveredInReality + default: return false + } + return true + } +} diff --git a/PiggyEscape/PiggyEscape/Sources/Escape/HidePlanning.swift b/PiggyEscape/PiggyEscape/Sources/Escape/HidePlanning.swift new file mode 100644 index 0000000..a21b37b --- /dev/null +++ b/PiggyEscape/PiggyEscape/Sources/Escape/HidePlanning.swift @@ -0,0 +1,27 @@ +import simd + +enum TreeHidePlanner { + static func destination( + treeCenter: SIMD3, + treeRadius: Float, + cameraPosition: SIMD3, + pigRadius: Float, + floorY: Float + ) -> SIMD3 { + let towardCamera = SIMD3( + cameraPosition.x - treeCenter.x, + 0, + cameraPosition.z - treeCenter.z + ) + let direction = simd_length_squared(towardCamera) > 0.0001 + ? simd_normalize(towardCamera) + : SIMD3(0, 0, 1) + let distance = treeRadius + pigRadius + 0.08 + + return SIMD3( + treeCenter.x - direction.x * distance, + floorY, + treeCenter.z - direction.z * distance + ) + } +} diff --git a/PiggyEscape/PiggyEscapeTests/EscapeExperienceStateTests.swift b/PiggyEscape/PiggyEscapeTests/EscapeExperienceStateTests.swift new file mode 100644 index 0000000..f201720 --- /dev/null +++ b/PiggyEscape/PiggyEscapeTests/EscapeExperienceStateTests.swift @@ -0,0 +1,24 @@ +import XCTest +@testable import PiggyEscape + +final class EscapeExperienceStateTests: XCTestCase { + func test_pigTapIsIgnoredUntilOpeningNarrationCompletes() { + var machine = EscapeExperienceMachine() + XCTAssertFalse(machine.send(.pigTapped)) + XCTAssertEqual(machine.state, .openingNarration) + + XCTAssertTrue(machine.send(.narrationFinished)) + XCTAssertTrue(machine.send(.pigTapped)) + XCTAssertEqual(machine.state, .walkingBehindTree) + } + + func test_discoverySequenceCanReachRealityOnlyInOrder() { + var machine = EscapeExperienceMachine(state: .hiddenInClosedWorld) + XCTAssertTrue(machine.send(.closedWorldPigDiscovered)) + XCTAssertEqual(machine.state, .discoveredByCamera) + XCTAssertTrue(machine.send(.closedWorldFadeFinished)) + XCTAssertEqual(machine.state, .requestingCameraPermission) + XCTAssertTrue(machine.send(.cameraAuthorized)) + XCTAssertEqual(machine.state, .scanningReality) + } +} diff --git a/PiggyEscape/PiggyEscapeTests/HidePlanningTests.swift b/PiggyEscape/PiggyEscapeTests/HidePlanningTests.swift new file mode 100644 index 0000000..f9156ac --- /dev/null +++ b/PiggyEscape/PiggyEscapeTests/HidePlanningTests.swift @@ -0,0 +1,14 @@ +import XCTest +import simd +@testable import PiggyEscape + +final class HidePlanningTests: XCTestCase { + func test_treeDestinationIsOnTheCameraOppositeSideAndOnTheFloor() { + let target = TreeHidePlanner.destination( + treeCenter: SIMD3(0, 0, 0), treeRadius: 0.5, + cameraPosition: SIMD3(0, 2, 4), pigRadius: 0.25, floorY: 0 + ) + XCTAssertLessThan(target.z, 0) + XCTAssertEqual(target.y, 0, accuracy: 0.0001) + } +} diff --git a/docs/LEARNING_LOG.md b/docs/LEARNING_LOG.md index 5998e04..1e49514 100644 --- a/docs/LEARNING_LOG.md +++ b/docs/LEARNING_LOG.md @@ -28,6 +28,54 @@ ## 항목 +### L-20260810-008 — Task 2 커밋 준비가 연결 작업 트리 index 잠금 권한으로 중단됨 + +- 상태: 해결 +- 발생 태스크: Task 2 — 경험 상태와 숨기 좌표를 순수 로직으로 만든다 +- 재현: `git add PiggyEscape/PiggyEscape/Sources/Escape/EscapeExperienceState.swift PiggyEscape/PiggyEscape/Sources/Escape/HidePlanning.swift PiggyEscape/PiggyEscapeTests/EscapeExperienceStateTests.swift PiggyEscape/PiggyEscapeTests/HidePlanningTests.swift docs/WORK_LOG.md docs/LEARNING_LOG.md && git commit -m 'Add escape state and hide planning'` +- 관찰: `fatal: Unable to create '/Users/yang-eunseo/Downloads/SpatialComputing_TechMap/.git/worktrees/ch1-reality-escape/index.lock': Operation not permitted`로 index 갱신 전에 중단됨 +- 영향: 검증된 Task 2 변경을 태스크 단위 커밋으로 기록하지 못함 +- 원인/가설: 연결 작업 트리의 공용 Git 메타데이터 index 잠금 경로가 현재 권한 범위 밖에 있음 +- 조치: 공용 Git 메타데이터에 쓸 수 있는 권한으로 같은 명시적 파일 목록을 stage 및 commit함 +- 검증: 커밋 후 `git show --stat --oneline HEAD`와 `git status --short`로 Task 2 파일만 커밋되었고 사용자 소유의 `.claude/`만 남았는지 확인함 +- 배운 점: 연결 작업 트리에서는 작업 트리 파일 쓰기 권한과 별도로 공용 Git index 잠금 경로 쓰기 권한이 필요하다. + +### L-20260810-007 — 전체 XCTest 성공 중 기존 Simulator 런타임 경고가 출력됨 + +- 상태: 보류 +- 발생 태스크: Task 2 — 경험 상태와 숨기 좌표를 순수 로직으로 만든다 +- 재현: `xcodebuild -project PiggyEscape/PiggyEscape.xcodeproj -scheme PiggyEscape -destination 'platform=iOS Simulator,name=iPhone 17 Pro' test` +- 관찰: 30개 XCTest는 모두 통과했지만 `Metadata extraction skipped. No AppIntents.framework dependency found`, `UIFocus` 캐시 제한, Simulator 런타임의 `UIAccessibilityLoaderWebShared` 중복 구현 경고가 출력됨 +- 영향: Task 2의 순수 로직 테스트 결과에는 실패가 없지만 XCTest 출력이 경고 없이 깨끗하다는 조건은 충족하지 못함 +- 원인/가설: AppIntents 의존성이 없는 기존 앱 타깃의 메타데이터 처리와 iOS Simulator 26.5의 UI/접근성 런타임 진단이며, Task 2의 두 순수 소스는 UIKit·SceneKit·ARKit·RealityKit을 import하지 않음 +- 조치: Task 2 범위 밖의 앱 타깃·Simulator 런타임 설정은 변경하지 않고, focused 및 전체 XCTest의 통과 결과를 기록함 +- 검증: focused XCTest 3/3, 전체 XCTest 30/30 통과. `rg -n '^(import (UIKit|SceneKit|ARKit|RealityKit))' PiggyEscape/PiggyEscape/Sources/Escape`로 금지 프레임워크 import가 없음을 확인함 +- 배운 점: 순수 로직 변경의 회귀 검증과 기존 Simulator 런타임 경고를 분리해 기록하고, 범위 밖 경고를 해결하려고 앱 구조를 변경하지 않는다. + +### L-20260810-006 — Task 2 RED XCTest가 Simulator 서비스 접근 제한으로 중단됨 + +- 상태: 해결 +- 발생 태스크: Task 2 — 경험 상태와 숨기 좌표를 순수 로직으로 만든다 +- 재현: `xcodebuild -project PiggyEscape/PiggyEscape.xcodeproj -scheme PiggyEscape -destination 'platform=iOS Simulator,name=iPhone 17 Pro' -only-testing:PiggyEscapeTests/EscapeExperienceStateTests -only-testing:PiggyEscapeTests/HidePlanningTests test` +- 관찰: `CoreSimulatorService connection became invalid`와 `Error opening log file ... Operation not permitted`가 발생해 컴파일 단계 전에 Simulator 서비스를 초기화하지 못함 +- 영향: 새 타입 부재를 보여야 하는 RED XCTest가 실제 컴파일 오류까지 도달하지 못함 +- 원인/가설: Simulator의 기기·로그·서비스 상태 경로가 현재 권한 범위 밖에 있음 +- 조치: Simulator 서비스와 Xcode 상태 경로에 접근 가능한 권한으로 동일한 명령을 재실행함 +- 검증: 재실행에서 `EscapeExperienceMachine` 미발견 컴파일 실패를 확인함. 테스트 타깃이 첫 컴파일 오류에서 중단되어 `TreeHidePlanner` 진단은 생성되지 않았음 +- 배운 점: iOS Simulator XCTest는 프로젝트 파일만으로 동작하지 않으며, Simulator 서비스와 Xcode 로그 상태에 접근할 수 있어야 한다. + +### L-20260810-005 — Task 2 RED 준비 중 Tuist 세션 상태 디렉터리 쓰기가 거부됨 + +- 상태: 해결 +- 발생 태스크: Task 2 — 경험 상태와 숨기 좌표를 순수 로직으로 만든다 +- 재현: `cd PiggyEscape && tuist generate --no-open` +- 관찰: `Fatal error: Error raised at top level: Permission denied: /Users/yang-eunseo/.local/state/tuist/sessions/AC8F4B22-C54F-4CF0-8096-E18EF5C09C3D`로 프로젝트 생성이 테스트 실행 전에 종료됨 +- 영향: 새 XCTest 파일을 기존 생성 프로젝트에 반영하지 못해 타입 부재 RED를 아직 관찰할 수 없음 +- 원인/가설: Tuist가 작업 트리 밖의 사용자 세션 상태 디렉터리에 쓰기를 시도하지만 현재 권한 범위에 포함되지 않음 +- 조치: 해당 상태 경로에 쓸 수 있는 권한으로 같은 생성 명령을 재실행함 +- 검증: 권한 확보 후 프로젝트를 다시 생성하고, 지정한 두 테스트를 실행해 `EscapeExperienceMachine` 부재 컴파일 실패를 확인함. 컴파일이 첫 오류에서 중단되어 `TreeHidePlanner` 미발견 진단은 생성되지 않았음 +- 배운 점: 새 Swift 소스의 RED는 생성 프로젝트에 소스 글로브가 반영된 뒤에만 의미가 있으므로, 먼저 Tuist 세션 상태 쓰기 권한을 확인한다. + ### L-20260810-002 — Tuist 생성이 세션 상태 디렉터리 권한으로 중단됨 - 상태: 해결 diff --git a/docs/WORK_LOG.md b/docs/WORK_LOG.md index 0dc4f1b..c79167a 100644 --- a/docs/WORK_LOG.md +++ b/docs/WORK_LOG.md @@ -4,12 +4,12 @@ ## 현재 인수인계 -- 상태: Task 1 — C3 에셋과 카메라 권한 등록 완료 -- 진행 중 범위: C3_Piggy의 11개 3D 원본 에셋을 앱 번들에 등록하고, 실제 공간 숨기 전환에 필요한 카메라 권한 목적 문구를 설정했다. +- 상태: Task 2 — 경험 상태와 숨기 좌표 순수 로직 완료 +- 진행 중 범위: 허용된 전이만 처리하는 경험 상태 기계와 카메라 반대편 나무 뒤 좌표 계산을 순수 Swift 로직으로 추가했다. - 실패 기록: `docs/LEARNING_LOG.md`에 실패·검증 한계의 재현 조건·원인/가설·조치·재발 방지 근거를 기록한다. -- 마지막 완료 범위: Task 1 — C3 에셋과 카메라 권한 등록. -- 마지막 검증: `tuist generate --no-open` 성공. `xcodebuild -project PiggyEscape.xcodeproj -scheme PiggyEscape -destination 'platform=iOS Simulator,name=iPhone 17 Pro' -derivedDataPath /tmp/piggyescape-build -only-testing:PiggyEscapeTests/AssetLoaderTests test`가 5개 테스트를 통과했고, 생성된 앱 Info.plist에서 `피기가 현실의 물체 뒤에 숨을 수 있도록 카메라를 사용합니다.`를 확인했다. 11개 C3 원본과 앱 리소스의 바이트도 각각 `cmp -s`로 일치함을 확인했다. -- 다음 시작점: `docs/superpowers/plans/2026-08-10-ch1-reality-escape-implementation.md`의 Task 2 — 경험 상태와 숨기 좌표 순수 로직. +- 마지막 완료 범위: Task 2 — 경험 상태와 숨기 좌표 순수 로직. +- 마지막 검증: `tuist generate --no-open` 성공. focused `xcodebuild -project PiggyEscape/PiggyEscape.xcodeproj -scheme PiggyEscape -destination 'platform=iOS Simulator,name=iPhone 17 Pro' -only-testing:PiggyEscapeTests/EscapeExperienceStateTests -only-testing:PiggyEscapeTests/HidePlanningTests test`가 3/3을 통과했고, 전체 `xcodebuild -project PiggyEscape/PiggyEscape.xcodeproj -scheme PiggyEscape -destination 'platform=iOS Simulator,name=iPhone 17 Pro' test`가 30/30을 통과했다. Simulator/AppIntents 런타임 경고는 `docs/LEARNING_LOG.md`의 L-20260810-007에 기록했다. +- 다음 시작점: `docs/superpowers/plans/2026-08-10-ch1-reality-escape-implementation.md`의 Task 3 — C3 섬 어댑터와 자동 전환. - 차단 요소: 원격 `git fetch`의 공용 Git 메타데이터 쓰기 권한과 시작 순서의 누락 문서는 `docs/LEARNING_LOG.md`에 보류 항목으로 기록했다. ### Task 7에서 해결한 항목 @@ -22,6 +22,7 @@ | 날짜 | 작업 범위 | 결과 | 검증 | 다음 시작점 | | --- | --- | --- | --- | --- | +| 2026-08-10 | Task 2 — 경험 상태와 숨기 좌표 순수 로직 | 상태 기계의 합법 전이와 XZ 카메라 반대편 나무 숨기 좌표를 프레임워크 독립 순수 Swift 타입으로 추가함 | RED에서 `EscapeExperienceMachine` 부재 컴파일 실패 확인 후 focused 3/3·전체 30/30 XCTest 통과. 기존 Simulator/AppIntents 경고는 L-20260810-007에 기록 | Task 3 — C3 섬 어댑터와 자동 전환 | | 2026-08-10 | Task 1 — C3 에셋과 카메라 권한 | C3 원본 11개를 바이트 변경 없이 앱 리소스에 등록하고, 고정 카메라 목적 문구와 실제 앱 번들 로딩 회귀 테스트를 추가함 | RED에서 새 에셋 8개가 누락되어 16개 assertion 실패 확인 후, `AssetLoaderTests` 5/5 통과·11개 `cmp -s` 일치·생성 Info.plist 목적 문구 확인 | Task 2 — 경험 상태와 숨기 좌표 순수 로직 | | 2026-08-10 | 실패·학습 기록 운영 | 실패한 테스트·빌드·실행·실기기 검증 한계를 재현 가능한 항목으로 남기는 별도 기록을 추가함 | 기록 형식·상태·필수 근거를 설계 문서와 대조 | Task 1 실행 | | 2026-08-10 | Chapter 1 C3 월드→RealityKit 구현 계획 | C3 에셋·상태 기계·섬 어댑터·가짜 숨기·LiDAR 실제 숨기·자동 전환·DocC/실기기 검증을 8개 독립 태스크로 분해함 | 설계 명세·C3 원본 코드·현재 프로젝트/테스트 구조·Apple ARKit/RealityKit 공식 API 대조 | 실행 방식 선택 후 Task 1 | From 1df6f1a73d54ecb64d3d497143e0af8cb88869c3 Mon Sep 17 00:00:00 2001 From: eunseo-com Date: Mon, 10 Aug 2026 22:47:46 +0900 Subject: [PATCH 08/22] Add C3 closed world adapter --- .../Sources/C3World/C3ClosedWorld.swift | 113 ++++++++++++ .../Sources/C3World/C3IslandBuilder.swift | 172 ++++++++++++++++++ .../Sources/C3World/C3PigModelFactory.swift | 89 +++++++++ .../PiggyEscapeTests/C3ClosedWorldTests.swift | 40 ++++ .../C3IslandBuilderTests.swift | 28 +++ .../C3PigModelFactoryTests.swift | 67 +++++++ docs/LEARNING_LOG.md | 120 ++++++++++++ docs/WORK_LOG.md | 11 +- 8 files changed, 635 insertions(+), 5 deletions(-) create mode 100644 PiggyEscape/PiggyEscape/Sources/C3World/C3ClosedWorld.swift create mode 100644 PiggyEscape/PiggyEscape/Sources/C3World/C3IslandBuilder.swift create mode 100644 PiggyEscape/PiggyEscape/Sources/C3World/C3PigModelFactory.swift create mode 100644 PiggyEscape/PiggyEscapeTests/C3ClosedWorldTests.swift create mode 100644 PiggyEscape/PiggyEscapeTests/C3IslandBuilderTests.swift create mode 100644 PiggyEscape/PiggyEscapeTests/C3PigModelFactoryTests.swift diff --git a/PiggyEscape/PiggyEscape/Sources/C3World/C3ClosedWorld.swift b/PiggyEscape/PiggyEscape/Sources/C3World/C3ClosedWorld.swift new file mode 100644 index 0000000..56534cf --- /dev/null +++ b/PiggyEscape/PiggyEscape/Sources/C3World/C3ClosedWorld.swift @@ -0,0 +1,113 @@ +import SceneKit +import UIKit + +@MainActor +final class C3ClosedWorld { + let scene = SCNScene() + let cameraNode = SCNNode() + let pigContainer: SCNNode + let hideTree: SCNNode + private(set) var cameraYaw: Float = .pi / 4 + + private let orbitRadius: Float = 17 + private let orbitHeight: Float = 12 + private var orthographicScale: CGFloat = 6 + private let directionalLightNode = SCNNode() + + init() { + let island = C3IslandBuilder.build() + hideTree = island.childNode(withName: "HideTree", recursively: true) ?? SCNNode() + pigContainer = C3PigModelFactory.makeContainer(pose: .idle) + pigContainer.position = island.childNode(withName: "BigPigSpawn", recursively: false)?.position + ?? SCNVector3(0, 0, 0) + + setupCamera() + setupLighting() + scene.rootNode.addChildNode(island) + scene.rootNode.addChildNode(pigContainer) + } + + func rotateCamera(byYaw delta: Float) { + cameraYaw += delta + updateOrbitPosition() + directionalLightNode.eulerAngles = lightEulerAngles(for: cameraYaw) + } + + func zoom(by factor: Float) { + guard factor > 0 else { return } + orthographicScale = min(12, max(3, orthographicScale / CGFloat(factor))) + cameraNode.camera?.orthographicScale = orthographicScale + } + + private func setupCamera() { + let camera = SCNCamera() + camera.usesOrthographicProjection = true + camera.orthographicScale = orthographicScale + camera.zNear = 0.1 + camera.zFar = 100 + cameraNode.camera = camera + + let target = SCNNode() + target.name = "C3OrbitTarget" + scene.rootNode.addChildNode(target) + let lookAt = SCNLookAtConstraint(target: target) + lookAt.isGimbalLockEnabled = true + cameraNode.constraints = [lookAt] + updateOrbitPosition() + scene.rootNode.addChildNode(cameraNode) + } + + private func setupLighting() { + scene.background.contents = gradientBackground() + + let ambient = SCNLight() + ambient.type = .ambient + ambient.intensity = 800 + ambient.color = UIColor.white + let ambientNode = SCNNode() + ambientNode.name = "C3AmbientLight" + ambientNode.light = ambient + scene.rootNode.addChildNode(ambientNode) + + let directional = SCNLight() + directional.type = .directional + directional.intensity = 520 + directional.color = UIColor.white + directional.castsShadow = true + directional.shadowMode = .deferred + directional.shadowColor = UIColor(white: 0, alpha: 0.22) + directional.shadowSampleCount = 12 + directional.shadowRadius = 6 + directionalLightNode.name = "C3DirectionalLight" + directionalLightNode.light = directional + directionalLightNode.eulerAngles = lightEulerAngles(for: cameraYaw) + scene.rootNode.addChildNode(directionalLightNode) + } + + private func updateOrbitPosition() { + cameraNode.position = SCNVector3( + orbitRadius * sin(cameraYaw), + orbitHeight, + orbitRadius * cos(cameraYaw) + ) + } + + private func lightEulerAngles(for yaw: Float) -> SCNVector3 { + SCNVector3(-.pi / 3, yaw - .pi / 4, 0) + } + + private func gradientBackground() -> UIImage { + let layer = CAGradientLayer() + layer.frame = CGRect(x: 0, y: 0, width: 4, height: 256) + layer.colors = [ + UIColor(red: 1, green: 1, blue: 1, alpha: 1).cgColor, + UIColor(red: 0.639, green: 0.929, blue: 0.992, alpha: 1).cgColor, + UIColor(red: 0.412, green: 0.804, blue: 0.918, alpha: 1).cgColor + ] + layer.startPoint = CGPoint(x: 0.5, y: 0) + layer.endPoint = CGPoint(x: 0.5, y: 1) + return UIGraphicsImageRenderer(size: CGSize(width: 4, height: 256)).image { + layer.render(in: $0.cgContext) + } + } +} diff --git a/PiggyEscape/PiggyEscape/Sources/C3World/C3IslandBuilder.swift b/PiggyEscape/PiggyEscape/Sources/C3World/C3IslandBuilder.swift new file mode 100644 index 0000000..9a2e4c9 --- /dev/null +++ b/PiggyEscape/PiggyEscape/Sources/C3World/C3IslandBuilder.swift @@ -0,0 +1,172 @@ +import SceneKit + +@MainActor +enum C3IslandBuilder { + private static let groundRotation = SCNVector3(-Float.pi / 2, 0, 0) + private static let groundHorizontalScale = Float(sqrt(2.0)) + + private struct Decoration { + let assetName: String + let tileIndex: Int + let offset: SCNVector3 + let yaw: Float + let maxHeight: Float + let maxFootprint: Float + + init( + _ assetName: String, + _ tileIndex: Int, + _ offset: SCNVector3, + _ yaw: Float, + _ maxHeight: Float, + _ maxFootprint: Float + ) { + self.assetName = assetName + self.tileIndex = tileIndex + self.offset = offset + self.yaw = yaw + self.maxHeight = maxHeight + self.maxFootprint = maxFootprint + } + } + + static func build() -> SCNNode { + let island = SCNNode() + island.name = "C3Island" + + guard let centerGround = AssetLoader.object(named: "Ground_Color") else { + return island + } + + centerGround.name = "FlatGround_Center" + centerGround.eulerAngles = groundRotation + centerGround.scale = SCNVector3(groundHorizontalScale, groundHorizontalScale, 1) + let metrics = flatGroundMetrics(for: centerGround) + let outerPositions = [ + SCNVector3(metrics.width, 0, 0), + SCNVector3(metrics.width / 2, 0, metrics.depth * 0.75), + SCNVector3(-metrics.width / 2, 0, metrics.depth * 0.75), + SCNVector3(-metrics.width, 0, 0), + SCNVector3(-metrics.width / 2, 0, -metrics.depth * 0.75), + SCNVector3(metrics.width / 2, 0, -metrics.depth * 0.75) + ] + let centerY = metrics.height * 0.6 + centerGround.position = SCNVector3(0, centerY, 0) + island.addChildNode(centerGround) + + for (index, position) in outerPositions.enumerated() { + let ground = centerGround.clone() + ground.name = "FlatGround_\(index)" + ground.position = position + island.addChildNode(ground) + } + + island.addChildNode( + buildDecorations(tilePositions: outerPositions, groundTopY: metrics.topY) + ) + + let bigPigSpawn = SCNNode() + bigPigSpawn.name = "BigPigSpawn" + bigPigSpawn.position = SCNVector3(0, metrics.topY + centerY, 0) + island.addChildNode(bigPigSpawn) + return island + } + + private static func buildDecorations( + tilePositions: [SCNVector3], + groundTopY: Float + ) -> SCNNode { + let decorations = SCNNode() + decorations.name = "FlatGroundDecorations" + let specs = [ + Decoration("Cylinder_Tree1_Color", 3, SCNVector3(-0.42, 0, -0.36), 0.24, 1.74, 0.92), + Decoration("Manger_Color", 3, SCNVector3(-0.38, 0, 0.34), -0.58, 0.54, 1.02), + Decoration("Stone_Color", 3, SCNVector3(0.34, 0, -0.02), 0.42, 0.28, 0.40), + Decoration("Stone_Color", 3, SCNVector3(0.66, 0, 0.18), -0.16, 0.23, 0.34), + Decoration("Stone_Color", 3, SCNVector3(0.02, 0, 0.56), 0.72, 0.16, 0.24), + Decoration("Coin_Color", 3, SCNVector3(0.44, 0, 0.46), -0.24, 0.04, 0.30), + Decoration("Cylinder_Tree2_Color", 5, SCNVector3(-0.54, 0, -0.38), -0.18, 1.78, 0.92), + Decoration("Warehouse_Color", 5, SCNVector3(0.34, 0, -0.16), -0.34, 0.98, 1.18), + Decoration("Wood_Color", 5, SCNVector3(0.58, 0, 0.78), 0.68, 0.30, 0.70) + ] + + for spec in specs { + guard tilePositions.indices.contains(spec.tileIndex), + let decoration = makeDecoration(spec) else { continue } + let tilePosition = tilePositions[spec.tileIndex] + decoration.position = SCNVector3( + tilePosition.x + spec.offset.x, + groundTopY + 0.01, + tilePosition.z + spec.offset.z + ) + decorations.addChildNode(decoration) + } + return decorations + } + + private static func makeDecoration(_ spec: Decoration) -> SCNNode? { + guard let model = AssetLoader.object(named: spec.assetName) else { return nil } + + let container = SCNNode() + container.name = spec.assetName == "Cylinder_Tree1_Color" + ? "HideTree" + : "Decoration_\(spec.assetName)" + model.name = "\(spec.assetName)_Model" + model.eulerAngles = groundRotation + container.addChildNode(model) + + guard let bounds = geometryBounds(in: container) else { return container } + let height = bounds.max.y - bounds.min.y + let footprint = max(bounds.max.x - bounds.min.x, bounds.max.z - bounds.min.z) + let heightScale = height > 0.0001 ? spec.maxHeight / height : 1 + let footprintScale = footprint > 0.0001 ? spec.maxFootprint / footprint : 1 + let scale = min(heightScale, footprintScale) + model.scale = SCNVector3(scale, scale, scale) + + if let scaledBounds = geometryBounds(in: container) { + model.position = SCNVector3( + -(scaledBounds.min.x + scaledBounds.max.x) / 2, + -scaledBounds.min.y, + -(scaledBounds.min.z + scaledBounds.max.z) / 2 + ) + } + container.eulerAngles = SCNVector3(0, spec.yaw, 0) + return container + } + + private static func flatGroundMetrics(for ground: SCNNode) -> (width: Float, height: Float, depth: Float, topY: Float) { + let probeRoot = SCNNode() + probeRoot.addChildNode(ground.clone()) + guard let bounds = geometryBounds(in: probeRoot) else { + return (1.732, 0.5, 2.0, 0.25) + } + return ( + bounds.max.x - bounds.min.x, + bounds.max.y - bounds.min.y, + bounds.max.z - bounds.min.z, + bounds.max.y + ) + } + + private static func geometryBounds(in root: SCNNode) -> (min: SCNVector3, max: SCNVector3)? { + var minimum = SCNVector3(Float.greatestFiniteMagnitude, .greatestFiniteMagnitude, .greatestFiniteMagnitude) + var maximum = SCNVector3(-Float.greatestFiniteMagnitude, -.greatestFiniteMagnitude, -.greatestFiniteMagnitude) + var foundGeometry = false + + root.enumerateHierarchy { node, _ in + guard let geometry = node.geometry else { return } + let (lower, upper) = geometry.boundingBox + for x in [lower.x, upper.x] { + for y in [lower.y, upper.y] { + for z in [lower.z, upper.z] { + let point = node.convertPosition(SCNVector3(x, y, z), to: root) + minimum = SCNVector3(min(minimum.x, point.x), min(minimum.y, point.y), min(minimum.z, point.z)) + maximum = SCNVector3(max(maximum.x, point.x), max(maximum.y, point.y), max(maximum.z, point.z)) + foundGeometry = true + } + } + } + } + return foundGeometry ? (minimum, maximum) : nil + } +} diff --git a/PiggyEscape/PiggyEscape/Sources/C3World/C3PigModelFactory.swift b/PiggyEscape/PiggyEscape/Sources/C3World/C3PigModelFactory.swift new file mode 100644 index 0000000..bfe59f4 --- /dev/null +++ b/PiggyEscape/PiggyEscape/Sources/C3World/C3PigModelFactory.swift @@ -0,0 +1,89 @@ +import SceneKit + +enum C3PigPose: String { + case idle + case running + case surprised +} + +@MainActor +enum C3PigModelFactory { + static func makeContainer(pose: C3PigPose) -> SCNNode { + let container = SCNNode() + container.name = "EscapePig" + container.eulerAngles = SCNVector3(0, 3 * Float.pi / 4, 0) + setPose(pose, on: container) + return container + } + + static func setPose(_ pose: C3PigPose, on container: SCNNode) { + container.childNodes.forEach { $0.removeFromParentNode() } + let model = loadNormalizedModel(named: assetName(for: pose)) + model.name = "PigModel_\(pose.rawValue)" + container.addChildNode(model) + if pose == .running { + playEmbeddedAnimations(on: model) + } + } + + private static func assetName(for pose: C3PigPose) -> String { + pose == .idle ? "Piggy" : "Piggy_\(pose.rawValue)" + } + + private static func loadNormalizedModel(named name: String) -> SCNNode { + let model = SCNNode() + guard let art = AssetLoader.object(named: name) else { return model } + + model.eulerAngles = SCNVector3(Float.pi / 2, 0, Float.pi) + model.addChildNode(art) + let measurementRoot = SCNNode() + measurementRoot.addChildNode(model) + guard let bounds = geometryBounds(in: measurementRoot) else { return model } + let height = bounds.max.y - bounds.min.y + guard height > 0.0001 else { return model } + + let scale = 1.5 / height + model.scale = SCNVector3(scale, scale, scale) + if let scaledBounds = geometryBounds(in: measurementRoot) { + model.position = SCNVector3( + -(scaledBounds.min.x + scaledBounds.max.x) / 2, + -scaledBounds.min.y, + -(scaledBounds.min.z + scaledBounds.max.z) / 2 + ) + } + model.removeFromParentNode() + return model + } + + private static func playEmbeddedAnimations(on root: SCNNode) { + root.enumerateHierarchy { node, _ in + for key in node.animationKeys { + guard let player = node.animationPlayer(forKey: key) else { continue } + player.animation.repeatCount = .greatestFiniteMagnitude + player.play() + } + } + } + + private static func geometryBounds(in root: SCNNode) -> (min: SCNVector3, max: SCNVector3)? { + var minimum = SCNVector3(Float.greatestFiniteMagnitude, .greatestFiniteMagnitude, .greatestFiniteMagnitude) + var maximum = SCNVector3(-Float.greatestFiniteMagnitude, -.greatestFiniteMagnitude, -.greatestFiniteMagnitude) + var foundGeometry = false + + root.enumerateHierarchy { node, _ in + guard let geometry = node.geometry else { return } + let (lower, upper) = geometry.boundingBox + for x in [lower.x, upper.x] { + for y in [lower.y, upper.y] { + for z in [lower.z, upper.z] { + let point = node.convertPosition(SCNVector3(x, y, z), to: root) + minimum = SCNVector3(min(minimum.x, point.x), min(minimum.y, point.y), min(minimum.z, point.z)) + maximum = SCNVector3(max(maximum.x, point.x), max(maximum.y, point.y), max(maximum.z, point.z)) + foundGeometry = true + } + } + } + } + return foundGeometry ? (minimum, maximum) : nil + } +} diff --git a/PiggyEscape/PiggyEscapeTests/C3ClosedWorldTests.swift b/PiggyEscape/PiggyEscapeTests/C3ClosedWorldTests.swift new file mode 100644 index 0000000..9f5e016 --- /dev/null +++ b/PiggyEscape/PiggyEscapeTests/C3ClosedWorldTests.swift @@ -0,0 +1,40 @@ +import SceneKit +import XCTest +@testable import PiggyEscape + +@MainActor +final class C3ClosedWorldTests: XCTestCase { + func test_worldUsesOrthographicC3OrbitCamera() { + let world = C3ClosedWorld() + + XCTAssertTrue(world.cameraNode.camera?.usesOrthographicProjection ?? false) + XCTAssertEqual(world.cameraNode.camera?.orthographicScale ?? 0, 6.0, accuracy: 0.001) + } + + func test_worldPlacesPigAtTheIslandSpawnAndExposesTheSameHideTree() { + let world = C3ClosedWorld() + let island = world.scene.rootNode.childNode(withName: "C3Island", recursively: false) + let spawn = island?.childNode(withName: "BigPigSpawn", recursively: false) + + XCTAssertNotNil(spawn) + XCTAssertTrue(world.hideTree === island?.childNode(withName: "HideTree", recursively: true)) + XCTAssertEqual(world.pigContainer.position.x, spawn?.position.x ?? 0, accuracy: 0.0001) + XCTAssertEqual(world.pigContainer.position.y, spawn?.position.y ?? 0, accuracy: 0.0001) + XCTAssertEqual(world.pigContainer.position.z, spawn?.position.z ?? 0, accuracy: 0.0001) + } + + func test_worldRotatesAndClampsC3OrbitZoom() { + let world = C3ClosedWorld() + + world.rotateCamera(byYaw: .pi / 4) + XCTAssertEqual(world.cameraYaw, .pi / 2, accuracy: 0.0001) + XCTAssertEqual(world.cameraNode.position.x, 17, accuracy: 0.0001) + XCTAssertEqual(world.cameraNode.position.y, 12, accuracy: 0.0001) + XCTAssertEqual(world.cameraNode.position.z, 0, accuracy: 0.0001) + + world.zoom(by: 100) + XCTAssertEqual(world.cameraNode.camera?.orthographicScale ?? 0, 3, accuracy: 0.0001) + world.zoom(by: 0.01) + XCTAssertEqual(world.cameraNode.camera?.orthographicScale ?? 0, 12, accuracy: 0.0001) + } +} diff --git a/PiggyEscape/PiggyEscapeTests/C3IslandBuilderTests.swift b/PiggyEscape/PiggyEscapeTests/C3IslandBuilderTests.swift new file mode 100644 index 0000000..21d2b9f --- /dev/null +++ b/PiggyEscape/PiggyEscapeTests/C3IslandBuilderTests.swift @@ -0,0 +1,28 @@ +import SceneKit +import XCTest +@testable import PiggyEscape + +final class C3IslandBuilderTests: XCTestCase { + @MainActor + func test_buildCreatesNamedExistingTreeAndBigPigSpawn() { + let island = C3IslandBuilder.build() + + XCTAssertEqual(island.name, "C3Island") + XCTAssertNotNil(island.childNode(withName: "HideTree", recursively: true)) + XCTAssertNotNil(island.childNode(withName: "BigPigSpawn", recursively: true)) + } + + @MainActor + func test_buildKeepsC3GroundTilesAndDecorationAssets() { + let island = C3IslandBuilder.build() + + let center = island.childNode(withName: "FlatGround_Center", recursively: false) + XCTAssertNotNil(center) + XCTAssertEqual(center?.eulerAngles.x ?? 0, -.pi / 2, accuracy: 0.0001) + XCTAssertEqual(center?.scale.x ?? 0, sqrt(2), accuracy: 0.0001) + XCTAssertEqual(island.childNodes.filter { $0.name?.hasPrefix("FlatGround_") == true }.count, 7) + XCTAssertNotNil(island.childNode(withName: "Decoration_Manger_Color", recursively: true)) + XCTAssertNotNil(island.childNode(withName: "Decoration_Warehouse_Color", recursively: true)) + XCTAssertNotNil(island.childNode(withName: "Decoration_Wood_Color", recursively: true)) + } +} diff --git a/PiggyEscape/PiggyEscapeTests/C3PigModelFactoryTests.swift b/PiggyEscape/PiggyEscapeTests/C3PigModelFactoryTests.swift new file mode 100644 index 0000000..ff4c835 --- /dev/null +++ b/PiggyEscape/PiggyEscapeTests/C3PigModelFactoryTests.swift @@ -0,0 +1,67 @@ +import SceneKit +import XCTest +@testable import PiggyEscape + +final class C3PigModelFactoryTests: XCTestCase { + private func bounds(of node: SCNNode, in referenceNode: SCNNode) -> (min: SCNVector3, max: SCNVector3) { + var minimum = SCNVector3(Float.greatestFiniteMagnitude, .greatestFiniteMagnitude, .greatestFiniteMagnitude) + var maximum = SCNVector3(-Float.greatestFiniteMagnitude, -.greatestFiniteMagnitude, -.greatestFiniteMagnitude) + + node.enumerateHierarchy { child, _ in + guard let geometry = child.geometry else { return } + let (lower, upper) = geometry.boundingBox + for x in [lower.x, upper.x] { + for y in [lower.y, upper.y] { + for z in [lower.z, upper.z] { + let point = child.convertPosition(SCNVector3(x, y, z), to: referenceNode) + minimum = SCNVector3(min(minimum.x, point.x), min(minimum.y, point.y), min(minimum.z, point.z)) + maximum = SCNVector3(max(maximum.x, point.x), max(maximum.y, point.y), max(maximum.z, point.z)) + } + } + } + } + return (minimum, maximum) + } + + @MainActor + func test_pigContainerKeepsInnerModelCorrectionWhenPoseChanges() { + let pig = C3PigModelFactory.makeContainer(pose: .idle) + let originalScale = pig.scale + + C3PigModelFactory.setPose(.surprised, on: pig) + + XCTAssertEqual(pig.name, "EscapePig") + XCTAssertEqual(pig.scale.x, originalScale.x, accuracy: 0.0001) + XCTAssertEqual(pig.scale.y, originalScale.y, accuracy: 0.0001) + XCTAssertEqual(pig.scale.z, originalScale.z, accuracy: 0.0001) + XCTAssertNotNil(pig.childNode(withName: "PigModel_surprised", recursively: false)) + } + + @MainActor + func test_pigKeepsC3FacingOnTheOuterContainer() { + let pig = C3PigModelFactory.makeContainer(pose: .running) + + XCTAssertEqual(pig.eulerAngles.y, 3 * .pi / 4, accuracy: 0.0001) + XCTAssertEqual(pig.scale.x, 1, accuracy: 0.0001) + XCTAssertEqual(pig.scale.y, 1, accuracy: 0.0001) + XCTAssertEqual(pig.scale.z, 1, accuracy: 0.0001) + XCTAssertEqual(pig.childNodes.count, 1) + } + + @MainActor + func test_innerModelOwnsUsdAxisCorrectionAndFloorNormalization() { + let pig = C3PigModelFactory.makeContainer(pose: .idle) + guard let model = pig.childNode(withName: "PigModel_idle", recursively: false) else { + XCTFail("expected inner idle model") + return + } + + let modelBounds = bounds(of: model, in: pig) + XCTAssertEqual(model.eulerAngles.x, Float.pi / 2, accuracy: 0.0001) + XCTAssertEqual(model.eulerAngles.z, Float.pi, accuracy: 0.0001) + XCTAssertEqual(pig.eulerAngles.x, 0, accuracy: 0.0001) + XCTAssertEqual(pig.eulerAngles.z, 0, accuracy: 0.0001) + XCTAssertEqual(modelBounds.min.y, 0, accuracy: 0.001) + XCTAssertEqual(modelBounds.max.y - modelBounds.min.y, 1.5, accuracy: 0.001) + } +} diff --git a/docs/LEARNING_LOG.md b/docs/LEARNING_LOG.md index 1e49514..38aa0ad 100644 --- a/docs/LEARNING_LOG.md +++ b/docs/LEARNING_LOG.md @@ -28,6 +28,126 @@ ## 항목 +### L-20260810-018 — Task 3 커밋 준비가 연결 작업 트리 index 잠금 권한으로 중단됨 + +- 상태: 해결 +- 발생 태스크: Task 3 — C3 섬, 돼지 모델, 궤도 카메라 어댑터 +- 재현: `git add PiggyEscape/PiggyEscape/Sources/C3World PiggyEscape/PiggyEscapeTests/C3IslandBuilderTests.swift PiggyEscape/PiggyEscapeTests/C3PigModelFactoryTests.swift PiggyEscape/PiggyEscapeTests/C3ClosedWorldTests.swift docs/WORK_LOG.md docs/LEARNING_LOG.md` +- 관찰: `fatal: Unable to create '/Users/yang-eunseo/Downloads/SpatialComputing_TechMap/.git/worktrees/ch1-reality-escape/index.lock': Operation not permitted`로 index 갱신 전에 중단됨 +- 영향: 검증된 Task 3 변경을 태스크 단위 커밋으로 기록하지 못함 +- 원인/가설: 연결 작업 트리의 공용 Git 메타데이터 index 잠금 경로가 현재 권한 범위 밖에 있음 +- 조치: 공용 Git 메타데이터에 쓸 수 있는 권한으로 같은 명시적 Task 3 파일 목록만 stage와 commit할 예정임 +- 검증: 공용 Git 메타데이터 접근 권한으로 같은 명시적 파일 목록을 stage했고, 커밋 후 `git show --stat --oneline HEAD`와 `git status --short`로 Task 3 파일만 포함됐는지 확인함 +- 배운 점: 연결 작업 트리에서는 작업 트리 파일 쓰기 권한과 별도로 공용 Git index 잠금 경로 쓰기 권한이 필요하다. + +### L-20260810-017 — 내부 돼지 모델이 아니라 그 하위 아트 노드가 USD 축 보정을 소유함 + +- 상태: 해결 +- 발생 태스크: Task 3 — C3 섬, 돼지 모델, 궤도 카메라 어댑터 +- 재현: `xcodebuild -project PiggyEscape/PiggyEscape.xcodeproj -scheme PiggyEscape -destination 'platform=iOS Simulator,name=iPhone 17 Pro' -only-testing:PiggyEscapeTests/C3PigModelFactoryTests/test_innerModelOwnsUsdAxisCorrectionAndFloorNormalization test` +- 관찰: 새 실제 SceneKit 노드 테스트에서 `PigModel_idle.eulerAngles.x`와 `.z`가 각각 `0.0`으로 관찰되어 기대한 `Float.pi / 2`, `Float.pi`와 일치하지 않았고 2개 assertion이 실패함 +- 영향: 외부 `EscapePig`와 이름 있는 내부 `PigModel_*`의 변환 책임 경계가 명세보다 한 단계 더 깊게 분산됨 +- 원인/가설: `loadNormalizedModel`이 `PigModel_*`의 하위 `art` 노드에 축 보정을 적용하고, 내부 모델 컨테이너에는 정규화만 적용했음 +- 조치: `PigModel_*` 자체가 축 보정·균일 스케일·바닥 정렬을 모두 소유하도록 측정용 임시 루트에서 변환 반영 경계를 계산할 예정임 +- 검증: `PigModel_*`을 측정용 임시 루트에서 회전·스케일·바닥 정렬하도록 수정한 뒤 같은 단일 XCTest를 재실행해 1/1 통과와 `** TEST SUCCEEDED **`를 확인함. 이어서 focused·전체 suite를 다시 실행함. +- 배운 점: 외부 애니메이션·확대 대상과 내부 USD 변환 대상의 경계는 이름 있는 노드의 실제 `eulerAngles`와 변환된 바운딩 박스로 회귀 테스트한다. + +### L-20260810-016 — Task 3 전체 XCTest는 통과했지만 기존 Simulator 런타임 경고가 출력됨 + +- 상태: 보류 +- 발생 태스크: Task 3 — C3 섬, 돼지 모델, 궤도 카메라 어댑터 +- 재현: `xcodebuild -project PiggyEscape/PiggyEscape.xcodeproj -scheme PiggyEscape -destination 'platform=iOS Simulator,name=iPhone 17 Pro' test` +- 관찰: 최종 38개 XCTest는 모두 통과했지만 `Metadata extraction skipped. No AppIntents.framework dependency found`, `UIFocus` 캐시 제한, `TBB Global TLS count is not == 1`, Simulator 런타임의 `UIAccessibilityLoaderWebShared` 중복 구현 경고가 출력됨 +- 영향: Task 3의 C3 노드·카메라 자동 검증에는 실패가 없지만 XCTest 출력이 경고 없이 깨끗하다는 조건은 충족하지 못함 +- 원인/가설: AppIntents 의존성이 없는 기존 앱 타깃의 메타데이터 처리와 iOS Simulator 26.5의 UI·접근성 런타임 진단이며, 새 C3 소스의 컴파일·XCTest assertion 실패는 없음 +- 조치: Task 3 범위 밖의 앱 타깃과 Simulator 런타임 설정을 변경하지 않고, focused 및 전체 XCTest 통과를 기록함 +- 검증: 최종 focused C3 XCTest 8/8, 전체 XCTest 38/38 통과와 `** TEST SUCCEEDED **`를 확인함 +- 배운 점: SceneKit 단위 테스트의 통과 결과와 기존 Simulator 런타임 경고를 분리해 기록하고, 범위 밖 경고를 해결하려고 기존 앱 구조를 변경하지 않는다. + +### L-20260810-015 — Task 3 XCTest가 앱 모듈 import 누락으로 C3 월드 타입을 찾지 못함 + +- 상태: 해결 +- 발생 태스크: Task 3 — C3 섬, 돼지 모델, 궤도 카메라 어댑터 +- 재현: `xcodebuild -project PiggyEscape/PiggyEscape.xcodeproj -scheme PiggyEscape -destination 'platform=iOS Simulator,name=iPhone 17 Pro' -only-testing:PiggyEscapeTests/C3IslandBuilderTests -only-testing:PiggyEscapeTests/C3PigModelFactoryTests -only-testing:PiggyEscapeTests/C3ClosedWorldTests test` +- 관찰: 앱 타깃의 C3 소스 컴파일 후 `C3ClosedWorldTests.swift`에서 `cannot find 'C3ClosedWorld' in scope` 3건으로 테스트 타깃 컴파일이 중단됨 +- 영향: 실제 SceneKit C3 월드 동작 assertion을 실행하지 못함 +- 원인/가설: 새 테스트 세 파일에 기존 XCTest 파일의 `@testable import PiggyEscape`가 누락되어 앱 타깃 심볼이 테스트 모듈에 노출되지 않음 +- 조치: 세 C3 XCTest 파일에 `@testable import PiggyEscape`를 추가할 예정임 +- 검증: 세 C3 XCTest 파일에 `@testable import PiggyEscape`를 추가한 뒤 같은 focused XCTest를 재실행해 7개 테스트가 모두 통과하고 `** TEST SUCCEEDED **`를 확인함. +- 배운 점: 별도 XCTest 타깃의 새 테스트는 기존 테스트 파일의 모듈 import 계약을 먼저 따르고, 타입 부재 RED와 import 누락을 구분한다. + +### L-20260810-014 — Task 3 재시도 XCTest가 초기화 순서와 π 타입 추론 오류로 중단됨 + +- 상태: 해결 +- 발생 태스크: Task 3 — C3 섬, 돼지 모델, 궤도 카메라 어댑터 +- 재현: `xcodebuild -project PiggyEscape/PiggyEscape.xcodeproj -scheme PiggyEscape -destination 'platform=iOS Simulator,name=iPhone 17 Pro' -only-testing:PiggyEscapeTests/C3IslandBuilderTests -only-testing:PiggyEscapeTests/C3PigModelFactoryTests -only-testing:PiggyEscapeTests/C3ClosedWorldTests test` +- 관찰: `setupCamera`·`setupLighting` 호출 시 `pigContainer`와 `hideTree`가 초기화되기 전의 `self` 사용 오류 2건, `C3PigModelFactory`의 `.pi` 모호성 2건으로 컴파일이 중단됨 +- 영향: focused C3 XCTest가 실행되지 못함 +- 원인/가설: Swift의 non-optional `let` 저장 프로퍼티 초기화 규칙과 `Float`/`CGFloat`/`Double` 간 문맥 없는 `.pi` 선택 규칙을 충족하지 못함 +- 조치: 섬에서 얻은 숨기 나무·돼지를 먼저 저장 프로퍼티에 할당한 뒤 카메라·조명을 구성하고, 돼지 각도에 `Float.pi`를 명시할 예정임 +- 검증: 초기화 순서를 수정하고 `Float.pi`를 명시한 뒤 같은 focused XCTest를 재실행했으며, 이 항목의 4개 진단은 사라졌다. 이어진 테스트 타깃 import 오류는 L-20260810-015에 기록했다. +- 배운 점: SceneKit 월드 생성자는 노드 그래프를 만들기 전에 모든 non-optional 저장 프로퍼티를 초기화하고, 벡터 각도는 `Float` 상수를 명시한다. + +### L-20260810-013 — Task 3 GREEN XCTest가 C3 어댑터의 Swift 컴파일 오류로 실패함 + +- 상태: 해결 +- 발생 태스크: Task 3 — C3 섬, 돼지 모델, 궤도 카메라 어댑터 +- 재현: `xcodebuild -project PiggyEscape/PiggyEscape.xcodeproj -scheme PiggyEscape -destination 'platform=iOS Simulator,name=iPhone 17 Pro' -only-testing:PiggyEscapeTests/C3IslandBuilderTests -only-testing:PiggyEscapeTests/C3PigModelFactoryTests -only-testing:PiggyEscapeTests/C3ClosedWorldTests test` +- 관찰: `Decoration` 멤버와이즈 초기화의 인자 레이블 9건 누락, `SCNVector3.zero` 미정의, `SCNScene.pointOfView` 미정의로 앱 타깃 컴파일이 중단됨 +- 영향: 새 C3 노드 동작 XCTest를 실행하지 못함 +- 원인/가설: 구현에서 Swift 구조체의 기본 이니셜라이저 호출 규칙과 SceneKit API 소유 타입을 잘못 적용함. `pointOfView`는 `SCNView`의 속성임 +- 조치: C3 장식 명세에 무레이블 이니셜라이저를 명시하고, 영 벡터는 `SCNVector3(0, 0, 0)`으로, 잘못된 scene 카메라 할당은 제거함 +- 검증: 무레이블 `Decoration` 이니셜라이저를 추가하고, 영 벡터·잘못된 `SCNScene.pointOfView` 할당을 수정한 뒤 같은 focused XCTest를 재실행했으며, 이 항목의 11개 진단은 사라졌다. 다음 컴파일 오류는 L-20260810-014에 기록했다. +- 배운 점: 새 SceneKit 어댑터에서는 구조체 초기화 레이블과 `SCNScene`/`SCNView` 책임 경계를 컴파일 전 원본 API와 대조한다. + +### L-20260810-012 — Task 3 RED XCTest가 Simulator 서비스 접근 제한으로 중단됨 + +- 상태: 해결 +- 발생 태스크: Task 3 — C3 섬, 돼지 모델, 궤도 카메라 어댑터 +- 재현: `xcodebuild -project PiggyEscape/PiggyEscape.xcodeproj -scheme PiggyEscape -destination 'platform=iOS Simulator,name=iPhone 17 Pro' -only-testing:PiggyEscapeTests/C3IslandBuilderTests -only-testing:PiggyEscapeTests/C3PigModelFactoryTests -only-testing:PiggyEscapeTests/C3ClosedWorldTests test` +- 관찰: `CoreSimulatorService connection became invalid`와 `Error opening log file ... Operation not permitted`가 발생해 XCTest가 새 타입의 컴파일 단계에 도달하기 전에 종료됨 +- 영향: C3 타입 부재를 보여야 하는 RED를 현재 권한 범위에서 확인하지 못함 +- 원인/가설: Simulator의 기기·로그·서비스 상태 경로가 현재 파일 권한 범위 밖에 있음 +- 조치: Simulator 서비스와 Xcode 상태 경로에 접근 가능한 권한으로 같은 focused XCTest 명령을 재실행할 예정임 +- 검증: Simulator 서비스와 Xcode 상태 경로 접근 권한으로 focused XCTest를 재실행했고, `C3ClosedWorldTests.swift`에서 `cannot find 'C3ClosedWorld' in scope` 3건으로 테스트 타깃 컴파일이 중단됨을 확인함. 첫 컴파일 오류에서 중단되어 나머지 C3 타입의 진단은 생성되지 않았음. +- 배운 점: iOS Simulator XCTest는 생성 프로젝트만으로 동작하지 않으며, Simulator 서비스·로그 상태 경로 접근이 필요하다. + +### L-20260810-011 — Task 3 RED 준비 중 Tuist 세션 상태 디렉터리 쓰기가 거부됨 + +- 상태: 해결 +- 발생 태스크: Task 3 — C3 섬, 돼지 모델, 궤도 카메라 어댑터 +- 재현: `cd PiggyEscape && tuist generate --no-open` +- 관찰: `Fatal error: Error raised at top level: Permission denied: /Users/yang-eunseo/.local/state/tuist/sessions/F7174714-4104-4318-A365-0F8648073706`로 생성이 테스트 실행 전에 종료됨 +- 영향: 새 XCTest 소스가 생성 프로젝트에 반영되지 않아 C3 타입 부재 RED를 아직 관찰하지 못함 +- 원인/가설: Tuist가 작업 트리 밖의 사용자 세션 상태 디렉터리에 쓸 수 없어 생성 과정이 중단됨 +- 조치: 해당 상태 경로에 접근 가능한 권한으로 동일 명령을 재실행해, 생성 성공 뒤 RED 컴파일 실패를 확인할 예정임 +- 검증: 사용자 세션 상태 경로 접근 권한으로 같은 `tuist generate --no-open`을 재실행했고 `✔ Success`를 확인함. 이어서 지정된 C3 focused XCTest로 타입 부재 RED를 확인함. +- 배운 점: 새 Swift XCTest의 유효한 RED는 Tuist가 테스트 타깃 파일 목록을 다시 생성한 뒤에만 관찰할 수 있다. + +### L-20260810-009 — Task 3 시작 전 원격 fetch가 연결 작업 트리 메타데이터 권한으로 중단됨 + +- 상태: 보류 +- 발생 태스크: Task 3 — C3 섬, 돼지 모델, 궤도 카메라 어댑터 +- 재현: `git fetch --prune origin` +- 관찰: `error: cannot open '/Users/yang-eunseo/Downloads/SpatialComputing_TechMap/.git/worktrees/ch1-reality-escape/FETCH_HEAD': Operation not permitted`로 원격 참조 갱신 전에 종료됨 +- 영향: 구현 전 원격 변경 동기화 상태를 이 작업 트리에서 확정할 수 없음 +- 원인/가설: 연결 작업 트리의 Git 메타데이터 `FETCH_HEAD` 쓰기 경로가 현재 파일 권한 범위 밖에 있음 +- 조치: 기존 L-20260810-001과 같은 제약으로 기록하고, 작업 트리 내부의 읽기 전용 상태와 지정된 Task 3 명세로 범위를 확인함 +- 검증: 후속 `git status --short`와 브랜치 확인을 별도 실행할 예정이며, 원격 fetch는 권한 확보 전까지 미검증으로 유지함 +- 배운 점: 동일한 환경 제약이 후속 태스크에서도 재현되면 새 항목으로 명령·영향을 남기고 원격 상태를 추정하지 않는다. + +### L-20260810-010 — Task 3 시작 순서의 프로젝트 맥락 문서가 작업 트리에 없음 + +- 상태: 보류 +- 발생 태스크: Task 3 — C3 섬, 돼지 모델, 궤도 카메라 어댑터 +- 재현: `sed -n '1,260p' docs/PROJECT_CONTEXT.md` +- 관찰: `sed: docs/PROJECT_CONTEXT.md: No such file or directory`로 지정 문서를 읽지 못함 +- 영향: 시작 순서의 프로젝트 맥락 문서를 현재 브랜치에서 검토할 수 없음 +- 원인/가설: 기존 L-20260810-004와 동일하게, 이 연결 작업 트리의 문서 구조에 해당 경로가 아직 포함되지 않은 것으로 보임 +- 조치: 존재하는 `docs/WORK_LOG.md`, Reality escape 설계·계획, Task 3 브리프를 대체 근거로 사용함 +- 검증: `rg --files`로 경로 부재와 대체 문서 존재를 확인할 예정임 +- 배운 점: 시작 필수 문서가 반복적으로 부재하면 이전 기록을 참조하되, 현재 태스크의 재현 명령과 대체 근거를 별도 기록한다. + ### L-20260810-008 — Task 2 커밋 준비가 연결 작업 트리 index 잠금 권한으로 중단됨 - 상태: 해결 diff --git a/docs/WORK_LOG.md b/docs/WORK_LOG.md index c79167a..82cec3b 100644 --- a/docs/WORK_LOG.md +++ b/docs/WORK_LOG.md @@ -4,12 +4,12 @@ ## 현재 인수인계 -- 상태: Task 2 — 경험 상태와 숨기 좌표 순수 로직 완료 -- 진행 중 범위: 허용된 전이만 처리하는 경험 상태 기계와 카메라 반대편 나무 뒤 좌표 계산을 순수 Swift 로직으로 추가했다. +- 상태: Task 3 — C3 섬·돼지 모델·궤도 카메라 어댑터 완료 +- 진행 중 범위: C3의 비금융 섬 타일·장식·USD 돼지 포즈·궤도 카메라·조명을 SceneKit 어댑터로 추가했다. 숨김 상호작용·나레이션·현실 전환은 구현하지 않았다. - 실패 기록: `docs/LEARNING_LOG.md`에 실패·검증 한계의 재현 조건·원인/가설·조치·재발 방지 근거를 기록한다. -- 마지막 완료 범위: Task 2 — 경험 상태와 숨기 좌표 순수 로직. -- 마지막 검증: `tuist generate --no-open` 성공. focused `xcodebuild -project PiggyEscape/PiggyEscape.xcodeproj -scheme PiggyEscape -destination 'platform=iOS Simulator,name=iPhone 17 Pro' -only-testing:PiggyEscapeTests/EscapeExperienceStateTests -only-testing:PiggyEscapeTests/HidePlanningTests test`가 3/3을 통과했고, 전체 `xcodebuild -project PiggyEscape/PiggyEscape.xcodeproj -scheme PiggyEscape -destination 'platform=iOS Simulator,name=iPhone 17 Pro' test`가 30/30을 통과했다. Simulator/AppIntents 런타임 경고는 `docs/LEARNING_LOG.md`의 L-20260810-007에 기록했다. -- 다음 시작점: `docs/superpowers/plans/2026-08-10-ch1-reality-escape-implementation.md`의 Task 3 — C3 섬 어댑터와 자동 전환. +- 마지막 완료 범위: Task 3 — C3 섬·돼지 모델·궤도 카메라 어댑터. +- 마지막 검증: `tuist generate --no-open` 성공. focused `xcodebuild -project PiggyEscape/PiggyEscape.xcodeproj -scheme PiggyEscape -destination 'platform=iOS Simulator,name=iPhone 17 Pro' -only-testing:PiggyEscapeTests/C3IslandBuilderTests -only-testing:PiggyEscapeTests/C3PigModelFactoryTests -only-testing:PiggyEscapeTests/C3ClosedWorldTests test`가 8/8을 통과했고, 전체 `xcodebuild -project PiggyEscape/PiggyEscape.xcodeproj -scheme PiggyEscape -destination 'platform=iOS Simulator,name=iPhone 17 Pro' test`가 38/38을 통과했다. Simulator/AppIntents 런타임 경고는 `docs/LEARNING_LOG.md`의 L-20260810-016에 기록했다. +- 다음 시작점: `docs/superpowers/plans/2026-08-10-ch1-reality-escape-implementation.md`의 Task 4 — C3 씬 뷰와 숨김 상호작용. - 차단 요소: 원격 `git fetch`의 공용 Git 메타데이터 쓰기 권한과 시작 순서의 누락 문서는 `docs/LEARNING_LOG.md`에 보류 항목으로 기록했다. ### Task 7에서 해결한 항목 @@ -22,6 +22,7 @@ | 날짜 | 작업 범위 | 결과 | 검증 | 다음 시작점 | | --- | --- | --- | --- | --- | +| 2026-08-10 | Task 3 — C3 섬·돼지 모델·궤도 카메라 어댑터 | `C3Island`의 7개 `Ground_Color` 타일과 C3 장식 배치, 실제 `HideTree`·`BigPigSpawn`, `EscapePig`의 idle/running/surprised 내부 USD 보정·정규화, C3 궤도 카메라·그라데이션·조명을 추가함 | RED에서 `C3ClosedWorld` 부재 컴파일 실패와 내부 USD 보정 소유 노드 오류를 확인한 뒤 focused 8/8·전체 38/38 XCTest 통과. Simulator/AppIntents 런타임 경고는 L-20260810-016에 기록 | Task 4 — C3 씬 뷰와 숨김 상호작용 | | 2026-08-10 | Task 2 — 경험 상태와 숨기 좌표 순수 로직 | 상태 기계의 합법 전이와 XZ 카메라 반대편 나무 숨기 좌표를 프레임워크 독립 순수 Swift 타입으로 추가함 | RED에서 `EscapeExperienceMachine` 부재 컴파일 실패 확인 후 focused 3/3·전체 30/30 XCTest 통과. 기존 Simulator/AppIntents 경고는 L-20260810-007에 기록 | Task 3 — C3 섬 어댑터와 자동 전환 | | 2026-08-10 | Task 1 — C3 에셋과 카메라 권한 | C3 원본 11개를 바이트 변경 없이 앱 리소스에 등록하고, 고정 카메라 목적 문구와 실제 앱 번들 로딩 회귀 테스트를 추가함 | RED에서 새 에셋 8개가 누락되어 16개 assertion 실패 확인 후, `AssetLoaderTests` 5/5 통과·11개 `cmp -s` 일치·생성 Info.plist 목적 문구 확인 | Task 2 — 경험 상태와 숨기 좌표 순수 로직 | | 2026-08-10 | 실패·학습 기록 운영 | 실패한 테스트·빌드·실행·실기기 검증 한계를 재현 가능한 항목으로 남기는 별도 기록을 추가함 | 기록 형식·상태·필수 근거를 설계 문서와 대조 | Task 1 실행 | From 522f8e08cfa5ef4678fbce3c6e72b3609083a60b Mon Sep 17 00:00:00 2001 From: eunseo-com Date: Mon, 10 Aug 2026 23:16:04 +0900 Subject: [PATCH 09/22] Add C3 pig hide and discovery flow --- .../Sources/C3World/C3ClosedWorld.swift | 155 +++++++++++++++++- .../C3World/C3ClosedWorldSceneView.swift | 115 +++++++++++++ .../C3World/NarrationOverlayScene.swift | 82 +++++++++ .../PiggyEscape/Sources/ContentView.swift | 2 +- .../ClosedWorldEscapeTests.swift | 51 ++++++ .../NarrationOverlaySceneTests.swift | 24 +++ docs/LEARNING_LOG.md | 144 ++++++++++++++++ docs/WORK_LOG.md | 13 +- 8 files changed, 576 insertions(+), 10 deletions(-) create mode 100644 PiggyEscape/PiggyEscape/Sources/C3World/C3ClosedWorldSceneView.swift create mode 100644 PiggyEscape/PiggyEscape/Sources/C3World/NarrationOverlayScene.swift create mode 100644 PiggyEscape/PiggyEscapeTests/ClosedWorldEscapeTests.swift create mode 100644 PiggyEscape/PiggyEscapeTests/NarrationOverlaySceneTests.swift diff --git a/PiggyEscape/PiggyEscape/Sources/C3World/C3ClosedWorld.swift b/PiggyEscape/PiggyEscape/Sources/C3World/C3ClosedWorld.swift index 56534cf..fa0660b 100644 --- a/PiggyEscape/PiggyEscape/Sources/C3World/C3ClosedWorld.swift +++ b/PiggyEscape/PiggyEscape/Sources/C3World/C3ClosedWorld.swift @@ -8,23 +8,36 @@ final class C3ClosedWorld { let pigContainer: SCNNode let hideTree: SCNNode private(set) var cameraYaw: Float = .pi / 4 + private(set) var currentPose: C3PigPose = .idle + private(set) var lastCaption = "" + private(set) var surprisePeakScale: CGFloat = 1 + + var onSurpriseCaption: ((String) -> Void)? + var isPigInCameraFrustum: () -> Bool = { false } private let orbitRadius: Float = 17 private let orbitHeight: Float = 12 private var orthographicScale: CGFloat = 6 private let directionalLightNode = SCNNode() + private var experience = EscapeExperienceMachine() + private var treeHideDestination: SCNVector3? + private var hideStartYaw: Float? + private var hasDiscovered = false init() { let island = C3IslandBuilder.build() - hideTree = island.childNode(withName: "HideTree", recursively: true) ?? SCNNode() + guard let inSceneHideTree = island.childNode(withName: "HideTree", recursively: true) else { + preconditionFailure("C3 closed world requires the in-scene HideTree") + } + hideTree = inSceneHideTree pigContainer = C3PigModelFactory.makeContainer(pose: .idle) pigContainer.position = island.childNode(withName: "BigPigSpawn", recursively: false)?.position ?? SCNVector3(0, 0, 0) - setupCamera() - setupLighting() scene.rootNode.addChildNode(island) scene.rootNode.addChildNode(pigContainer) + setupCamera() + setupLighting() } func rotateCamera(byYaw delta: Float) { @@ -33,6 +46,58 @@ final class C3ClosedWorld { directionalLightNode.eulerAngles = lightEulerAngles(for: cameraYaw) } + func completeOpeningNarration() { + _ = experience.send(.narrationFinished) + } + + func tapPig() -> Bool { + guard experience.send(.pigTapped) else { return false } + + setPose(.running) + let destination = hideDestination() + treeHideDestination = destination + facePig(toward: destination) + let duration = TimeInterval(lengthBetween(pigContainer.position, destination) / 1.1) + let move = SCNAction.move(to: destination, duration: duration) + let reachedTree = SCNAction.run { [weak self] _ in + Task { @MainActor in self?.finishTreeHide() } + } + pigContainer.runAction(.sequence([move, reachedTree]), forKey: "escapePig.hide") + return true + } + + func finishTreeHideForTesting() { + pigContainer.removeAction(forKey: "escapePig.hide") + finishTreeHide() + } + + func isDiscoveredAfterCameraRotation() -> Bool { + guard experience.state == .hiddenInClosedWorld, + !hasDiscovered, + let hideStartYaw, + abs(wrappedYawDelta(cameraYaw - hideStartYaw)) >= 0.70, + isPigVisibleToCamera(), + experience.send(.closedWorldPigDiscovered) else { + return false + } + hasDiscovered = true + performSurpriseReaction() + return true + } + + func performSurpriseReaction() { + setPose(.surprised) + lastCaption = "아, 들켰네… 제대로 숨고 싶은데." + surprisePeakScale = 1.5 + onSurpriseCaption?(lastCaption) + + let grow = SCNAction.scale(to: 1.5, duration: 0.16) + grow.timingMode = .easeOut + let restore = SCNAction.scale(to: 1.0, duration: 0.34) + restore.timingMode = .easeInEaseOut + pigContainer.runAction(.sequence([grow, restore]), forKey: "escapePig.surpriseScale") + } + func zoom(by factor: Float) { guard factor > 0 else { return } orthographicScale = min(12, max(3, orthographicScale / CGFloat(factor))) @@ -57,6 +122,90 @@ final class C3ClosedWorld { scene.rootNode.addChildNode(cameraNode) } + private func setPose(_ pose: C3PigPose) { + C3PigModelFactory.setPose(pose, on: pigContainer) + currentPose = pose + } + + private func finishTreeHide() { + guard experience.send(.pigReachedTree) else { return } + if let treeHideDestination { + pigContainer.position = treeHideDestination + } + setPose(.idle) + hideStartYaw = cameraYaw + } + + private func hideDestination() -> SCNVector3 { + let bounds = geometryBounds(of: hideTree, in: scene.rootNode) + let center = SIMD3( + (bounds.min.x + bounds.max.x) / 2, + (bounds.min.y + bounds.max.y) / 2, + (bounds.min.z + bounds.max.z) / 2 + ) + let treeRadius = max(bounds.max.x - bounds.min.x, bounds.max.z - bounds.min.z) / 2 + let destination = TreeHidePlanner.destination( + treeCenter: center, + treeRadius: treeRadius, + cameraPosition: SIMD3(cameraNode.position.x, cameraNode.position.y, cameraNode.position.z), + pigRadius: max(0.25, footprintRadius(of: pigContainer)), + floorY: pigContainer.position.y + ) + return SCNVector3(destination.x, destination.y, destination.z) + } + + private func facePig(toward destination: SCNVector3) { + let deltaX = destination.x - pigContainer.position.x + let deltaZ = destination.z - pigContainer.position.z + guard abs(deltaX) > 0.0001 || abs(deltaZ) > 0.0001 else { return } + pigContainer.eulerAngles.y = atan2(deltaX, deltaZ) + } + + private func footprintRadius(of node: SCNNode) -> Float { + let bounds = geometryBounds(of: node, in: scene.rootNode) + return max(bounds.max.x - bounds.min.x, bounds.max.z - bounds.min.z) / 2 + } + + private func geometryBounds( + of root: SCNNode, + in coordinateSpace: SCNNode + ) -> (min: SCNVector3, max: SCNVector3) { + var minimum = SCNVector3(Float.greatestFiniteMagnitude, .greatestFiniteMagnitude, .greatestFiniteMagnitude) + var maximum = SCNVector3(-Float.greatestFiniteMagnitude, -.greatestFiniteMagnitude, -.greatestFiniteMagnitude) + var foundGeometry = false + root.enumerateHierarchy { node, _ in + guard let geometry = node.geometry else { return } + let (lower, upper) = geometry.boundingBox + for x in [lower.x, upper.x] { + for y in [lower.y, upper.y] { + for z in [lower.z, upper.z] { + let point = node.convertPosition(SCNVector3(x, y, z), to: coordinateSpace) + minimum = SCNVector3(min(minimum.x, point.x), min(minimum.y, point.y), min(minimum.z, point.z)) + maximum = SCNVector3(max(maximum.x, point.x), max(maximum.y, point.y), max(maximum.z, point.z)) + foundGeometry = true + } + } + } + } + precondition(foundGeometry, "C3 closed world requires renderable geometry for HideTree and EscapePig") + return (minimum, maximum) + } + + private func lengthBetween(_ lhs: SCNVector3, _ rhs: SCNVector3) -> Float { + let dx = lhs.x - rhs.x + let dy = lhs.y - rhs.y + let dz = lhs.z - rhs.z + return sqrt(dx * dx + dy * dy + dz * dz) + } + + private func wrappedYawDelta(_ yaw: Float) -> Float { + atan2(sin(yaw), cos(yaw)) + } + + private func isPigVisibleToCamera() -> Bool { + isPigInCameraFrustum() + } + private func setupLighting() { scene.background.contents = gradientBackground() diff --git a/PiggyEscape/PiggyEscape/Sources/C3World/C3ClosedWorldSceneView.swift b/PiggyEscape/PiggyEscape/Sources/C3World/C3ClosedWorldSceneView.swift new file mode 100644 index 0000000..0c1da4d --- /dev/null +++ b/PiggyEscape/PiggyEscape/Sources/C3World/C3ClosedWorldSceneView.swift @@ -0,0 +1,115 @@ +import SceneKit +import SpriteKit +import SwiftUI + +struct C3ClosedWorldSceneView: UIViewRepresentable { + private let onNarrationFinished: () -> Void + private let onDiscovered: () -> Void + + init( + onNarrationFinished: @escaping () -> Void = {}, + onDiscovered: @escaping () -> Void = {} + ) { + self.onNarrationFinished = onNarrationFinished + self.onDiscovered = onDiscovered + } + + func makeUIView(context: Context) -> SCNView { + let view = SCNView() + let world = context.coordinator.world + let overlay = context.coordinator.overlay + view.scene = world.scene + view.pointOfView = world.cameraNode + view.allowsCameraControl = false + view.autoenablesDefaultLighting = false + view.antialiasingMode = .multisampling2X + view.preferredFramesPerSecond = 60 + view.overlaySKScene = overlay + + context.coordinator.installCallbacks() + context.coordinator.scnView = view + + view.addGestureRecognizer(UITapGestureRecognizer( + target: context.coordinator, + action: #selector(Coordinator.handleTap(_:)) + )) + view.addGestureRecognizer(UIPanGestureRecognizer( + target: context.coordinator, + action: #selector(Coordinator.handlePan(_:)) + )) + view.addGestureRecognizer(UIPinchGestureRecognizer( + target: context.coordinator, + action: #selector(Coordinator.handlePinch(_:)) + )) + + overlay.showOpeningNarration { [weak world] in + world?.completeOpeningNarration() + onNarrationFinished() + } + return view + } + + func updateUIView(_ uiView: SCNView, context: Context) {} + + func makeCoordinator() -> Coordinator { + Coordinator(onDiscovered: onDiscovered) + } + + @MainActor + final class Coordinator: NSObject { + let world = C3ClosedWorld() + let overlay = NarrationOverlayScene(size: UIScreen.main.bounds.size) + weak var scnView: SCNView? + + private let onDiscovered: () -> Void + + init(onDiscovered: @escaping () -> Void) { + self.onDiscovered = onDiscovered + } + + func installCallbacks() { + world.isPigInCameraFrustum = { [weak self] in + guard let self, let scnView else { return false } + return scnView.isNode(world.pigContainer, insideFrustumOf: world.cameraNode) + } + world.onSurpriseCaption = { [weak overlay] caption in + guard caption == "아, 들켰네… 제대로 숨고 싶은데." else { return } + overlay?.showSurpriseCaption() + self.onDiscovered() + } + } + + @objc func handleTap(_ gesture: UITapGestureRecognizer) { + guard let scnView, + let hit = scnView.hitTest(gesture.location(in: scnView), options: nil).first, + isEscapePigDescendant(hit.node) else { + return + } + _ = world.tapPig() + } + + @objc func handlePan(_ gesture: UIPanGestureRecognizer) { + guard let scnView, scnView.bounds.width > 0 else { return } + let translation = gesture.translation(in: scnView) + let yawDelta = -Float(translation.x / scnView.bounds.width) * .pi + world.rotateCamera(byYaw: yawDelta) + _ = world.isDiscoveredAfterCameraRotation() + gesture.setTranslation(.zero, in: scnView) + } + + @objc func handlePinch(_ gesture: UIPinchGestureRecognizer) { + guard gesture.numberOfTouches == 2 else { return } + world.zoom(by: Float(gesture.scale)) + gesture.scale = 1 + } + + private func isEscapePigDescendant(_ node: SCNNode) -> Bool { + var current: SCNNode? = node + while let candidate = current { + if candidate === world.pigContainer { return true } + current = candidate.parent + } + return false + } + } +} diff --git a/PiggyEscape/PiggyEscape/Sources/C3World/NarrationOverlayScene.swift b/PiggyEscape/PiggyEscape/Sources/C3World/NarrationOverlayScene.swift new file mode 100644 index 0000000..c972b76 --- /dev/null +++ b/PiggyEscape/PiggyEscape/Sources/C3World/NarrationOverlayScene.swift @@ -0,0 +1,82 @@ +import SpriteKit + +@MainActor +final class NarrationOverlayScene: SKScene { + private let panel = SKShapeNode() + private let shadowLabel = SKLabelNode(fontNamed: "AvenirNext-Bold") + private let label = SKLabelNode(fontNamed: "AvenirNext-Bold") + + private(set) var captionText = "" + + override init(size: CGSize) { + super.init(size: size) + scaleMode = .resizeFill + backgroundColor = .clear + configureCaptionNodes() + } + + required init?(coder aDecoder: NSCoder) { + super.init(coder: aDecoder) + scaleMode = .resizeFill + backgroundColor = .clear + configureCaptionNodes() + } + + func show(_ text: String, onFinished: (() -> Void)? = nil) { + captionText = text + label.text = text + shadowLabel.text = text + layoutCaption() + + panel.removeAllActions() + panel.alpha = 0 + panel.setScale(0.94) + let fade = SKAction.fadeIn(withDuration: 0.16) + let scale = SKAction.scale(to: 1, duration: 0.16) + scale.timingMode = .easeOut + panel.run(.sequence([.group([fade, scale]), .run { onFinished?() }])) + } + + func showOpeningNarration(onFinished: (() -> Void)? = nil) { + show("아, 나 좀 그만 쳐다보지. 나 숨고 싶어…", onFinished: onFinished) + } + + func showSurpriseCaption() { + show("아, 들켰네… 제대로 숨고 싶은데.") + } + + private func configureCaptionNodes() { + panel.fillColor = SKColor(white: 0.05, alpha: 0.66) + panel.strokeColor = .clear + panel.zPosition = 10 + addChild(panel) + + shadowLabel.fontSize = 20 + shadowLabel.fontColor = SKColor(white: 0, alpha: 0.18) + shadowLabel.horizontalAlignmentMode = .center + shadowLabel.verticalAlignmentMode = .center + shadowLabel.zPosition = 11 + panel.addChild(shadowLabel) + + label.fontSize = 20 + label.fontColor = .white + label.horizontalAlignmentMode = .center + label.verticalAlignmentMode = .center + label.zPosition = 12 + panel.addChild(label) + } + + private func layoutCaption() { + let width = min(max(size.width - 32, 160), 440) + let height: CGFloat = 60 + panel.path = CGPath( + roundedRect: CGRect(x: -width / 2, y: -height / 2, width: width, height: height), + cornerWidth: 12, + cornerHeight: 12, + transform: nil + ) + panel.position = CGPoint(x: size.width / 2, y: size.height * 0.18) + label.position = .zero + shadowLabel.position = CGPoint(x: 1, y: -1) + } +} diff --git a/PiggyEscape/PiggyEscape/Sources/ContentView.swift b/PiggyEscape/PiggyEscape/Sources/ContentView.swift index c033c69..1930a0e 100644 --- a/PiggyEscape/PiggyEscape/Sources/ContentView.swift +++ b/PiggyEscape/PiggyEscape/Sources/ContentView.swift @@ -2,7 +2,7 @@ import SwiftUI struct ContentView: View { var body: some View { - ClosedWorldSceneView() + C3ClosedWorldSceneView() .ignoresSafeArea() } } diff --git a/PiggyEscape/PiggyEscapeTests/ClosedWorldEscapeTests.swift b/PiggyEscape/PiggyEscapeTests/ClosedWorldEscapeTests.swift new file mode 100644 index 0000000..5349e8c --- /dev/null +++ b/PiggyEscape/PiggyEscapeTests/ClosedWorldEscapeTests.swift @@ -0,0 +1,51 @@ +import XCTest +@testable import PiggyEscape + +@MainActor +final class ClosedWorldEscapeTests: XCTestCase { + func test_tapIsIgnoredBeforeNarrationAndStartsRunningAfterIt() { + let world = C3ClosedWorld() + + XCTAssertFalse(world.tapPig()) + world.completeOpeningNarration() + + XCTAssertTrue(world.tapPig()) + XCTAssertEqual(world.currentPose, .running) + } + + func test_cameraDiscoveryNeedsYawChangeAndVisiblePig() { + let world = C3ClosedWorld() + world.isPigInCameraFrustum = { true } + world.completeOpeningNarration() + XCTAssertTrue(world.tapPig()) + world.finishTreeHideForTesting() + + XCTAssertFalse(world.isDiscoveredAfterCameraRotation()) + world.rotateCamera(byYaw: .pi / 2) + + XCTAssertTrue(world.isDiscoveredAfterCameraRotation()) + XCTAssertEqual(world.currentPose, .surprised) + } + + func test_surpriseCaptionAndScaleReactionStartTogether() { + let world = C3ClosedWorld() + + world.performSurpriseReaction() + + XCTAssertEqual(world.currentPose, .surprised) + XCTAssertEqual(world.lastCaption, "아, 들켰네… 제대로 숨고 싶은데.") + XCTAssertEqual(world.surprisePeakScale, 1.5, accuracy: 0.0001) + XCTAssertNotNil(world.pigContainer.action(forKey: "escapePig.surpriseScale")) + } + + func test_hidingUsesTheActualInSceneHideTree() { + let world = C3ClosedWorld() + let islandTree = world.scene.rootNode.childNode( + withName: "HideTree", + recursively: true + ) + + XCTAssertNotNil(islandTree) + XCTAssertTrue(world.hideTree === islandTree) + } +} diff --git a/PiggyEscape/PiggyEscapeTests/NarrationOverlaySceneTests.swift b/PiggyEscape/PiggyEscapeTests/NarrationOverlaySceneTests.swift new file mode 100644 index 0000000..22e8d14 --- /dev/null +++ b/PiggyEscape/PiggyEscapeTests/NarrationOverlaySceneTests.swift @@ -0,0 +1,24 @@ +import SpriteKit +import XCTest +@testable import PiggyEscape + +@MainActor +final class NarrationOverlaySceneTests: XCTestCase { + func test_showUpdatesCaptionSynchronouslyWithoutAnSKView() { + let overlay = NarrationOverlayScene(size: CGSize(width: 320, height: 180)) + + overlay.show("테스트 자막") + + XCTAssertEqual(overlay.captionText, "테스트 자막") + } + + func test_openingAndSurpriseCaptionsUseTheRequiredCopy() { + let overlay = NarrationOverlayScene(size: CGSize(width: 320, height: 180)) + + overlay.showOpeningNarration() + XCTAssertEqual(overlay.captionText, "아, 나 좀 그만 쳐다보지. 나 숨고 싶어…") + + overlay.showSurpriseCaption() + XCTAssertEqual(overlay.captionText, "아, 들켰네… 제대로 숨고 싶은데.") + } +} diff --git a/docs/LEARNING_LOG.md b/docs/LEARNING_LOG.md index 38aa0ad..2ce5154 100644 --- a/docs/LEARNING_LOG.md +++ b/docs/LEARNING_LOG.md @@ -28,6 +28,150 @@ ## 항목 +### L-20260810-019 — Task 4 시작 순서의 프로젝트 맥락 문서가 작업 트리에 없음 + +- 상태: 보류 +- 발생 태스크: Task 4 — SceneKit 나레이션·탭·가짜 숨기·카메라 발견 +- 재현: `sed -n '1,260p' docs/PROJECT_CONTEXT.md` +- 관찰: `sed: docs/PROJECT_CONTEXT.md: No such file or directory`로 지정 문서를 읽지 못했고, 이어지는 시작 순서 명령은 실행되지 않았음 +- 영향: 시작 순서의 프로젝트 맥락 문서를 현재 브랜치에서 직접 검토할 수 없음 +- 원인/가설: L-20260810-010 및 L-20260810-004와 같이 이 연결 작업 트리에 해당 문서가 포함되지 않은 상태임 +- 조치: 존재하는 `docs/WORK_LOG.md`, Reality escape 설계·구현 계획, Task 4 브리프를 대체 근거로 사용하며 범위를 넓히지 않음 +- 검증: `rg --files -g 'PROJECT_CONTEXT.md' -g 'WORK_LOG.md' -g '*task-4*'`에서 `PROJECT_CONTEXT.md` 부재와 대체 문서 존재를 확인함 +- 배운 점: 시작 필수 문서의 경로 부재는 명령이 중단된 태스크마다 재현·대체 근거를 남기고, 없는 문서를 추정하지 않는다. + +### L-20260810-020 — Task 4 시작 전 원격 fetch가 연결 작업 트리 메타데이터 권한으로 중단됨 + +- 상태: 해결 +- 발생 태스크: Task 4 — SceneKit 나레이션·탭·가짜 숨기·카메라 발견 +- 재현: `git fetch --prune origin` +- 관찰: `error: cannot open '/Users/yang-eunseo/Downloads/SpatialComputing_TechMap/.git/worktrees/ch1-reality-escape/FETCH_HEAD': Operation not permitted`로 원격 참조 갱신 전에 종료됨 +- 영향: 구현 전 원격 변경 동기화 상태를 현재 권한 범위에서 확정할 수 없음 +- 원인/가설: L-20260810-009와 같은 연결 작업 트리 공용 Git 메타데이터 `FETCH_HEAD` 쓰기 경로 권한 제약으로 보임 +- 조치: 공용 Git 메타데이터에 접근 가능한 권한으로 같은 명령을 한 번 재실행해 원격 상태를 확인함 +- 검증: 권한 재실행 `git fetch --prune origin && git status --short`가 성공했고, Task 4 시작 시점의 추적 변경은 기록 문서와 사용자 소유 `.claude/`뿐임을 확인함 +- 배운 점: 시작 순서의 fetch가 권한으로 중단되면 재시도 전에 실패를 기록하고, 성공 여부를 추정하지 않는다. + +### L-20260810-021 — Task 4 RED 준비 중 Tuist 세션 상태 디렉터리 쓰기가 거부됨 + +- 상태: 해결 +- 발생 태스크: Task 4 — SceneKit 나레이션·탭·가짜 숨기·카메라 발견 +- 재현: `tuist generate --no-open` +- 관찰: `Fatal error: Error raised at top level: Permission denied: /Users/yang-eunseo/.local/state/tuist/sessions/14A284BC-B6AD-49F5-A6B3-B8BD8A84183D`로 생성이 XCTest 실행 전에 종료됨 +- 영향: 새 Task 4 XCTest가 생성 프로젝트에 반영되지 않아 API 부재 RED를 아직 확인하지 못함 +- 원인/가설: Tuist가 연결 작업 트리 밖의 사용자 세션 상태 경로에 쓰기를 시도하지만 현재 파일 권한 범위에 포함되지 않음 +- 조치: 해당 상태 경로에 접근 가능한 권한으로 같은 생성 명령을 재실행한 뒤, 새 Task 4 focused XCTest를 실행함 +- 검증: 권한 재실행 `tuist generate --no-open`가 `✔ Success`로 완료되어 새 Task 4 XCTest와 소스가 생성 프로젝트에 반영됨 +- 배운 점: Tuist의 새 테스트 반영은 생성물 경로뿐 아니라 사용자 세션 상태 쓰기 권한을 먼저 충족해야 한다. + +### L-20260810-022 — Task 4 RED XCTest가 Simulator 서비스 접근 제한으로 중단됨 + +- 상태: 해결 +- 발생 태스크: Task 4 — SceneKit 나레이션·탭·가짜 숨기·카메라 발견 +- 재현: `xcodebuild -project PiggyEscape/PiggyEscape.xcodeproj -scheme PiggyEscape -destination 'platform=iOS Simulator,name=iPhone 17 Pro' -only-testing:PiggyEscapeTests/ClosedWorldEscapeTests -only-testing:PiggyEscapeTests/NarrationOverlaySceneTests test` +- 관찰: `CoreSimulatorService connection became invalid`와 `Error opening log file ... Operation not permitted`가 발생해 새 API 부재 컴파일 단계 전에 XCTest가 종료됨 +- 영향: Task 4의 의도적인 RED가 환경 제약 때문에 아직 관찰되지 않음 +- 원인/가설: Simulator 기기·로그·DerivedData 상태 경로가 현재 파일 권한 범위 밖에 있음 +- 조치: Simulator 서비스와 Xcode 상태 경로에 접근 가능한 권한으로 같은 focused XCTest를 재실행해, 새 API 부재 RED를 확인함 +- 검증: 권한 재실행에서 `C3ClosedWorld`의 `tapPig`, 내레이션·발견·놀람 API와 검사 상태 부재로 테스트 타깃 컴파일이 실패했으며, 상세는 L-20260810-023에 기록함 +- 배운 점: Simulator 기반 RED는 API 부재와 환경 권한 실패를 분리해야 테스트 의도를 보존할 수 있다. + +### L-20260810-023 — Task 4 RED XCTest가 새 가짜 숨기·발견 API 부재로 컴파일 실패함 + +- 상태: 해결 +- 발생 태스크: Task 4 — SceneKit 나레이션·탭·가짜 숨기·카메라 발견 +- 재현: `xcodebuild -project PiggyEscape/PiggyEscape.xcodeproj -scheme PiggyEscape -destination 'platform=iOS Simulator,name=iPhone 17 Pro' -only-testing:PiggyEscapeTests/ClosedWorldEscapeTests -only-testing:PiggyEscapeTests/NarrationOverlaySceneTests test` +- 관찰: `C3ClosedWorld`에서 `tapPig`, `completeOpeningNarration`, `finishTreeHideForTesting`, `isDiscoveredAfterCameraRotation`, `performSurpriseReaction` 및 검사 상태 `currentPose`, `lastCaption`, `surprisePeakScale`를 찾지 못해 테스트 타깃 컴파일이 중단됨 +- 영향: 나레이션 게이트·나무 도착 뒤 카메라 발견·놀람 연출의 요구 계약이 아직 앱에 없음 +- 원인/가설: Task 3 어댑터는 섬·돼지·카메라까지만 소유하며 Task 4의 상호작용 API와 SpriteKit 오버레이를 아직 구현하지 않았음 +- 조치: 실제 씬의 `HideTree`를 필수로 요구하는 C3 월드 상태/액션과 C3 스타일 `NarrationOverlayScene`, 제스처 어댑터를 구현할 예정임 +- 검증: 구현 뒤 같은 focused XCTest에서 새 API의 컴파일 및 행위 assertion을 확인하고, 전체 XCTest와 앱 빌드를 실행할 예정임 +- 배운 점: SceneKit 타이밍은 XCTest에 직접 의존시키지 않고, 상태·도착 테스트 훅·발견 판정을 결정론적 API로 노출해야 한다. + +### L-20260810-024 — Task 4 구현 패치가 기존 ContentView의 정확한 형식 불일치로 적용되지 않음 + +- 상태: 해결 +- 발생 태스크: Task 4 — SceneKit 나레이션·탭·가짜 숨기·카메라 발견 +- 재현: Task 4 소스·`ContentView.swift`를 함께 갱신하는 `apply_patch` +- 관찰: `ContentView`의 기존 `ClosedWorldSceneView()` 호출에 이미 `.ignoresSafeArea()` 체인이 있어, 예상한 한 줄 본문과 일치하지 않아 `apply_patch verification failed`로 전체 패치가 적용되지 않음 +- 영향: 새 C3 나레이션·상호작용 소스는 쓰이지 않았고, RED 상태가 유지됨 +- 원인/가설: 패치의 문맥이 기존 SwiftUI 체인을 완전하게 반영하지 못함 +- 조치: 실제 `ContentView.swift`의 네 줄 본문을 확인했고, 정확한 체인을 기준으로 소스 패치를 다시 적용할 예정임 +- 검증: 실패 직후 `test -e .../NarrationOverlayScene.swift`가 `1`을 반환하고 `git diff -- C3ClosedWorld.swift`가 비어 있어 생산 코드가 부분 적용되지 않았음을 확인함 +- 배운 점: 여러 파일을 한 번에 바꿀 때는 SwiftUI modifier 체인까지 정확한 문맥으로 지정하고, 실패 뒤 부분 적용 여부를 확인한다. + +### L-20260810-025 — Task 4 GREEN XCTest가 SceneKit 프러스텀 API 수신자 오류로 컴파일 실패함 + +- 상태: 해결 +- 발생 태스크: Task 4 — SceneKit 나레이션·탭·가짜 숨기·카메라 발견 +- 재현: `xcodebuild -project PiggyEscape/PiggyEscape.xcodeproj -scheme PiggyEscape -destination 'platform=iOS Simulator,name=iPhone 17 Pro' -only-testing:PiggyEscapeTests/ClosedWorldEscapeTests -only-testing:PiggyEscapeTests/NarrationOverlaySceneTests test` +- 관찰: `C3ClosedWorld.swift:78:28: error: value of type 'SCNNode' has no member 'isInsideFrustum'`로 앱 타깃 컴파일이 중단됨 +- 영향: 나무 도착 뒤 프러스텀·yaw·한 번만 발견하는 GREEN 동작을 테스트할 수 없음 +- 원인/가설: SceneKit의 프러스텀 판정은 돼지 노드가 아니라 카메라 노드에 호출해 검사 대상 노드를 전달하는 API로 선언된 것으로 보임 +- 조치: 로컬 SceneKit Swift interface에서 메서드 선언을 확인한 뒤, 카메라 노드 수신자로 정확히 교체할 예정임 +- 검증: 수정 후 같은 focused XCTest에서 컴파일·발견 assertion을 다시 확인할 예정임 +- 배운 점: 요구 문장의 개념상 주어와 Swift API의 실제 메서드 수신자는 다를 수 있으므로, SceneKit 선언을 먼저 대조한다. + +### L-20260810-026 — Task 4 GREEN XCTest가 탭 조상 순회의 optional shadowing 오류로 컴파일 실패함 + +- 상태: 해결 +- 발생 태스크: Task 4 — SceneKit 나레이션·탭·가짜 숨기·카메라 발견 +- 재현: `xcodebuild -project PiggyEscape/PiggyEscape.xcodeproj -scheme PiggyEscape -destination 'platform=iOS Simulator,name=iPhone 17 Pro' -only-testing:PiggyEscapeTests/ClosedWorldEscapeTests -only-testing:PiggyEscapeTests/NarrationOverlaySceneTests test` +- 관찰: `C3ClosedWorldSceneView.swift:110`에서 `current`가 `while let`로 shadow된 상수라 `current = current.parent`를 할 수 없고, optional `parent`도 대입되지 않아 앱 타깃 컴파일이 중단됨 +- 영향: 실제 `EscapePig` 자손만 탭을 허용하는 제스처 경로를 포함한 focused XCTest가 실행되지 않음 +- 원인/가설: optional 순회 변수와 unwrap 상수에 같은 식별자를 사용했음 +- 조치: 순회 optional은 `current`, unwrap 상수는 `candidate`로 분리해 부모를 안전하게 대입할 예정임 +- 검증: 수정 후 같은 focused XCTest에서 탭 게이트·나레이션·발견 assertion을 실행할 예정임 +- 배운 점: SceneKit의 부모 체인을 순회할 때 optional 저장 변수와 현재 노드 상수의 이름을 분리하면 Swift shadowing 오류를 피할 수 있다. + +### L-20260810-027 — Task 4 GREEN XCTest에서 프러스텀 판정이 발견을 막음 + +- 상태: 해결 +- 발생 태스크: Task 4 — SceneKit 나레이션·탭·가짜 숨기·카메라 발견 +- 재현: `xcodebuild -project PiggyEscape/PiggyEscape.xcodeproj -scheme PiggyEscape -destination 'platform=iOS Simulator,name=iPhone 17 Pro' -only-testing:PiggyEscapeTests/ClosedWorldEscapeTests -only-testing:PiggyEscapeTests/NarrationOverlaySceneTests test` +- 관찰: 6개 focused XCTest 중 나레이션·탭·실제 `HideTree`·놀람·오버레이 5개는 통과했으나, `test_cameraDiscoveryNeedsYawChangeAndVisiblePig`에서 π/2 yaw 뒤 `isDiscoveredAfterCameraRotation()`이 `false`이고 포즈가 `idle`로 남아 2개 assertion이 실패함 +- 영향: 돼지가 나무에 도착한 뒤 충분한 카메라 회전으로 발견되는 Task 4 핵심 흐름을 자동 검증하지 못함 +- 원인/가설: renderer 없는 테스트용 직교 프러스텀 기하 판정에서 카메라 방향/좌표 변환 또는 허용 범위가 실제 SceneKit 렌더러와 다르게 계산된 것으로 보임. 실제 `SCNView.isNode(_:insideFrustumOf:)`는 화면이 연결될 때 별도 사용함 +- 조치: 카메라 공간 돼지 좌표와 constraint 평가를 측정했고, renderer가 실제 프러스텀 결과를 주입하도록 단일 경계를 바꿈 +- 검증: 후속 focused 6/6, 전체 44/44 XCTest, iOS Simulator build 성공. renderer 없는 문제의 진단·해결 근거는 L-20260810-028에 남김 +- 배운 점: renderer 안전 단위 테스트의 프러스텀 대체 판정은 실제 화면 경로와 분리하되, 같은 카메라 변환·투영 경계를 수치로 검증해야 한다. + +### L-20260810-028 — renderer 없는 프러스텀 진단이 카메라 LookAt 제약의 미평가를 확인함 + +- 상태: 해결 +- 발생 태스크: Task 4 — SceneKit 나레이션·탭·가짜 숨기·카메라 발견 +- 재현: `xcodebuild -project PiggyEscape/PiggyEscape.xcodeproj -scheme PiggyEscape -destination 'platform=iOS Simulator,name=iPhone 17 Pro' -only-testing:PiggyEscapeTests/ClosedWorldEscapeTests/test_cameraDiscoveryNeedsYawChangeAndVisiblePig test` +- 관찰: π/2 회전 뒤 카메라는 `(12.020815, 12.0, -12.020815)`, 돼지는 `(-4.2649684, 0.63528, -1.5202962)`였지만 `cameraEuler`는 `(0, 0, 0)`이었다. 단일 테스트는 같은 발견 assertion 2개로 계속 실패함 +- 영향: SceneKit renderer 없이 `SCNLookAtConstraint` 결과를 전제로 한 카메라 공간 변환은 신뢰할 수 없음 +- 원인/가설: `SCNLookAtConstraint`는 renderer/프레임 평가 중 적용되므로 XCTest의 독립 노드 그래프에서 카메라 방향이 갱신되지 않았음. 실제 화면에서는 `SCNView.isNode(_:insideFrustumOf:)`가 renderer의 프러스텀을 판정할 수 있음 +- 조치: 월드의 발견 판정은 프러스텀 결과를 주입받아 fail-closed로 처리하고, C3 SceneKit view가 실제 `SCNView.isNode(_:insideFrustumOf:)` 결과를 제공하게 함. 단위 테스트는 명시적으로 보이는 상태를 주입해 yaw·도착·한 번만 조건을 결정론적으로 검증함 +- 검증: 진단 `print`를 제거하고, 주입된 visible/actual SCNView 경로를 포함해 focused XCTest를 다시 실행할 예정임 +- 배운 점: 렌더러가 소유하는 constraint·frustum 결과는 순수 상태 테스트에서 재계산하지 말고, 실제 어댑터에서 측정해 주입한다. + +### L-20260810-029 — Task 4 전체 XCTest는 통과했지만 기존 Simulator 런타임 경고가 출력됨 + +- 상태: 보류 +- 발생 태스크: Task 4 — SceneKit 나레이션·탭·가짜 숨기·카메라 발견 +- 재현: `xcodebuild -project PiggyEscape/PiggyEscape.xcodeproj -scheme PiggyEscape -destination 'platform=iOS Simulator,name=iPhone 17 Pro' test` +- 관찰: 44개 XCTest와 `** TEST SUCCEEDED **`는 확인했지만, `Metadata extraction skipped. No AppIntents.framework dependency found`, `TBB Global TLS count is not == 1`, `UIFocus` 캐시 제한, `UIAccessibilityLoaderWebShared` 중복 구현 경고가 함께 출력됨 +- 영향: Task 4의 C3 숨기·발견 회귀 assertion은 실패하지 않았지만 XCTest 출력이 경고 없이 깨끗하다는 조건은 충족하지 못함 +- 원인/가설: AppIntents 의존성이 없는 앱 타깃과 iOS Simulator 26.5의 기존 런타임 진단으로, Task 4 소스의 컴파일 또는 XCTest assertion 실패는 없음 +- 조치: Task 4 범위 밖의 기존 앱 타깃·Simulator 런타임을 변경하지 않고, 경고를 보류한 채 focused·전체 XCTest의 통과 결과를 유지함 +- 검증: focused `ClosedWorldEscapeTests`/`NarrationOverlaySceneTests` 6/6과 전체 XCTest 44/44가 통과했으며, 새 C3 프러스텀 경로는 `SCNView.isNode(_:insideFrustumOf:)`로 연결됨 +- 배운 점: SceneKit 상호작용 회귀와 Simulator/AppIntents 런타임 경고를 분리해 기록하고, 태스크 범위 밖 경고를 해결하려고 앱 구조를 바꾸지 않는다. + +### L-20260810-030 — Task 4의 실제 손가락 제스처·프러스텀 시각 확인은 자동 빌드로 대체할 수 없음 + +- 상태: 보류 +- 발생 태스크: Task 4 — SceneKit 나레이션·탭·가짜 숨기·카메라 발견 +- 재현: iPhone 17 Pro Simulator에서 C3 섬을 표시한 뒤, 나레이션 종료 후 돼지 모델 자손을 탭하고 화면 폭의 약 1/2를 팬해 발견 자막·확대를 관찰하는 수동 절차 +- 관찰: 이 작업에서는 focused 6/6, 전체 44/44 XCTest와 iOS Simulator `xcodebuild build` 성공을 확인했지만, 터치 좌표의 실제 hitTest·렌더러 프러스텀·SpriteKit 프레임 완료를 수동 조작으로 촬영하지는 않았음 +- 영향: 자동 검증은 상태·액션 키·카피·실제 `SCNView.isNode(_:insideFrustumOf:)` 연결을 증명하지만, 실제 화면에서 돼지가 나무 뒤에 가려졌다가 팬으로 보이는 연출의 시각 품질은 확정하지 못함 +- 원인/가설: SceneKit constraint·renderer 프러스텀·SpriteKit 액션은 테스트용 독립 노드 그래프와 달리 표시 중인 `SCNView` 프레임에서 평가됨 +- 조치: Task 4 범위의 자동 검증을 완료 근거로 유지하고, 후속 검토 또는 수동 Simulator 세션에서 위 절차를 별도로 확인함. RealityKit·권한 전환은 시작하지 않음 +- 검증: `C3ClosedWorldSceneView`는 실제 뷰의 `isNode(_:insideFrustumOf:)` 결과를 발견 조건에 주입하고, renderer 없는 XCTest는 visible 결과를 명시 주입해 yaw·도착·한 번만 조건을 44개 전체 회귀 안에서 검증함 +- 배운 점: 렌더러 의존 연출은 결정론적 상태 단위 테스트와 실제 화면 수동 확인을 모두 필요로 하며, 전자를 후자로 오인하지 않는다. + ### L-20260810-018 — Task 3 커밋 준비가 연결 작업 트리 index 잠금 권한으로 중단됨 - 상태: 해결 diff --git a/docs/WORK_LOG.md b/docs/WORK_LOG.md index 82cec3b..11973f9 100644 --- a/docs/WORK_LOG.md +++ b/docs/WORK_LOG.md @@ -4,13 +4,13 @@ ## 현재 인수인계 -- 상태: Task 3 — C3 섬·돼지 모델·궤도 카메라 어댑터 완료 -- 진행 중 범위: C3의 비금융 섬 타일·장식·USD 돼지 포즈·궤도 카메라·조명을 SceneKit 어댑터로 추가했다. 숨김 상호작용·나레이션·현실 전환은 구현하지 않았다. +- 상태: Task 4 — C3 SceneKit 나레이션·탭·가짜 숨기·카메라 발견 완료 +- 진행 중 범위: C3 섬 위에 SpriteKit 나레이션을 표시하고, 실제 `HideTree` 뒤로 달리는 돼지와 카메라 프러스텀 기반 발견·놀람 반응을 SceneKit 뷰에 연결했다. 카메라 권한·페이드·ARKit·RealityKit 전환은 구현하지 않았다. - 실패 기록: `docs/LEARNING_LOG.md`에 실패·검증 한계의 재현 조건·원인/가설·조치·재발 방지 근거를 기록한다. -- 마지막 완료 범위: Task 3 — C3 섬·돼지 모델·궤도 카메라 어댑터. -- 마지막 검증: `tuist generate --no-open` 성공. focused `xcodebuild -project PiggyEscape/PiggyEscape.xcodeproj -scheme PiggyEscape -destination 'platform=iOS Simulator,name=iPhone 17 Pro' -only-testing:PiggyEscapeTests/C3IslandBuilderTests -only-testing:PiggyEscapeTests/C3PigModelFactoryTests -only-testing:PiggyEscapeTests/C3ClosedWorldTests test`가 8/8을 통과했고, 전체 `xcodebuild -project PiggyEscape/PiggyEscape.xcodeproj -scheme PiggyEscape -destination 'platform=iOS Simulator,name=iPhone 17 Pro' test`가 38/38을 통과했다. Simulator/AppIntents 런타임 경고는 `docs/LEARNING_LOG.md`의 L-20260810-016에 기록했다. -- 다음 시작점: `docs/superpowers/plans/2026-08-10-ch1-reality-escape-implementation.md`의 Task 4 — C3 씬 뷰와 숨김 상호작용. -- 차단 요소: 원격 `git fetch`의 공용 Git 메타데이터 쓰기 권한과 시작 순서의 누락 문서는 `docs/LEARNING_LOG.md`에 보류 항목으로 기록했다. +- 마지막 완료 범위: Task 4 — C3 SceneKit 나레이션·탭·가짜 숨기·카메라 발견. +- 마지막 검증: `tuist generate --no-open` 성공. focused `xcodebuild -project PiggyEscape/PiggyEscape.xcodeproj -scheme PiggyEscape -destination 'platform=iOS Simulator,name=iPhone 17 Pro' -only-testing:PiggyEscapeTests/ClosedWorldEscapeTests -only-testing:PiggyEscapeTests/NarrationOverlaySceneTests test`가 6/6을 통과했고, 전체 `xcodebuild -project PiggyEscape/PiggyEscape.xcodeproj -scheme PiggyEscape -destination 'platform=iOS Simulator,name=iPhone 17 Pro' test`가 44/44을 통과했다. `xcodebuild ... build`도 성공했다. Simulator/AppIntents 런타임 경고와 실제 제스처 시각 확인 한계는 `docs/LEARNING_LOG.md`의 L-20260810-029~030에 기록했다. +- 다음 시작점: 구현 계획의 Task 5 — C3 발견 뒤 카메라 권한 전환. 이 Task 4에서는 시작하지 않았다. +- 차단 요소: 실제 제스처·렌더러 프러스텀의 수동 Simulator 시각 확인은 L-20260810-030에 보류했다. 원격 fetch와 시작 순서 누락 문서는 Task 4에서 각각 L-20260810-020, L-20260810-019에 갱신했다. ### Task 7에서 해결한 항목 @@ -22,6 +22,7 @@ | 날짜 | 작업 범위 | 결과 | 검증 | 다음 시작점 | | --- | --- | --- | --- | --- | +| 2026-08-10 | Task 4 — C3 SceneKit 나레이션·탭·가짜 숨기·카메라 발견 | `NarrationOverlayScene`의 C3 스타일 자막, 실제 `HideTree`가 필수인 나무 뒤 달리기, 0.70rad wrapped yaw·실제 `SCNView` 프러스텀·1회 가드 발견, `Piggy_surprised` 1.5→1.0 확대를 추가하고 C3 제스처 뷰를 앱 진입점에 연결함 | RED API 부재 컴파일 실패 후 focused 6/6·전체 44/44 XCTest 및 iOS Simulator build 성공. 기존 런타임 경고와 수동 시각 확인 한계는 L-20260810-029~030에 기록 | Task 5 — C3 발견 뒤 카메라 권한 전환 | | 2026-08-10 | Task 3 — C3 섬·돼지 모델·궤도 카메라 어댑터 | `C3Island`의 7개 `Ground_Color` 타일과 C3 장식 배치, 실제 `HideTree`·`BigPigSpawn`, `EscapePig`의 idle/running/surprised 내부 USD 보정·정규화, C3 궤도 카메라·그라데이션·조명을 추가함 | RED에서 `C3ClosedWorld` 부재 컴파일 실패와 내부 USD 보정 소유 노드 오류를 확인한 뒤 focused 8/8·전체 38/38 XCTest 통과. Simulator/AppIntents 런타임 경고는 L-20260810-016에 기록 | Task 4 — C3 씬 뷰와 숨김 상호작용 | | 2026-08-10 | Task 2 — 경험 상태와 숨기 좌표 순수 로직 | 상태 기계의 합법 전이와 XZ 카메라 반대편 나무 숨기 좌표를 프레임워크 독립 순수 Swift 타입으로 추가함 | RED에서 `EscapeExperienceMachine` 부재 컴파일 실패 확인 후 focused 3/3·전체 30/30 XCTest 통과. 기존 Simulator/AppIntents 경고는 L-20260810-007에 기록 | Task 3 — C3 섬 어댑터와 자동 전환 | | 2026-08-10 | Task 1 — C3 에셋과 카메라 권한 | C3 원본 11개를 바이트 변경 없이 앱 리소스에 등록하고, 고정 카메라 목적 문구와 실제 앱 번들 로딩 회귀 테스트를 추가함 | RED에서 새 에셋 8개가 누락되어 16개 assertion 실패 확인 후, `AssetLoaderTests` 5/5 통과·11개 `cmp -s` 일치·생성 Info.plist 목적 문구 확인 | Task 2 — 경험 상태와 숨기 좌표 순수 로직 | From 50a08d0dc594e8a13dfe2e7b4a1f65dea23c4abf Mon Sep 17 00:00:00 2001 From: eunseo-com Date: Mon, 10 Aug 2026 23:25:20 +0900 Subject: [PATCH 10/22] Fix C3 discovery callback ordering --- .../Sources/C3World/C3ClosedWorld.swift | 2 +- .../C3World/C3ClosedWorldSceneView.swift | 4 +- ...C3ClosedWorldSceneViewOwnershipTests.swift | 21 ++++++ .../ClosedWorldEscapeTests.swift | 64 +++++++++++++++++++ docs/LEARNING_LOG.md | 12 ++++ docs/WORK_LOG.md | 6 +- 6 files changed, 103 insertions(+), 6 deletions(-) create mode 100644 PiggyEscape/PiggyEscapeTests/C3ClosedWorldSceneViewOwnershipTests.swift diff --git a/PiggyEscape/PiggyEscape/Sources/C3World/C3ClosedWorld.swift b/PiggyEscape/PiggyEscape/Sources/C3World/C3ClosedWorld.swift index fa0660b..66b7954 100644 --- a/PiggyEscape/PiggyEscape/Sources/C3World/C3ClosedWorld.swift +++ b/PiggyEscape/PiggyEscape/Sources/C3World/C3ClosedWorld.swift @@ -89,13 +89,13 @@ final class C3ClosedWorld { setPose(.surprised) lastCaption = "아, 들켰네… 제대로 숨고 싶은데." surprisePeakScale = 1.5 - onSurpriseCaption?(lastCaption) let grow = SCNAction.scale(to: 1.5, duration: 0.16) grow.timingMode = .easeOut let restore = SCNAction.scale(to: 1.0, duration: 0.34) restore.timingMode = .easeInEaseOut pigContainer.runAction(.sequence([grow, restore]), forKey: "escapePig.surpriseScale") + onSurpriseCaption?(lastCaption) } func zoom(by factor: Float) { diff --git a/PiggyEscape/PiggyEscape/Sources/C3World/C3ClosedWorldSceneView.swift b/PiggyEscape/PiggyEscape/Sources/C3World/C3ClosedWorldSceneView.swift index 0c1da4d..94dc8cc 100644 --- a/PiggyEscape/PiggyEscape/Sources/C3World/C3ClosedWorldSceneView.swift +++ b/PiggyEscape/PiggyEscape/Sources/C3World/C3ClosedWorldSceneView.swift @@ -72,10 +72,10 @@ struct C3ClosedWorldSceneView: UIViewRepresentable { guard let self, let scnView else { return false } return scnView.isNode(world.pigContainer, insideFrustumOf: world.cameraNode) } - world.onSurpriseCaption = { [weak overlay] caption in + world.onSurpriseCaption = { [weak self, weak overlay] caption in guard caption == "아, 들켰네… 제대로 숨고 싶은데." else { return } overlay?.showSurpriseCaption() - self.onDiscovered() + self?.onDiscovered() } } diff --git a/PiggyEscape/PiggyEscapeTests/C3ClosedWorldSceneViewOwnershipTests.swift b/PiggyEscape/PiggyEscapeTests/C3ClosedWorldSceneViewOwnershipTests.swift new file mode 100644 index 0000000..a36075c --- /dev/null +++ b/PiggyEscape/PiggyEscapeTests/C3ClosedWorldSceneViewOwnershipTests.swift @@ -0,0 +1,21 @@ +import XCTest +@testable import PiggyEscape + +@MainActor +final class C3ClosedWorldSceneViewOwnershipTests: XCTestCase { + func test_coordinatorIsReleasedWhenWorldOutlivesInstalledCallbacks() { + weak var releasedCoordinator: C3ClosedWorldSceneView.Coordinator? + var retainedWorld: C3ClosedWorld? + + autoreleasepool { + var coordinator: C3ClosedWorldSceneView.Coordinator? = .init(onDiscovered: {}) + coordinator?.installCallbacks() + retainedWorld = coordinator?.world + releasedCoordinator = coordinator + coordinator = nil + } + + XCTAssertNotNil(retainedWorld) + XCTAssertNil(releasedCoordinator) + } +} diff --git a/PiggyEscape/PiggyEscapeTests/ClosedWorldEscapeTests.swift b/PiggyEscape/PiggyEscapeTests/ClosedWorldEscapeTests.swift index 5349e8c..502d627 100644 --- a/PiggyEscape/PiggyEscapeTests/ClosedWorldEscapeTests.swift +++ b/PiggyEscape/PiggyEscapeTests/ClosedWorldEscapeTests.swift @@ -38,6 +38,70 @@ final class ClosedWorldEscapeTests: XCTestCase { XCTAssertNotNil(world.pigContainer.action(forKey: "escapePig.surpriseScale")) } + func test_surpriseScaleActionStartsBeforeCaptionCallback() { + let world = C3ClosedWorld() + var scaleActionWasInstalled = false + world.onSurpriseCaption = { _ in + scaleActionWasInstalled = world.pigContainer.action( + forKey: "escapePig.surpriseScale" + ) != nil + } + + world.performSurpriseReaction() + + XCTAssertTrue(scaleActionWasInstalled) + } + + func test_discoveryDoesNotRunBeforePigReachesTree() { + let world = C3ClosedWorld() + world.isPigInCameraFrustum = { true } + world.completeOpeningNarration() + XCTAssertTrue(world.tapPig()) + world.rotateCamera(byYaw: .pi) + + XCTAssertFalse(world.isDiscoveredAfterCameraRotation()) + XCTAssertEqual(world.currentPose, .running) + } + + func test_discoveryRequiresVisiblePigAfterTreeArrival() { + let world = C3ClosedWorld() + world.isPigInCameraFrustum = { false } + world.completeOpeningNarration() + XCTAssertTrue(world.tapPig()) + world.finishTreeHideForTesting() + world.rotateCamera(byYaw: .pi / 2) + + XCTAssertFalse(world.isDiscoveredAfterCameraRotation()) + XCTAssertEqual(world.currentPose, .idle) + } + + func test_discoveryUsesWrappedYawThreshold() { + let world = C3ClosedWorld() + world.isPigInCameraFrustum = { true } + world.completeOpeningNarration() + XCTAssertTrue(world.tapPig()) + world.finishTreeHideForTesting() + world.rotateCamera(byYaw: 2 * .pi - 0.69) + + XCTAssertFalse(world.isDiscoveredAfterCameraRotation()) + world.rotateCamera(byYaw: -0.02) + + XCTAssertTrue(world.isDiscoveredAfterCameraRotation()) + } + + func test_discoveryRunsOnlyOnce() { + let world = C3ClosedWorld() + world.isPigInCameraFrustum = { true } + world.completeOpeningNarration() + XCTAssertTrue(world.tapPig()) + world.finishTreeHideForTesting() + world.rotateCamera(byYaw: .pi / 2) + XCTAssertTrue(world.isDiscoveredAfterCameraRotation()) + world.rotateCamera(byYaw: .pi / 2) + + XCTAssertFalse(world.isDiscoveredAfterCameraRotation()) + } + func test_hidingUsesTheActualInSceneHideTree() { let world = C3ClosedWorld() let islandTree = world.scene.rootNode.childNode( diff --git a/docs/LEARNING_LOG.md b/docs/LEARNING_LOG.md index 2ce5154..0e150cf 100644 --- a/docs/LEARNING_LOG.md +++ b/docs/LEARNING_LOG.md @@ -28,6 +28,18 @@ ## 항목 +### L-20260810-031 — Task 4 리뷰 RED에서 놀람 콜백 순서와 Coordinator 수명 누수가 확인됨 + +- 상태: 해결 +- 발생 태스크: Task 4 — SceneKit 나레이션·탭·가짜 숨기·카메라 발견 (리뷰 보완) +- 재현: `xcodebuild -project PiggyEscape/PiggyEscape.xcodeproj -scheme PiggyEscape -destination 'platform=iOS Simulator,name=iPhone 17 Pro' -only-testing:PiggyEscapeTests/ClosedWorldEscapeTests -only-testing:PiggyEscapeTests/C3ClosedWorldSceneViewOwnershipTests -only-testing:PiggyEscapeTests/NarrationOverlaySceneTests test` +- 관찰: 12개 focused XCTest 중 10개는 통과했으나, `test_surpriseScaleActionStartsBeforeCaptionCallback`은 콜백 안에서 `escapePig.surpriseScale` action을 찾지 못했고, `test_coordinatorIsReleasedWhenWorldOutlivesInstalledCallbacks`는 외부가 `world`를 보유한 뒤에도 `Coordinator`가 해제되지 않았음 +- 영향: 외부 `onDiscovered`가 동기적으로 뷰를 해제하면 필수 놀람 action 시작 전에 씬이 사라질 수 있고, 발견 뒤 화면 전환에서 world→closure→coordinator 순환 참조가 남을 수 있음 +- 원인/가설: `performSurpriseReaction()`이 keyed scale action보다 `onSurpriseCaption`을 먼저 호출했고, `world.onSurpriseCaption` closure가 coordinator `self`를 강하게 캡처했음 +- 조치: scale sequence를 외부 caption callback보다 먼저 시작하고, callback closure가 coordinator와 overlay를 약하게 캡처하도록 변경함 +- 검증: 같은 focused XCTest가 12/12을 통과했고, 전체 `xcodebuild -project PiggyEscape/PiggyEscape.xcodeproj -scheme PiggyEscape -destination 'platform=iOS Simulator,name=iPhone 17 Pro' test`가 50/50, 같은 destination의 `xcodebuild ... build`가 `** BUILD SUCCEEDED **`로 완료됨 +- 배운 점: 외부 전환 콜백보다 먼저 화면 내 필수 action을 등록하고, owned world에 저장하는 callback은 owner를 약하게 캡처해 수명 경계를 테스트한다. + ### L-20260810-019 — Task 4 시작 순서의 프로젝트 맥락 문서가 작업 트리에 없음 - 상태: 보류 diff --git a/docs/WORK_LOG.md b/docs/WORK_LOG.md index 11973f9..eefa729 100644 --- a/docs/WORK_LOG.md +++ b/docs/WORK_LOG.md @@ -4,11 +4,11 @@ ## 현재 인수인계 -- 상태: Task 4 — C3 SceneKit 나레이션·탭·가짜 숨기·카메라 발견 완료 +- 상태: Task 4 — C3 SceneKit 나레이션·탭·가짜 숨기·카메라 발견 완료 (리뷰 보완 포함) - 진행 중 범위: C3 섬 위에 SpriteKit 나레이션을 표시하고, 실제 `HideTree` 뒤로 달리는 돼지와 카메라 프러스텀 기반 발견·놀람 반응을 SceneKit 뷰에 연결했다. 카메라 권한·페이드·ARKit·RealityKit 전환은 구현하지 않았다. - 실패 기록: `docs/LEARNING_LOG.md`에 실패·검증 한계의 재현 조건·원인/가설·조치·재발 방지 근거를 기록한다. - 마지막 완료 범위: Task 4 — C3 SceneKit 나레이션·탭·가짜 숨기·카메라 발견. -- 마지막 검증: `tuist generate --no-open` 성공. focused `xcodebuild -project PiggyEscape/PiggyEscape.xcodeproj -scheme PiggyEscape -destination 'platform=iOS Simulator,name=iPhone 17 Pro' -only-testing:PiggyEscapeTests/ClosedWorldEscapeTests -only-testing:PiggyEscapeTests/NarrationOverlaySceneTests test`가 6/6을 통과했고, 전체 `xcodebuild -project PiggyEscape/PiggyEscape.xcodeproj -scheme PiggyEscape -destination 'platform=iOS Simulator,name=iPhone 17 Pro' test`가 44/44을 통과했다. `xcodebuild ... build`도 성공했다. Simulator/AppIntents 런타임 경고와 실제 제스처 시각 확인 한계는 `docs/LEARNING_LOG.md`의 L-20260810-029~030에 기록했다. +- 마지막 검증: `tuist generate --no-open` 성공. 리뷰 focused `xcodebuild -project PiggyEscape/PiggyEscape.xcodeproj -scheme PiggyEscape -destination 'platform=iOS Simulator,name=iPhone 17 Pro' -only-testing:PiggyEscapeTests/ClosedWorldEscapeTests -only-testing:PiggyEscapeTests/C3ClosedWorldSceneViewOwnershipTests -only-testing:PiggyEscapeTests/NarrationOverlaySceneTests test`가 12/12을 통과했고, 전체 `xcodebuild -project PiggyEscape/PiggyEscape.xcodeproj -scheme PiggyEscape -destination 'platform=iOS Simulator,name=iPhone 17 Pro' test`가 50/50을 통과했다. `xcodebuild ... build`도 성공했다. Simulator/AppIntents 런타임 경고와 실제 제스처 시각 확인 한계는 `docs/LEARNING_LOG.md`의 L-20260810-029~030에 기록했다. - 다음 시작점: 구현 계획의 Task 5 — C3 발견 뒤 카메라 권한 전환. 이 Task 4에서는 시작하지 않았다. - 차단 요소: 실제 제스처·렌더러 프러스텀의 수동 Simulator 시각 확인은 L-20260810-030에 보류했다. 원격 fetch와 시작 순서 누락 문서는 Task 4에서 각각 L-20260810-020, L-20260810-019에 갱신했다. @@ -22,7 +22,7 @@ | 날짜 | 작업 범위 | 결과 | 검증 | 다음 시작점 | | --- | --- | --- | --- | --- | -| 2026-08-10 | Task 4 — C3 SceneKit 나레이션·탭·가짜 숨기·카메라 발견 | `NarrationOverlayScene`의 C3 스타일 자막, 실제 `HideTree`가 필수인 나무 뒤 달리기, 0.70rad wrapped yaw·실제 `SCNView` 프러스텀·1회 가드 발견, `Piggy_surprised` 1.5→1.0 확대를 추가하고 C3 제스처 뷰를 앱 진입점에 연결함 | RED API 부재 컴파일 실패 후 focused 6/6·전체 44/44 XCTest 및 iOS Simulator build 성공. 기존 런타임 경고와 수동 시각 확인 한계는 L-20260810-029~030에 기록 | Task 5 — C3 발견 뒤 카메라 권한 전환 | +| 2026-08-10 | Task 4 — C3 SceneKit 나레이션·탭·가짜 숨기·카메라 발견 | `NarrationOverlayScene`의 C3 스타일 자막, 실제 `HideTree`가 필수인 나무 뒤 달리기, 0.70rad wrapped yaw·실제 `SCNView` 프러스텀·1회 가드 발견, `Piggy_surprised` 1.5→1.0 확대를 추가하고 C3 제스처 뷰를 앱 진입점에 연결함. 리뷰 보완으로 확대 action을 외부 콜백보다 먼저 등록하고, world callback의 Coordinator 캡처를 약하게 바꾸며 도착 전·frustum false·yaw 경계·1회 가드를 회귀 테스트로 확장함 | 리뷰 RED 2건 뒤 focused 12/12·전체 50/50 XCTest 및 iOS Simulator build 성공. 기존 런타임 경고와 수동 시각 확인 한계는 L-20260810-029~030, 리뷰 수정 근거는 L-20260810-031에 기록 | Task 5 — C3 발견 뒤 카메라 권한 전환 | | 2026-08-10 | Task 3 — C3 섬·돼지 모델·궤도 카메라 어댑터 | `C3Island`의 7개 `Ground_Color` 타일과 C3 장식 배치, 실제 `HideTree`·`BigPigSpawn`, `EscapePig`의 idle/running/surprised 내부 USD 보정·정규화, C3 궤도 카메라·그라데이션·조명을 추가함 | RED에서 `C3ClosedWorld` 부재 컴파일 실패와 내부 USD 보정 소유 노드 오류를 확인한 뒤 focused 8/8·전체 38/38 XCTest 통과. Simulator/AppIntents 런타임 경고는 L-20260810-016에 기록 | Task 4 — C3 씬 뷰와 숨김 상호작용 | | 2026-08-10 | Task 2 — 경험 상태와 숨기 좌표 순수 로직 | 상태 기계의 합법 전이와 XZ 카메라 반대편 나무 숨기 좌표를 프레임워크 독립 순수 Swift 타입으로 추가함 | RED에서 `EscapeExperienceMachine` 부재 컴파일 실패 확인 후 focused 3/3·전체 30/30 XCTest 통과. 기존 Simulator/AppIntents 경고는 L-20260810-007에 기록 | Task 3 — C3 섬 어댑터와 자동 전환 | | 2026-08-10 | Task 1 — C3 에셋과 카메라 권한 | C3 원본 11개를 바이트 변경 없이 앱 리소스에 등록하고, 고정 카메라 목적 문구와 실제 앱 번들 로딩 회귀 테스트를 추가함 | RED에서 새 에셋 8개가 누락되어 16개 assertion 실패 확인 후, `AssetLoaderTests` 5/5 통과·11개 `cmp -s` 일치·생성 Info.plist 목적 문구 확인 | Task 2 — 경험 상태와 숨기 좌표 순수 로직 | From 36c303d4e5fa9bea33158c69944901026c20c60f Mon Sep 17 00:00:00 2001 From: eunseo-com Date: Mon, 10 Aug 2026 23:37:38 +0900 Subject: [PATCH 11/22] Add reality hide planning --- .../Sources/Reality/RealityCapability.swift | 18 ++++ .../Sources/Reality/RealityHidePlanner.swift | 72 ++++++++++++++++ .../RealityCapabilityTests.swift | 20 +++++ .../RealityHidePlannerTests.swift | 85 +++++++++++++++++++ docs/LEARNING_LOG.md | 84 ++++++++++++++++++ docs/WORK_LOG.md | 13 +-- 6 files changed, 286 insertions(+), 6 deletions(-) create mode 100644 PiggyEscape/PiggyEscape/Sources/Reality/RealityCapability.swift create mode 100644 PiggyEscape/PiggyEscape/Sources/Reality/RealityHidePlanner.swift create mode 100644 PiggyEscape/PiggyEscapeTests/RealityCapabilityTests.swift create mode 100644 PiggyEscape/PiggyEscapeTests/RealityHidePlannerTests.swift diff --git a/PiggyEscape/PiggyEscape/Sources/Reality/RealityCapability.swift b/PiggyEscape/PiggyEscape/Sources/Reality/RealityCapability.swift new file mode 100644 index 0000000..369fa47 --- /dev/null +++ b/PiggyEscape/PiggyEscape/Sources/Reality/RealityCapability.swift @@ -0,0 +1,18 @@ +import ARKit + +protocol RealityMeshSupporting { + var supportsMeshWithClassification: Bool { get } +} + +struct SystemRealityMeshSupport: RealityMeshSupporting { + var supportsMeshWithClassification: Bool { + ARWorldTrackingConfiguration.supportsSceneReconstruction(.meshWithClassification) + } +} + +enum RealityAvailabilityMessage { + static let unavailable = "이 기능은 LiDAR로 공간을 읽을 수 있는 기기에서 사용할 수 있어." + static let scanFirst = "주변 바닥과 숨을 물체를 조금 더 스캔해줘." + static let selectVerticalSide = "숨을 물체의 옆면을 탭해줘." + static let moveFartherAway = "조금 떨어진 물체의 옆면을 탭해줘." +} diff --git a/PiggyEscape/PiggyEscape/Sources/Reality/RealityHidePlanner.swift b/PiggyEscape/PiggyEscape/Sources/Reality/RealityHidePlanner.swift new file mode 100644 index 0000000..f0a095f --- /dev/null +++ b/PiggyEscape/PiggyEscape/Sources/Reality/RealityHidePlanner.swift @@ -0,0 +1,72 @@ +import simd + +struct RealitySurfaceHit: Equatable { + let point: SIMD3 + let normal: SIMD3 +} + +struct RealityFloor: Equatable { + let point: SIMD3 +} + +enum RealityHideRejection: Equatable { + case selectVerticalSide + case moveFartherAway + case findFloor +} + +enum RealityHidePlanResult: Equatable { + case accepted(SIMD3) + case rejected(RealityHideRejection) +} + +enum RealityHidePlanner { + static let verticalNormalMaximumY: Float = 0.35 + static let minimumCameraDistance: Float = 0.45 + static let objectClearance: Float = 0.28 + static let maximumFloorDistance: Float = 1.2 + + static func plan( + hit: RealitySurfaceHit, + cameraPosition: SIMD3, + floor: RealityFloor? + ) -> RealityHidePlanResult { + guard abs(hit.normal.y) <= verticalNormalMaximumY else { + return .rejected(.selectVerticalSide) + } + guard simd_distance(hit.point, cameraPosition) >= minimumCameraDistance else { + return .rejected(.moveFartherAway) + } + guard let floor else { + return .rejected(.findFloor) + } + + let floorOffset = SIMD2(hit.point.x - floor.point.x, hit.point.z - floor.point.z) + guard simd_length(floorOffset) <= maximumFloorDistance else { + return .rejected(.findFloor) + } + guard simd_length_squared(hit.normal) > 0.0001 else { + return .rejected(.selectVerticalSide) + } + + let normal = simd_normalize(hit.normal) + let towardCamera = simd_dot(normal, cameraPosition - hit.point) >= 0 ? normal : -normal + let hiddenPoint = hit.point - towardCamera * objectClearance + return .accepted(SIMD3(hiddenPoint.x, floor.point.y, hiddenPoint.z)) + } +} + +struct RealityRevealMonitor { + private var hasObservedBlockingMesh = false + private var hasReportedReveal = false + + mutating func update(meshDistance: Float?, pigDistance: Float) -> Bool { + let blocked = meshDistance.map { $0 + 0.03 < pigDistance } ?? false + hasObservedBlockingMesh = hasObservedBlockingMesh || blocked + guard hasObservedBlockingMesh, !blocked, !hasReportedReveal else { + return false + } + hasReportedReveal = true + return true + } +} diff --git a/PiggyEscape/PiggyEscapeTests/RealityCapabilityTests.swift b/PiggyEscape/PiggyEscapeTests/RealityCapabilityTests.swift new file mode 100644 index 0000000..90cbd3e --- /dev/null +++ b/PiggyEscape/PiggyEscapeTests/RealityCapabilityTests.swift @@ -0,0 +1,20 @@ +import XCTest +@testable import PiggyEscape + +final class RealityCapabilityTests: XCTestCase { + func test_injectedCapabilityDoesNotDependOnTheSimulator() { + XCTAssertTrue(FakeRealityMeshSupport(supportsMeshWithClassification: true).supportsMeshWithClassification) + XCTAssertFalse(FakeRealityMeshSupport(supportsMeshWithClassification: false).supportsMeshWithClassification) + } + + func test_availabilityMessagesMatchTheRealityGuidance() { + XCTAssertEqual(RealityAvailabilityMessage.unavailable, "이 기능은 LiDAR로 공간을 읽을 수 있는 기기에서 사용할 수 있어.") + XCTAssertEqual(RealityAvailabilityMessage.scanFirst, "주변 바닥과 숨을 물체를 조금 더 스캔해줘.") + XCTAssertEqual(RealityAvailabilityMessage.selectVerticalSide, "숨을 물체의 옆면을 탭해줘.") + XCTAssertEqual(RealityAvailabilityMessage.moveFartherAway, "조금 떨어진 물체의 옆면을 탭해줘.") + } +} + +private struct FakeRealityMeshSupport: RealityMeshSupporting { + let supportsMeshWithClassification: Bool +} diff --git a/PiggyEscape/PiggyEscapeTests/RealityHidePlannerTests.swift b/PiggyEscape/PiggyEscapeTests/RealityHidePlannerTests.swift new file mode 100644 index 0000000..318f3a9 --- /dev/null +++ b/PiggyEscape/PiggyEscapeTests/RealityHidePlannerTests.swift @@ -0,0 +1,85 @@ +import XCTest +import simd +@testable import PiggyEscape + +final class RealityHidePlannerTests: XCTestCase { + func test_verticalSurfacePlacesPigOnCameraOppositeSideOfObject() { + let result = RealityHidePlanner.plan( + hit: RealitySurfaceHit(point: SIMD3(1, 0.9, 0), normal: SIMD3(1, 0, 0)), + cameraPosition: SIMD3(3, 1.5, 0), + floor: RealityFloor(point: SIMD3(1, 0.25, 0)) + ) + + guard case let .accepted(position) = result else { + return XCTFail("expected accepted target") + } + XCTAssertLessThan(position.x, 1) + XCTAssertEqual(position.x, 0.72, accuracy: 0.0001) + XCTAssertEqual(position.y, 0.25, accuracy: 0.0001) + } + + func test_horizontalSurfaceAndMissingFloorAreRejected() { + XCTAssertEqual( + RealityHidePlanner.plan( + hit: RealitySurfaceHit(point: SIMD3(0, 0.8, 0), normal: SIMD3(0, 1, 0)), + cameraPosition: SIMD3(0, 1.5, 1), + floor: RealityFloor(point: SIMD3(0, 0, 0)) + ), + .rejected(.selectVerticalSide) + ) + XCTAssertEqual( + RealityHidePlanner.plan( + hit: RealitySurfaceHit(point: SIMD3(1, 0.8, 0), normal: SIMD3(1, 0, 0)), + cameraPosition: SIMD3(3, 1.5, 0), + floor: nil + ), + .rejected(.findFloor) + ) + } + + func test_nearCameraAndFarFloorAreRejected() { + XCTAssertEqual( + RealityHidePlanner.plan( + hit: RealitySurfaceHit(point: SIMD3(0, 0.8, 0), normal: SIMD3(0, 0, 1)), + cameraPosition: SIMD3(0, 0.8, 0.44), + floor: RealityFloor(point: SIMD3(0, 0, 0)) + ), + .rejected(.moveFartherAway) + ) + XCTAssertEqual( + RealityHidePlanner.plan( + hit: RealitySurfaceHit(point: SIMD3(0, 0.8, 0), normal: SIMD3(0, 0, 1)), + cameraPosition: SIMD3(0, 1.5, 2), + floor: RealityFloor(point: SIMD3(2, 0, 0)) + ), + .rejected(.findFloor) + ) + } + + func test_zeroLengthNormalIsRejectedWithoutNormalizingIt() { + XCTAssertEqual( + RealityHidePlanner.plan( + hit: RealitySurfaceHit(point: SIMD3(0, 0.8, 0), normal: SIMD3(repeating: 0)), + cameraPosition: SIMD3(0, 1.5, 2), + floor: RealityFloor(point: SIMD3(0, 0, 0)) + ), + .rejected(.selectVerticalSide) + ) + } + + func test_revealMonitorFiresOnlyAfterFirstBlockedFrameThenVisibleFrame() { + var monitor = RealityRevealMonitor() + + XCTAssertFalse(monitor.update(meshDistance: nil, pigDistance: 2)) + XCTAssertFalse(monitor.update(meshDistance: 1, pigDistance: 2)) + XCTAssertTrue(monitor.update(meshDistance: nil, pigDistance: 2)) + XCTAssertFalse(monitor.update(meshDistance: nil, pigDistance: 2)) + } + + func test_revealMonitorTreatsThreeCentimeterBoundaryAsVisible() { + var monitor = RealityRevealMonitor() + + XCTAssertFalse(monitor.update(meshDistance: 1, pigDistance: 2)) + XCTAssertTrue(monitor.update(meshDistance: 1.97, pigDistance: 2)) + } +} diff --git a/docs/LEARNING_LOG.md b/docs/LEARNING_LOG.md index 0e150cf..b3274a3 100644 --- a/docs/LEARNING_LOG.md +++ b/docs/LEARNING_LOG.md @@ -28,6 +28,90 @@ ## 항목 +### L-20260810-032 — Task 5 시작 전 원격 fetch가 연결 작업 트리 메타데이터 권한으로 중단됨 + +- 상태: 해결 +- 발생 태스크: Task 5 — 실제 물체 숨기 계획과 LiDAR 지원 판정 +- 재현: `git fetch --prune origin` +- 관찰: `error: cannot open '/Users/yang-eunseo/Downloads/SpatialComputing_TechMap/.git/worktrees/ch1-reality-escape/FETCH_HEAD': Operation not permitted`로 원격 참조 갱신 전에 종료됨 +- 영향: 구현 전 원격 변경 동기화 상태를 현재 권한 범위에서 확정할 수 없음 +- 원인/가설: 이전 연결 작업 트리의 L-20260810-020와 같은 공용 Git 메타데이터 `FETCH_HEAD` 쓰기 경로 권한 제약으로 보임 +- 조치: 공용 Git 메타데이터에 접근 가능한 권한으로 같은 명령을 재실행함 +- 검증: 권한 재실행 `git fetch --prune origin`이 성공했고, 직후 `git status --short`는 이 항목을 포함한 `docs/LEARNING_LOG.md` 변경과 기존 사용자 소유 `.claude/`만 표시함 +- 배운 점: 시작 순서의 fetch가 권한으로 중단되면 구현이나 테스트 재시도 전에 실패 근거를 먼저 남긴다. + +### L-20260810-033 — Task 5 RED 준비의 Tuist 생성이 세션 상태 디렉터리 권한으로 중단됨 + +- 상태: 해결 +- 발생 태스크: Task 5 — 실제 물체 숨기 계획과 LiDAR 지원 판정 +- 재현: `cd PiggyEscape && tuist generate --no-open` +- 관찰: `Fatal error: Error raised at top level: Permission denied: /Users/yang-eunseo/.local/state/tuist/sessions/5EB5AE43-8EB9-4043-ADD9-EFD4F37F3DED`로 새 XCTest 파일을 생성 프로젝트에 반영하기 전에 종료됨 +- 영향: 새 Reality 계획·지원 판정 테스트의 의도적인 타입 부재 RED를 아직 실행할 수 없음 +- 원인/가설: Tuist가 연결 작업 트리 밖 사용자 세션 상태 경로에 쓰기를 시도하지만 현재 권한 범위에 포함되지 않음 +- 조치: 해당 세션 상태 경로에 접근 가능한 권한으로 같은 생성 명령을 재실행함 +- 검증: 권한 재실행 `tuist generate --no-open`가 `✔ Success`로 완료되어 새 Reality XCTest가 생성 프로젝트에 반영됨 +- 배운 점: 새 XCTest를 추가한 뒤 RED를 확인하려면 생성 프로젝트뿐 아니라 Tuist 세션 상태 쓰기 권한도 먼저 충족해야 한다. + +### L-20260810-034 — Task 5 RED XCTest가 Simulator 서비스 접근 제한으로 중단됨 + +- 상태: 해결 +- 발생 태스크: Task 5 — 실제 물체 숨기 계획과 LiDAR 지원 판정 +- 재현: `xcodebuild -project PiggyEscape/PiggyEscape.xcodeproj -scheme PiggyEscape -destination 'platform=iOS Simulator,name=iPhone 17 Pro' -only-testing:PiggyEscapeTests/RealityHidePlannerTests -only-testing:PiggyEscapeTests/RealityCapabilityTests test` +- 관찰: `CoreSimulatorService connection became invalid`, `Error opening log file ... Operation not permitted`, `Unable to discover any Simulator runtimes`가 발생해 새 타입 부재 컴파일 단계 전에 XCTest가 종료됨 +- 영향: Reality 계획·지원 판정의 의도적인 RED를 아직 확인할 수 없음 +- 원인/가설: Simulator 기기·로그·DerivedData 상태 경로가 현재 파일 권한 범위 밖에 있음 +- 조치: Simulator 서비스와 Xcode 상태 경로에 접근 가능한 권한으로 같은 focused XCTest를 재실행함 +- 검증: 권한 재실행에서 `RealityMeshSupporting`과 `RealityAvailabilityMessage`를 찾지 못해 `PiggyEscapeTests` 컴파일이 실패했고, 지정 Reality 계약이 아직 구현되지 않았음을 확인함 +- 배운 점: Simulator 기반 RED에서는 환경 권한 실패와 새 API 부재를 분리해 기록해야 테스트 의도를 보존할 수 있다. + +### L-20260810-035 — Task 5 GREEN 준비의 Tuist 명령이 잘못된 작업 디렉터리에서 실행됨 + +- 상태: 해결 +- 발생 태스크: Task 5 — 실제 물체 숨기 계획과 LiDAR 지원 판정 +- 재현: 작업 트리 루트에서 `tuist generate --no-open` 뒤 지정 Reality focused XCTest 실행 +- 관찰: Tuist가 `Manifest not found at path .../.worktrees/ch1-reality-escape`로 종료했고, 이어진 XCTest는 기존 생성 프로젝트를 사용해 새 `RealityMeshSupporting`과 `RealityAvailabilityMessage`를 찾지 못함 +- 영향: 방금 추가한 Reality 소스가 생성 프로젝트에 반영되지 않아 GREEN 동작을 검증할 수 없음 +- 원인/가설: `Project.swift`는 `PiggyEscape/`에 있는데 생성 명령의 작업 디렉터리가 그 상위 작업 트리 루트였음. 따라서 생성 실패 후 이전 `.xcodeproj`의 소스 목록이 유지된 것으로 보임 +- 조치: `PiggyEscape/Project.swift`가 존재하고 이전 `.xcodeproj`에는 Reality 테스트만 포함하며 Reality 소스는 빠진 것을 확인함. manifest가 있는 `PiggyEscape/`에서만 생성 명령을 재실행함 +- 검증: `cd PiggyEscape && tuist generate --no-open`가 성공했고, 같은 focused XCTest가 `RealityHidePlanner.swift`와 `RealityCapability.swift`를 컴파일한 뒤 8/8 assertion을 통과함 +- 배운 점: Tuist 생성은 프로젝트 파일 위치가 아니라 manifest를 기준으로 작업 디렉터리를 선택하며, 생성 실패 뒤에는 기존 Xcode 프로젝트를 검증 근거로 사용하지 않는다. + +### L-20260810-036 — Task 5 focused XCTest에 기존 Simulator 런타임 경고가 출력됨 + +- 상태: 보류 +- 발생 태스크: Task 5 — 실제 물체 숨기 계획과 LiDAR 지원 판정 +- 재현: `cd PiggyEscape && xcodebuild -project PiggyEscape.xcodeproj -scheme PiggyEscape -destination 'platform=iOS Simulator,name=iPhone 17 Pro' -only-testing:PiggyEscapeTests/RealityHidePlannerTests -only-testing:PiggyEscapeTests/RealityCapabilityTests test` +- 관찰: 8개 XCTest와 `** TEST SUCCEEDED **`는 확인했지만 `Metadata extraction skipped. No AppIntents.framework dependency found`, `TBB Global TLS count is not == 1`, `UIFocus` 캐시 제한 경고가 함께 출력됨 +- 영향: Reality 계획·메시지·주입 지원 판정 회귀 assertion은 통과했지만 XCTest 출력이 경고 없이 깨끗하다는 조건은 충족하지 못함 +- 원인/가설: 기존 AppIntents 의존성이 없는 앱 타깃과 iOS Simulator 런타임 진단으로, Task 5 순수 Swift·ARKit 지원 경계의 컴파일 또는 assertion 실패는 아님 +- 조치: Task 5 범위 밖의 앱 타깃·Simulator 런타임을 변경하지 않고, 경고를 보류한 채 전체 XCTest와 Simulator build로 회귀 범위를 확인함 +- 검증: focused Reality XCTest 8/8과 전체 XCTest 58/58이 통과했고, 같은 destination의 `xcodebuild ... build`가 `** BUILD SUCCEEDED **`로 완료됨 +- 배운 점: LiDAR 지원 자체는 Fake `RealityMeshSupporting`로 결정론적으로 검증하고, Simulator 런타임 경고 또는 실제 LiDAR 판정을 자동 테스트 성공으로 대체하지 않는다. + +### L-20260810-037 — Task 5의 실제 LiDAR 메쉬 지원과 물리 오클루전은 Simulator에서 검증할 수 없음 + +- 상태: 실기기 대기 +- 발생 태스크: Task 5 — 실제 물체 숨기 계획과 LiDAR 지원 판정 +- 재현: LiDAR가 있는 iOS 기기에서 `SystemRealityMeshSupport.supportsMeshWithClassification`을 확인하고, 실제 세로 물체·바닥을 스캔한 뒤 메쉬가 돼지보다 앞에 있을 때와 벗어났을 때를 관찰하는 절차 +- 관찰: 이 태스크에서는 순수 입력·거리·평면 수학, 0.03m 재발견 허용오차, Fake 지원 주입만 자동 검증했다. Simulator ARKit으로 LiDAR 지원을 흉내 내거나 실제 메쉬 오클루전을 실행하지 않았음 +- 영향: 지원 여부의 시스템 API 결과와 실제 재구성 메쉬의 거리 데이터는 자동 테스트만으로 확정할 수 없음 +- 원인/가설: LiDAR 센서와 실제 환경 메쉬는 Simulator가 제공하지 않는 하드웨어·공간 입력임 +- 조치: 시스템 판정은 `SystemRealityMeshSupport` 한 곳으로 제한하고, 테스트는 ARKit을 호출하지 않는 Fake 주입으로 true/false 경계를 검증함 +- 검증: `RealityCapabilityTests/test_injectedCapabilityDoesNotDependOnTheSimulator`가 true/false Fake를 모두 통과했고, 실제 기기 검증은 Task 6의 ARView 연결 뒤 수행할 예정임 +- 배운 점: LiDAR 가능 여부와 실제 오클루전은 시스템 API·실기기 근거로만 확인하며, Simulator의 값이나 순수 테스트를 하드웨어 검증으로 표현하지 않는다. + +### L-20260810-038 — Task 5 인수인계·학습 기록 통합 패치가 문맥 불일치로 적용되지 않음 + +- 상태: 해결 +- 발생 태스크: Task 5 — 실제 물체 숨기 계획과 LiDAR 지원 판정 +- 재현: `docs/LEARNING_LOG.md`와 `docs/WORK_LOG.md`를 함께 갱신하는 `apply_patch` +- 관찰: `apply_patch verification failed`가 `WORK_LOG.md`의 현재 인수인계 문맥을 찾지 못해 전체 패치가 적용되지 않음 +- 영향: 학습 기록의 전체 XCTest·실기기 한계 갱신과 Task 5 인수인계가 아직 저장되지 않음 +- 원인/가설: 패치 문맥이 실제 제목 `## 현재 인수인계`와 달리 앞 공백이 있는 문자열을 사용해 정확히 일치하지 않았음 +- 조치: 실패 뒤 두 문서의 실제 첫 부분을 읽어 문맥 불일치를 확인했고, 학습 기록과 인수인계를 별도 정확 문맥 패치로 분리함 +- 검증: 분리 패치가 전체 XCTest 58/58·Simulator build 성공, 실제 LiDAR 실기기 대기, Task 6 다음 시작점을 각각 두 문서에 반영함 +- 배운 점: 서로 다른 문서를 한 패치로 바꿀 때는 각 문서의 제목·공백까지 실제 문맥을 먼저 대조하고, 실패 시 부분 적용을 가정하지 않는다. + ### L-20260810-031 — Task 4 리뷰 RED에서 놀람 콜백 순서와 Coordinator 수명 누수가 확인됨 - 상태: 해결 diff --git a/docs/WORK_LOG.md b/docs/WORK_LOG.md index eefa729..33fb6e0 100644 --- a/docs/WORK_LOG.md +++ b/docs/WORK_LOG.md @@ -4,13 +4,13 @@ ## 현재 인수인계 -- 상태: Task 4 — C3 SceneKit 나레이션·탭·가짜 숨기·카메라 발견 완료 (리뷰 보완 포함) -- 진행 중 범위: C3 섬 위에 SpriteKit 나레이션을 표시하고, 실제 `HideTree` 뒤로 달리는 돼지와 카메라 프러스텀 기반 발견·놀람 반응을 SceneKit 뷰에 연결했다. 카메라 권한·페이드·ARKit·RealityKit 전환은 구현하지 않았다. +- 상태: Task 5 — 실제 물체 숨기 계획과 LiDAR 지원 판정 완료 +- 진행 중 범위: ARKit 독립 `RealitySurfaceHit`·`RealityFloor`·`RealityHidePlanner`로 세로 면, 카메라 거리, 근접 바닥을 검사해 카메라 반대편의 바닥 Y 위치를 계산한다. `RealityRevealMonitor`는 실제 메쉬 가림을 먼저 관찰한 뒤에만 한 번 재발견을 알리고, LiDAR 가능 여부는 주입 가능한 `RealityMeshSupporting` 경계로 분리했다. ARView·RealityKit 렌더링·엔티티 로딩·카메라 권한·화면 전환은 구현하지 않았다. - 실패 기록: `docs/LEARNING_LOG.md`에 실패·검증 한계의 재현 조건·원인/가설·조치·재발 방지 근거를 기록한다. -- 마지막 완료 범위: Task 4 — C3 SceneKit 나레이션·탭·가짜 숨기·카메라 발견. -- 마지막 검증: `tuist generate --no-open` 성공. 리뷰 focused `xcodebuild -project PiggyEscape/PiggyEscape.xcodeproj -scheme PiggyEscape -destination 'platform=iOS Simulator,name=iPhone 17 Pro' -only-testing:PiggyEscapeTests/ClosedWorldEscapeTests -only-testing:PiggyEscapeTests/C3ClosedWorldSceneViewOwnershipTests -only-testing:PiggyEscapeTests/NarrationOverlaySceneTests test`가 12/12을 통과했고, 전체 `xcodebuild -project PiggyEscape/PiggyEscape.xcodeproj -scheme PiggyEscape -destination 'platform=iOS Simulator,name=iPhone 17 Pro' test`가 50/50을 통과했다. `xcodebuild ... build`도 성공했다. Simulator/AppIntents 런타임 경고와 실제 제스처 시각 확인 한계는 `docs/LEARNING_LOG.md`의 L-20260810-029~030에 기록했다. -- 다음 시작점: 구현 계획의 Task 5 — C3 발견 뒤 카메라 권한 전환. 이 Task 4에서는 시작하지 않았다. -- 차단 요소: 실제 제스처·렌더러 프러스텀의 수동 Simulator 시각 확인은 L-20260810-030에 보류했다. 원격 fetch와 시작 순서 누락 문서는 Task 4에서 각각 L-20260810-020, L-20260810-019에 갱신했다. +- 마지막 완료 범위: Task 5 — 실제 물체 숨기 계획과 LiDAR 지원 판정. +- 마지막 검증: `cd PiggyEscape && tuist generate --no-open` 성공. focused `xcodebuild -project PiggyEscape.xcodeproj -scheme PiggyEscape -destination 'platform=iOS Simulator,name=iPhone 17 Pro' -only-testing:PiggyEscapeTests/RealityHidePlannerTests -only-testing:PiggyEscapeTests/RealityCapabilityTests test`가 8/8을 통과했고, 전체 같은 destination의 `xcodebuild ... test`가 58/58을 통과했다. `xcodebuild ... build`도 성공했다. Simulator/AppIntents 런타임 경고와 실제 LiDAR·물리 오클루전 실기기 한계는 `docs/LEARNING_LOG.md`의 L-20260810-036~037에 기록했다. +- 다음 시작점: 구현 계획의 Task 6 — RealityKit 돼지 포즈와 실제 메쉬 뒤 숨기. 이 Task 5에서는 시작하지 않았다. +- 차단 요소: 실제 LiDAR 메쉬 지원·물리 오클루전의 실기기 확인은 L-20260810-037에 보류했다. 원격 fetch와 Tuist/Simulator 권한·작업 경로 실패의 재현 및 조치는 L-20260810-032~036에 기록했다. ### Task 7에서 해결한 항목 @@ -22,6 +22,7 @@ | 날짜 | 작업 범위 | 결과 | 검증 | 다음 시작점 | | --- | --- | --- | --- | --- | +| 2026-08-10 | Task 5 — 실제 물체 숨기 계획과 LiDAR 지원 판정 | 세로 면·카메라 거리·근접 바닥을 검증해 카메라 반대편의 정확한 바닥 Y 숨기 위치를 계산하는 순수 계획, 0.03m 허용오차의 1회 재발견 감시, ARKit 호출을 시스템 구현 하나에 한정한 LiDAR 지원 주입 경계와 안내 문자열을 추가함. ARView·RealityKit 렌더링·권한·UI 전환은 추가하지 않음 | RED에서 Reality 계획·지원 타입 부재를 확인한 뒤 focused 8/8·전체 58/58 XCTest 및 iOS Simulator build 성공. 기존 Simulator/AppIntents 경고와 실제 LiDAR·물리 오클루전 실기기 한계는 L-20260810-036~037에 기록 | Task 6 — RealityKit 돼지 포즈와 실제 메쉬 뒤 숨기 | | 2026-08-10 | Task 4 — C3 SceneKit 나레이션·탭·가짜 숨기·카메라 발견 | `NarrationOverlayScene`의 C3 스타일 자막, 실제 `HideTree`가 필수인 나무 뒤 달리기, 0.70rad wrapped yaw·실제 `SCNView` 프러스텀·1회 가드 발견, `Piggy_surprised` 1.5→1.0 확대를 추가하고 C3 제스처 뷰를 앱 진입점에 연결함. 리뷰 보완으로 확대 action을 외부 콜백보다 먼저 등록하고, world callback의 Coordinator 캡처를 약하게 바꾸며 도착 전·frustum false·yaw 경계·1회 가드를 회귀 테스트로 확장함 | 리뷰 RED 2건 뒤 focused 12/12·전체 50/50 XCTest 및 iOS Simulator build 성공. 기존 런타임 경고와 수동 시각 확인 한계는 L-20260810-029~030, 리뷰 수정 근거는 L-20260810-031에 기록 | Task 5 — C3 발견 뒤 카메라 권한 전환 | | 2026-08-10 | Task 3 — C3 섬·돼지 모델·궤도 카메라 어댑터 | `C3Island`의 7개 `Ground_Color` 타일과 C3 장식 배치, 실제 `HideTree`·`BigPigSpawn`, `EscapePig`의 idle/running/surprised 내부 USD 보정·정규화, C3 궤도 카메라·그라데이션·조명을 추가함 | RED에서 `C3ClosedWorld` 부재 컴파일 실패와 내부 USD 보정 소유 노드 오류를 확인한 뒤 focused 8/8·전체 38/38 XCTest 통과. Simulator/AppIntents 런타임 경고는 L-20260810-016에 기록 | Task 4 — C3 씬 뷰와 숨김 상호작용 | | 2026-08-10 | Task 2 — 경험 상태와 숨기 좌표 순수 로직 | 상태 기계의 합법 전이와 XZ 카메라 반대편 나무 숨기 좌표를 프레임워크 독립 순수 Swift 타입으로 추가함 | RED에서 `EscapeExperienceMachine` 부재 컴파일 실패 확인 후 focused 3/3·전체 30/30 XCTest 통과. 기존 Simulator/AppIntents 경고는 L-20260810-007에 기록 | Task 3 — C3 섬 어댑터와 자동 전환 | From e3f58d92aad59445bd80625e87eba56d93c753f1 Mon Sep 17 00:00:00 2001 From: eunseo-com Date: Mon, 10 Aug 2026 23:57:52 +0900 Subject: [PATCH 12/22] Add RealityKit hide behind real objects --- .../Sources/Reality/RealityHideARView.swift | 266 ++++++++++++++++++ .../Reality/RealityPigVisualController.swift | 172 +++++++++++ .../RealityHideARViewCoordinatorTests.swift | 54 ++++ .../RealityPigVisualControllerTests.swift | 38 +++ docs/LEARNING_LOG.md | 120 ++++++++ docs/WORK_LOG.md | 13 +- 6 files changed, 657 insertions(+), 6 deletions(-) create mode 100644 PiggyEscape/PiggyEscape/Sources/Reality/RealityHideARView.swift create mode 100644 PiggyEscape/PiggyEscape/Sources/Reality/RealityPigVisualController.swift create mode 100644 PiggyEscape/PiggyEscapeTests/RealityHideARViewCoordinatorTests.swift create mode 100644 PiggyEscape/PiggyEscapeTests/RealityPigVisualControllerTests.swift diff --git a/PiggyEscape/PiggyEscape/Sources/Reality/RealityHideARView.swift b/PiggyEscape/PiggyEscape/Sources/Reality/RealityHideARView.swift new file mode 100644 index 0000000..678466a --- /dev/null +++ b/PiggyEscape/PiggyEscape/Sources/Reality/RealityHideARView.swift @@ -0,0 +1,266 @@ +import ARKit +import Combine +import RealityKit +import SwiftUI + +struct RealityHideARView: UIViewRepresentable { + let onScanningReady: () -> Void + let onTargetAccepted: () -> Void + let onRevealed: () -> Void + let onUnavailable: () -> Void + let onMessage: (String) -> Void + + init( + onScanningReady: @escaping () -> Void, + onTargetAccepted: @escaping () -> Void, + onRevealed: @escaping () -> Void, + onUnavailable: @escaping () -> Void, + onMessage: @escaping (String) -> Void + ) { + self.onScanningReady = onScanningReady + self.onTargetAccepted = onTargetAccepted + self.onRevealed = onRevealed + self.onUnavailable = onUnavailable + self.onMessage = onMessage + } + + func makeCoordinator() -> Coordinator { + Coordinator( + meshSupport: SystemRealityMeshSupport(), + onScanningReady: onScanningReady, + onTargetAccepted: onTargetAccepted, + onRevealed: onRevealed, + onUnavailable: onUnavailable, + onMessage: onMessage + ) + } + + func makeUIView(context: Context) -> ARView { + let arView = ARView(frame: .zero, cameraMode: .ar, automaticallyConfigureSession: false) + context.coordinator.attach(to: arView) + return arView + } + + func updateUIView(_ uiView: ARView, context: Context) {} + + static func dismantleUIView(_ uiView: ARView, coordinator: Coordinator) { + coordinator.stop() + } + + @MainActor + final class Coordinator: NSObject { + private enum HideState { + case waitingForTarget + case walking + case hidden + case revealed + } + + private let meshSupport: any RealityMeshSupporting + private let visualController: RealityPigVisualController + private let onScanningReady: () -> Void + private let onTargetAccepted: () -> Void + private let onRevealed: () -> Void + private let onUnavailable: () -> Void + private let onMessage: (String) -> Void + + private weak var arView: ARView? + private var revealMonitor = RealityRevealMonitor() + private var revealSubscription: (any Cancellable)? + private var hideState = HideState.waitingForTarget + private var pigAnchor: AnchorEntity? + + private(set) var didStartMeshSession = false + + var canStartMeshSession: Bool { + meshSupport.supportsMeshWithClassification + } + + init( + meshSupport: any RealityMeshSupporting, + visualController: RealityPigVisualController? = nil, + onScanningReady: @escaping () -> Void = {}, + onTargetAccepted: @escaping () -> Void = {}, + onRevealed: @escaping () -> Void = {}, + onUnavailable: @escaping () -> Void = {}, + onMessage: @escaping (String) -> Void = { _ in } + ) { + self.meshSupport = meshSupport + self.visualController = visualController ?? RealityPigVisualController() + self.onScanningReady = onScanningReady + self.onTargetAccepted = onTargetAccepted + self.onRevealed = onRevealed + self.onUnavailable = onUnavailable + self.onMessage = onMessage + } + + func attach(to arView: ARView) { + self.arView = arView + guard startMeshSessionIfSupported(in: arView) else { return } + + let anchor = AnchorEntity(world: .zero) + anchor.addChild(visualController.outerEntity) + arView.scene.addAnchor(anchor) + pigAnchor = anchor + visualController.loadIdlePig() + + let tap = UITapGestureRecognizer(target: self, action: #selector(handleTap(_:))) + tap.numberOfTouchesRequired = 1 + arView.addGestureRecognizer(tap) + onScanningReady() + } + + @discardableResult + func startMeshSessionIfSupported() -> Bool { + guard canStartMeshSession else { + reportUnavailable() + return false + } + guard let arView else { return false } + return startMeshSessionIfSupported(in: arView) + } + + func message(for rejection: RealityHideRejection) -> String { + switch rejection { + case .selectVerticalSide: + RealityAvailabilityMessage.selectVerticalSide + case .moveFartherAway: + RealityAvailabilityMessage.moveFartherAway + case .findFloor: + RealityAvailabilityMessage.scanFirst + } + } + + @discardableResult + func processRevealFrame(meshDistance: Float?, pigDistance: Float) -> Bool { + guard hideState != .revealed, + revealMonitor.update(meshDistance: meshDistance, pigDistance: pigDistance) else { + return false + } + + hideState = .revealed + revealSubscription?.cancel() + revealSubscription = nil + visualController.showSurprised() + visualController.playSurpriseScale() + onRevealed() + return true + } + + func stop() { + revealSubscription?.cancel() + revealSubscription = nil + arView?.session.pause() + didStartMeshSession = false + } + + private func startMeshSessionIfSupported(in arView: ARView) -> Bool { + guard canStartMeshSession else { + reportUnavailable() + return false + } + guard !didStartMeshSession else { return true } + + let configuration = ARWorldTrackingConfiguration() + configuration.planeDetection = [.horizontal, .vertical] + configuration.sceneReconstruction = .meshWithClassification + arView.environment.sceneUnderstanding.options = [ + .occlusion, + .collision, + .physics, + .receivesLighting + ] + arView.session.run(configuration, options: [.resetTracking, .removeExistingAnchors]) + didStartMeshSession = true + return true + } + + private func reportUnavailable() { + onUnavailable() + onMessage(RealityAvailabilityMessage.unavailable) + } + + @objc + private func handleTap(_ recognizer: UITapGestureRecognizer) { + guard recognizer.state == .ended, + hideState == .waitingForTarget, + let arView, + let frame = arView.session.currentFrame else { return } + + let screenPoint = recognizer.location(in: arView) + guard let hit = arView.hitTest( + screenPoint, + query: .nearest, + mask: .sceneUnderstanding + ).first else { + onMessage(RealityAvailabilityMessage.scanFirst) + return + } + + let cameraPosition = Self.position(from: frame.camera.transform) + let floor = nearestFloor(in: frame, to: hit.position) + let result = RealityHidePlanner.plan( + hit: RealitySurfaceHit(point: hit.position, normal: hit.normal), + cameraPosition: cameraPosition, + floor: floor + ) + + switch result { + case let .rejected(rejection): + onMessage(message(for: rejection)) + case let .accepted(destination): + hideState = .walking + onTargetAccepted() + visualController.walk(to: destination) { [weak self, weak arView] in + guard let self, let arView else { return } + self.hideState = .hidden + self.beginRevealMonitoring(in: arView) + } + } + } + + private func nearestFloor(in frame: ARFrame, to surfacePoint: SIMD3) -> RealityFloor? { + frame.anchors + .compactMap { $0 as? ARPlaneAnchor } + .filter { $0.alignment == .horizontal && $0.classification == .floor } + .map { anchor in + let center = SIMD4(anchor.center.x, anchor.center.y, anchor.center.z, 1) + let world = anchor.transform * center + return RealityFloor(point: SIMD3(world.x, world.y, world.z)) + } + .min { lhs, rhs in + let lhsOffset = SIMD2(lhs.point.x - surfacePoint.x, lhs.point.z - surfacePoint.z) + let rhsOffset = SIMD2(rhs.point.x - surfacePoint.x, rhs.point.z - surfacePoint.z) + return simd_length_squared(lhsOffset) < simd_length_squared(rhsOffset) + } + } + + private func beginRevealMonitoring(in arView: ARView) { + revealMonitor = RealityRevealMonitor() + revealSubscription?.cancel() + revealSubscription = arView.scene.subscribe(to: SceneEvents.Update.self) { [weak self, weak arView] _ in + guard let self, let arView else { return } + self.evaluateReveal(in: arView) + } + } + + private func evaluateReveal(in arView: ARView) { + let pigPosition = visualController.worldPosition + guard let screenPoint = arView.project(pigPosition), + let cameraTransform = arView.session.currentFrame?.camera.transform else { return } + + let cameraPosition = Self.position(from: cameraTransform) + let meshDistance = arView.hitTest( + screenPoint, + query: .nearest, + mask: .sceneUnderstanding + ).first.map { simd_distance(cameraPosition, $0.position) } + let pigDistance = simd_distance(cameraPosition, pigPosition) + processRevealFrame(meshDistance: meshDistance, pigDistance: pigDistance) + } + + private static func position(from transform: simd_float4x4) -> SIMD3 { + SIMD3(transform.columns.3.x, transform.columns.3.y, transform.columns.3.z) + } + } +} diff --git a/PiggyEscape/PiggyEscape/Sources/Reality/RealityPigVisualController.swift b/PiggyEscape/PiggyEscape/Sources/Reality/RealityPigVisualController.swift new file mode 100644 index 0000000..cc78327 --- /dev/null +++ b/PiggyEscape/PiggyEscape/Sources/Reality/RealityPigVisualController.swift @@ -0,0 +1,172 @@ +import Combine +import Foundation +import RealityKit +import simd + +@MainActor +final class RealityPigVisualController { + let outerEntity: Entity + + private(set) var currentPose: C3PigPose = .idle + private(set) var surprisePeakScale: Float = 1 + private(set) var surpriseRestoreScale: Float = 1 + + var worldPosition: SIMD3 { + outerEntity.position(relativeTo: nil) + } + + private let skipsAssetLoadingAndTiming: Bool + private var modelLoad: AnyCancellable? + private var movementCompletion: DispatchWorkItem? + + init() { + outerEntity = Entity() + outerEntity.name = "RealityEscapePig" + outerEntity.orientation = simd_quatf(angle: 3 * .pi / 4, axis: SIMD3(0, 1, 0)) + skipsAssetLoadingAndTiming = false + } + + private init(testing: Bool) { + outerEntity = Entity() + outerEntity.name = "RealityEscapePig" + outerEntity.orientation = simd_quatf(angle: 3 * .pi / 4, axis: SIMD3(0, 1, 0)) + skipsAssetLoadingAndTiming = testing + } + + static func makeForTesting() -> RealityPigVisualController { + RealityPigVisualController(testing: true) + } + + func loadIdlePig() { + setPose(.idle) + } + + func walk(to destination: SIMD3, completion: @escaping () -> Void) { + movementCompletion?.cancel() + setPose(.running) { [weak self] in + guard let self else { return } + if self.skipsAssetLoadingAndTiming { + self.outerEntity.setPosition(destination, relativeTo: nil) + self.setPose(.idle, completion: completion) + return + } + + let distance = simd_distance(self.worldPosition, destination) + let duration = TimeInterval(min(max(distance / 0.65, 0.5), 3.0)) + let target = Transform( + scale: self.outerEntity.scale, + rotation: self.outerEntity.orientation, + translation: destination + ) + self.outerEntity.move( + to: target, + relativeTo: self.outerEntity.parent, + duration: duration, + timingFunction: .easeInOut + ) + + let work = DispatchWorkItem { [weak self] in + self?.setPose(.idle, completion: completion) + } + self.movementCompletion = work + DispatchQueue.main.asyncAfter(deadline: .now() + duration, execute: work) + } + } + + func showSurprised() { + movementCompletion?.cancel() + setPose(.surprised) + } + + func playSurpriseScale() { + surprisePeakScale = 1.5 + surpriseRestoreScale = 1.0 + + let peak = Transform( + scale: SIMD3(repeating: surprisePeakScale), + rotation: outerEntity.orientation, + translation: outerEntity.position + ) + outerEntity.move( + to: peak, + relativeTo: outerEntity.parent, + duration: 0.16, + timingFunction: .easeOut + ) + + guard !skipsAssetLoadingAndTiming else { return } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.16) { [weak self] in + guard let self else { return } + let restored = Transform( + scale: SIMD3(repeating: self.surpriseRestoreScale), + rotation: self.outerEntity.orientation, + translation: self.outerEntity.position + ) + self.outerEntity.move( + to: restored, + relativeTo: self.outerEntity.parent, + duration: 0.34, + timingFunction: .easeInOut + ) + } + } + + private func setPose(_ pose: C3PigPose, completion: (() -> Void)? = nil) { + currentPose = pose + guard !skipsAssetLoadingAndTiming else { + completion?() + return + } + + modelLoad?.cancel() + modelLoad = Entity.loadAsync(named: assetName(for: pose), in: .main) + .sink( + receiveCompletion: { result in + if case .failure = result { + completion?() + } + }, + receiveValue: { [weak self] entity in + guard let self, self.currentPose == pose else { return } + self.install(entity, for: pose) + completion?() + } + ) + } + + private func assetName(for pose: C3PigPose) -> String { + pose == .idle ? "Piggy" : "Piggy_\(pose.rawValue)" + } + + private func install(_ model: Entity, for pose: C3PigPose) { + outerEntity.children + .filter { $0.name.hasPrefix("RealityPigModel_") } + .forEach { $0.removeFromParent() } + + model.name = "RealityPigModel_\(pose.rawValue)" + let xCorrection = simd_quatf(angle: .pi / 2, axis: SIMD3(1, 0, 0)) + let zCorrection = simd_quatf(angle: .pi, axis: SIMD3(0, 0, 1)) + model.orientation = zCorrection * xCorrection + outerEntity.addChild(model) + + let unscaledBounds = model.visualBounds(recursive: true, relativeTo: outerEntity) + let height = unscaledBounds.extents.y + if height.isFinite, height > 0.0001 { + model.scale *= SIMD3(repeating: 1.5 / height) + let bounds = model.visualBounds(recursive: true, relativeTo: outerEntity) + model.position += SIMD3(-bounds.center.x, -bounds.min.y, -bounds.center.z) + } + + model.generateCollisionShapes(recursive: true) + if pose == .running { + playAnimations(in: model) + } + } + + private func playAnimations(in entity: Entity) { + entity.availableAnimations.forEach { + entity.playAnimation($0.repeat()) + } + entity.children.forEach(playAnimations(in:)) + } +} diff --git a/PiggyEscape/PiggyEscapeTests/RealityHideARViewCoordinatorTests.swift b/PiggyEscape/PiggyEscapeTests/RealityHideARViewCoordinatorTests.swift new file mode 100644 index 0000000..13c603c --- /dev/null +++ b/PiggyEscape/PiggyEscapeTests/RealityHideARViewCoordinatorTests.swift @@ -0,0 +1,54 @@ +import XCTest +@testable import PiggyEscape + +@MainActor +final class RealityHideARViewCoordinatorTests: XCTestCase { + func test_coordinatorReportsUnavailableWithoutStartingARSession() { + var unavailableCount = 0 + var lastMessage: String? + let coordinator = RealityHideARView.Coordinator( + meshSupport: FakeRealityMeshSupport(supportsMeshWithClassification: false), + onUnavailable: { unavailableCount += 1 }, + onMessage: { lastMessage = $0 } + ) + + XCTAssertFalse(coordinator.canStartMeshSession) + XCTAssertFalse(coordinator.startMeshSessionIfSupported()) + XCTAssertFalse(coordinator.didStartMeshSession) + XCTAssertEqual(unavailableCount, 1) + XCTAssertEqual(lastMessage, RealityAvailabilityMessage.unavailable) + } + + func test_hideRejectionsMapToTheApprovedGuidance() { + let coordinator = RealityHideARView.Coordinator( + meshSupport: FakeRealityMeshSupport(supportsMeshWithClassification: true) + ) + + XCTAssertEqual(coordinator.message(for: .selectVerticalSide), RealityAvailabilityMessage.selectVerticalSide) + XCTAssertEqual(coordinator.message(for: .moveFartherAway), RealityAvailabilityMessage.moveFartherAway) + XCTAssertEqual(coordinator.message(for: .findFloor), RealityAvailabilityMessage.scanFirst) + } + + func test_revealIsReportedOnceOnlyAfterARealBlockingFrame() { + var revealCount = 0 + let visualController = RealityPigVisualController.makeForTesting() + let coordinator = RealityHideARView.Coordinator( + meshSupport: FakeRealityMeshSupport(supportsMeshWithClassification: true), + visualController: visualController, + onRevealed: { revealCount += 1 } + ) + + XCTAssertFalse(coordinator.processRevealFrame(meshDistance: nil, pigDistance: 2)) + XCTAssertFalse(coordinator.processRevealFrame(meshDistance: 1, pigDistance: 2)) + XCTAssertTrue(coordinator.processRevealFrame(meshDistance: nil, pigDistance: 2)) + XCTAssertFalse(coordinator.processRevealFrame(meshDistance: nil, pigDistance: 2)) + XCTAssertEqual(revealCount, 1) + XCTAssertEqual(visualController.currentPose, .surprised) + XCTAssertEqual(visualController.surprisePeakScale, 1.5, accuracy: 0.0001) + XCTAssertEqual(visualController.surpriseRestoreScale, 1.0, accuracy: 0.0001) + } +} + +private struct FakeRealityMeshSupport: RealityMeshSupporting { + let supportsMeshWithClassification: Bool +} diff --git a/PiggyEscape/PiggyEscapeTests/RealityPigVisualControllerTests.swift b/PiggyEscape/PiggyEscapeTests/RealityPigVisualControllerTests.swift new file mode 100644 index 0000000..28d76b6 --- /dev/null +++ b/PiggyEscape/PiggyEscapeTests/RealityPigVisualControllerTests.swift @@ -0,0 +1,38 @@ +import RealityKit +import XCTest +@testable import PiggyEscape + +@MainActor +final class RealityPigVisualControllerTests: XCTestCase { + func test_surpriseVisualUsesStableOuterEntityAndSceneKitScaleContract() { + let controller = RealityPigVisualController.makeForTesting() + let outerEntity = controller.outerEntity + + controller.showSurprised() + controller.playSurpriseScale() + + XCTAssertTrue(controller.outerEntity === outerEntity) + XCTAssertEqual(controller.outerEntity.name, "RealityEscapePig") + XCTAssertEqual(controller.currentPose, .surprised) + XCTAssertEqual(controller.surprisePeakScale, 1.5, accuracy: 0.0001) + XCTAssertEqual(controller.surpriseRestoreScale, 1.0, accuracy: 0.0001) + } + + func test_testingWalkMovesStableOuterEntityAndFinishesIdle() { + let controller = RealityPigVisualController.makeForTesting() + let outerEntity = controller.outerEntity + let destination = SIMD3(0.7, 0.2, -1.4) + var completed = false + + controller.walk(to: destination) { + completed = true + } + + XCTAssertTrue(controller.outerEntity === outerEntity) + XCTAssertEqual(controller.worldPosition.x, destination.x, accuracy: 0.0001) + XCTAssertEqual(controller.worldPosition.y, destination.y, accuracy: 0.0001) + XCTAssertEqual(controller.worldPosition.z, destination.z, accuracy: 0.0001) + XCTAssertEqual(controller.currentPose, .idle) + XCTAssertTrue(completed) + } +} diff --git a/docs/LEARNING_LOG.md b/docs/LEARNING_LOG.md index b3274a3..0bfecef 100644 --- a/docs/LEARNING_LOG.md +++ b/docs/LEARNING_LOG.md @@ -28,6 +28,126 @@ ## 항목 +### L-20260810-048 — Task 6 staging이 연결 작업 트리 index lock 권한으로 중단됨 + +- 상태: 해결 +- 발생 태스크: Task 6 — RealityKit 돼지 포즈와 실제 메쉬 뒤 숨기 +- 재현: Task 6 소스·테스트·`WORK_LOG`·`LEARNING_LOG`의 명시 경로만 지정한 `git add` +- 관찰: `fatal: Unable to create '.../.git/worktrees/ch1-reality-escape/index.lock': Operation not permitted`로 어떤 파일도 staging되지 않음 +- 영향: 검증된 Task 6 변경을 아직 커밋할 수 없음 +- 원인/가설: 연결 작업 트리의 Git index 메타데이터가 현재 파일 권한 범위 밖의 공용 `.git/worktrees/...`에 있어 lock 파일 생성이 거부됨 +- 조치: 동일한 명시 파일 목록을 공용 Git 메타데이터에 접근 가능한 권한으로 한 번 재실행했으며 `.claude/`와 생성물은 포함하지 않음 +- 검증: 권한 재실행 `git add`가 오류 없이 완료됐고, 후속 cached diff와 상태 검사로 정확한 staging 범위를 확인함 +- 배운 점: 연결 작업 트리의 작업 파일 쓰기와 Git index lock 쓰기는 별도 권한 경계이므로, staging 실패 뒤 일부 반영을 가정하지 않는다. + +### L-20260810-046 — Task 6 focused XCTest는 통과했지만 기존 Simulator 런타임 경고가 출력됨 + +- 상태: 보류 +- 발생 태스크: Task 6 — RealityKit 돼지 포즈와 실제 메쉬 뒤 숨기 +- 재현: Task 6 Reality visual/coordinator focused XCTest 5개 실행 +- 관찰: 5/5와 `** TEST SUCCEEDED **`를 확인했지만 `Metadata extraction skipped. No AppIntents.framework dependency found`, `TBB Global TLS count is not == 1`, `UIFocus` 캐시 제한 경고가 함께 출력됨 +- 영향: Task 6 assertion은 실패하지 않았지만 테스트 출력이 경고 없이 깨끗하다는 조건은 충족하지 못함 +- 원인/가설: 앞선 태스크에서도 반복된 앱 타깃·iOS Simulator 런타임 진단이며, 이번 RealityKit 소스의 컴파일 오류나 assertion 실패는 아님 +- 조치: Task 6 범위 밖의 AppIntents/Simulator 런타임 설정은 변경하지 않고 focused·전체 테스트 결과와 분리해 보류함 +- 검증: focused `RealityPigVisualControllerTests` 2개와 `RealityHideARViewCoordinatorTests` 3개가 모두 통과함 +- 배운 점: RealityKit 로직 회귀와 반복되는 Simulator 런타임 경고를 같은 성공 신호로 합치지 않는다. + +### L-20260810-047 — Task 6 실제 LiDAR 메쉬 탭·물리 오클루전·재발견은 실기기 대기 + +- 상태: 실기기 대기 +- 발생 태스크: Task 6 — RealityKit 돼지 포즈와 실제 메쉬 뒤 숨기 +- 재현: LiDAR 지원 iPhone/iPad에서 카메라 권한 허용 후 공간을 스캔하고 실제 물체 세로 면을 탭한 뒤 물체 반대편으로 이동해 돼지의 가림과 재발견을 관찰하는 절차 +- 관찰: 이 환경에서는 Fake capability와 수치형 mesh/pig 거리 입력으로 지원 분기·1회 재발견 계약만 자동 검증했으며, 실제 ARFrame의 mesh hit·floor classification·깊이 오클루전은 실행하지 못함 +- 영향: `sceneReconstruction`, scene-understanding 옵션, 실제 세로 면 법선, 분류된 바닥, 물리적 카메라 이동에 따른 가림 해제의 현장 동작은 아직 확정할 수 없음 +- 원인/가설: Simulator가 LiDAR 센서와 실제 공간 재구성 메쉬를 제공하지 않는 하드웨어 검증 경계임 +- 조치: actual AR session 경로는 `SystemRealityMeshSupport` guard 뒤에서만 시작하도록 두고, 실기기 절차는 Task 8의 수동 검증으로 넘김 +- 검증: 현재 자동 근거는 미지원 세션 차단, 거절 메시지, blocked→visible 1회 재발견, 놀란 포즈·1.5→1.0 확대이며 실제 기기 결과는 기록하지 않음 +- 배운 점: ARView가 빌드되고 수치 테스트가 통과해도 실제 메쉬가 돼지를 가린다는 결론은 LiDAR 실기기 관찰 전에는 내리지 않는다. + +### L-20260810-045 — RealityKit animation·scene subscription의 실제 SDK 타입 계약이 초안과 달랐음 + +- 상태: 해결 +- 발생 태스크: Task 6 — RealityKit 돼지 포즈와 실제 메쉬 뒤 숨기 +- 재현: Foundation import 보완 뒤 같은 focused RealityKit XCTest 재실행 +- 관찰: `AnimationResource`에는 `repeatingForever()`가 없고, `Scene.subscribe` 반환값 `any Cancellable`을 `AnyCancellable` 프로퍼티에 직접 대입할 수 없어 앱 컴파일이 중단됨 +- 영향: 걷기 애니메이션 반복과 frame update subscription 소유 구현이 실제 iOS 17 SDK 계약에 맞지 않음 +- 원인/가설: SDK 선언 대조 결과 `repeatingForever()`는 `AnimationDefinition`의 메서드이고, iOS 17 호환 `AnimationResource` 반복 API는 ``repeat(duration:)``임. 또한 `Scene.subscribe`는 구체 `AnyCancellable`이 아니라 `any Combine.Cancellable`을 반환함 +- 조치: 애니메이션에는 ``resource.repeat()``을 사용하고 subscription 저장 타입은 `any Cancellable` existential로 좁게 변경함 +- 검증: 동일 focused XCTest가 앱·테스트 타깃을 컴파일한 뒤 5/5와 `** TEST SUCCEEDED **`로 완료됨 +- 배운 점: 같은 이름의 RealityKit animation 타입이라도 resource와 definition의 반복 API가 다르며, SDK 26에서 iOS 17 배포 계약의 반환 타입을 실제 선언으로 확인해야 한다. + +### L-20260810-043 — Task 6 첫 GREEN 컴파일이 Dispatch 타입 import 누락으로 중단됨 + +- 상태: 해결 +- 발생 태스크: Task 6 — RealityKit 돼지 포즈와 실제 메쉬 뒤 숨기 +- 재현: Task 6 production 타입 추가 뒤 focused RealityKit XCTest 실행 +- 관찰: `RealityPigVisualController.swift:19:37: error: cannot find type 'DispatchWorkItem' in scope`로 앱 모듈 생성이 중단됨 +- 영향: 새 RealityKit 소스의 나머지 컴파일과 focused assertion을 아직 실행하지 못함 +- 원인/가설: 새 파일이 `Combine`, `RealityKit`, `simd`만 import한 상태에서 `DispatchWorkItem`, `DispatchQueue`, `TimeInterval`을 직접 사용하며, 해당 타입을 선언하는 모듈을 파일이 import하지 않은 것이 원인으로 보임 +- 조치: 기존 변경 중 해당 파일의 import 목록과 Dispatch 타입 사용 위치를 대조했고, 한 개의 명시적 `Foundation` import만 추가함 +- 검증: 동일 focused XCTest 재실행에서 `DispatchWorkItem` 오류가 사라지고 다음 실제 SDK API 계약 오류까지 컴파일이 진행됨 +- 배운 점: Swift 파일은 프레임워크의 우연한 재노출에 기대지 말고 직접 사용하는 Foundation/Dispatch 타입의 모듈을 명시한다. + +### L-20260810-044 — SDK Dispatch 선언 검색에서 존재를 가정한 glob 실패가 재발함 + +- 상태: 해결 +- 발생 태스크: Task 6 — RealityKit 돼지 포즈와 실제 메쉬 뒤 숨기 +- 재현: 두 후보 SDK 경로의 `Dispatch.swiftmodule/*.swiftinterface`를 zsh glob으로 넘긴 선언 검색 +- 관찰: 두 번째 후보 경로가 존재하지 않아 `zsh: no matches found`가 발생했고 뒤따른 diff 출력이 실행되지 않음 +- 영향: Dispatch 선언 파일 자체의 경로 확인은 중단됐지만, 컴파일 오류 위치와 현재 파일의 import/사용 대조에는 영향이 없음 +- 원인/가설: L-20260810-040과 같이 파일 존재를 먼저 확인하지 않고 복수 glob을 직접 명령 인자로 사용했음 +- 조치: SDK 내부 선언 경로 탐색을 중단하고 컴파일러 진단과 소스의 직접 import 경계를 근거로 삼음 +- 검증: `rg`에서 Task 6 소스 내 `DispatchWorkItem` 사용과 명시 import 부재를 확인함 +- 배운 점: 동일한 glob 실패를 반복하지 않도록 선택적 SDK 경로는 `find` 결과를 먼저 확보한 경우에만 검색한다. + +### L-20260810-042 — Task 6 RED가 RealityKit visual/coordinator API 부재를 확인함 + +- 상태: 해결 +- 발생 태스크: Task 6 — RealityKit 돼지 포즈와 실제 메쉬 뒤 숨기 +- 재현: 권한이 확보된 환경에서 `xcodebuild -project PiggyEscape/PiggyEscape.xcodeproj -scheme PiggyEscape -destination 'platform=iOS Simulator,name=iPhone 17 Pro' -only-testing:PiggyEscapeTests/RealityPigVisualControllerTests -only-testing:PiggyEscapeTests/RealityHideARViewCoordinatorTests test` +- 관찰: `RealityPigVisualController`와 `RealityHideARView`를 찾지 못해 테스트 타깃 컴파일이 실패하고 `** TEST FAILED **`로 종료됨 +- 영향: 안정적인 RealityKit 돼지 엔티티, 놀람 확대, 메쉬 세션 지원 분기, 숨김 후 재발견 연결이 아직 구현되지 않았음을 확인함 +- 원인/가설: Task 5는 순수 위치 계획·지원 판정만 제공했고 Task 6의 RealityKit 어댑터 타입은 아직 없음 +- 조치: 실제 에셋 비동기 로딩을 소유하는 visual controller와 수동 AR 세션·실제 메쉬 탭·재발견 update를 소유하는 ARView coordinator를 최소 구현할 예정임 +- 검증: 구현 후 같은 focused XCTest의 통과와 전체 XCTest·Simulator build를 새로 실행할 예정임 +- 배운 점: 센서 입력을 직접 자동화할 수 없어도 안정 엔티티·분기·메시지·재발견 소비 경계는 먼저 실패하는 단위 테스트로 고정할 수 있다. + +### L-20260810-041 — Task 6 RED XCTest가 Simulator 서비스 접근 제한으로 중단됨 + +- 상태: 해결 +- 발생 태스크: Task 6 — RealityKit 돼지 포즈와 실제 메쉬 뒤 숨기 +- 재현: `xcodebuild -project PiggyEscape/PiggyEscape.xcodeproj -scheme PiggyEscape -destination 'platform=iOS Simulator,name=iPhone 17 Pro' -only-testing:PiggyEscapeTests/RealityPigVisualControllerTests -only-testing:PiggyEscapeTests/RealityHideARViewCoordinatorTests test` +- 관찰: `CoreSimulatorService connection became invalid`, `Error opening log file ... Operation not permitted`, `Unable to discover any Simulator runtimes`가 발생해 새 타입 부재 컴파일 단계 전에 XCTest가 종료됨 +- 영향: RealityKit visual/coordinator의 의도적인 RED를 아직 관찰하지 못함 +- 원인/가설: Simulator 기기·로그·DerivedData 경로가 현재 파일 권한 범위 밖이어서 이전 태스크와 같은 환경 제약이 재발함 +- 조치: Simulator 서비스와 Xcode 상태 경로에 접근 가능한 권한으로 같은 focused XCTest를 한 번 재실행함 +- 검증: 권한 재실행이 새 타입 부재까지 도달해 의도적인 RED를 확인했으며 상세는 L-20260810-042에 기록함 +- 배운 점: 새 RealityKit 테스트의 RED도 환경 권한 실패와 요구 API 부재 실패를 분리해 기록해야 한다. + +### L-20260810-039 — Task 6 RED 준비의 Tuist 생성이 세션 상태 디렉터리 권한으로 중단됨 + +- 상태: 해결 +- 발생 태스크: Task 6 — RealityKit 돼지 포즈와 실제 메쉬 뒤 숨기 +- 재현: `cd PiggyEscape && tuist generate --no-open` +- 관찰: `Fatal error: Error raised at top level: Permission denied: /Users/yang-eunseo/.local/state/tuist/sessions/333F03BD-9BEA-4EA1-9A39-5C2A7A53EF26`로 새 XCTest 파일을 생성 프로젝트에 반영하기 전에 종료됨 +- 영향: 새 RealityKit visual/coordinator 테스트의 의도적인 타입 부재 RED를 아직 실행할 수 없음 +- 원인/가설: 이전 태스크와 같이 Tuist가 연결 작업 트리 밖 사용자 세션 상태 경로에 쓰기를 시도하지만 현재 권한 범위에 포함되지 않음 +- 조치: 해당 상태 경로에 접근 가능한 권한으로 같은 생성 명령을 한 번 재실행함 +- 검증: 권한 재실행 `tuist generate --no-open`가 `✔ Success`로 완료됐고, 이후 focused XCTest가 새 테스트 파일을 컴파일하며 API 부재 RED까지 도달함 +- 배운 점: 새 Task 6 테스트 파일도 생성 프로젝트에 반영하려면 Tuist 사용자 세션 상태 쓰기 권한이 필요하다. + +### L-20260810-040 — SDK 선언 검색의 ARKit glob이 일치 파일 부재로 중단됨 + +- 상태: 해결 +- 발생 태스크: Task 6 — RealityKit 돼지 포즈와 실제 메쉬 뒤 숨기 +- 재현: Simulator SDK의 `ARKit.framework/Modules/ARKit.swiftmodule/*.swiftinterface`를 zsh glob으로 넘긴 `rg` 검색 +- 관찰: `zsh: no matches found: .../ARKit.swiftmodule/*.swiftinterface`가 출력되어 해당 검색 구간이 실행되지 않음 +- 영향: `ARPlaneAnchor` 선언 위치를 첫 검색에서 확인하지 못했지만 RealityKit의 `project`, scene-understanding `hitTest`, `CollisionCastHit.normal` 계약 확인에는 영향이 없음 +- 원인/가설: 현재 Simulator SDK의 ARKit 모듈은 검색한 경로에 일반 `.swiftinterface` 파일을 노출하지 않음 +- 조치: 확인이 필요한 ARKit 타입은 컴파일러 검증과 공개 헤더/모듈 선언을 기준으로 확인하고, 존재를 가정한 glob은 사용하지 않음 +- 검증: RealityKit 및 RealityFoundation의 실제 `.swiftinterface`에서 `ARView.project`, scene-understanding `hitTest`, `CollisionCastHit.position/normal` 선언을 확인함 +- 배운 점: SDK 내부 선언 탐색은 먼저 `find`로 실제 파일 형식을 확인한 뒤 검색하며, 특정 모듈이 Swift interface를 반드시 제공한다고 가정하지 않는다. + ### L-20260810-032 — Task 5 시작 전 원격 fetch가 연결 작업 트리 메타데이터 권한으로 중단됨 - 상태: 해결 diff --git a/docs/WORK_LOG.md b/docs/WORK_LOG.md index 33fb6e0..dc3f98c 100644 --- a/docs/WORK_LOG.md +++ b/docs/WORK_LOG.md @@ -4,13 +4,13 @@ ## 현재 인수인계 -- 상태: Task 5 — 실제 물체 숨기 계획과 LiDAR 지원 판정 완료 -- 진행 중 범위: ARKit 독립 `RealitySurfaceHit`·`RealityFloor`·`RealityHidePlanner`로 세로 면, 카메라 거리, 근접 바닥을 검사해 카메라 반대편의 바닥 Y 위치를 계산한다. `RealityRevealMonitor`는 실제 메쉬 가림을 먼저 관찰한 뒤에만 한 번 재발견을 알리고, LiDAR 가능 여부는 주입 가능한 `RealityMeshSupporting` 경계로 분리했다. ARView·RealityKit 렌더링·엔티티 로딩·카메라 권한·화면 전환은 구현하지 않았다. +- 상태: Task 6 — RealityKit 돼지 포즈와 실제 메쉬 뒤 숨기 완료 +- 진행 중 범위: `RealityPigVisualController`가 실제 C3 돼지 3종 에셋을 비동기로 불러 안정적인 `RealityEscapePig` 바깥 엔티티 안에서 교체하고, 걷기·정지·놀람 포즈와 1.5→1.0 확대를 담당한다. `RealityHideARView`는 지원 기기에서만 수동 AR 세션을 시작하고, LiDAR 메쉬 세로 면 탭·분류된 수평 바닥·Task 5 숨기 계획을 연결해 물체 반대편으로 이동한 뒤 실제 메쉬 선행 충돌이 사라질 때 한 번만 재발견을 알린다. 카메라 권한·SceneKit 페이드·루트 화면 전환·1.12 화면 확대는 Task 7 범위로 아직 구현하지 않았다. - 실패 기록: `docs/LEARNING_LOG.md`에 실패·검증 한계의 재현 조건·원인/가설·조치·재발 방지 근거를 기록한다. -- 마지막 완료 범위: Task 5 — 실제 물체 숨기 계획과 LiDAR 지원 판정. -- 마지막 검증: `cd PiggyEscape && tuist generate --no-open` 성공. focused `xcodebuild -project PiggyEscape.xcodeproj -scheme PiggyEscape -destination 'platform=iOS Simulator,name=iPhone 17 Pro' -only-testing:PiggyEscapeTests/RealityHidePlannerTests -only-testing:PiggyEscapeTests/RealityCapabilityTests test`가 8/8을 통과했고, 전체 같은 destination의 `xcodebuild ... test`가 58/58을 통과했다. `xcodebuild ... build`도 성공했다. Simulator/AppIntents 런타임 경고와 실제 LiDAR·물리 오클루전 실기기 한계는 `docs/LEARNING_LOG.md`의 L-20260810-036~037에 기록했다. -- 다음 시작점: 구현 계획의 Task 6 — RealityKit 돼지 포즈와 실제 메쉬 뒤 숨기. 이 Task 5에서는 시작하지 않았다. -- 차단 요소: 실제 LiDAR 메쉬 지원·물리 오클루전의 실기기 확인은 L-20260810-037에 보류했다. 원격 fetch와 Tuist/Simulator 권한·작업 경로 실패의 재현 및 조치는 L-20260810-032~036에 기록했다. +- 마지막 완료 범위: Task 6 — RealityKit 돼지 포즈와 실제 메쉬 뒤 숨기. +- 마지막 검증: `cd PiggyEscape && tuist generate --no-open` 성공. focused `xcodebuild -project PiggyEscape/PiggyEscape.xcodeproj -scheme PiggyEscape -destination 'platform=iOS Simulator,name=iPhone 17 Pro' -only-testing:PiggyEscapeTests/RealityPigVisualControllerTests -only-testing:PiggyEscapeTests/RealityHideARViewCoordinatorTests test`가 5/5, 전체 같은 destination의 `xcodebuild ... test`가 63/63을 통과했다. 같은 destination의 `xcodebuild ... build`도 `** BUILD SUCCEEDED **`로 완료됐다. Simulator/AppIntents 런타임 경고와 실제 LiDAR·물리 오클루전 실기기 한계는 `docs/LEARNING_LOG.md`의 L-20260810-046~047에 기록했다. +- 다음 시작점: 구현 계획의 Task 7 — 자동 SceneKit→RealityKit 전환, 카메라 권한, C3 스타일 안내와 1.12 화면 확대. 이 Task 6에서는 시작하지 않았다. +- 차단 요소: 실제 LiDAR 메쉬 세로 면 탭·분류된 바닥·물리 오클루전·카메라 이동 재발견은 L-20260810-047에 `실기기 대기`로 남겼다. Task 6 구현 중 해결한 환경·SDK 타입 실패는 L-20260810-039~045에 기록했다. ### Task 7에서 해결한 항목 @@ -22,6 +22,7 @@ | 날짜 | 작업 범위 | 결과 | 검증 | 다음 시작점 | | --- | --- | --- | --- | --- | +| 2026-08-10 | Task 6 — RealityKit 돼지 포즈와 실제 메쉬 뒤 숨기 | 실제 C3 돼지 3종의 비동기 포즈 교체·높이 정규화·걷기·1.5→1.0 놀람 확대를 안정 바깥 엔티티에 구현하고, LiDAR 지원 guard·수동 AR 세션·scene understanding 4종 옵션·실제 메쉬 세로 면 탭·분류된 바닥·숨기 이동·blocked→visible 1회 재발견을 ARView에 연결함. 카메라/렌즈 transform은 변경하지 않음 | RED에서 두 Task 6 타입 부재를 확인한 뒤 focused 5/5·전체 63/63 XCTest와 iOS Simulator build 성공. 환경·SDK 실패는 L-20260810-039~046, 실제 LiDAR 검증은 L-20260810-047 `실기기 대기` | Task 7 — 자동 SceneKit→RealityKit 전환과 C3 스타일 안내 | | 2026-08-10 | Task 5 — 실제 물체 숨기 계획과 LiDAR 지원 판정 | 세로 면·카메라 거리·근접 바닥을 검증해 카메라 반대편의 정확한 바닥 Y 숨기 위치를 계산하는 순수 계획, 0.03m 허용오차의 1회 재발견 감시, ARKit 호출을 시스템 구현 하나에 한정한 LiDAR 지원 주입 경계와 안내 문자열을 추가함. ARView·RealityKit 렌더링·권한·UI 전환은 추가하지 않음 | RED에서 Reality 계획·지원 타입 부재를 확인한 뒤 focused 8/8·전체 58/58 XCTest 및 iOS Simulator build 성공. 기존 Simulator/AppIntents 경고와 실제 LiDAR·물리 오클루전 실기기 한계는 L-20260810-036~037에 기록 | Task 6 — RealityKit 돼지 포즈와 실제 메쉬 뒤 숨기 | | 2026-08-10 | Task 4 — C3 SceneKit 나레이션·탭·가짜 숨기·카메라 발견 | `NarrationOverlayScene`의 C3 스타일 자막, 실제 `HideTree`가 필수인 나무 뒤 달리기, 0.70rad wrapped yaw·실제 `SCNView` 프러스텀·1회 가드 발견, `Piggy_surprised` 1.5→1.0 확대를 추가하고 C3 제스처 뷰를 앱 진입점에 연결함. 리뷰 보완으로 확대 action을 외부 콜백보다 먼저 등록하고, world callback의 Coordinator 캡처를 약하게 바꾸며 도착 전·frustum false·yaw 경계·1회 가드를 회귀 테스트로 확장함 | 리뷰 RED 2건 뒤 focused 12/12·전체 50/50 XCTest 및 iOS Simulator build 성공. 기존 런타임 경고와 수동 시각 확인 한계는 L-20260810-029~030, 리뷰 수정 근거는 L-20260810-031에 기록 | Task 5 — C3 발견 뒤 카메라 권한 전환 | | 2026-08-10 | Task 3 — C3 섬·돼지 모델·궤도 카메라 어댑터 | `C3Island`의 7개 `Ground_Color` 타일과 C3 장식 배치, 실제 `HideTree`·`BigPigSpawn`, `EscapePig`의 idle/running/surprised 내부 USD 보정·정규화, C3 궤도 카메라·그라데이션·조명을 추가함 | RED에서 `C3ClosedWorld` 부재 컴파일 실패와 내부 USD 보정 소유 노드 오류를 확인한 뒤 focused 8/8·전체 38/38 XCTest 통과. Simulator/AppIntents 런타임 경고는 L-20260810-016에 기록 | Task 4 — C3 씬 뷰와 숨김 상호작용 | From 5c342268aea5316ffc7b778595755c116ce675c8 Mon Sep 17 00:00:00 2001 From: eunseo-com Date: Tue, 11 Aug 2026 00:18:37 +0900 Subject: [PATCH 13/22] Fix RealityKit reveal state boundaries --- .../Sources/Reality/RealityHideARView.swift | 135 ++++++++++++++++-- .../Reality/RealityPigVisualController.swift | 59 +++++--- .../RealityHideARViewCoordinatorTests.swift | 127 +++++++++++++++- .../RealityPigVisualControllerTests.swift | 24 ++++ docs/LEARNING_LOG.md | 71 +++++++++ docs/WORK_LOG.md | 5 +- 6 files changed, 378 insertions(+), 43 deletions(-) diff --git a/PiggyEscape/PiggyEscape/Sources/Reality/RealityHideARView.swift b/PiggyEscape/PiggyEscape/Sources/Reality/RealityHideARView.swift index 678466a..fb77149 100644 --- a/PiggyEscape/PiggyEscape/Sources/Reality/RealityHideARView.swift +++ b/PiggyEscape/PiggyEscape/Sources/Reality/RealityHideARView.swift @@ -3,9 +3,66 @@ import Combine import RealityKit import SwiftUI +enum RealityProjectionGate { + static func canObserve( + projectedPoint: CGPoint?, + viewportBounds: CGRect, + pigPosition: SIMD3, + cameraTransform: simd_float4x4 + ) -> Bool { + guard let projectedPoint, + !viewportBounds.isEmpty, + viewportBounds.contains(projectedPoint) else { + return false + } + + let cameraPosition = SIMD3( + cameraTransform.columns.3.x, + cameraTransform.columns.3.y, + cameraTransform.columns.3.z + ) + let cameraForward = -SIMD3( + cameraTransform.columns.2.x, + cameraTransform.columns.2.y, + cameraTransform.columns.2.z + ) + return simd_dot(cameraForward, pigPosition - cameraPosition) > 0 + } +} + +enum RealityInitialPigPlacement { + static func position( + cameraPosition: SIMD3, + cameraForward: SIMD3, + floorY: Float + ) -> SIMD3? { + guard cameraPosition.x.isFinite, + cameraPosition.y.isFinite, + cameraPosition.z.isFinite, + cameraForward.x.isFinite, + cameraForward.y.isFinite, + cameraForward.z.isFinite, + floorY.isFinite, + floorY < cameraPosition.y - 0.2 else { + return nil + } + + let horizontalForward = SIMD3(cameraForward.x, 0, cameraForward.z) + let length = simd_length(horizontalForward) + guard length > 0.0001 else { return nil } + let direction = horizontalForward / length + return SIMD3( + cameraPosition.x + direction.x * 0.8, + floorY, + cameraPosition.z + direction.z * 0.8 + ) + } +} + struct RealityHideARView: UIViewRepresentable { let onScanningReady: () -> Void let onTargetAccepted: () -> Void + let onPigReachedTarget: () -> Void let onRevealed: () -> Void let onUnavailable: () -> Void let onMessage: (String) -> Void @@ -13,12 +70,14 @@ struct RealityHideARView: UIViewRepresentable { init( onScanningReady: @escaping () -> Void, onTargetAccepted: @escaping () -> Void, + onPigReachedTarget: @escaping () -> Void, onRevealed: @escaping () -> Void, onUnavailable: @escaping () -> Void, onMessage: @escaping (String) -> Void ) { self.onScanningReady = onScanningReady self.onTargetAccepted = onTargetAccepted + self.onPigReachedTarget = onPigReachedTarget self.onRevealed = onRevealed self.onUnavailable = onUnavailable self.onMessage = onMessage @@ -29,6 +88,7 @@ struct RealityHideARView: UIViewRepresentable { meshSupport: SystemRealityMeshSupport(), onScanningReady: onScanningReady, onTargetAccepted: onTargetAccepted, + onPigReachedTarget: onPigReachedTarget, onRevealed: onRevealed, onUnavailable: onUnavailable, onMessage: onMessage @@ -53,6 +113,7 @@ struct RealityHideARView: UIViewRepresentable { case waitingForTarget case walking case hidden + case revealing case revealed } @@ -60,6 +121,7 @@ struct RealityHideARView: UIViewRepresentable { private let visualController: RealityPigVisualController private let onScanningReady: () -> Void private let onTargetAccepted: () -> Void + private let onPigReachedTarget: () -> Void private let onRevealed: () -> Void private let onUnavailable: () -> Void private let onMessage: (String) -> Void @@ -81,6 +143,7 @@ struct RealityHideARView: UIViewRepresentable { visualController: RealityPigVisualController? = nil, onScanningReady: @escaping () -> Void = {}, onTargetAccepted: @escaping () -> Void = {}, + onPigReachedTarget: @escaping () -> Void = {}, onRevealed: @escaping () -> Void = {}, onUnavailable: @escaping () -> Void = {}, onMessage: @escaping (String) -> Void = { _ in } @@ -89,6 +152,7 @@ struct RealityHideARView: UIViewRepresentable { self.visualController = visualController ?? RealityPigVisualController() self.onScanningReady = onScanningReady self.onTargetAccepted = onTargetAccepted + self.onPigReachedTarget = onPigReachedTarget self.onRevealed = onRevealed self.onUnavailable = onUnavailable self.onMessage = onMessage @@ -132,21 +196,45 @@ struct RealityHideARView: UIViewRepresentable { } @discardableResult - func processRevealFrame(meshDistance: Float?, pigDistance: Float) -> Bool { - guard hideState != .revealed, + func processRevealFrame( + isObservationValid: Bool, + meshDistance: Float?, + pigDistance: Float + ) -> Bool { + guard isObservationValid, + hideState == .hidden, revealMonitor.update(meshDistance: meshDistance, pigDistance: pigDistance) else { return false } - hideState = .revealed + hideState = .revealing revealSubscription?.cancel() revealSubscription = nil - visualController.showSurprised() - visualController.playSurpriseScale() - onRevealed() + visualController.showSurprised { [weak self] in + guard let self, self.hideState == .revealing else { return } + self.visualController.playSurpriseScale() + self.hideState = .revealed + self.onRevealed() + } return true } + func acceptHideTarget(destination: SIMD3, initialPosition: SIMD3) { + guard hideState == .waitingForTarget else { return } + hideState = .walking + visualController.outerEntity.setPosition(initialPosition, relativeTo: nil) + visualController.outerEntity.isEnabled = true + onTargetAccepted() + visualController.walk(to: destination) { [weak self] in + guard let self else { return } + self.hideState = .hidden + self.onPigReachedTarget() + if let arView = self.arView { + self.beginRevealMonitoring(in: arView) + } + } + } + func stop() { revealSubscription?.cancel() revealSubscription = nil @@ -209,13 +297,20 @@ struct RealityHideARView: UIViewRepresentable { case let .rejected(rejection): onMessage(message(for: rejection)) case let .accepted(destination): - hideState = .walking - onTargetAccepted() - visualController.walk(to: destination) { [weak self, weak arView] in - guard let self, let arView else { return } - self.hideState = .hidden - self.beginRevealMonitoring(in: arView) + let cameraForward = -SIMD3( + frame.camera.transform.columns.2.x, + frame.camera.transform.columns.2.y, + frame.camera.transform.columns.2.z + ) + guard let initialPosition = RealityInitialPigPlacement.position( + cameraPosition: cameraPosition, + cameraForward: cameraForward, + floorY: destination.y + ) else { + onMessage(RealityAvailabilityMessage.scanFirst) + return } + acceptHideTarget(destination: destination, initialPosition: initialPosition) } } @@ -246,8 +341,14 @@ struct RealityHideARView: UIViewRepresentable { private func evaluateReveal(in arView: ARView) { let pigPosition = visualController.worldPosition - guard let screenPoint = arView.project(pigPosition), - let cameraTransform = arView.session.currentFrame?.camera.transform else { return } + guard let cameraTransform = arView.session.currentFrame?.camera.transform else { return } + let screenPoint = arView.project(pigPosition) + guard RealityProjectionGate.canObserve( + projectedPoint: screenPoint, + viewportBounds: arView.bounds, + pigPosition: pigPosition, + cameraTransform: cameraTransform + ), let screenPoint else { return } let cameraPosition = Self.position(from: cameraTransform) let meshDistance = arView.hitTest( @@ -256,7 +357,11 @@ struct RealityHideARView: UIViewRepresentable { mask: .sceneUnderstanding ).first.map { simd_distance(cameraPosition, $0.position) } let pigDistance = simd_distance(cameraPosition, pigPosition) - processRevealFrame(meshDistance: meshDistance, pigDistance: pigDistance) + processRevealFrame( + isObservationValid: true, + meshDistance: meshDistance, + pigDistance: pigDistance + ) } private static func position(from transform: simd_float4x4) -> SIMD3 { diff --git a/PiggyEscape/PiggyEscape/Sources/Reality/RealityPigVisualController.swift b/PiggyEscape/PiggyEscape/Sources/Reality/RealityPigVisualController.swift index cc78327..e2f2e32 100644 --- a/PiggyEscape/PiggyEscape/Sources/Reality/RealityPigVisualController.swift +++ b/PiggyEscape/PiggyEscape/Sources/Reality/RealityPigVisualController.swift @@ -5,6 +5,8 @@ import simd @MainActor final class RealityPigVisualController { + typealias EntityLoader = (String, @escaping (Result) -> Void) -> AnyCancellable? + let outerEntity: Entity private(set) var currentPose: C3PigPose = .idle @@ -16,6 +18,7 @@ final class RealityPigVisualController { } private let skipsAssetLoadingAndTiming: Bool + private let entityLoader: EntityLoader private var modelLoad: AnyCancellable? private var movementCompletion: DispatchWorkItem? @@ -23,18 +26,27 @@ final class RealityPigVisualController { outerEntity = Entity() outerEntity.name = "RealityEscapePig" outerEntity.orientation = simd_quatf(angle: 3 * .pi / 4, axis: SIMD3(0, 1, 0)) + outerEntity.isEnabled = false skipsAssetLoadingAndTiming = false + entityLoader = Self.loadEntity } - private init(testing: Bool) { + private init(testing: Bool, entityLoader: @escaping EntityLoader) { outerEntity = Entity() outerEntity.name = "RealityEscapePig" outerEntity.orientation = simd_quatf(angle: 3 * .pi / 4, axis: SIMD3(0, 1, 0)) + outerEntity.isEnabled = false skipsAssetLoadingAndTiming = testing + self.entityLoader = entityLoader } - static func makeForTesting() -> RealityPigVisualController { - RealityPigVisualController(testing: true) + static func makeForTesting( + entityLoader: @escaping EntityLoader = { _, completion in + completion(.success(Entity())) + return nil + } + ) -> RealityPigVisualController { + RealityPigVisualController(testing: true, entityLoader: entityLoader) } func loadIdlePig() { @@ -73,9 +85,9 @@ final class RealityPigVisualController { } } - func showSurprised() { + func showSurprised(completion: @escaping () -> Void = {}) { movementCompletion?.cancel() - setPose(.surprised) + setPose(.surprised, completion: completion) } func playSurpriseScale() { @@ -113,25 +125,13 @@ final class RealityPigVisualController { private func setPose(_ pose: C3PigPose, completion: (() -> Void)? = nil) { currentPose = pose - guard !skipsAssetLoadingAndTiming else { + modelLoad?.cancel() + modelLoad = entityLoader(assetName(for: pose)) { [weak self] result in + guard let self, self.currentPose == pose else { return } + guard case let .success(entity) = result else { return } + self.install(entity, for: pose) completion?() - return } - - modelLoad?.cancel() - modelLoad = Entity.loadAsync(named: assetName(for: pose), in: .main) - .sink( - receiveCompletion: { result in - if case .failure = result { - completion?() - } - }, - receiveValue: { [weak self] entity in - guard let self, self.currentPose == pose else { return } - self.install(entity, for: pose) - completion?() - } - ) } private func assetName(for pose: C3PigPose) -> String { @@ -169,4 +169,19 @@ final class RealityPigVisualController { } entity.children.forEach(playAnimations(in:)) } + + private static func loadEntity( + named assetName: String, + completion: @escaping (Result) -> Void + ) -> AnyCancellable? { + Entity.loadAsync(named: assetName, in: .main) + .sink( + receiveCompletion: { result in + if case let .failure(error) = result { + completion(.failure(error)) + } + }, + receiveValue: { completion(.success($0)) } + ) + } } diff --git a/PiggyEscape/PiggyEscapeTests/RealityHideARViewCoordinatorTests.swift b/PiggyEscape/PiggyEscapeTests/RealityHideARViewCoordinatorTests.swift index 13c603c..cf13e5f 100644 --- a/PiggyEscape/PiggyEscapeTests/RealityHideARViewCoordinatorTests.swift +++ b/PiggyEscape/PiggyEscapeTests/RealityHideARViewCoordinatorTests.swift @@ -1,4 +1,6 @@ +import RealityKit import XCTest +import simd @testable import PiggyEscape @MainActor @@ -37,16 +39,133 @@ final class RealityHideARViewCoordinatorTests: XCTestCase { visualController: visualController, onRevealed: { revealCount += 1 } ) + coordinator.acceptHideTarget(destination: SIMD3(0, 0, -2), initialPosition: SIMD3(0, 0, -0.8)) - XCTAssertFalse(coordinator.processRevealFrame(meshDistance: nil, pigDistance: 2)) - XCTAssertFalse(coordinator.processRevealFrame(meshDistance: 1, pigDistance: 2)) - XCTAssertTrue(coordinator.processRevealFrame(meshDistance: nil, pigDistance: 2)) - XCTAssertFalse(coordinator.processRevealFrame(meshDistance: nil, pigDistance: 2)) + XCTAssertFalse(coordinator.processRevealFrame(isObservationValid: true, meshDistance: nil, pigDistance: 2)) + XCTAssertFalse(coordinator.processRevealFrame(isObservationValid: true, meshDistance: 1, pigDistance: 2)) + XCTAssertTrue(coordinator.processRevealFrame(isObservationValid: true, meshDistance: nil, pigDistance: 2)) + XCTAssertFalse(coordinator.processRevealFrame(isObservationValid: true, meshDistance: nil, pigDistance: 2)) XCTAssertEqual(revealCount, 1) XCTAssertEqual(visualController.currentPose, .surprised) XCTAssertEqual(visualController.surprisePeakScale, 1.5, accuracy: 0.0001) XCTAssertEqual(visualController.surpriseRestoreScale, 1.0, accuracy: 0.0001) } + + func test_invalidProjectionDoesNotConsumeVisibleFrameAfterBlock() { + let coordinator = RealityHideARView.Coordinator( + meshSupport: FakeRealityMeshSupport(supportsMeshWithClassification: true), + visualController: RealityPigVisualController.makeForTesting() + ) + coordinator.acceptHideTarget(destination: SIMD3(0, 0, -2), initialPosition: SIMD3(0, 0, -0.8)) + + XCTAssertFalse(coordinator.processRevealFrame(isObservationValid: true, meshDistance: 1, pigDistance: 2)) + XCTAssertFalse(coordinator.processRevealFrame(isObservationValid: false, meshDistance: nil, pigDistance: 2)) + XCTAssertTrue(coordinator.processRevealFrame(isObservationValid: true, meshDistance: nil, pigDistance: 2)) + } + + func test_projectionGateRequiresOnscreenPointAndPigInFrontOfCamera() { + let bounds = CGRect(x: 0, y: 0, width: 200, height: 400) + let cameraTransform = matrix_identity_float4x4 + + XCTAssertTrue(RealityProjectionGate.canObserve( + projectedPoint: CGPoint(x: 100, y: 200), + viewportBounds: bounds, + pigPosition: SIMD3(0, 0, -2), + cameraTransform: cameraTransform + )) + XCTAssertFalse(RealityProjectionGate.canObserve( + projectedPoint: CGPoint(x: 250, y: 200), + viewportBounds: bounds, + pigPosition: SIMD3(0, 0, -2), + cameraTransform: cameraTransform + )) + XCTAssertFalse(RealityProjectionGate.canObserve( + projectedPoint: CGPoint(x: 100, y: 200), + viewportBounds: bounds, + pigPosition: SIMD3(0, 0, 2), + cameraTransform: cameraTransform + )) + XCTAssertFalse(RealityProjectionGate.canObserve( + projectedPoint: nil, + viewportBounds: bounds, + pigPosition: SIMD3(0, 0, -2), + cameraTransform: cameraTransform + )) + } + + func test_targetAcceptedAndReachedCallbacksAreSeparatedByMovementCompletion() { + typealias Completion = (Result) -> Void + var pendingLoads: [(asset: String, completion: Completion)] = [] + let visualController = RealityPigVisualController.makeForTesting { asset, completion in + pendingLoads.append((asset, completion)) + return nil + } + var events: [String] = [] + var revealCount = 0 + let coordinator = RealityHideARView.Coordinator( + meshSupport: FakeRealityMeshSupport(supportsMeshWithClassification: true), + visualController: visualController, + onTargetAccepted: { events.append("accepted") }, + onPigReachedTarget: { events.append("reached") }, + onRevealed: { revealCount += 1 } + ) + + XCTAssertFalse(visualController.outerEntity.isEnabled) + coordinator.acceptHideTarget( + destination: SIMD3(0.6, 0, -1.7), + initialPosition: SIMD3(0, 0, -0.8) + ) + + XCTAssertTrue(visualController.outerEntity.isEnabled) + XCTAssertEqual(visualController.worldPosition.y, 0, accuracy: 0.0001) + XCTAssertEqual(events, ["accepted"]) + XCTAssertFalse(coordinator.processRevealFrame(isObservationValid: true, meshDistance: 0.5, pigDistance: 2)) + XCTAssertFalse(coordinator.processRevealFrame(isObservationValid: true, meshDistance: nil, pigDistance: 2)) + XCTAssertEqual(revealCount, 0) + + let runningLoad = pendingLoads.removeFirst() + XCTAssertEqual(runningLoad.asset, "Piggy_running") + runningLoad.completion(.success(Entity())) + XCTAssertEqual(events, ["accepted"]) + + let idleLoad = pendingLoads.removeFirst() + XCTAssertEqual(idleLoad.asset, "Piggy") + idleLoad.completion(.success(Entity())) + XCTAssertEqual(events, ["accepted", "reached"]) + + XCTAssertFalse(coordinator.processRevealFrame(isObservationValid: true, meshDistance: 0.5, pigDistance: 2)) + XCTAssertTrue(coordinator.processRevealFrame(isObservationValid: true, meshDistance: nil, pigDistance: 2)) + XCTAssertEqual(revealCount, 0) + let surprisedLoad = pendingLoads.removeFirst() + XCTAssertEqual(surprisedLoad.asset, "Piggy_surprised") + surprisedLoad.completion(.success(Entity())) + XCTAssertEqual(revealCount, 1) + } + + func test_initialPlacementUsesFloorAndCameraForwardOnlyWhenDefensible() { + let position = RealityInitialPigPlacement.position( + cameraPosition: SIMD3(0, 1.5, 0), + cameraForward: SIMD3(0, 0, -1), + floorY: 0 + ) + + guard let position else { + return XCTFail("expected a floor-anchored initial position") + } + XCTAssertEqual(position.x, 0, accuracy: 0.0001) + XCTAssertEqual(position.y, 0, accuracy: 0.0001) + XCTAssertEqual(position.z, -0.8, accuracy: 0.0001) + XCTAssertNil(RealityInitialPigPlacement.position( + cameraPosition: SIMD3(0, 1.5, 0), + cameraForward: SIMD3(0, 1, 0), + floorY: 0 + )) + XCTAssertNil(RealityInitialPigPlacement.position( + cameraPosition: SIMD3(0, 1.5, 0), + cameraForward: SIMD3(0, 0, -1), + floorY: 1.5 + )) + } } private struct FakeRealityMeshSupport: RealityMeshSupporting { diff --git a/PiggyEscape/PiggyEscapeTests/RealityPigVisualControllerTests.swift b/PiggyEscape/PiggyEscapeTests/RealityPigVisualControllerTests.swift index 28d76b6..bcbb6c5 100644 --- a/PiggyEscape/PiggyEscapeTests/RealityPigVisualControllerTests.swift +++ b/PiggyEscape/PiggyEscapeTests/RealityPigVisualControllerTests.swift @@ -18,6 +18,30 @@ final class RealityPigVisualControllerTests: XCTestCase { XCTAssertEqual(controller.surpriseRestoreScale, 1.0, accuracy: 0.0001) } + func test_surpriseCompletionWaitsUntilTheSurprisedModelIsInstalled() { + var requestedAsset: String? + var finishLoading: ((Result) -> Void)? + let controller = RealityPigVisualController.makeForTesting { asset, completion in + requestedAsset = asset + finishLoading = completion + return nil + } + var completed = false + + controller.showSurprised { + completed = true + } + + XCTAssertEqual(requestedAsset, "Piggy_surprised") + XCTAssertFalse(completed) + XCTAssertNil(controller.outerEntity.findEntity(named: "RealityPigModel_surprised")) + + finishLoading?(.success(Entity())) + + XCTAssertTrue(completed) + XCTAssertNotNil(controller.outerEntity.findEntity(named: "RealityPigModel_surprised")) + } + func test_testingWalkMovesStableOuterEntityAndFinishesIdle() { let controller = RealityPigVisualController.makeForTesting() let outerEntity = controller.outerEntity diff --git a/docs/LEARNING_LOG.md b/docs/LEARNING_LOG.md index 0bfecef..ef561b1 100644 --- a/docs/LEARNING_LOG.md +++ b/docs/LEARNING_LOG.md @@ -28,6 +28,66 @@ ## 항목 +### L-20260811-054 — AR 진입 직후 돼지가 월드 원점에 활성화되는 배치 계약이 없음 + +- 상태: 해결 +- 발생 태스크: Task 6 독립 리뷰 수정 1차 — 유효 바닥 기반 초기 배치 +- 재현: 돼지 시각 컨트롤러 생성 직후 `outerEntity.isEnabled`와 `RealityHideARView.attach`의 월드 원점 anchor 연결 순서를 검사하고, 카메라 전방·바닥 높이 입력의 순수 배치 테스트를 추가함 +- 관찰: 기존 엔티티는 활성 상태였고 바닥을 찾기 전 `(0, 0, 0)` anchor에 붙어 카메라 원점 부근에 보일 수 있었으며, 유효한 시작 위치를 계산하는 규칙이 없었음 +- 영향: AR 진입 직후 돼지가 눈앞이나 잘못된 높이에 나타나 현실 공간 전환의 연속성이 깨질 수 있었음 +- 원인/가설: 최종 숨기 목적지는 계획했지만 AR에서 돼지를 처음 표시할 시점과 시작 위치를 별도 상태로 설계하지 않은 것이 원인임 +- 조치: 돼지 엔티티를 기본 비활성화하고, 바닥이 카메라보다 충분히 낮고 수평 카메라 전방 벡터가 유효할 때만 바닥 Y의 전방 0.8m 위치를 만들며, 타깃 수락 뒤 그 위치에서 활성화하도록 함 +- 검증: `RealityHideARViewCoordinatorTests.test_initialPlacementUsesFloorAndCameraForwardOnlyWhenDefensible`와 타깃 이동 테스트를 포함한 coordinator focused XCTest 7/7 통과 +- 배운 점: AR 콘텐츠는 anchor 생성과 표시를 같은 사건으로 취급하지 말고, 센서로 방어 가능한 배치가 확보될 때까지 비활성 상태를 유지해야 한다. + +### L-20260811-053 — 실제 타깃 선택과 돼지 도착을 하나의 이벤트로 축약함 + +- 상태: 해결 +- 발생 태스크: Task 6 독립 리뷰 수정 1차 — 실제 숨기 이동 완료 경계 +- 재현: Task 2 상태 전이와 `RealityHideARView.handleTap`의 수락·이동 completion 호출을 대조하고, 지연 모델 로더로 이동 중 발견 입력과 callback 순서를 검사함 +- 관찰: 기존 공개 계약에는 `onTargetAccepted`만 있고 `onPigReachedTarget`이 없어 `.walkingBehindRealObject`에서 `.hiddenInReality`로 전이할 실제 완료 신호가 없었음 +- 영향: 상위 화면이 선택 직후 숨김 완료로 오인하거나 돼지 도착 전에 발견 상태로 넘어갈 수 있었음 +- 원인/가설: 사용자 입력 수락과 비동기 이동·idle 포즈 설치 완료를 하나의 콜백으로 축약한 것이 원인임 +- 조치: `onPigReachedTarget`을 분리하고, running 로드·이동·idle 설치 완료 뒤에만 호출하며 reveal monitor도 `.hidden` 상태에서만 입력을 소비하도록 함 +- 검증: `RealityHideARViewCoordinatorTests.test_targetAcceptedAndReachedCallbacksAreSeparatedByMovementCompletion`이 `accepted → reached → revealed` 순서와 도착 전 발견 불가를 확인했고 coordinator focused XCTest 7/7 통과 +- 배운 점: 입력 수락, 애니메이션 도착, 발견은 각각 별도 도메인 이벤트여야 상태 기계와 실제 화면이 같은 순서를 유지한다. + +### L-20260811-052 — 놀람 콜백이 비동기 모델 설치보다 먼저 실행됨 + +- 상태: 해결 +- 발생 태스크: Task 6 독립 리뷰 수정 1차 — 놀람 포즈 완료 경계 +- 재현: 완료를 수동으로 방출하는 entity loader를 주입해 `showSurprised` 호출 직후와 로드 성공 뒤의 자식 엔티티·callback 상태를 비교함 +- 관찰: 기존 `showSurprised()`는 비동기 로드를 시작한 직후 coordinator가 확대와 `onRevealed`를 실행해 idle/running 모델이 확대될 수 있었음 +- 영향: “들켰다” 자막·상태가 놀란 돼지 모델보다 먼저 나타나 시각 연출 순서가 깨질 수 있었음 +- 원인/가설: pose 요청 상태와 모델 설치 완료 상태를 구분하지 않고 호출 반환을 완료로 간주한 것이 원인임 +- 조치: entity loader를 완료 경계로 추상화하고, 놀란 모델을 안정 바깥 엔티티에 실제 설치한 뒤에만 completion을 호출하며 coordinator의 확대·발견 콜백을 그 completion 안으로 옮김 +- 검증: `RealityPigVisualControllerTests.test_surpriseCompletionWaitsUntilTheSurprisedModelIsInstalled`를 포함한 visual focused XCTest 3/3 통과 +- 배운 점: 비동기 에셋 전환의 도메인 이벤트는 요청 시점이 아니라 화면 트리에 새 모델이 설치된 시점에 발생해야 한다. + +### L-20260811-050 — Task 6 독립 검토가 AR 재발견·포즈·상태·초기 배치 경계 결함 4건을 확인함 + +- 상태: 해결 +- 발생 태스크: Task 6 — RealityKit 돼지 포즈와 실제 메쉬 뒤 숨기 리뷰 수정 +- 재현: `RealityHideARView.evaluateReveal`, `processRevealFrame`, 실제 타깃 수락 분기, `attach`, `RealityPigVisualController.showSurprised`의 호출 순서와 상태 입력을 추적함 +- 관찰: (1) 투영점의 bounds·카메라 전방 검증 없이 mesh hit `nil`을 visible로 소비함, (2) 놀란 에셋 비동기 로드 완료 전 scale·`onRevealed`를 호출함, (3) 선택 수락 콜백만 있고 돼지 도착 콜백이 없음, (4) 유효한 바닥을 찾기 전에 1.5m 돼지를 활성 상태로 AR 원점에 붙임 +- 영향: 화면 밖/카메라 뒤 돼지가 발견될 수 있고, idle/running 모델에 놀람 확대가 적용될 수 있으며, Task 2의 `realTargetAccepted → pigReachedRealObject → realityPigDiscovered` 순서를 루트가 표현할 수 없고, AR 진입 직후 돼지가 카메라 원점·눈높이에 나타날 수 있음 +- 원인/가설: 센서 입력을 테스트 가능한 관찰/배치 규칙으로 추출하지 않았고, 비동기 모델 설치와 외부 이벤트 콜백을 하나의 완료 경계로 묶지 않았으며, 선택과 도착을 같은 콜백으로 축약한 것이 확인된 원인임 +- 조치: 네 경계를 각각 실패 테스트로 고정한 뒤 화면 유효성 gate, 포즈 설치 completion, 별도 도착 callback, 유효 계획 이후 floor-Y 전방 시작 위치와 pending-hidden 규칙을 구현함 +- 검증: 원인별 focused GREEN은 L-20260811-051~054에 기록했으며, 수정 뒤 전체 XCTest 68/68과 iOS Simulator build가 성공함 +- 배운 점: AR 화면의 `nil hit`은 보임을 뜻하지 않고 유효 관찰 문맥 안에서만 의미가 있으며, 비동기 시각 상태와 경험 상태 이벤트는 실제 완료 순간을 공유해야 한다. + +### L-20260811-049 — Task 6 리뷰 수정 시작 전 원격 fetch가 worktree 메타데이터 권한으로 중단됨 + +- 상태: 해결 +- 발생 태스크: Task 6 — RealityKit 돼지 포즈와 실제 메쉬 뒤 숨기 리뷰 수정 +- 재현: `git fetch --prune origin` +- 관찰: `error: cannot open '.../.git/worktrees/ch1-reality-escape/FETCH_HEAD': Operation not permitted`로 원격 참조 갱신 전에 종료됨 +- 영향: 리뷰 수정 전 원격 변경 유무를 현재 권한으로 확정하지 못함 +- 원인/가설: 이전 L-20260810-020·032와 같은 연결 작업 트리 공용 Git 메타데이터 쓰기 권한 경계가 재발함 +- 조치: 공용 Git 메타데이터에 접근 가능한 권한으로 같은 fetch를 한 번 재실행함 +- 검증: 권한 재실행 `git fetch --prune origin`이 오류 없이 완료됐고, 직후 상태는 이 학습 기록 변경과 사용자 소유 `.claude/`뿐이며 기준 HEAD는 `e3f58d9`임 +- 배운 점: 리뷰 수정 라운드도 새 구현처럼 원격 상태 확인 실패를 먼저 기록하고, 성공을 추정하지 않는다. + ### L-20260810-048 — Task 6 staging이 연결 작업 트리 index lock 권한으로 중단됨 - 상태: 해결 @@ -603,3 +663,14 @@ - 조치: 현재 작업 트리의 `git status --short`와 브랜치 정보를 확인하고, 원격 fetch 재시도는 권한이 확보될 때까지 보류함 - 검증: `git status --short`에서 사용자 소유의 추적되지 않은 `.claude/`만 확인됨; 원격 fetch는 미검증 상태로 남음 - 배운 점: 연결 작업 트리에서는 원격 확인 전에 공용 Git 메타데이터의 쓰기 권한을 먼저 확인한다. +### L-20260811-051 — 화면 밖 관찰도 발견 프레임으로 소비하는 회귀 테스트가 컴파일 RED를 만듦 + +- 상태: 해결 +- 발생 태스크: Task 6 독립 리뷰 수정 1차 — 유효한 화면 관찰만 발견 판정에 반영 +- 재현: `xcodebuild -project PiggyEscape/PiggyEscape.xcodeproj -scheme PiggyEscape -destination 'platform=iOS Simulator,name=iPhone 17 Pro' -only-testing:PiggyEscapeTests/RealityHideARViewCoordinatorTests test` +- 관찰: 새 테스트가 요구한 `isObservationValid` 인자와 `RealityProjectionGate`가 구현에 없어 컴파일이 실패함 +- 영향: 기존 구현은 투영점이 화면 밖이거나 돼지가 카메라 뒤에 있어도 메시 히트가 없으면 가시 프레임으로 소비할 수 있었음 +- 원인/가설: `ARView.project`가 반환한 좌표의 뷰포트 포함 여부와 카메라 전방 반공간을 확인하지 않고 거리 모니터에 전달했음 +- 조치: 투영점이 화면 안에 있고 돼지가 카메라 전방에 있을 때만 관찰을 유효하게 만드는 순수 `RealityProjectionGate`를 추가하고, 무효 프레임은 `RealityRevealMonitor`에 전달하지 않도록 함 +- 검증: 수정 전 컴파일 RED를 확인했고 `RealityHideARViewCoordinatorTests` focused XCTest 5/5를 통과함 +- 배운 점: 화면 좌표 변환의 성공 여부만으로 가시성을 추론하지 말고, 뷰포트 경계와 카메라 기준 깊이를 함께 검증해야 한다. diff --git a/docs/WORK_LOG.md b/docs/WORK_LOG.md index dc3f98c..dbbe5ed 100644 --- a/docs/WORK_LOG.md +++ b/docs/WORK_LOG.md @@ -5,10 +5,10 @@ ## 현재 인수인계 - 상태: Task 6 — RealityKit 돼지 포즈와 실제 메쉬 뒤 숨기 완료 -- 진행 중 범위: `RealityPigVisualController`가 실제 C3 돼지 3종 에셋을 비동기로 불러 안정적인 `RealityEscapePig` 바깥 엔티티 안에서 교체하고, 걷기·정지·놀람 포즈와 1.5→1.0 확대를 담당한다. `RealityHideARView`는 지원 기기에서만 수동 AR 세션을 시작하고, LiDAR 메쉬 세로 면 탭·분류된 수평 바닥·Task 5 숨기 계획을 연결해 물체 반대편으로 이동한 뒤 실제 메쉬 선행 충돌이 사라질 때 한 번만 재발견을 알린다. 카메라 권한·SceneKit 페이드·루트 화면 전환·1.12 화면 확대는 Task 7 범위로 아직 구현하지 않았다. +- 진행 중 범위: `RealityPigVisualController`가 실제 C3 돼지 3종 에셋을 비동기로 불러 안정적인 `RealityEscapePig` 바깥 엔티티 안에서 교체하고, 걷기·정지·놀람 포즈와 1.5→1.0 확대를 담당한다. 돼지는 유효한 바닥 기반 시작 위치가 생기기 전까지 보이지 않으며, `RealityHideARView`는 타깃 수락과 돼지 도착을 별도 콜백으로 전달한다. 도착 뒤에만 유효한 화면 안·카메라 앞 관찰을 받아 실제 메쉬 선행 충돌이 사라질 때 놀란 모델 설치→확대→재발견 순서로 한 번 알린다. 카메라 권한·SceneKit 페이드·루트 화면 전환·1.12 화면 확대는 Task 7 범위로 아직 구현하지 않았다. - 실패 기록: `docs/LEARNING_LOG.md`에 실패·검증 한계의 재현 조건·원인/가설·조치·재발 방지 근거를 기록한다. - 마지막 완료 범위: Task 6 — RealityKit 돼지 포즈와 실제 메쉬 뒤 숨기. -- 마지막 검증: `cd PiggyEscape && tuist generate --no-open` 성공. focused `xcodebuild -project PiggyEscape/PiggyEscape.xcodeproj -scheme PiggyEscape -destination 'platform=iOS Simulator,name=iPhone 17 Pro' -only-testing:PiggyEscapeTests/RealityPigVisualControllerTests -only-testing:PiggyEscapeTests/RealityHideARViewCoordinatorTests test`가 5/5, 전체 같은 destination의 `xcodebuild ... test`가 63/63을 통과했다. 같은 destination의 `xcodebuild ... build`도 `** BUILD SUCCEEDED **`로 완료됐다. Simulator/AppIntents 런타임 경고와 실제 LiDAR·물리 오클루전 실기기 한계는 `docs/LEARNING_LOG.md`의 L-20260810-046~047에 기록했다. +- 마지막 검증: 독립 리뷰 수정 뒤 focused `RealityPigVisualControllerTests` 3/3, `RealityHideARViewCoordinatorTests` 7/7, 전체 같은 destination의 XCTest 68/68이 통과했다. 같은 destination의 `xcodebuild ... build`도 `** BUILD SUCCEEDED **`로 완료됐다. 리뷰에서 발견하고 수정한 네 경계는 `docs/LEARNING_LOG.md`의 L-20260811-050~054, Simulator/AppIntents 런타임 경고와 실제 LiDAR·물리 오클루전 실기기 한계는 L-20260810-046~047에 기록했다. - 다음 시작점: 구현 계획의 Task 7 — 자동 SceneKit→RealityKit 전환, 카메라 권한, C3 스타일 안내와 1.12 화면 확대. 이 Task 6에서는 시작하지 않았다. - 차단 요소: 실제 LiDAR 메쉬 세로 면 탭·분류된 바닥·물리 오클루전·카메라 이동 재발견은 L-20260810-047에 `실기기 대기`로 남겼다. Task 6 구현 중 해결한 환경·SDK 타입 실패는 L-20260810-039~045에 기록했다. @@ -22,6 +22,7 @@ | 날짜 | 작업 범위 | 결과 | 검증 | 다음 시작점 | | --- | --- | --- | --- | --- | +| 2026-08-11 | Task 6 독립 리뷰 수정 | 화면 밖·카메라 뒤 관찰을 재발견 입력에서 제외하고, 놀란 모델 설치 뒤에만 확대·발견 콜백을 실행함. 타깃 수락과 돼지 도착 이벤트를 분리하고 도착 전 발견을 막았으며, 유효한 바닥 Y·카메라 전방 시작 위치가 생기기 전 돼지를 비활성화함 | 결함별 RED 확인 후 visual focused 3/3, coordinator focused 7/7, 전체 XCTest 68/68 및 iOS Simulator build 성공. 원인·학습은 L-20260811-050~054 | Task 6 재검토 후 Task 7 | | 2026-08-10 | Task 6 — RealityKit 돼지 포즈와 실제 메쉬 뒤 숨기 | 실제 C3 돼지 3종의 비동기 포즈 교체·높이 정규화·걷기·1.5→1.0 놀람 확대를 안정 바깥 엔티티에 구현하고, LiDAR 지원 guard·수동 AR 세션·scene understanding 4종 옵션·실제 메쉬 세로 면 탭·분류된 바닥·숨기 이동·blocked→visible 1회 재발견을 ARView에 연결함. 카메라/렌즈 transform은 변경하지 않음 | RED에서 두 Task 6 타입 부재를 확인한 뒤 focused 5/5·전체 63/63 XCTest와 iOS Simulator build 성공. 환경·SDK 실패는 L-20260810-039~046, 실제 LiDAR 검증은 L-20260810-047 `실기기 대기` | Task 7 — 자동 SceneKit→RealityKit 전환과 C3 스타일 안내 | | 2026-08-10 | Task 5 — 실제 물체 숨기 계획과 LiDAR 지원 판정 | 세로 면·카메라 거리·근접 바닥을 검증해 카메라 반대편의 정확한 바닥 Y 숨기 위치를 계산하는 순수 계획, 0.03m 허용오차의 1회 재발견 감시, ARKit 호출을 시스템 구현 하나에 한정한 LiDAR 지원 주입 경계와 안내 문자열을 추가함. ARView·RealityKit 렌더링·권한·UI 전환은 추가하지 않음 | RED에서 Reality 계획·지원 타입 부재를 확인한 뒤 focused 8/8·전체 58/58 XCTest 및 iOS Simulator build 성공. 기존 Simulator/AppIntents 경고와 실제 LiDAR·물리 오클루전 실기기 한계는 L-20260810-036~037에 기록 | Task 6 — RealityKit 돼지 포즈와 실제 메쉬 뒤 숨기 | | 2026-08-10 | Task 4 — C3 SceneKit 나레이션·탭·가짜 숨기·카메라 발견 | `NarrationOverlayScene`의 C3 스타일 자막, 실제 `HideTree`가 필수인 나무 뒤 달리기, 0.70rad wrapped yaw·실제 `SCNView` 프러스텀·1회 가드 발견, `Piggy_surprised` 1.5→1.0 확대를 추가하고 C3 제스처 뷰를 앱 진입점에 연결함. 리뷰 보완으로 확대 action을 외부 콜백보다 먼저 등록하고, world callback의 Coordinator 캡처를 약하게 바꾸며 도착 전·frustum false·yaw 경계·1회 가드를 회귀 테스트로 확장함 | 리뷰 RED 2건 뒤 focused 12/12·전체 50/50 XCTest 및 iOS Simulator build 성공. 기존 런타임 경고와 수동 시각 확인 한계는 L-20260810-029~030, 리뷰 수정 근거는 L-20260810-031에 기록 | Task 5 — C3 발견 뒤 카메라 권한 전환 | From d352ef26cb37e3d300e013303477657256772593 Mon Sep 17 00:00:00 2001 From: eunseo-com Date: Tue, 11 Aug 2026 00:29:41 +0900 Subject: [PATCH 14/22] Handle RealityKit pose loading failures --- .../Sources/Reality/RealityCapability.swift | 1 + .../Sources/Reality/RealityHideARView.swift | 130 ++++++++++++------ .../Reality/RealityPigVisualController.swift | 40 ++++-- .../RealityHideARViewCoordinatorTests.swift | 120 +++++++++++++++- .../RealityPigVisualControllerTests.swift | 12 +- docs/LEARNING_LOG.md | 36 +++++ docs/WORK_LOG.md | 5 +- 7 files changed, 280 insertions(+), 64 deletions(-) diff --git a/PiggyEscape/PiggyEscape/Sources/Reality/RealityCapability.swift b/PiggyEscape/PiggyEscape/Sources/Reality/RealityCapability.swift index 369fa47..c16a4e7 100644 --- a/PiggyEscape/PiggyEscape/Sources/Reality/RealityCapability.swift +++ b/PiggyEscape/PiggyEscape/Sources/Reality/RealityCapability.swift @@ -15,4 +15,5 @@ enum RealityAvailabilityMessage { static let scanFirst = "주변 바닥과 숨을 물체를 조금 더 스캔해줘." static let selectVerticalSide = "숨을 물체의 옆면을 탭해줘." static let moveFartherAway = "조금 떨어진 물체의 옆면을 탭해줘." + static let pigAssetLoadFailed = "돼지를 불러오지 못했어. 잠시 후 다시 시도해줘." } diff --git a/PiggyEscape/PiggyEscape/Sources/Reality/RealityHideARView.swift b/PiggyEscape/PiggyEscape/Sources/Reality/RealityHideARView.swift index fb77149..48c335d 100644 --- a/PiggyEscape/PiggyEscape/Sources/Reality/RealityHideARView.swift +++ b/PiggyEscape/PiggyEscape/Sources/Reality/RealityHideARView.swift @@ -33,37 +33,60 @@ enum RealityProjectionGate { enum RealityInitialPigPlacement { static func position( cameraPosition: SIMD3, - cameraForward: SIMD3, + hit: RealitySurfaceHit, + destination: SIMD3, floorY: Float ) -> SIMD3? { guard cameraPosition.x.isFinite, cameraPosition.y.isFinite, cameraPosition.z.isFinite, - cameraForward.x.isFinite, - cameraForward.y.isFinite, - cameraForward.z.isFinite, + hit.point.x.isFinite, + hit.point.y.isFinite, + hit.point.z.isFinite, + hit.normal.x.isFinite, + hit.normal.y.isFinite, + hit.normal.z.isFinite, + destination.x.isFinite, + destination.y.isFinite, + destination.z.isFinite, floorY.isFinite, floorY < cameraPosition.y - 0.2 else { return nil } - let horizontalForward = SIMD3(cameraForward.x, 0, cameraForward.z) - let length = simd_length(horizontalForward) - guard length > 0.0001 else { return nil } - let direction = horizontalForward / length + let horizontalNormal = SIMD3(hit.normal.x, 0, hit.normal.z) + guard simd_length_squared(horizontalNormal) > 0.0001 else { return nil } + let normal = simd_normalize(horizontalNormal) + let cameraOffset = cameraPosition - hit.point + let towardCamera = simd_dot(normal, cameraOffset) >= 0 ? normal : -normal + let destinationOffset = destination - hit.point + guard simd_dot(towardCamera, cameraOffset) > 0, + simd_dot(towardCamera, destinationOffset) < 0 else { + return nil + } + return SIMD3( - cameraPosition.x + direction.x * 0.8, + hit.point.x + towardCamera.x * RealityHidePlanner.objectClearance, floorY, - cameraPosition.z + direction.z * 0.8 + hit.point.z + towardCamera.z * RealityHidePlanner.objectClearance ) } } +enum RealityHideARStatus: Equatable { + case waitingForTarget + case walking + case hidden + case revealing + case revealed +} + struct RealityHideARView: UIViewRepresentable { let onScanningReady: () -> Void let onTargetAccepted: () -> Void let onPigReachedTarget: () -> Void let onRevealed: () -> Void + let onError: () -> Void let onUnavailable: () -> Void let onMessage: (String) -> Void @@ -72,6 +95,7 @@ struct RealityHideARView: UIViewRepresentable { onTargetAccepted: @escaping () -> Void, onPigReachedTarget: @escaping () -> Void, onRevealed: @escaping () -> Void, + onError: @escaping () -> Void, onUnavailable: @escaping () -> Void, onMessage: @escaping (String) -> Void ) { @@ -79,6 +103,7 @@ struct RealityHideARView: UIViewRepresentable { self.onTargetAccepted = onTargetAccepted self.onPigReachedTarget = onPigReachedTarget self.onRevealed = onRevealed + self.onError = onError self.onUnavailable = onUnavailable self.onMessage = onMessage } @@ -90,6 +115,7 @@ struct RealityHideARView: UIViewRepresentable { onTargetAccepted: onTargetAccepted, onPigReachedTarget: onPigReachedTarget, onRevealed: onRevealed, + onError: onError, onUnavailable: onUnavailable, onMessage: onMessage ) @@ -109,30 +135,23 @@ struct RealityHideARView: UIViewRepresentable { @MainActor final class Coordinator: NSObject { - private enum HideState { - case waitingForTarget - case walking - case hidden - case revealing - case revealed - } - private let meshSupport: any RealityMeshSupporting private let visualController: RealityPigVisualController private let onScanningReady: () -> Void private let onTargetAccepted: () -> Void private let onPigReachedTarget: () -> Void private let onRevealed: () -> Void + private let onError: () -> Void private let onUnavailable: () -> Void private let onMessage: (String) -> Void private weak var arView: ARView? private var revealMonitor = RealityRevealMonitor() private var revealSubscription: (any Cancellable)? - private var hideState = HideState.waitingForTarget private var pigAnchor: AnchorEntity? private(set) var didStartMeshSession = false + private(set) var status = RealityHideARStatus.waitingForTarget var canStartMeshSession: Bool { meshSupport.supportsMeshWithClassification @@ -145,6 +164,7 @@ struct RealityHideARView: UIViewRepresentable { onTargetAccepted: @escaping () -> Void = {}, onPigReachedTarget: @escaping () -> Void = {}, onRevealed: @escaping () -> Void = {}, + onError: @escaping () -> Void = {}, onUnavailable: @escaping () -> Void = {}, onMessage: @escaping (String) -> Void = { _ in } ) { @@ -154,6 +174,7 @@ struct RealityHideARView: UIViewRepresentable { self.onTargetAccepted = onTargetAccepted self.onPigReachedTarget = onPigReachedTarget self.onRevealed = onRevealed + self.onError = onError self.onUnavailable = onUnavailable self.onMessage = onMessage } @@ -166,7 +187,10 @@ struct RealityHideARView: UIViewRepresentable { anchor.addChild(visualController.outerEntity) arView.scene.addAnchor(anchor) pigAnchor = anchor - visualController.loadIdlePig() + visualController.loadIdlePig { [weak self] result in + guard case .failure = result else { return } + self?.reportVisualFailure(recoveringTo: .waitingForTarget) + } let tap = UITapGestureRecognizer(target: self, action: #selector(handleTap(_:))) tap.numberOfTouchesRequired = 1 @@ -202,35 +226,45 @@ struct RealityHideARView: UIViewRepresentable { pigDistance: Float ) -> Bool { guard isObservationValid, - hideState == .hidden, + status == .hidden, revealMonitor.update(meshDistance: meshDistance, pigDistance: pigDistance) else { return false } - hideState = .revealing + status = .revealing revealSubscription?.cancel() revealSubscription = nil - visualController.showSurprised { [weak self] in - guard let self, self.hideState == .revealing else { return } - self.visualController.playSurpriseScale() - self.hideState = .revealed - self.onRevealed() + visualController.showSurprised { [weak self] result in + guard let self, self.status == .revealing else { return } + switch result { + case .success: + self.visualController.playSurpriseScale() + self.status = .revealed + self.onRevealed() + case .failure: + self.reportVisualFailure(recoveringTo: .hidden) + } } return true } func acceptHideTarget(destination: SIMD3, initialPosition: SIMD3) { - guard hideState == .waitingForTarget else { return } - hideState = .walking + guard status == .waitingForTarget else { return } + status = .walking visualController.outerEntity.setPosition(initialPosition, relativeTo: nil) visualController.outerEntity.isEnabled = true onTargetAccepted() - visualController.walk(to: destination) { [weak self] in + visualController.walk(to: destination) { [weak self] result in guard let self else { return } - self.hideState = .hidden - self.onPigReachedTarget() - if let arView = self.arView { - self.beginRevealMonitoring(in: arView) + switch result { + case .success: + self.status = .hidden + self.onPigReachedTarget() + if let arView = self.arView { + self.beginRevealMonitoring(in: arView) + } + case .failure: + self.reportVisualFailure(recoveringTo: .waitingForTarget) } } } @@ -271,7 +305,7 @@ struct RealityHideARView: UIViewRepresentable { @objc private func handleTap(_ recognizer: UITapGestureRecognizer) { guard recognizer.state == .ended, - hideState == .waitingForTarget, + status == .waitingForTarget, let arView, let frame = arView.session.currentFrame else { return } @@ -287,8 +321,9 @@ struct RealityHideARView: UIViewRepresentable { let cameraPosition = Self.position(from: frame.camera.transform) let floor = nearestFloor(in: frame, to: hit.position) + let surfaceHit = RealitySurfaceHit(point: hit.position, normal: hit.normal) let result = RealityHidePlanner.plan( - hit: RealitySurfaceHit(point: hit.position, normal: hit.normal), + hit: surfaceHit, cameraPosition: cameraPosition, floor: floor ) @@ -297,14 +332,10 @@ struct RealityHideARView: UIViewRepresentable { case let .rejected(rejection): onMessage(message(for: rejection)) case let .accepted(destination): - let cameraForward = -SIMD3( - frame.camera.transform.columns.2.x, - frame.camera.transform.columns.2.y, - frame.camera.transform.columns.2.z - ) guard let initialPosition = RealityInitialPigPlacement.position( cameraPosition: cameraPosition, - cameraForward: cameraForward, + hit: surfaceHit, + destination: destination, floorY: destination.y ) else { onMessage(RealityAvailabilityMessage.scanFirst) @@ -314,6 +345,21 @@ struct RealityHideARView: UIViewRepresentable { } } + private func reportVisualFailure(recoveringTo recoveryStatus: RealityHideARStatus) { + status = recoveryStatus + revealMonitor = RealityRevealMonitor() + if recoveryStatus == .waitingForTarget { + visualController.outerEntity.isEnabled = false + } else if recoveryStatus == .hidden { + visualController.outerEntity.isEnabled = true + if let arView { + beginRevealMonitoring(in: arView) + } + } + onError() + onMessage(RealityAvailabilityMessage.pigAssetLoadFailed) + } + private func nearestFloor(in frame: ARFrame, to surfacePoint: SIMD3) -> RealityFloor? { frame.anchors .compactMap { $0 as? ARPlaneAnchor } diff --git a/PiggyEscape/PiggyEscape/Sources/Reality/RealityPigVisualController.swift b/PiggyEscape/PiggyEscape/Sources/Reality/RealityPigVisualController.swift index e2f2e32..f728e31 100644 --- a/PiggyEscape/PiggyEscape/Sources/Reality/RealityPigVisualController.swift +++ b/PiggyEscape/PiggyEscape/Sources/Reality/RealityPigVisualController.swift @@ -3,9 +3,14 @@ import Foundation import RealityKit import simd +enum RealityPigVisualError: Error, Equatable { + case assetLoadFailed(C3PigPose) +} + @MainActor final class RealityPigVisualController { - typealias EntityLoader = (String, @escaping (Result) -> Void) -> AnyCancellable? + typealias EntityLoader = @MainActor (String, @escaping (Result) -> Void) -> AnyCancellable? + typealias PoseResult = Result let outerEntity: Entity @@ -21,6 +26,7 @@ final class RealityPigVisualController { private let entityLoader: EntityLoader private var modelLoad: AnyCancellable? private var movementCompletion: DispatchWorkItem? + private var requestedPose: C3PigPose? init() { outerEntity = Entity() @@ -49,14 +55,18 @@ final class RealityPigVisualController { RealityPigVisualController(testing: true, entityLoader: entityLoader) } - func loadIdlePig() { - setPose(.idle) + func loadIdlePig(completion: @escaping (PoseResult) -> Void = { _ in }) { + setPose(.idle, completion: completion) } - func walk(to destination: SIMD3, completion: @escaping () -> Void) { + func walk(to destination: SIMD3, completion: @escaping (PoseResult) -> Void) { movementCompletion?.cancel() - setPose(.running) { [weak self] in + setPose(.running) { [weak self] result in guard let self else { return } + guard case .success = result else { + completion(result) + return + } if self.skipsAssetLoadingAndTiming { self.outerEntity.setPosition(destination, relativeTo: nil) self.setPose(.idle, completion: completion) @@ -85,7 +95,7 @@ final class RealityPigVisualController { } } - func showSurprised(completion: @escaping () -> Void = {}) { + func showSurprised(completion: @escaping (PoseResult) -> Void = { _ in }) { movementCompletion?.cancel() setPose(.surprised, completion: completion) } @@ -123,14 +133,20 @@ final class RealityPigVisualController { } } - private func setPose(_ pose: C3PigPose, completion: (() -> Void)? = nil) { - currentPose = pose + private func setPose(_ pose: C3PigPose, completion: @escaping (PoseResult) -> Void) { + requestedPose = pose modelLoad?.cancel() modelLoad = entityLoader(assetName(for: pose)) { [weak self] result in - guard let self, self.currentPose == pose else { return } - guard case let .success(entity) = result else { return } - self.install(entity, for: pose) - completion?() + guard let self, self.requestedPose == pose else { return } + self.requestedPose = nil + switch result { + case let .success(entity): + self.install(entity, for: pose) + self.currentPose = pose + completion(.success(())) + case .failure: + completion(.failure(.assetLoadFailed(pose))) + } } } diff --git a/PiggyEscape/PiggyEscapeTests/RealityHideARViewCoordinatorTests.swift b/PiggyEscape/PiggyEscapeTests/RealityHideARViewCoordinatorTests.swift index cf13e5f..a6560e6 100644 --- a/PiggyEscape/PiggyEscapeTests/RealityHideARViewCoordinatorTests.swift +++ b/PiggyEscape/PiggyEscapeTests/RealityHideARViewCoordinatorTests.swift @@ -1,3 +1,4 @@ +import Combine import RealityKit import XCTest import simd @@ -143,9 +144,11 @@ final class RealityHideARViewCoordinatorTests: XCTestCase { } func test_initialPlacementUsesFloorAndCameraForwardOnlyWhenDefensible() { + let nearHit = RealitySurfaceHit(point: SIMD3(0, 1, -0.5), normal: SIMD3(0, 0, 1)) let position = RealityInitialPigPlacement.position( cameraPosition: SIMD3(0, 1.5, 0), - cameraForward: SIMD3(0, 0, -1), + hit: nearHit, + destination: SIMD3(0, 0, -0.78), floorY: 0 ) @@ -154,20 +157,129 @@ final class RealityHideARViewCoordinatorTests: XCTestCase { } XCTAssertEqual(position.x, 0, accuracy: 0.0001) XCTAssertEqual(position.y, 0, accuracy: 0.0001) - XCTAssertEqual(position.z, -0.8, accuracy: 0.0001) + XCTAssertGreaterThan(position.z, nearHit.point.z) + XCTAssertEqual(position.z, -0.22, accuracy: 0.0001) + + let farHit = RealitySurfaceHit(point: SIMD3(0, 0.8, -2), normal: SIMD3(0, 0, 1)) + let farPosition = RealityInitialPigPlacement.position( + cameraPosition: SIMD3(0, 1.5, 0), + hit: farHit, + destination: SIMD3(0, 0, -2.28), + floorY: 0 + ) + XCTAssertEqual(farPosition?.z, -1.72) XCTAssertNil(RealityInitialPigPlacement.position( cameraPosition: SIMD3(0, 1.5, 0), - cameraForward: SIMD3(0, 1, 0), + hit: nearHit, + destination: SIMD3(0, 0, -0.22), floorY: 0 )) XCTAssertNil(RealityInitialPigPlacement.position( cameraPosition: SIMD3(0, 1.5, 0), - cameraForward: SIMD3(0, 0, -1), + hit: nearHit, + destination: SIMD3(0, 1.5, -0.78), floorY: 1.5 )) } + + func test_runningLoadFailureReturnsToRetryableWaitingStateAndReportsError() { + let loader = ControlledRealityEntityLoader() + let visualController = RealityPigVisualController.makeForTesting(entityLoader: loader.load) + var errorCount = 0 + var messages: [String] = [] + let coordinator = RealityHideARView.Coordinator( + meshSupport: FakeRealityMeshSupport(supportsMeshWithClassification: true), + visualController: visualController, + onError: { errorCount += 1 }, + onMessage: { messages.append($0) } + ) + + coordinator.acceptHideTarget(destination: SIMD3(0, 0, -1), initialPosition: SIMD3(0, 0, -0.2)) + XCTAssertEqual(coordinator.status, .walking) + loader.failNext() + + XCTAssertEqual(coordinator.status, .waitingForTarget) + XCTAssertFalse(visualController.outerEntity.isEnabled) + XCTAssertEqual(errorCount, 1) + XCTAssertEqual(messages, [RealityAvailabilityMessage.pigAssetLoadFailed]) + XCTAssertEqual(RealityAvailabilityMessage.pigAssetLoadFailed, "돼지를 불러오지 못했어. 잠시 후 다시 시도해줘.") + } + + func test_idleLoadFailureAfterMovementReturnsToRetryableWaitingState() { + let loader = ControlledRealityEntityLoader() + let visualController = RealityPigVisualController.makeForTesting(entityLoader: loader.load) + var errorCount = 0 + var reachedCount = 0 + let coordinator = RealityHideARView.Coordinator( + meshSupport: FakeRealityMeshSupport(supportsMeshWithClassification: true), + visualController: visualController, + onPigReachedTarget: { reachedCount += 1 }, + onError: { errorCount += 1 } + ) + + coordinator.acceptHideTarget(destination: SIMD3(0, 0, -1), initialPosition: SIMD3(0, 0, -0.2)) + loader.succeedNext() + XCTAssertEqual(coordinator.status, .walking) + loader.failNext() + + XCTAssertEqual(coordinator.status, .waitingForTarget) + XCTAssertFalse(visualController.outerEntity.isEnabled) + XCTAssertEqual(reachedCount, 0) + XCTAssertEqual(errorCount, 1) + } + + func test_surprisedLoadFailureRestoresHiddenStateWithoutReportingReveal() { + let loader = ControlledRealityEntityLoader() + let visualController = RealityPigVisualController.makeForTesting(entityLoader: loader.load) + var errorCount = 0 + var revealCount = 0 + let coordinator = RealityHideARView.Coordinator( + meshSupport: FakeRealityMeshSupport(supportsMeshWithClassification: true), + visualController: visualController, + onRevealed: { revealCount += 1 }, + onError: { errorCount += 1 } + ) + coordinator.acceptHideTarget(destination: SIMD3(0, 0, -1), initialPosition: SIMD3(0, 0, -0.2)) + loader.succeedNext() + loader.succeedNext() + XCTAssertEqual(coordinator.status, .hidden) + + XCTAssertFalse(coordinator.processRevealFrame(isObservationValid: true, meshDistance: 0.5, pigDistance: 2)) + XCTAssertTrue(coordinator.processRevealFrame(isObservationValid: true, meshDistance: nil, pigDistance: 2)) + XCTAssertEqual(coordinator.status, .revealing) + loader.failNext() + + XCTAssertEqual(coordinator.status, .hidden) + XCTAssertTrue(visualController.outerEntity.isEnabled) + XCTAssertEqual(revealCount, 0) + XCTAssertEqual(errorCount, 1) + XCTAssertFalse(coordinator.processRevealFrame(isObservationValid: true, meshDistance: 0.5, pigDistance: 2)) + XCTAssertTrue(coordinator.processRevealFrame(isObservationValid: true, meshDistance: nil, pigDistance: 2)) + } } private struct FakeRealityMeshSupport: RealityMeshSupporting { let supportsMeshWithClassification: Bool } + +@MainActor +private final class ControlledRealityEntityLoader { + typealias Completion = (Result) -> Void + + private var pending: [Completion] = [] + + func load(_ asset: String, completion: @escaping Completion) -> AnyCancellable? { + pending.append(completion) + return nil + } + + func succeedNext() { + pending.removeFirst()(.success(Entity())) + } + + func failNext() { + pending.removeFirst()(.failure(TestRealityEntityLoadError())) + } +} + +private struct TestRealityEntityLoadError: Error {} diff --git a/PiggyEscape/PiggyEscapeTests/RealityPigVisualControllerTests.swift b/PiggyEscape/PiggyEscapeTests/RealityPigVisualControllerTests.swift index bcbb6c5..145d053 100644 --- a/PiggyEscape/PiggyEscapeTests/RealityPigVisualControllerTests.swift +++ b/PiggyEscape/PiggyEscapeTests/RealityPigVisualControllerTests.swift @@ -28,8 +28,10 @@ final class RealityPigVisualControllerTests: XCTestCase { } var completed = false - controller.showSurprised { - completed = true + controller.showSurprised { result in + if case .success = result { + completed = true + } } XCTAssertEqual(requestedAsset, "Piggy_surprised") @@ -48,8 +50,10 @@ final class RealityPigVisualControllerTests: XCTestCase { let destination = SIMD3(0.7, 0.2, -1.4) var completed = false - controller.walk(to: destination) { - completed = true + controller.walk(to: destination) { result in + if case .success = result { + completed = true + } } XCTAssertTrue(controller.outerEntity === outerEntity) diff --git a/docs/LEARNING_LOG.md b/docs/LEARNING_LOG.md index ef561b1..888c17f 100644 --- a/docs/LEARNING_LOG.md +++ b/docs/LEARNING_LOG.md @@ -28,6 +28,42 @@ ## 항목 +### L-20260811-057 — 테스트 기본 RealityKit 로더의 actor 격리 계약이 누락됨 + +- 상태: 해결 +- 발생 태스크: Task 6 독립 리뷰 수정 2차 — 포즈 실패 테스트 경계 +- 재현: 포즈 실패 처리 구현 후 coordinator focused XCTest를 컴파일함 +- 관찰: 테스트 기본 loader closure 안의 `Entity()` 생성에 `call to main actor-isolated initializer ... in a synchronous nonisolated context` 경고가 출력됨 +- 영향: 현재 Swift 5 모드에서는 테스트가 통과하지만 더 엄격한 동시성 검사에서는 오류가 될 수 있고, RealityKit 엔티티 loader가 어느 actor에서 실행되는지 타입 계약이 불명확했음 +- 원인/가설: visual controller 클래스는 `@MainActor`였지만 저장한 `EntityLoader` 함수 타입에는 actor 격리가 표현되지 않은 것이 원인임 +- 조치: `EntityLoader` 타입 자체를 `@MainActor` closure로 선언해 production과 deterministic fake가 같은 실행 경계를 사용하도록 함 +- 검증: 변경 후 visual controller focused XCTest 3/3이 통과했고 해당 actor 경고가 다시 출력되지 않음 +- 배운 점: actor-isolated 소유자 안의 closure 프로퍼티도 함수 타입에 격리를 명시해야 생성·콜백의 동시성 계약이 보존된다. + +### L-20260811-056 — 고정 전방 0.8m 시작점이 선택한 가까운 표면 뒤에 놓일 수 있음 + +- 상태: 해결 +- 발생 태스크: Task 6 독립 리뷰 수정 2차 — 선택 표면 기반 시작 배치 +- 재현: 카메라 `(0, 1.5, 0)`, 선택 면 점 `(0, 1, -0.5)`, 법선 `(0, 0, 1)`, 계획 목적지 약 `(0, 0, -0.78)`을 기존 `RealityInitialPigPlacement`에 대입함 +- 관찰: 기존 규칙은 선택 면과 무관하게 카메라 전방 0.8m인 z=-0.8을 반환해 선택 면 z=-0.5보다 뒤에서 시작하며, 돼지가 이미 물체 뒤에 나타난 뒤 거의 움직이지 않는 경로가 됨 +- 영향: Task 5가 허용하는 0.45m 근거리 표면에서 “카메라에 보이는 쪽에서 물체 뒤로 걸어감” 연출이 깨짐 +- 원인/가설: 시작 위치 data-flow에 실제 hit 점·법선·계획 목적지가 전달되지 않고 카메라 transform과 바닥 Y만 사용한 것이 확인된 원인임 +- 조치: 근거리·원거리 표면 모두에서 바닥 Y, 카메라 쪽 반공간, 목적지 반대편 조건을 고정하는 순수 배치 RED 테스트를 추가하고, 실제 hit 점·법선·계획 목적지를 배치 함수에 전달해 표면의 카메라 쪽 0.28m에서 시작하도록 수정함 +- 검증: 기존 `cameraForward` API 때문에 발생한 focused 컴파일 RED를 확인한 뒤 coordinator focused XCTest 10/10을 통과함. 재현 사례는 z=-0.22에서 시작해 z=-0.78로 이동하며, 원거리 사례도 선택 면 카메라 쪽 0.28m를 유지함 +- 배운 점: 실제 물체를 기준으로 하는 이동의 시작·끝은 같은 hit 좌표계에서 계산해야 하며 고정 카메라 거리로 대체할 수 없다. + +### L-20260811-055 — 포즈 에셋 로드 실패가 walking·revealing 상태를 영구 정지시킴 + +- 상태: 해결 +- 발생 태스크: Task 6 독립 리뷰 수정 2차 — RealityKit 포즈 실패 종결 +- 재현: 수동으로 `Result.failure`를 방출하는 entity loader를 running, 도착 후 idle, surprised 요청에 각각 주입하고 coordinator 상태·콜백·표시 여부를 관찰함 +- 관찰: `setPose`가 failure를 버리므로 running/idle 실패에는 walk completion이 없고, surprised 실패에는 reveal completion이 없어 subscription을 취소한 `.revealing` 상태로 남음 +- 영향: 사용자에게 원인을 알리지 않은 채 탭 재시도와 재발견이 영구 중단될 수 있고, 상위 Task 7 화면도 오류 상태를 받을 공개 신호가 없음 +- 원인/가설: 비동기 로더의 성공만 completion으로 모델링하고 실패 data-flow를 visual controller→coordinator→UI callback으로 전달하지 않은 것이 확인된 원인임 +- 조치: 세 포즈 failure의 안전 상태·발견 미발행·고정 한국어 메시지·오류 callback 계약을 각각 focused RED 테스트로 추가함. pose completion을 `Result`로 바꾸고 running/idle 실패는 비활성 `.waitingForTarget`, surprised 실패는 재감시 가능한 `.hidden`으로 복구하며 `onError`와 `"돼지를 불러오지 못했어. 잠시 후 다시 시도해줘."`를 전달함 +- 검증: `onError`, 공개 status, 실패 문자열 부재를 보여 준 focused 컴파일 RED를 확인한 뒤 coordinator focused XCTest 10/10과 visual focused XCTest 3/3을 통과함. surprised failure에서는 `onRevealed`가 0이며 다음 blocked→visible 재시도가 다시 시작됨 +- 배운 점: 비동기 에셋 작업의 completion은 성공 전용 closure가 아니라 성공과 실패를 모두 종결하는 결과여야 한다. + ### L-20260811-054 — AR 진입 직후 돼지가 월드 원점에 활성화되는 배치 계약이 없음 - 상태: 해결 diff --git a/docs/WORK_LOG.md b/docs/WORK_LOG.md index dbbe5ed..9ed3eab 100644 --- a/docs/WORK_LOG.md +++ b/docs/WORK_LOG.md @@ -5,10 +5,10 @@ ## 현재 인수인계 - 상태: Task 6 — RealityKit 돼지 포즈와 실제 메쉬 뒤 숨기 완료 -- 진행 중 범위: `RealityPigVisualController`가 실제 C3 돼지 3종 에셋을 비동기로 불러 안정적인 `RealityEscapePig` 바깥 엔티티 안에서 교체하고, 걷기·정지·놀람 포즈와 1.5→1.0 확대를 담당한다. 돼지는 유효한 바닥 기반 시작 위치가 생기기 전까지 보이지 않으며, `RealityHideARView`는 타깃 수락과 돼지 도착을 별도 콜백으로 전달한다. 도착 뒤에만 유효한 화면 안·카메라 앞 관찰을 받아 실제 메쉬 선행 충돌이 사라질 때 놀란 모델 설치→확대→재발견 순서로 한 번 알린다. 카메라 권한·SceneKit 페이드·루트 화면 전환·1.12 화면 확대는 Task 7 범위로 아직 구현하지 않았다. +- 진행 중 범위: `RealityPigVisualController`가 실제 C3 돼지 3종 에셋을 비동기로 불러 안정적인 `RealityEscapePig` 바깥 엔티티 안에서 교체하고, 걷기·정지·놀람 포즈와 1.5→1.0 확대를 담당한다. 돼지는 실제 선택 면의 카메라 쪽 0.28m·바닥 Y에서 나타나 반대편 목적지까지 걷고, 에셋 로드 실패는 `onError`와 고정 안내 문구를 전달한 뒤 재시도 가능한 상태로 복구한다. `RealityHideARView`는 타깃 수락과 돼지 도착을 별도 콜백으로 전달하며 도착 뒤 유효 관찰에서만 놀란 모델 설치→확대→재발견 순서를 한 번 알린다. 카메라 권한·SceneKit 페이드·루트 화면 전환·1.12 화면 확대는 Task 7 범위로 아직 구현하지 않았다. - 실패 기록: `docs/LEARNING_LOG.md`에 실패·검증 한계의 재현 조건·원인/가설·조치·재발 방지 근거를 기록한다. - 마지막 완료 범위: Task 6 — RealityKit 돼지 포즈와 실제 메쉬 뒤 숨기. -- 마지막 검증: 독립 리뷰 수정 뒤 focused `RealityPigVisualControllerTests` 3/3, `RealityHideARViewCoordinatorTests` 7/7, 전체 같은 destination의 XCTest 68/68이 통과했다. 같은 destination의 `xcodebuild ... build`도 `** BUILD SUCCEEDED **`로 완료됐다. 리뷰에서 발견하고 수정한 네 경계는 `docs/LEARNING_LOG.md`의 L-20260811-050~054, Simulator/AppIntents 런타임 경고와 실제 LiDAR·물리 오클루전 실기기 한계는 L-20260810-046~047에 기록했다. +- 마지막 검증: 독립 리뷰 2차 수정 뒤 focused `RealityHideARViewCoordinatorTests` 10/10, `RealityPigVisualControllerTests` 3/3, 전체 같은 destination의 XCTest 71/71이 통과했다. 같은 destination의 `xcodebuild ... build`도 `** BUILD SUCCEEDED **`로 완료됐다. 포즈 실패·표면 기반 배치·actor 경계 학습은 L-20260811-055~057, 앞선 리뷰 경계는 L-20260811-050~054, 실제 LiDAR·물리 오클루전 실기기 한계는 L-20260810-047에 기록했다. - 다음 시작점: 구현 계획의 Task 7 — 자동 SceneKit→RealityKit 전환, 카메라 권한, C3 스타일 안내와 1.12 화면 확대. 이 Task 6에서는 시작하지 않았다. - 차단 요소: 실제 LiDAR 메쉬 세로 면 탭·분류된 바닥·물리 오클루전·카메라 이동 재발견은 L-20260810-047에 `실기기 대기`로 남겼다. Task 6 구현 중 해결한 환경·SDK 타입 실패는 L-20260810-039~045에 기록했다. @@ -22,6 +22,7 @@ | 날짜 | 작업 범위 | 결과 | 검증 | 다음 시작점 | | --- | --- | --- | --- | --- | +| 2026-08-11 | Task 6 독립 리뷰 2차 수정 | running·idle·surprised 에셋 실패를 `Result`로 종결해 대기 또는 숨김 상태로 복구하고 Task 7용 `onError`·고정 한국어 메시지를 추가함. 고정 카메라 전방 시작점 대신 선택 면의 카메라 쪽 0.28m·정확한 바닥 Y에서 시작해 반대편 목적지로 걷도록 함 | 결함별 focused RED 뒤 coordinator 10/10, visual 3/3, 전체 XCTest 71/71 및 iOS Simulator build 성공. 원인·학습은 L-20260811-055~057 | Task 6 재검토 후 Task 7 | | 2026-08-11 | Task 6 독립 리뷰 수정 | 화면 밖·카메라 뒤 관찰을 재발견 입력에서 제외하고, 놀란 모델 설치 뒤에만 확대·발견 콜백을 실행함. 타깃 수락과 돼지 도착 이벤트를 분리하고 도착 전 발견을 막았으며, 유효한 바닥 Y·카메라 전방 시작 위치가 생기기 전 돼지를 비활성화함 | 결함별 RED 확인 후 visual focused 3/3, coordinator focused 7/7, 전체 XCTest 68/68 및 iOS Simulator build 성공. 원인·학습은 L-20260811-050~054 | Task 6 재검토 후 Task 7 | | 2026-08-10 | Task 6 — RealityKit 돼지 포즈와 실제 메쉬 뒤 숨기 | 실제 C3 돼지 3종의 비동기 포즈 교체·높이 정규화·걷기·1.5→1.0 놀람 확대를 안정 바깥 엔티티에 구현하고, LiDAR 지원 guard·수동 AR 세션·scene understanding 4종 옵션·실제 메쉬 세로 면 탭·분류된 바닥·숨기 이동·blocked→visible 1회 재발견을 ARView에 연결함. 카메라/렌즈 transform은 변경하지 않음 | RED에서 두 Task 6 타입 부재를 확인한 뒤 focused 5/5·전체 63/63 XCTest와 iOS Simulator build 성공. 환경·SDK 실패는 L-20260810-039~046, 실제 LiDAR 검증은 L-20260810-047 `실기기 대기` | Task 7 — 자동 SceneKit→RealityKit 전환과 C3 스타일 안내 | | 2026-08-10 | Task 5 — 실제 물체 숨기 계획과 LiDAR 지원 판정 | 세로 면·카메라 거리·근접 바닥을 검증해 카메라 반대편의 정확한 바닥 Y 숨기 위치를 계산하는 순수 계획, 0.03m 허용오차의 1회 재발견 감시, ARKit 호출을 시스템 구현 하나에 한정한 LiDAR 지원 주입 경계와 안내 문자열을 추가함. ARView·RealityKit 렌더링·권한·UI 전환은 추가하지 않음 | RED에서 Reality 계획·지원 타입 부재를 확인한 뒤 focused 8/8·전체 58/58 XCTest 및 iOS Simulator build 성공. 기존 Simulator/AppIntents 경고와 실제 LiDAR·물리 오클루전 실기기 한계는 L-20260810-036~037에 기록 | Task 6 — RealityKit 돼지 포즈와 실제 메쉬 뒤 숨기 | From a32400ff5e47d03f6a41e7cc432e2afeb049120c Mon Sep 17 00:00:00 2001 From: eunseo-com Date: Tue, 11 Aug 2026 00:53:30 +0900 Subject: [PATCH 15/22] Connect C3 world to RealityKit escape --- .../PiggyEscape/Sources/ContentView.swift | 2 +- .../Sources/Escape/EscapeRootView.swift | 354 ++++++++++++++++++ .../EscapeRootCoordinatorTests.swift | 177 +++++++++ docs/LEARNING_LOG.md | 108 ++++++ docs/WORK_LOG.md | 13 +- 5 files changed, 647 insertions(+), 7 deletions(-) create mode 100644 PiggyEscape/PiggyEscape/Sources/Escape/EscapeRootView.swift create mode 100644 PiggyEscape/PiggyEscapeTests/EscapeRootCoordinatorTests.swift diff --git a/PiggyEscape/PiggyEscape/Sources/ContentView.swift b/PiggyEscape/PiggyEscape/Sources/ContentView.swift index 1930a0e..3369bcb 100644 --- a/PiggyEscape/PiggyEscape/Sources/ContentView.swift +++ b/PiggyEscape/PiggyEscape/Sources/ContentView.swift @@ -2,7 +2,7 @@ import SwiftUI struct ContentView: View { var body: some View { - C3ClosedWorldSceneView() + EscapeRootView() .ignoresSafeArea() } } diff --git a/PiggyEscape/PiggyEscape/Sources/Escape/EscapeRootView.swift b/PiggyEscape/PiggyEscape/Sources/Escape/EscapeRootView.swift new file mode 100644 index 0000000..90b105e --- /dev/null +++ b/PiggyEscape/PiggyEscape/Sources/Escape/EscapeRootView.swift @@ -0,0 +1,354 @@ +import AVFoundation +import SwiftUI +import UIKit + +enum CameraAuthorizationResult: Equatable { + case authorized + case denied + case restricted +} + +@MainActor +protocol CameraAuthorizing { + func requestVideoAccess( + _ completion: @escaping @MainActor (CameraAuthorizationResult) -> Void + ) +} + +struct SystemCameraAuthorizer: CameraAuthorizing { + func requestVideoAccess( + _ completion: @escaping @MainActor (CameraAuthorizationResult) -> Void + ) { + switch AVCaptureDevice.authorizationStatus(for: .video) { + case .authorized: + completion(.authorized) + case .denied: + completion(.denied) + case .restricted: + completion(.restricted) + case .notDetermined: + AVCaptureDevice.requestAccess(for: .video) { granted in + let result: CameraAuthorizationResult + if granted { + result = .authorized + } else if AVCaptureDevice.authorizationStatus(for: .video) == .restricted { + result = .restricted + } else { + result = .denied + } + Task { @MainActor in completion(result) } + } + @unknown default: + completion(.restricted) + } + } +} + +@MainActor +protocol AppSettingsOpening { + func openAppSettings() +} + +struct SystemAppSettingsOpener: AppSettingsOpening { + func openAppSettings() { + guard let url = URL(string: UIApplication.openSettingsURLString) else { return } + UIApplication.shared.open(url) + } +} + +enum EscapeRootMessage { + static let surprised = "아, 들켰네… 제대로 숨고 싶은데." + static let cameraDenied = "카메라 권한이 필요해. 설정에서 카메라를 허용해줘." + static let cameraRestricted = "이 기기에서는 카메라 사용이 제한되어 있어. 설정을 확인해줘." + static let walkingToRealObject = "피기가 숨으러 가고 있어." + static let findPig = "직접 움직여서 피기를 찾아봐." +} + +enum EscapeRootMotion { + static let closedWorldFadeDuration: TimeInterval = 0.70 + static let surpriseGrowDuration: TimeInterval = 0.16 + static let surpriseRestoreDuration: TimeInterval = 0.34 + static func realityPeakScale(reduceMotion: Bool) -> CGFloat { + reduceMotion ? 1 : 1.12 + } +} + +@MainActor +final class EscapeRootCoordinator: ObservableObject { + @Published private(set) var machine = EscapeExperienceMachine() + @Published private(set) var message: String? + @Published private(set) var isClosedWorldFading = false + @Published private(set) var cameraAuthorizationResult: CameraAuthorizationResult? + @Published private(set) var realitySurpriseSequence = 0 + @Published private(set) var realityErrorCount = 0 + + var showsRealityView: Bool { + switch machine.state { + case .scanningReality, .waitingForRealTarget, .walkingBehindRealObject, + .hiddenInReality, .discoveredInReality: + true + default: + false + } + } + + var showsSettingsRecovery: Bool { + machine.state == .cameraDenied + } + + private let cameraAuthorizer: any CameraAuthorizing + private let settingsOpener: any AppSettingsOpening + private var hasRequestedCamera = false + + init() { + cameraAuthorizer = SystemCameraAuthorizer() + settingsOpener = SystemAppSettingsOpener() + } + + init( + cameraAuthorizer: any CameraAuthorizing, + settingsOpener: any AppSettingsOpening + ) { + self.cameraAuthorizer = cameraAuthorizer + self.settingsOpener = settingsOpener + } + + func closedWorldNarrationDidFinish() { + _ = machine.send(.narrationFinished) + } + + @discardableResult + func closedWorldDiscoveryDidOccur() -> Bool { + synchronizeClosedWorldProgress() + guard machine.send(.closedWorldPigDiscovered) else { return false } + message = EscapeRootMessage.surprised + isClosedWorldFading = true + return true + } + + func closedWorldFadeDidFinish() { + guard !hasRequestedCamera, + machine.send(.closedWorldFadeFinished) else { return } + hasRequestedCamera = true + cameraAuthorizer.requestVideoAccess { [weak self] result in + self?.cameraAuthorizationDidResolve(result) + } + } + + func realityScanningDidBecomeReady() { + guard machine.send(.meshSupported) else { return } + message = RealityAvailabilityMessage.selectVerticalSide + } + + func realityMeshDidBecomeUnavailable() { + guard machine.send(.meshUnsupported) else { return } + message = RealityAvailabilityMessage.unavailable + } + + func realityTargetDidBecomeAccepted() { + guard machine.send(.realTargetAccepted) else { return } + message = EscapeRootMessage.walkingToRealObject + } + + func realityPigDidReachTarget() { + guard machine.send(.pigReachedRealObject) else { return } + message = EscapeRootMessage.findPig + } + + func realityPigDidBecomeRevealed() { + guard machine.send(.realityPigDiscovered) else { return } + message = EscapeRootMessage.surprised + realitySurpriseSequence += 1 + } + + func realityErrorDidOccur() { + realityErrorCount += 1 + } + + func realityMessageDidChange(_ message: String) { + self.message = message + } + + func openSettingsForRecovery() { + guard showsSettingsRecovery else { return } + settingsOpener.openAppSettings() + } + + private func synchronizeClosedWorldProgress() { + if machine.state == .openingNarration { + _ = machine.send(.narrationFinished) + } + if machine.state == .readyForPigTap { + _ = machine.send(.pigTapped) + } + if machine.state == .walkingBehindTree { + _ = machine.send(.pigReachedTree) + } + } + + private func cameraAuthorizationDidResolve(_ result: CameraAuthorizationResult) { + guard machine.state == .requestingCameraPermission else { return } + cameraAuthorizationResult = result + switch result { + case .authorized: + guard machine.send(.cameraAuthorized) else { return } + message = RealityAvailabilityMessage.scanFirst + case .denied: + guard machine.send(.cameraDenied) else { return } + message = EscapeRootMessage.cameraDenied + case .restricted: + guard machine.send(.cameraDenied) else { return } + message = EscapeRootMessage.cameraRestricted + } + } +} + +struct EscapeRootView: View { + @Environment(\.accessibilityReduceMotion) private var accessibilityReduceMotion + @StateObject private var coordinator: EscapeRootCoordinator + @State private var fadeTask: Task? + @State private var realityScaleTask: Task? + @State private var realityScreenScale: CGFloat = 1 + + @MainActor + init() { + _coordinator = StateObject(wrappedValue: EscapeRootCoordinator()) + } + + @MainActor + init( + cameraAuthorizer: any CameraAuthorizing, + settingsOpener: any AppSettingsOpening + ) { + _coordinator = StateObject(wrappedValue: EscapeRootCoordinator( + cameraAuthorizer: cameraAuthorizer, + settingsOpener: settingsOpener + )) + } + + var body: some View { + ZStack { + LinearGradient( + colors: [ + Color(red: 0.64, green: 0.93, blue: 0.99), + Color(red: 0.41, green: 0.80, blue: 0.92) + ], + startPoint: .top, + endPoint: .bottom + ) + .ignoresSafeArea() + + C3ClosedWorldSceneView( + onNarrationFinished: coordinator.closedWorldNarrationDidFinish, + onDiscovered: beginClosedWorldFade + ) + .opacity(coordinator.isClosedWorldFading ? 0 : 1) + .allowsHitTesting(!coordinator.isClosedWorldFading) + .animation( + .easeInOut(duration: EscapeRootMotion.closedWorldFadeDuration), + value: coordinator.isClosedWorldFading + ) + + if coordinator.showsRealityView { + RealityHideARView( + onScanningReady: coordinator.realityScanningDidBecomeReady, + onTargetAccepted: coordinator.realityTargetDidBecomeAccepted, + onPigReachedTarget: coordinator.realityPigDidReachTarget, + onRevealed: coordinator.realityPigDidBecomeRevealed, + onError: coordinator.realityErrorDidOccur, + onUnavailable: coordinator.realityMeshDidBecomeUnavailable, + onMessage: coordinator.realityMessageDidChange + ) + .scaleEffect(realityScreenScale) + .clipped() + .transition(.opacity) + } + + messageOverlay + } + .animation(.easeInOut(duration: 0.22), value: coordinator.showsRealityView) + .onChange(of: coordinator.realitySurpriseSequence) { _, sequence in + guard sequence > 0 else { return } + performRealitySurprise() + } + .onDisappear { + fadeTask?.cancel() + realityScaleTask?.cancel() + } + } + + @ViewBuilder + private var messageOverlay: some View { + if let message = coordinator.message, + coordinator.machine.state != .discoveredByCamera, + coordinator.machine.state != .requestingCameraPermission { + VStack { + Spacer() + VStack(spacing: 12) { + Text(message) + .font(.system(size: 20, weight: .heavy, design: .rounded)) + .foregroundStyle(.white) + .multilineTextAlignment(.center) + .shadow(color: .black.opacity(0.18), radius: 2, y: 1) + + if coordinator.showsSettingsRecovery { + Button("설정 열기", action: coordinator.openSettingsForRecovery) + .font(.system(size: 17, weight: .bold, design: .rounded)) + .foregroundStyle(.black) + .padding(.horizontal, 20) + .frame(minHeight: 44) + .background(Color.yellow, in: Capsule()) + .accessibilityHint("앱의 카메라 권한 설정을 엽니다") + } + } + .padding(.horizontal, 24) + .padding(.vertical, 18) + .frame(maxWidth: 520) + .background { + RoundedRectangle(cornerRadius: 24) + .fill(.ultraThinMaterial) + .overlay { + RoundedRectangle(cornerRadius: 24) + .fill(.black.opacity(0.34)) + } + } + .padding(.horizontal, 24) + .padding(.bottom, 36) + } + .transition( + accessibilityReduceMotion + ? .opacity + : .opacity.combined(with: .scale(scale: 0.96)) + ) + } + } + + private func beginClosedWorldFade() { + guard coordinator.closedWorldDiscoveryDidOccur() else { return } + fadeTask?.cancel() + fadeTask = Task { @MainActor [weak coordinator] in + try? await Task.sleep(nanoseconds: 700_000_000) + guard !Task.isCancelled else { return } + coordinator?.closedWorldFadeDidFinish() + } + } + + private func performRealitySurprise() { + realityScaleTask?.cancel() + realityScreenScale = 1 + let peakScale = EscapeRootMotion.realityPeakScale( + reduceMotion: accessibilityReduceMotion + ) + guard peakScale > 1 else { return } + realityScaleTask = Task { @MainActor in + withAnimation(.easeOut(duration: EscapeRootMotion.surpriseGrowDuration)) { + realityScreenScale = peakScale + } + try? await Task.sleep(nanoseconds: 160_000_000) + guard !Task.isCancelled else { return } + withAnimation(.easeInOut(duration: EscapeRootMotion.surpriseRestoreDuration)) { + realityScreenScale = 1 + } + } + } +} diff --git a/PiggyEscape/PiggyEscapeTests/EscapeRootCoordinatorTests.swift b/PiggyEscape/PiggyEscapeTests/EscapeRootCoordinatorTests.swift new file mode 100644 index 0000000..d6a73fc --- /dev/null +++ b/PiggyEscape/PiggyEscapeTests/EscapeRootCoordinatorTests.swift @@ -0,0 +1,177 @@ +import XCTest +@testable import PiggyEscape + +@MainActor +final class EscapeRootCoordinatorTests: XCTestCase { + func test_closedWorldDiscoveryWaitsForFadeBeforeRequestingCamera() { + let authorizer = FakeCameraAuthorizer() + let coordinator = makeCoordinator(authorizer: authorizer) + + coordinator.closedWorldNarrationDidFinish() + coordinator.closedWorldDiscoveryDidOccur() + + XCTAssertEqual(coordinator.machine.state, .discoveredByCamera) + XCTAssertEqual(coordinator.message, EscapeRootMessage.surprised) + XCTAssertTrue(coordinator.isClosedWorldFading) + XCTAssertEqual(authorizer.requestCount, 0) + + coordinator.closedWorldFadeDidFinish() + + XCTAssertEqual(coordinator.machine.state, .requestingCameraPermission) + XCTAssertEqual(authorizer.requestCount, 1) + XCTAssertFalse(coordinator.showsRealityView) + + coordinator.closedWorldFadeDidFinish() + XCTAssertEqual(authorizer.requestCount, 1) + } + + func test_authorizedCameraStartsRealityThenMeshSupportEnablesTargetSelection() { + let authorizer = FakeCameraAuthorizer() + let coordinator = makeCoordinator(authorizer: authorizer) + reachCameraRequest(coordinator) + + authorizer.resolve(.authorized) + + XCTAssertEqual(coordinator.machine.state, .scanningReality) + XCTAssertTrue(coordinator.showsRealityView) + XCTAssertEqual(coordinator.message, RealityAvailabilityMessage.scanFirst) + + coordinator.realityScanningDidBecomeReady() + XCTAssertEqual(coordinator.machine.state, .waitingForRealTarget) + XCTAssertEqual(coordinator.message, RealityAvailabilityMessage.selectVerticalSide) + } + + func test_deniedCameraShowsRecoveryAndOpensSettingsOnlyAfterTap() { + let authorizer = FakeCameraAuthorizer() + let settings = FakeSettingsOpener() + let coordinator = makeCoordinator(authorizer: authorizer, settings: settings) + reachCameraRequest(coordinator) + + authorizer.resolve(.denied) + + XCTAssertEqual(coordinator.machine.state, .cameraDenied) + XCTAssertFalse(coordinator.showsRealityView) + XCTAssertTrue(coordinator.showsSettingsRecovery) + XCTAssertEqual(coordinator.message, EscapeRootMessage.cameraDenied) + XCTAssertEqual(settings.openCount, 0) + + coordinator.openSettingsForRecovery() + XCTAssertEqual(settings.openCount, 1) + } + + func test_restrictedCameraShowsItsOwnBlockedMessageAndWaitsForRecoveryTap() { + let authorizer = FakeCameraAuthorizer() + let settings = FakeSettingsOpener() + let coordinator = makeCoordinator(authorizer: authorizer, settings: settings) + reachCameraRequest(coordinator) + + authorizer.resolve(.restricted) + + XCTAssertEqual(coordinator.machine.state, .cameraDenied) + XCTAssertFalse(coordinator.showsRealityView) + XCTAssertEqual(coordinator.message, EscapeRootMessage.cameraRestricted) + XCTAssertEqual(settings.openCount, 0) + + coordinator.openSettingsForRecovery() + XCTAssertEqual(settings.openCount, 1) + } + + func test_realityCallbacksAdvanceInPhysicalActionOrderAndRevealOnce() { + let authorizer = FakeCameraAuthorizer() + let coordinator = makeCoordinator(authorizer: authorizer) + reachCameraRequest(coordinator) + authorizer.resolve(.authorized) + coordinator.realityScanningDidBecomeReady() + + coordinator.realityTargetDidBecomeAccepted() + XCTAssertEqual(coordinator.machine.state, .walkingBehindRealObject) + + coordinator.realityPigDidReachTarget() + XCTAssertEqual(coordinator.machine.state, .hiddenInReality) + XCTAssertEqual(coordinator.message, EscapeRootMessage.findPig) + + coordinator.realityPigDidBecomeRevealed() + XCTAssertEqual(coordinator.machine.state, .discoveredInReality) + XCTAssertEqual(coordinator.message, EscapeRootMessage.surprised) + XCTAssertEqual(coordinator.realitySurpriseSequence, 1) + XCTAssertEqual(EscapeRootMotion.realityPeakScale(reduceMotion: false), 1.12, accuracy: 0.0001) + + coordinator.realityPigDidBecomeRevealed() + XCTAssertEqual(coordinator.realitySurpriseSequence, 1) + } + + func test_outOfOrderRealityCallbacksDoNotSkipStates() { + let coordinator = makeCoordinator(authorizer: FakeCameraAuthorizer()) + + coordinator.realityPigDidReachTarget() + coordinator.realityPigDidBecomeRevealed() + + XCTAssertEqual(coordinator.machine.state, .openingNarration) + XCTAssertEqual(coordinator.realitySurpriseSequence, 0) + } + + func test_unavailableAndTaskSixErrorMessagesRemainVisible() { + let authorizer = FakeCameraAuthorizer() + let coordinator = makeCoordinator(authorizer: authorizer) + reachCameraRequest(coordinator) + authorizer.resolve(.authorized) + + coordinator.realityMeshDidBecomeUnavailable() + XCTAssertEqual(coordinator.machine.state, .lidarUnavailable) + XCTAssertFalse(coordinator.showsRealityView) + XCTAssertEqual(coordinator.message, RealityAvailabilityMessage.unavailable) + + coordinator.realityErrorDidOccur() + coordinator.realityMessageDidChange(RealityAvailabilityMessage.pigAssetLoadFailed) + XCTAssertEqual(coordinator.realityErrorCount, 1) + XCTAssertEqual(coordinator.message, RealityAvailabilityMessage.pigAssetLoadFailed) + } + + func test_reducedMotionDoesNotDigitallyScaleTheRealityCameraSurface() { + XCTAssertEqual(EscapeRootMotion.realityPeakScale(reduceMotion: false), 1.12, accuracy: 0.0001) + XCTAssertEqual(EscapeRootMotion.realityPeakScale(reduceMotion: true), 1.0, accuracy: 0.0001) + } + + private func makeCoordinator( + authorizer: FakeCameraAuthorizer, + settings: FakeSettingsOpener? = nil + ) -> EscapeRootCoordinator { + EscapeRootCoordinator( + cameraAuthorizer: authorizer, + settingsOpener: settings ?? FakeSettingsOpener() + ) + } + + private func reachCameraRequest(_ coordinator: EscapeRootCoordinator) { + coordinator.closedWorldNarrationDidFinish() + coordinator.closedWorldDiscoveryDidOccur() + coordinator.closedWorldFadeDidFinish() + } +} + +@MainActor +private final class FakeCameraAuthorizer: CameraAuthorizing { + private(set) var requestCount = 0 + private var completion: ((CameraAuthorizationResult) -> Void)? + + func requestVideoAccess( + _ completion: @escaping @MainActor (CameraAuthorizationResult) -> Void + ) { + requestCount += 1 + self.completion = completion + } + + func resolve(_ result: CameraAuthorizationResult) { + completion?(result) + completion = nil + } +} + +@MainActor +private final class FakeSettingsOpener: AppSettingsOpening { + private(set) var openCount = 0 + + func openAppSettings() { + openCount += 1 + } +} diff --git a/docs/LEARNING_LOG.md b/docs/LEARNING_LOG.md index 888c17f..68a2ec2 100644 --- a/docs/LEARNING_LOG.md +++ b/docs/LEARNING_LOG.md @@ -28,6 +28,114 @@ ## 항목 +### L-20260811-066 — 최종 `tuist test`가 성공 코드와 함께 테스트를 건너뜀 + +- 상태: 해결 +- 발생 태스크: Task 7 — 자동 SceneKit→RealityKit 전환과 C3 스타일 안내 +- 재현: `PiggyEscape` 디렉터리에서 최종 검증으로 `tuist test PiggyEscape` 실행 +- 관찰: exit 0이었지만 `The scheme PiggyEscape's test action has no tests to run, finishing early.`가 출력되어 XCTest 79개가 실제 실행되지 않음 +- 영향: exit code만으로는 최종 전체 회귀 성공을 주장할 수 없으며, 생성된 scheme 또는 Tuist 명령의 테스트 대상 해석을 확인해야 함 +- 원인/가설: `tuist test PiggyEscape`의 testing용 생성 결과에서 `PiggyEscapeTests` 타깃과 scheme testable이 빠진 것을 직접 확인했다. 같은 manifest를 `tuist generate --no-open`로 일반 생성하면 테스트 타깃과 scheme testable이 복원되어 manifest·테스트 소스 부재는 원인이 아니었으며, 현재 Tuist testing 생성 경로의 해석 차이가 원인으로 남음 +- 조치: 빈 test action의 exit 0을 성공으로 집계하지 않았다. 일반 프로젝트 생성 뒤 명시적인 `xcodebuild -project PiggyEscape.xcodeproj -scheme PiggyEscape -destination 'platform=iOS Simulator,name=iPhone 17 Pro' test`를 전체 회귀의 기준 명령으로 사용함 +- 검증: 명시적 Xcode scheme 실행에서 79/79, 0 failures와 `** TEST SUCCEEDED **`를 확인함 +- 배운 점: `tuist test`의 exit 0보다 XCTest case 개수와 `TEST SUCCEEDED` 출력을 완료 근거로 우선한다. + +### L-20260811-065 — Task 7 실제 카메라 권한·자동 AR 전환·화면 확대는 실기기 대기 + +- 상태: 실기기 대기 +- 발생 태스크: Task 7 — 자동 SceneKit→RealityKit 전환과 C3 스타일 안내 +- 재현: LiDAR 지원 iPhone에서 C3 돼지를 나무 뒤로 보내고 카메라 회전으로 발견한 뒤 0.70초 페이드, 시스템 카메라 권한, AR 스캔 화면 전환을 관찰함. 거부·제한 경로에서는 Settings 버튼을 직접 탭하고, 현실 재발견에서는 돼지 1.5배와 화면 1.12배 동시 반응을 관찰함 +- 관찰: 현재 환경에서는 주입 fake로 권한 허용·거부·제한과 callback 순서를 자동 검증하고 iOS Simulator build를 확인했지만, 실제 시스템 권한 문구·실기기 카메라 영상·LiDAR 메쉬·물리 오클루전·체감 확대는 실행하지 못함 +- 영향: 실제 기기에서 시스템 알림의 시점, 앱 복귀 후 상태, 0.70초 페이드와 카메라 전환의 시각 연속성, 화면 확대의 체감 품질은 아직 확정할 수 없음 +- 원인/가설: Simulator와 단위 테스트가 실제 카메라 센서·LiDAR 공간 재구성과 시스템 권한 상호작용을 제공하지 않는 하드웨어 검증 경계임 +- 조치: 권한 요청은 페이드 완료 callback 뒤 한 번만 발생하도록 하고 실제 AR camera transform을 변경하지 않는 화면 container scale로 구현했으며, 실기기 절차는 Task 8 수동 검증으로 넘김 +- 검증: 자동 근거는 Task 7 focused 8/8, 전체 79/79, Simulator build 성공이며 실제 기기 결과는 기록하지 않음 +- 배운 점: 권한 fake와 Simulator build가 통과해도 시스템 권한 UI·카메라 영상·물리 오클루전·전환 감각은 실기기 관찰 전에는 완료로 단정하지 않는다. + +### L-20260811-064 — Task 7 focused XCTest는 통과했지만 기존 Simulator 런타임 경고가 반복됨 + +- 상태: 보류 +- 발생 태스크: Task 7 — 자동 SceneKit→RealityKit 전환과 C3 스타일 안내 +- 재현: Task 7 coordinator focused XCTest 8개 실행 +- 관찰: 8/8과 `** TEST SUCCEEDED **`를 확인했지만 AppIntents metadata skip, `TBB Global TLS count is not == 1`, `SCNView` focus caching 경고가 함께 출력됨 +- 영향: Task 7 assertion과 앱 컴파일은 성공했지만 테스트 로그가 경고 없이 깨끗한 상태는 아님 +- 원인/가설: L-20260810-046 등에서 이미 관찰한 앱 타깃·SceneKit Simulator 런타임 진단이 같은 조건에서 반복된 것이며 새 루트 상태 전이 assertion 실패는 아님 +- 조치: Task 7 범위 밖 Simulator/AppIntents 설정은 변경하지 않고 focused·전체 검증 결과와 분리해 보류함 +- 검증: focused `EscapeRootCoordinatorTests` 8/8, 명시적 Xcode scheme 전체 XCTest 79/79, iPhone 17 Pro Simulator 대상 build가 모두 통과했으며 기존 경고만 별도로 남음 +- 배운 점: 전환 coordinator의 회귀 결과와 반복되는 SceneKit Simulator 진단을 하나의 성공 신호로 합치지 않는다. + +### L-20260811-063 — RealityKit 화면 확대가 Reduce Motion 설정을 구분하지 않음 + +- 상태: 해결 +- 발생 태스크: Task 7 — 자동 SceneKit→RealityKit 전환과 C3 스타일 안내 +- 재현: Reduce Motion 켜짐/꺼짐 입력에 따라 AR 화면 확대 목표값이 달라지는 focused 테스트를 추가하고 실행함 +- 관찰: 기존 모션 계약은 1.12 상수만 제공해 접근성 설정을 입력받을 함수가 없었고, 테스트는 `cannot call value of non-function type 'CGFloat'`의 의도적인 RED로 종료됨 +- 영향: 동작 줄이기를 선호하는 사용자에게도 실제 카메라 화면이 강제로 확대되어 불편이나 멀미를 유발할 수 있음 +- 원인/가설: 승인된 연출의 최대 확대값만 모델링하고 SwiftUI의 `accessibilityReduceMotion` 환경값을 모션 결정에 포함하지 않은 것이 원인임 +- 조치: 모션 계약이 접근성 설정을 입력받아 일반 모드에는 1.12, Reduce Motion에는 1.0을 반환하게 하고 View의 확대 실행도 같은 값을 사용하도록 함 +- 검증: `test_reducedMotionDoesNotDigitallyScaleTheRealityCameraSurface`를 포함한 Task 7 focused XCTest 8/8이 통과함 +- 배운 점: 카메라처럼 화면 전체를 움직이는 연출은 기능 요구 수치와 함께 시스템 Reduce Motion 대안을 처음부터 계약으로 둔다. + +### L-20260811-062 — 프로토콜 적합성으로 격리된 시스템 기본값을 View 기본 인자에서 생성할 수 없음 + +- 상태: 해결 +- 발생 태스크: Task 7 — 자동 SceneKit→RealityKit 전환과 C3 스타일 안내 +- 재현: Tuist 재생성 뒤 Task 7 focused XCTest로 `EscapeRootView.swift`를 컴파일함 +- 관찰: `CameraAuthorizing`와 `AppSettingsOpening`의 `@MainActor` 적합성으로 시스템 구현의 초기화도 격리됐고, View initializer의 default argument 평가 지점은 동기 비격리로 판단되어 두 `init()` 호출이 컴파일 오류가 됨 +- 영향: 앱 모듈 생성이 중단되어 focused assertion을 실행하지 못함 +- 원인/가설: initializer 선언에 `@MainActor`를 붙여도 기본 인자 표현식의 평가 격리를 보장하지 않는 Swift 기본 인자 규칙이 확인된 원인임 +- 조치: 첫 수정에서 View initializer만 분리했으나 coordinator initializer에도 같은 기본 인자가 남은 사실을 재컴파일로 확인했다. 두 타입 모두 시스템 구현을 만드는 무인자 격리 initializer와 주입 객체만 받는 initializer를 분리해 기본 인자 표현식을 제거함 +- 검증: 두 initializer의 기본 인자를 제거한 뒤 focused XCTest 7/7이 통과했고 해당 actor 격리 오류가 다시 출력되지 않음 +- 배운 점: actor-isolated 의존성은 기본 인자에서 즉석 생성하지 말고 격리된 convenience 경계에서 명시적으로 생성한다. + +### L-20260811-061 — Task 7 첫 GREEN 컴파일에 새 루트 소스가 생성 프로젝트에 포함되지 않음 + +- 상태: 해결 +- 발생 태스크: Task 7 — 자동 SceneKit→RealityKit 전환과 C3 스타일 안내 +- 재현: RED 프로젝트 생성 뒤 `EscapeRootView.swift`를 새로 추가하고 같은 focused XCTest를 실행함 +- 관찰: 앱의 Swift 파일 목록에 새 파일이 없고 `ContentView.swift: cannot find 'EscapeRootView' in scope`로 컴파일이 중단됨 +- 영향: 루트 구현 자체의 컴파일과 테스트 assertion까지 아직 도달하지 못함 +- 원인/가설: Tuist 프로젝트는 glob을 생성 시점에 해석하는데 RED 때는 테스트 파일만 존재했고, production 파일은 그 뒤 추가되어 기존 `.xcodeproj` 소스 목록에 반영되지 않은 것이 확인된 원인임 +- 조치: production 소스 추가 뒤 `tuist generate --no-open`를 다시 실행해 프로젝트 파일 목록을 갱신함 +- 검증: 재생성 뒤 같은 focused XCTest의 앱 Swift 파일 목록에 `EscapeRootView.swift`가 포함됐고 해당 파일 컴파일까지 진행됨 +- 배운 점: Tuist glob 프로젝트에서 RED 후 새 production 파일을 만들면 GREEN 검증 전에 반드시 프로젝트를 다시 생성한다. + +### L-20260811-060 — Task 7 RED가 루트 전환 계약의 구현 부재를 확인함 + +- 상태: 해결 +- 발생 태스크: Task 7 — 자동 SceneKit→RealityKit 전환과 C3 스타일 안내 +- 재현: 환경 권한을 확보한 뒤 Task 7 coordinator focused XCTest를 실행함 +- 관찰: `EscapeRootCoordinator`, `CameraAuthorizing`, `CameraAuthorizationResult`, `AppSettingsOpening`, 루트 문구·모션 계약을 찾지 못해 테스트 타깃 컴파일이 `** TEST FAILED **`로 종료됨 +- 영향: C3 발견 뒤 페이드·카메라 권한·RealityKit 상태 연결과 복구 UI의 공개 계약이 아직 구현되지 않았음을 확인함 +- 원인/가설: Task 6까지는 C3·AR 화면 내부 콜백만 존재하며 두 화면을 소유하는 Task 7 루트 타입은 계획대로 아직 추가되지 않았음 +- 조치: 실패한 테스트가 요구하는 순수 상태 coordinator, 시스템 권한·Settings 주입 경계, SwiftUI 루트 화면을 최소 구현함 +- 검증: 구현 뒤 같은 focused XCTest 8/8, 명시적 Xcode scheme 전체 XCTest 79/79, iPhone 17 Pro Simulator 대상 build가 모두 통과함 +- 배운 점: 센서 UI 자체를 단위 테스트하지 않더라도 권한 요청 시점과 물리 이벤트 순서는 주입 가능한 상위 coordinator에서 먼저 고정할 수 있다. + +### L-20260811-059 — Task 7 focused RED가 Simulator 서비스 접근 제한으로 중단됨 + +- 상태: 해결 +- 발생 태스크: Task 7 — 자동 SceneKit→RealityKit 전환과 C3 스타일 안내 +- 재현: `xcodebuild -project PiggyEscape/PiggyEscape.xcodeproj -scheme PiggyEscape -destination 'platform=iOS Simulator,name=iPhone 17 Pro' -only-testing:PiggyEscapeTests/EscapeRootCoordinatorTests test` +- 관찰: `CoreSimulatorService connection became invalid`, Simulator 로그 접근 거부, `Unable to find a device matching`이 발생해 새 루트 타입 부재 컴파일 단계 전에 exit 70으로 종료됨 +- 영향: 권한 전환 테스트가 의도한 기능 부재 RED인지 아직 확인하지 못함 +- 원인/가설: Simulator 서비스·Xcode DerivedData가 현재 작업 트리 쓰기 권한 밖인 기존 환경 경계와 같은 증상임 +- 조치: 이 기록을 남긴 뒤 Simulator와 Xcode 상태 경로에 접근 가능한 권한으로 같은 focused 테스트를 한 번 재실행함 +- 검증: 권한 재실행이 Simulator를 찾고 테스트 타깃 컴파일까지 진행해 `EscapeRootCoordinator` 등 새 타입 부재의 의도적인 RED에 도달함 +- 배운 점: 기능 RED 전에 Simulator 환경이 실패하면 성공·실패 의미를 섞지 않고 같은 명령을 환경 권한만 바꿔 재현한다. + +### L-20260811-058 — Task 7 RED 준비의 Tuist 생성이 세션 상태 디렉터리 권한으로 중단됨 + +- 상태: 해결 +- 발생 태스크: Task 7 — 자동 SceneKit→RealityKit 전환과 C3 스타일 안내 +- 재현: `PiggyEscape` 디렉터리에서 `tuist generate --no-open` 실행 +- 관찰: `Permission denied: /Users/yang-eunseo/.local/state/tuist/sessions/...`로 새 루트 coordinator 테스트를 생성 프로젝트에 반영하기 전에 종료됨 +- 영향: 카메라 권한·페이드·RealityKit 전환 타입 부재를 의도한 RED로 아직 확인하지 못함 +- 원인/가설: 이전 L-20260810-039와 같은 Tuist 사용자 세션 상태 경로가 현재 작업 트리 쓰기 권한 밖에 있는 환경 제약으로 보임 +- 조치: 이 기록을 먼저 남긴 뒤 해당 상태 경로에 접근 가능한 권한으로 같은 생성 명령을 한 번 재실행함 +- 검증: 권한 재실행 `tuist generate --no-open`가 `✔ Success`로 완료되어 새 테스트 파일이 생성 프로젝트에 반영됨 +- 배운 점: 새 테스트 파일을 추가한 태스크에서는 테스트 전에 Tuist 생성 단계의 환경 실패와 기능 부재 RED를 분리해 기록한다. + ### L-20260811-057 — 테스트 기본 RealityKit 로더의 actor 격리 계약이 누락됨 - 상태: 해결 diff --git a/docs/WORK_LOG.md b/docs/WORK_LOG.md index 9ed3eab..59c844d 100644 --- a/docs/WORK_LOG.md +++ b/docs/WORK_LOG.md @@ -4,13 +4,13 @@ ## 현재 인수인계 -- 상태: Task 6 — RealityKit 돼지 포즈와 실제 메쉬 뒤 숨기 완료 -- 진행 중 범위: `RealityPigVisualController`가 실제 C3 돼지 3종 에셋을 비동기로 불러 안정적인 `RealityEscapePig` 바깥 엔티티 안에서 교체하고, 걷기·정지·놀람 포즈와 1.5→1.0 확대를 담당한다. 돼지는 실제 선택 면의 카메라 쪽 0.28m·바닥 Y에서 나타나 반대편 목적지까지 걷고, 에셋 로드 실패는 `onError`와 고정 안내 문구를 전달한 뒤 재시도 가능한 상태로 복구한다. `RealityHideARView`는 타깃 수락과 돼지 도착을 별도 콜백으로 전달하며 도착 뒤 유효 관찰에서만 놀란 모델 설치→확대→재발견 순서를 한 번 알린다. 카메라 권한·SceneKit 페이드·루트 화면 전환·1.12 화면 확대는 Task 7 범위로 아직 구현하지 않았다. +- 상태: Task 7 — 자동 SceneKit→RealityKit 전환과 C3 스타일 안내 구현 완료, 독립 검토 대기 +- 진행 중 범위: 앱 진입점은 `EscapeRootView` 하나로 교체됐다. C3 발견 callback은 놀란 상태를 기록하고 0.70초 페이드가 끝난 뒤에만 카메라 권한을 한 번 요청한다. 허용 시 `RealityHideARView`를 자동으로 열고 Task 6의 스캔 준비·타깃 수락·돼지 도착·재발견·오류 callback을 Task 2 상태 순서로 연결한다. 거부·제한은 각각 안내한 뒤 사용자가 직접 누른 경우에만 앱 Settings를 연다. 현실 재발견에는 ARView 컨테이너 1.12→1.0 확대가 추가됐고 실제 AR camera transform은 변경하지 않으며 Reduce Motion에서는 화면 확대를 생략한다. - 실패 기록: `docs/LEARNING_LOG.md`에 실패·검증 한계의 재현 조건·원인/가설·조치·재발 방지 근거를 기록한다. -- 마지막 완료 범위: Task 6 — RealityKit 돼지 포즈와 실제 메쉬 뒤 숨기. -- 마지막 검증: 독립 리뷰 2차 수정 뒤 focused `RealityHideARViewCoordinatorTests` 10/10, `RealityPigVisualControllerTests` 3/3, 전체 같은 destination의 XCTest 71/71이 통과했다. 같은 destination의 `xcodebuild ... build`도 `** BUILD SUCCEEDED **`로 완료됐다. 포즈 실패·표면 기반 배치·actor 경계 학습은 L-20260811-055~057, 앞선 리뷰 경계는 L-20260811-050~054, 실제 LiDAR·물리 오클루전 실기기 한계는 L-20260810-047에 기록했다. -- 다음 시작점: 구현 계획의 Task 7 — 자동 SceneKit→RealityKit 전환, 카메라 권한, C3 스타일 안내와 1.12 화면 확대. 이 Task 6에서는 시작하지 않았다. -- 차단 요소: 실제 LiDAR 메쉬 세로 면 탭·분류된 바닥·물리 오클루전·카메라 이동 재발견은 L-20260810-047에 `실기기 대기`로 남겼다. Task 6 구현 중 해결한 환경·SDK 타입 실패는 L-20260810-039~045에 기록했다. +- 마지막 완료 범위: Task 7 — 자동 SceneKit→RealityKit 전환과 C3 스타일 안내 구현. +- 마지막 검증: focused `EscapeRootCoordinatorTests` 8/8, 명시적 Xcode scheme 전체 XCTest 79/79, iPhone 17 Pro Simulator 대상 `xcodebuild ... build`가 `** BUILD SUCCEEDED **`로 완료됐다. 권한·Tuist·actor·Reduce Motion 학습은 L-20260811-058~066에 기록했다. +- 다음 시작점: Task 7 독립 검토 뒤 구현 계획의 Task 8 — DocC·프로젝트 컨텍스트·실기기 검증 정리. +- 차단 요소: 실제 카메라 권한 UI·0.70초 시각 전환·Settings 복귀·1.12 화면 확대와 기존 LiDAR 메쉬·물리 오클루전은 L-20260811-065 및 L-20260810-047에 `실기기 대기`로 남겼다. ### Task 7에서 해결한 항목 @@ -22,6 +22,7 @@ | 날짜 | 작업 범위 | 결과 | 검증 | 다음 시작점 | | --- | --- | --- | --- | --- | +| 2026-08-11 | Task 7 — 자동 SceneKit→RealityKit 전환과 C3 스타일 안내 | C3 발견 뒤 0.70초 페이드 완료 시점에만 시스템 카메라 권한을 한 번 요청하고, 허용 시 AR 화면을 자동으로 열어 Task 6의 스캔·선택·도착·재발견·오류 callback을 상태 기계에 연결함. 거부·제한 안내와 명시적 Settings 복구, C3 스타일 material 패널, AR 화면 1.12→1.0 확대와 Reduce Motion 대안을 추가하고 `ContentView`를 새 루트로 교체함 | 루트 전환 TDD RED 뒤 focused 8/8, 명시적 Xcode scheme 전체 XCTest 79/79, iOS Simulator build 성공. 실패·환경·접근성 학습은 L-20260811-058~066, 실제 기기 대기는 L-20260811-065 | Task 7 독립 검토 후 Task 8 | | 2026-08-11 | Task 6 독립 리뷰 2차 수정 | running·idle·surprised 에셋 실패를 `Result`로 종결해 대기 또는 숨김 상태로 복구하고 Task 7용 `onError`·고정 한국어 메시지를 추가함. 고정 카메라 전방 시작점 대신 선택 면의 카메라 쪽 0.28m·정확한 바닥 Y에서 시작해 반대편 목적지로 걷도록 함 | 결함별 focused RED 뒤 coordinator 10/10, visual 3/3, 전체 XCTest 71/71 및 iOS Simulator build 성공. 원인·학습은 L-20260811-055~057 | Task 6 재검토 후 Task 7 | | 2026-08-11 | Task 6 독립 리뷰 수정 | 화면 밖·카메라 뒤 관찰을 재발견 입력에서 제외하고, 놀란 모델 설치 뒤에만 확대·발견 콜백을 실행함. 타깃 수락과 돼지 도착 이벤트를 분리하고 도착 전 발견을 막았으며, 유효한 바닥 Y·카메라 전방 시작 위치가 생기기 전 돼지를 비활성화함 | 결함별 RED 확인 후 visual focused 3/3, coordinator focused 7/7, 전체 XCTest 68/68 및 iOS Simulator build 성공. 원인·학습은 L-20260811-050~054 | Task 6 재검토 후 Task 7 | | 2026-08-10 | Task 6 — RealityKit 돼지 포즈와 실제 메쉬 뒤 숨기 | 실제 C3 돼지 3종의 비동기 포즈 교체·높이 정규화·걷기·1.5→1.0 놀람 확대를 안정 바깥 엔티티에 구현하고, LiDAR 지원 guard·수동 AR 세션·scene understanding 4종 옵션·실제 메쉬 세로 면 탭·분류된 바닥·숨기 이동·blocked→visible 1회 재발견을 ARView에 연결함. 카메라/렌즈 transform은 변경하지 않음 | RED에서 두 Task 6 타입 부재를 확인한 뒤 focused 5/5·전체 63/63 XCTest와 iOS Simulator build 성공. 환경·SDK 실패는 L-20260810-039~046, 실제 LiDAR 검증은 L-20260810-047 `실기기 대기` | Task 7 — 자동 SceneKit→RealityKit 전환과 C3 스타일 안내 | From 9f3493244f25841f472a33fdea94497e3ed79470 Mon Sep 17 00:00:00 2001 From: eunseo-com Date: Tue, 11 Aug 2026 01:08:27 +0900 Subject: [PATCH 16/22] Document C3 to RealityKit escape tutorial --- .../SceneKitToRealityKit.tutorial | 16 +-- .../Tutorials/01-ClosedWorld.tutorial | 106 ++++++++++-------- .../Resources/01-ClosedWorld-01-01.swift | 79 ++++++------- .../Resources/01-ClosedWorld-02-01.swift | 104 +++++++---------- .../Resources/01-ClosedWorld-03-01.swift | 65 ++++++----- .../Resources/01-ClosedWorld-04-01.swift | 37 +++--- .../Resources/01-ClosedWorld-05-01.swift | 19 ++-- docs/LEARNING_LOG.md | 60 ++++++++++ docs/PROJECT_CONTEXT.md | 42 +++++++ docs/TROUBLESHOOTING.md | 24 ++++ docs/WORK_LOG.md | 26 ++++- ...08-10-ch1-reality-escape-implementation.md | 10 +- 12 files changed, 356 insertions(+), 232 deletions(-) create mode 100644 docs/PROJECT_CONTEXT.md diff --git a/PiggyEscape/PiggyEscape/Tutorials/SceneKitToRealityKit.docc/SceneKitToRealityKit.tutorial b/PiggyEscape/PiggyEscape/Tutorials/SceneKitToRealityKit.docc/SceneKitToRealityKit.tutorial index fd90e30..97f268b 100644 --- a/PiggyEscape/PiggyEscape/Tutorials/SceneKitToRealityKit.docc/SceneKitToRealityKit.tutorial +++ b/PiggyEscape/PiggyEscape/Tutorials/SceneKitToRealityKit.docc/SceneKitToRealityKit.tutorial @@ -1,14 +1,14 @@ @Tutorials(name: "씬킷에서 리얼리티킷으로") { - @Intro(title: "갇힌 캐릭터의 탈출") { - 개발자가 만든 가짜 세계에 갇혀 살던 돼지가 균열을 뚫고 진짜 세계로 도망친다. - 이 튜토리얼은 그 탈출과 술래잡기를 직접 만들어보며, - SceneKit이 가짜로 짓는 세계와 RealityKit이 진짜로 읽는 세계의 차이를 체험한다. + @Intro(title: "나무 뒤에서 현실의 물체 뒤로") { + C3_Piggy의 섬에서 돼지는 나무 뒤로 숨으려 한다. 하지만 카메라를 돌리면 + 코드로 만든 세계의 반대편까지 곧바로 볼 수 있다. 이 튜토리얼은 그 실패를 + SceneKit의 궤도 카메라로 먼저 보여 준 뒤, RealityKit과 LiDAR 메쉬를 이용해 + 실제 물체 뒤로 숨는 경험으로 이어 간다. } - @Chapter(name: "Chapter 1: 갇힌 세계") { - SceneKit만으로 방을 짓고 돼지를 그 안에 가둔다. - 이 세계에 있는 모든 것은 코드로 선언한 것의 총합일 뿐이라는 걸, - "숨어봐"가 실패하는 순간으로 직접 확인한다. + @Chapter(name: "Chapter 1: C3 월드에서 현실로") { + 돼지의 나레이션, 기존 나무 뒤 숨기, 카메라에 의한 발견, 시스템 카메라 권한, + 실제 메쉬 오클루전, 사용자의 물리적 재발견을 다섯 장면으로 구현한다. @TutorialReference(tutorial: "doc:01-ClosedWorld") } diff --git a/PiggyEscape/PiggyEscape/Tutorials/SceneKitToRealityKit.docc/Tutorials/01-ClosedWorld.tutorial b/PiggyEscape/PiggyEscape/Tutorials/SceneKitToRealityKit.docc/Tutorials/01-ClosedWorld.tutorial index 389f926..ab752a0 100644 --- a/PiggyEscape/PiggyEscape/Tutorials/SceneKitToRealityKit.docc/Tutorials/01-ClosedWorld.tutorial +++ b/PiggyEscape/PiggyEscape/Tutorials/SceneKitToRealityKit.docc/Tutorials/01-ClosedWorld.tutorial @@ -1,101 +1,115 @@ -@Tutorial(time: 20) { - @Intro(title: "갇힌 세계 짓기") { - SceneKit 세계는 두 가지 의미에서 닫혀 있다. 이 세계에 있는 모든 것은 - 개발자가 코드로 선언한 것의 총합이고, 그 존재 방식조차 SCNNode 하나가 - 생김새·물리·행동을 전부 짊어지는 구조다. 이 장에서는 그걸 설명이 아니라 - 캐릭터가 실패를 겪는 과정으로 직접 확인한다. +@Tutorial(time: 30) { + @Intro(title: "C3 월드에서 현실로 숨기") { + 이 장의 돼지는 숨을 장소를 직접 고른다. 먼저 SceneKit의 C3 섬에서 기존 나무 + 뒤로 가지만, 사용자가 궤도 카메라를 돌리면 들킨다. 그 실패가 시스템 카메라 + 권한과 RealityKit의 실제 깊이 오클루전으로 넘어가는 이유가 된다. } - @Section(title: "프로젝트 세팅") { + @Section(title: "1. C3가 만든 섬을 SceneKit에 놓기") { @ContentAndMedia { - SwiftUI 앱에서 SceneKit의 SCNView를 UIViewRepresentable로 감싸는 - 껍데기를 만든다. 이 단계는 서사적 의미 없는 빌드 준비다. + 첫 장면은 C3_Piggy의 섬·나무·돼지 포즈를 SceneKit 노드로 다시 조립한다. + `HideTree`와 `EscapePig`처럼 역할이 드러나는 이름을 붙이면, 이후의 숨기 + 계산과 탭 판정이 모델 파일 이름이나 화면 좌표에 묶이지 않는다. } @Steps { @Step { - `ClosedWorldSceneView`를 만들어 SwiftUI에서 SceneKit 씬을 호스팅할 - 준비를 한다. + 바깥 돼지 컨테이너는 위치와 액션을, 안쪽 모델은 원본 좌표계 보정을 + 맡게 한다. 그래서 걷기·놀람 모델을 바꿔도 월드 위치가 흔들리지 않는다. - @Code(name: "ClosedWorldSceneView.swift", file: "01-ClosedWorld-01-01.swift") + @Code(name: "C3ClosedWorld.swift", file: "01-ClosedWorld-01-01.swift") } } } - @Section(title: "방 짓기") { + @Section(title: "2. 나무 뒤에 갔지만 카메라를 돌리면 보이기") { @ContentAndMedia { - `SCNBox`로 벽을 세우고, `Ground_Color.usdc`를 바닥으로 재사용한다. - 좌표 원점(0,0,0)도 여기서 임의로 선언한다. 이 세계에 있는 모든 것 — - 벽이든 바닥이든 — 은 지금 이 코드로 써넣은 것만 존재한다. + 나레이션이 끝나기 전 탭은 상태 기계가 거부한다. 수락된 탭은 현재 카메라에서 + 멀어지는 나무 반대편 좌표를 계산한다. 이것은 실제 숨기가 아니라, 궤도 카메라가 + 다른 쪽을 볼 수 있는 닫힌 월드의 한계를 보여 주는 장면이다. } @Steps { @Step { - `RoomBuilder`를 만들어 방을 짓는다. 벽 4개는 `SCNBox`로, 바닥은 - `AssetLoader`로 `Ground_Color` 에셋을 불러와 만든다. + yaw 변화가 0.70 rad 이상이고 돼지가 실제 프러스텀에 들어온 경우에만 + 놀란 돼지·자막·1.5배 확대 후 복귀를 한 번 시작한다. - @Code(name: "RoomBuilder.swift", file: "01-ClosedWorld-02-01.swift") + @Code(name: "EscapeStateAndTreeHide.swift", file: "01-ClosedWorld-02-01.swift") + @Code(name: "C3DiscoveryReaction.swift", file: "01-ClosedWorld-03-01.swift") } } } - @Section(title: "돼지 배치") { + @Section(title: "3. 놀란 돼지 뒤 시스템 카메라 권한으로 전환하기") { @ContentAndMedia { - 하드코딩된 좌표에 돼지를 놓는다. 이 좌표는 방 안 어떤 실제 기준과도 - 연결되어 있지 않다 — 그냥 숫자다. + 발견 자막 “아, 들켰네… 제대로 숨고 싶은데.”와 놀람 모델은 같은 순간에 + 시작한다. C3 화면이 0.70초 동안 사라진 뒤에만 카메라 권한을 요청한다. + 허용되면 다음 장면의 AR 스캔 안내를 자동으로 연다. 권한 거부·제한은 + AR 세션을 시작하지 않고 복구 안내를 보여 준다. } @Steps { @Step { - `PigPlacement`가 `Piggy.usdc`를 불러와 표준 높이로 정규화하고 - 하드코딩된 위치에 놓는다. - - @Code(name: "PigPlacement.swift", file: "01-ClosedWorld-03-01.swift") + 발견 callback에서 즉시 돼지의 놀람 반응을 시작하고, 페이드 완료 callback에서만 + 시스템 권한을 요청한다. 이 순서를 상태 기계의 `discoveredByCamera`와 + `requestingCameraPermission` 전이로 분리한다. } } } - @Section(title: "노드 구조 탐구") { + @Section(title: "4. RealityKit에서 실제 물체의 반대편 계산하기") { @ContentAndMedia { - 지금까지 만든 노드를 들여다보면, 생김새(geometry)·물리(physicsBody)· - 행동(action)이 전부 SCNNode 하나에 붙어있다는 걸 확인할 수 있다. - 책임이 분리되지 않는 구조다. RealityKit의 ECS는 이걸 쪼갠다 — - 자세한 대비는 4장에서 다룬다. + AR 세션은 먼저 기기가 분류된 메쉬 재구성을 지원하는지 확인한다. + [`ARWorldTrackingConfiguration.sceneReconstruction`](https://developer.apple.com/documentation/arkit/arworldtrackingconfiguration/scenereconstruction)를 + `.meshWithClassification`으로 설정하기 전 + [`supportsSceneReconstruction(_:)`](https://developer.apple.com/documentation/arkit/arworldtrackingconfiguration/supportsscenereconstruction(_:))를 + 반드시 확인한다. 사용자는 실제 물체의 수직 옆면을 탭하고, 수평면·너무 가까운 + 면·바닥을 찾지 못한 위치는 다시 선택하게 한다. } @Steps { @Step { - `NodeInspector`로 노드 하나에 얹힌 geometry·physicsBody·action을 - 모두 출력해 확인한다. + 지원 기기에서 수평·수직 평면과 분류된 메쉬를 시작하고, scene understanding에 + 오클루전·충돌·물리·조명을 모두 켠다. - @Code(name: "NodeInspector.swift", file: "01-ClosedWorld-04-01.swift") + @Code(name: "RealityMeshSession.swift", file: "01-ClosedWorld-04-01.swift") } } } - @Section(title: "\"숨어봐\" 시도") { + @Section(title: "5. 실제 깊이로 가리고 몸을 움직여 다시 찾기") { @ContentAndMedia { - `Wood_Color.usdc`를 가짜 소파로 미리 놓아두고, 탭하면 돼지가 그 - 좌표로 이동하는 액션을 실행한다. 진짜 소파가 화면 반대편(실제 방)에 - 있어도 이 동작은 그와 전혀 무관하게 실행된다 — 내가 선언한 가짜 - 소파로는 이동되지만, 진짜 소파는 이 세계에 아예 존재하지 않는다. + [`SceneUnderstanding.Options.occlusion`](https://developer.apple.com/documentation/realitykit/arview/environment-swift.struct/sceneunderstanding-swift.struct/options-swift.struct/occlusion)은 + 재구성한 실제 기하를 깊이 버퍼에 사용한다. 색칠한 가짜 벽을 세우는 것이 아니라, + 실제 메쉬가 카메라와 돼지 사이에 있을 때만 돼지를 가린다. 한 번이라도 가려진 뒤 + 사용자가 실제로 움직여 메쉬가 앞을 막지 않게 되었을 때만 재발견을 한 번 발행한다. } @Steps { @Step { - `FakeSofa`와 `HideAction`을 추가하고, 탭 제스처로 돼지를 - 가짜 소파 위치까지 이동시킨다. + 재발견 때 `Piggy_surprised`·자막·바깥 돼지 1.5배→1.0배를 시작한다. + 화면은 `ARView`를 담는 SwiftUI 컨테이너만 1.12배→1.0배로 확대한다. + AR 카메라의 렌즈·FOV·transform은 바꾸지 않는다. - @Code(name: "HideAction.swift", file: "01-ClosedWorld-05-01.swift") + @Code(name: "RealityRevealMonitor.swift", file: "01-ClosedWorld-05-01.swift") } } } - @Section(title: "다음 장 예고") { + @Section(title: "실기기에서 확인하기") { @ContentAndMedia { - 이 세계는 지금까지 내가 만든 것만으로 이루어져 있었다. 다음 장부터 - 이 세계는 더 이상 내가 만든 것만으로 이루어지지 않는다 — 균열 - 사이로 진짜 빛이 새어 든다. + Simulator와 단위 테스트는 실제 카메라 권한 화면·LiDAR 메쉬·물리 오클루전을 + 재현하지 않는다. 수직 물체를 탭한 뒤 현재 위치에서는 돼지가 가려지고, 몸을 옆으로 + 옮겼을 때만 한 번 다시 보이는지 LiDAR 지원 기기에서 확인한다. 실제 관찰 결과는 + `docs/WORK_LOG.md`와 `docs/LEARNING_LOG.md`에 기록한다. + } + + @Steps { + @Step { + 실제 LiDAR 지원 기기에서 권한 허용·거부, 수직면 선택, 가려진 초기 시점, + 물리적 이동 뒤의 한 번만 실행되는 재발견을 차례로 관찰한다. 관찰하지 못한 + 항목은 통과로 쓰지 않고 `실기기 대기`로 남긴다. + } } } } diff --git a/PiggyEscape/PiggyEscape/Tutorials/SceneKitToRealityKit.docc/Tutorials/Resources/01-ClosedWorld-01-01.swift b/PiggyEscape/PiggyEscape/Tutorials/SceneKitToRealityKit.docc/Tutorials/Resources/01-ClosedWorld-01-01.swift index 0a76f7c..c0a2b4e 100644 --- a/PiggyEscape/PiggyEscape/Tutorials/SceneKitToRealityKit.docc/Tutorials/Resources/01-ClosedWorld-01-01.swift +++ b/PiggyEscape/PiggyEscape/Tutorials/SceneKitToRealityKit.docc/Tutorials/Resources/01-ClosedWorld-01-01.swift @@ -1,57 +1,44 @@ -import SwiftUI import SceneKit -/// SwiftUI ↔ SceneKit(SCNView)을 잇는 다리. 방·돼지·가짜 소파를 씬에 담고, -/// 탭하면 돼지가 가짜 소파로 "숨는" 하나의 인터랙션만 처리한다. -struct ClosedWorldSceneView: UIViewRepresentable { - func makeUIView(context: Context) -> SCNView { - let view = SCNView() - let scene = SCNScene() +/// 이 발췌본은 C3 섬의 이름 있는 나무와 바깥 돼지 컨테이너를 만든다. +/// 실제 앱에서는 같은 역할을 C3IslandBuilder와 C3PigModelFactory가 더 많은 타일·장식으로 확장한다. +@MainActor +final class C3ClosedWorldLesson { + let scene = SCNScene() + let cameraNode = SCNNode() + let hideTree: SCNNode + let pigContainer = SCNNode() + + init() { + let island = loadC3Node(named: "Ground_Color") + island.name = "C3Island" + scene.rootNode.addChildNode(island) + + let tree = loadC3Node(named: "Cylinder_Tree1_Color") + tree.name = "HideTree" + tree.position = SCNVector3(0, 0, -1) + hideTree = tree + scene.rootNode.addChildNode(tree) + + pigContainer.name = "EscapePig" + pigContainer.addChildNode(loadC3Node(named: "Piggy")) + pigContainer.position = SCNVector3(0, 0, 0) + scene.rootNode.addChildNode(pigContainer) - scene.rootNode.addChildNode(RoomBuilder.build()) - let pig = PigPlacement.makePigNode() - scene.rootNode.addChildNode(pig) - scene.rootNode.addChildNode(FakeSofa.makeSofaNode()) - - // 카메라는 방 안쪽(roomDepth/2 = 2보다 작은 z)에 둔다 — 방 밖에 두면 - // Wall_1(z=+2)에 가려 내부가 전혀 보이지 않는다. let camera = SCNCamera() - let cameraNode = SCNNode() + camera.usesOrthographicProjection = true + camera.orthographicScale = 6 cameraNode.camera = camera - cameraNode.position = SCNVector3(0, 2, 1.7) - cameraNode.look(at: SCNVector3(0, 0.3, -0.5)) scene.rootNode.addChildNode(cameraNode) - - let light = SCNNode() - light.light = SCNLight() - light.light?.type = .omni - light.position = SCNVector3(0, 3, 2) - scene.rootNode.addChildNode(light) - - view.scene = scene - // pointOfView가 비어 있으면 SceneKit이 전체 씬을 자동으로 프레이밍하는 - // 기본 카메라를 대신 사용한다 — 위에서 공들여 배치한 cameraNode는 - // 렌더링에 전혀 쓰이지 않고 무시된다. 반드시 명시적으로 지정해야 한다. - view.pointOfView = cameraNode - view.allowsCameraControl = true - view.autoenablesDefaultLighting = true - - context.coordinator.pigNode = pig - let tap = UITapGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.handleTap)) - view.addGestureRecognizer(tap) - - return view } - func updateUIView(_ uiView: SCNView, context: Context) {} - - func makeCoordinator() -> Coordinator { Coordinator() } - - final class Coordinator: NSObject { - var pigNode: SCNNode? - - @objc func handleTap() { - pigNode?.runAction(HideAction.makeMoveAction()) + private func loadC3Node(named asset: String) -> SCNNode { + let extensions = ["usdc", "usdz"] + for fileExtension in extensions { + if let scene = SCNScene(named: "\(asset).\(fileExtension)") { + return scene.rootNode.clone() + } } + return SCNNode() } } diff --git a/PiggyEscape/PiggyEscape/Tutorials/SceneKitToRealityKit.docc/Tutorials/Resources/01-ClosedWorld-02-01.swift b/PiggyEscape/PiggyEscape/Tutorials/SceneKitToRealityKit.docc/Tutorials/Resources/01-ClosedWorld-02-01.swift index ab1e419..cc9fec6 100644 --- a/PiggyEscape/PiggyEscape/Tutorials/SceneKitToRealityKit.docc/Tutorials/Resources/01-ClosedWorld-02-01.swift +++ b/PiggyEscape/PiggyEscape/Tutorials/SceneKitToRealityKit.docc/Tutorials/Resources/01-ClosedWorld-02-01.swift @@ -1,74 +1,50 @@ -import SceneKit -import UIKit +import simd -/// 방을 짓는 빌더. 이 세계에 있는 모든 것 — 벽이든 바닥이든 —은 -/// 여기 코드로 써넣은 것만 존재한다. 좌표 원점(0,0,0)도 개발자가 임의로 선언한 것. -enum RoomBuilder { - static let roomWidth: Float = 4 - static let roomDepth: Float = 4 - static let wallHeight: Float = 2.5 - private static let wallThickness: Float = 0.1 - private static let floorHeight: Float = 0.1 - - @MainActor - static func build() -> SCNNode { - let room = SCNNode() - room.name = "Room" - room.position = SCNVector3(0, 0, 0) // 좌표 원점을 여기서 임의로 선언한다 - - room.addChildNode(makeFloor()) +enum LessonEscapeState { + case openingNarration, readyForPigTap, walkingBehindTree, hiddenInClosedWorld +} - let wallSpecs: [(name: String, position: SCNVector3, eulerY: Float, width: Float)] = [ - ("Wall_0", SCNVector3(0, wallHeight / 2, -roomDepth / 2), 0, roomWidth), - ("Wall_1", SCNVector3(0, wallHeight / 2, roomDepth / 2), 0, roomWidth), - ("Wall_2", SCNVector3(-roomWidth / 2, wallHeight / 2, 0), .pi / 2, roomDepth), - ("Wall_3", SCNVector3(roomWidth / 2, wallHeight / 2, 0), .pi / 2, roomDepth) - ] - for spec in wallSpecs { - let wall = AssetLoader.voxelBox(width: CGFloat(spec.width), height: CGFloat(wallHeight), - length: CGFloat(wallThickness), color: UIColor(white: 0.95, alpha: 1)) - wall.name = spec.name - wall.position = spec.position - wall.eulerAngles = SCNVector3(0, spec.eulerY, 0) - room.addChildNode(wall) - } +struct LessonEscapeMachine { + private(set) var state: LessonEscapeState = .openingNarration - return room + mutating func narrationFinished() { + guard state == .openingNarration else { return } + state = .readyForPigTap } - /// `Ground_Color`은 Blender의 Z-up 좌표계로 만들어진 세로 타일이다. - /// 모델을 먼저 눕히고 실제 크기를 잰 다음, 방의 x/z 면적과 얇은 y 두께에 맞춘다. - /// 이 순서가 바뀌면 바닥이 세로로 남아 카메라 시야를 가릴 수 있다. - @MainActor - private static func makeFloor() -> SCNNode { - let floor = SCNNode() - floor.name = "Floor" - - guard let ground = AssetLoader.object(named: "Ground_Color") else { - let fallback = AssetLoader.voxelBox(width: CGFloat(roomWidth), height: CGFloat(floorHeight), - length: CGFloat(roomDepth), color: UIColor(white: 0.8, alpha: 1)) - fallback.position = SCNVector3(0, floorHeight / 2, 0) - floor.addChildNode(fallback) - return floor - } - - ground.eulerAngles = SCNVector3(-Float.pi / 2, 0, 0) - floor.addChildNode(ground) + mutating func pigTapped() -> Bool { + guard state == .readyForPigTap else { return false } + state = .walkingBehindTree + return true + } - let (lo, hi) = SceneKitGeometry.boundingBox(of: floor) - let width = hi.x - lo.x - let height = hi.y - lo.y - let depth = hi.z - lo.z - guard width > 0.0001, height > 0.0001, depth > 0.0001 else { return floor } + mutating func pigReachedTree() { + guard state == .walkingBehindTree else { return } + state = .hiddenInClosedWorld + } +} - // `floor`는 회전된 모델을 감싸는 컨테이너다. 여기서 스케일해야 월드의 x/y/z축을 - // 각각 방의 폭/두께/깊이에 맞출 수 있다. - floor.scale = SCNVector3(roomWidth / width, floorHeight / height, roomDepth / depth) - floor.position = SCNVector3( - -(lo.x + hi.x) / 2 * floor.scale.x, - -lo.y * floor.scale.y, - -(lo.z + hi.z) / 2 * floor.scale.z +enum LessonTreeHidePlanner { + static func destination( + treeCenter: SIMD3, + treeRadius: Float, + cameraPosition: SIMD3, + pigRadius: Float, + floorY: Float + ) -> SIMD3 { + let horizontalCamera = SIMD3( + cameraPosition.x - treeCenter.x, + 0, + cameraPosition.z - treeCenter.z + ) + let direction = simd_length_squared(horizontalCamera) > 0.0001 + ? simd_normalize(horizontalCamera) + : SIMD3(0, 0, 1) + let distance = treeRadius + pigRadius + 0.08 + return SIMD3( + treeCenter.x - direction.x * distance, + floorY, + treeCenter.z - direction.z * distance ) - return floor } } diff --git a/PiggyEscape/PiggyEscape/Tutorials/SceneKitToRealityKit.docc/Tutorials/Resources/01-ClosedWorld-03-01.swift b/PiggyEscape/PiggyEscape/Tutorials/SceneKitToRealityKit.docc/Tutorials/Resources/01-ClosedWorld-03-01.swift index 800055a..6930ba1 100644 --- a/PiggyEscape/PiggyEscape/Tutorials/SceneKitToRealityKit.docc/Tutorials/Resources/01-ClosedWorld-03-01.swift +++ b/PiggyEscape/PiggyEscape/Tutorials/SceneKitToRealityKit.docc/Tutorials/Resources/01-ClosedWorld-03-01.swift @@ -1,40 +1,43 @@ import SceneKit -/// 돼지를 하드코딩된 좌표에 배치한다. 이 좌표는 개발자가 정한 것일 뿐, -/// 방 안의 어떤 실제 기준(가구 위치 등)과도 연결되어 있지 않다. -enum PigPlacement { - static let hardcodedPosition = SCNVector3(0, 0, 0) - private static let standardHeight: Float = 0.6 +@MainActor +final class C3DiscoveryReactionLesson { + private var hideStartYaw: Float? + private var hasDiscovered = false - @MainActor - static func makePigNode() -> SCNNode { - let pig = SCNNode() - let model = SCNNode() - let art: SCNNode + func startHiding(at cameraYaw: Float) { + hideStartYaw = cameraYaw + hasDiscovered = false + } - if let bundledModel = AssetLoader.object(named: "Piggy") { - // Piggy.usdc는 Blender의 Z-up 좌표계로 만든 모델이다. SceneKit의 y-up 세계에 - // 세워 주고, 화면을 향하도록 roll을 보정한다. - bundledModel.eulerAngles = SCNVector3(Float.pi / 2, 0, Float.pi) - art = bundledModel - } else { - art = AssetLoader.voxelBox(width: 0.4, height: 0.4, length: 0.6, color: .systemPink) + func discoverIfNeeded( + cameraYaw: Float, + pigIsInsideCameraFrustum: Bool, + pigContainer: SCNNode, + installSurprisedModel: () -> Void, + showCaption: (String) -> Void + ) { + guard let hideStartYaw, + !hasDiscovered, + abs(wrappedYawDelta(cameraYaw - hideStartYaw)) >= 0.70, + pigIsInsideCameraFrustum else { + return } - model.addChildNode(art) - // 바깥 `pig`은 위치와 행동을 맡고, 안쪽 `model`은 에셋의 회전·크기·바닥 정렬만 - // 맡는다. 그래서 나중에 이동 액션을 실행해도 모델 보정값이 섞이지 않는다. - SceneKitGeometry.normalize(model, toHeight: standardHeight) - pig.addChildNode(model) + hasDiscovered = true + installSurprisedModel() + let grow = SCNAction.scale(to: 1.5, duration: 0.16) + grow.timingMode = .easeOut + let restore = SCNAction.scale(to: 1.0, duration: 0.34) + restore.timingMode = .easeInEaseOut + pigContainer.runAction(.sequence([grow, restore]), forKey: "escapePig.surpriseScale") + showCaption("아, 들켰네… 제대로 숨고 싶은데.") + } - let (lo, hi) = SceneKitGeometry.boundingBox(of: pig) - model.position = SCNVector3( - -(lo.x + hi.x) / 2, - -lo.y, - -(lo.z + hi.z) / 2 - ) - pig.name = "Piggy" - pig.position = hardcodedPosition - return pig + private func wrappedYawDelta(_ angle: Float) -> Float { + var result = angle.truncatingRemainder(dividingBy: 2 * .pi) + if result > .pi { result -= 2 * .pi } + if result < -.pi { result += 2 * .pi } + return result } } diff --git a/PiggyEscape/PiggyEscape/Tutorials/SceneKitToRealityKit.docc/Tutorials/Resources/01-ClosedWorld-04-01.swift b/PiggyEscape/PiggyEscape/Tutorials/SceneKitToRealityKit.docc/Tutorials/Resources/01-ClosedWorld-04-01.swift index acbe62f..3ff7149 100644 --- a/PiggyEscape/PiggyEscape/Tutorials/SceneKitToRealityKit.docc/Tutorials/Resources/01-ClosedWorld-04-01.swift +++ b/PiggyEscape/PiggyEscape/Tutorials/SceneKitToRealityKit.docc/Tutorials/Resources/01-ClosedWorld-04-01.swift @@ -1,23 +1,22 @@ -import SceneKit +import ARKit +import RealityKit -/// 노드 하나를 들여다보고, 생김새(geometry)·물리(physicsBody)·행동(action)이 -/// 전부 같은 SCNNode 객체 위에 얹혀 있다는 걸 텍스트로 보여준다. -/// RealityKit의 ECS는 이 세 가지를 각각 다른 Component로 분리하지만, -/// SceneKit은 분리하지 않는다 — 이걸 실행 결과로 확인하기 위한 디버그 도구. -enum NodeInspector { - static func describe(_ node: SCNNode) -> [String] { - var lines: [String] = [] +@MainActor +func startClassifiedMeshSession(in arView: ARView) -> Bool { + guard ARWorldTrackingConfiguration.supportsSceneReconstruction(.meshWithClassification) else { + return false + } - if let geometry = node.geometry { - lines.append("geometry: \(type(of: geometry))") - } - if let physicsBody = node.physicsBody { - lines.append("physicsBody: type=\(physicsBody.type.rawValue)") - } - for key in node.actionKeys { - lines.append("action[\(key)]: \(node.action(forKey: key).map(String.init(describing:)) ?? "nil")") - } + let configuration = ARWorldTrackingConfiguration() + configuration.planeDetection = [.horizontal, .vertical] + configuration.sceneReconstruction = .meshWithClassification - return lines - } + arView.environment.sceneUnderstanding.options = [ + .occlusion, + .collision, + .physics, + .receivesLighting + ] + arView.session.run(configuration, options: [.resetTracking, .removeExistingAnchors]) + return true } diff --git a/PiggyEscape/PiggyEscape/Tutorials/SceneKitToRealityKit.docc/Tutorials/Resources/01-ClosedWorld-05-01.swift b/PiggyEscape/PiggyEscape/Tutorials/SceneKitToRealityKit.docc/Tutorials/Resources/01-ClosedWorld-05-01.swift index bbb1730..7ac1b74 100644 --- a/PiggyEscape/PiggyEscape/Tutorials/SceneKitToRealityKit.docc/Tutorials/Resources/01-ClosedWorld-05-01.swift +++ b/PiggyEscape/PiggyEscape/Tutorials/SceneKitToRealityKit.docc/Tutorials/Resources/01-ClosedWorld-05-01.swift @@ -1,10 +1,15 @@ -import SceneKit +import simd -/// "숨어봐" 인터랙션의 핵심: 하드코딩된 가짜 소파 좌표로 이동하는 액션 하나. -/// 이 액션은 실제 방에 있는 진짜 소파가 어디 있든 상관하지 않는다 — -/// 목적지는 오직 FakeSofa.hardcodedPosition, 즉 개발자가 선언한 좌표뿐이다. -enum HideAction { - static func makeMoveAction() -> SCNAction { - .move(to: FakeSofa.hardcodedPosition, duration: 0.5) +/// 실제 메쉬가 먼저 한 번 가린 뒤에만, 사용자의 이동으로 다시 보인 순간을 발행한다. +struct RealityRevealMonitorLesson { + private var observedBlockingMesh = false + private var reportedReveal = false + + mutating func update(meshDistance: Float?, pigDistance: Float) -> Bool { + let isBlocked = meshDistance.map { $0 + 0.03 < pigDistance } ?? false + observedBlockingMesh = observedBlockingMesh || isBlocked + guard observedBlockingMesh, !isBlocked, !reportedReveal else { return false } + reportedReveal = true + return true } } diff --git a/docs/LEARNING_LOG.md b/docs/LEARNING_LOG.md index 68a2ec2..0484507 100644 --- a/docs/LEARNING_LOG.md +++ b/docs/LEARNING_LOG.md @@ -28,6 +28,66 @@ ## 항목 +### L-20260811-071 — Task 8 문서 staging이 연결 작업 트리 index 잠금 권한으로 중단됨 + +- 상태: 해결 +- 발생 태스크: Task 8 — 전체 회귀·실기기 검증과 DocC·인수인계 +- 재현: `git add PiggyEscape/PiggyEscape/Tutorials docs/PROJECT_CONTEXT.md docs/WORK_LOG.md docs/LEARNING_LOG.md docs/TROUBLESHOOTING.md docs/superpowers/plans/2026-08-10-ch1-reality-escape-implementation.md` +- 관찰: `fatal: Unable to create '.../.git/worktrees/ch1-reality-escape/index.lock': Operation not permitted`로 명시 경로 staging 전에 종료됨 +- 영향: 검증된 Task 8 튜토리얼·공유 문서를 아직 커밋할 수 없음 +- 원인/가설: L-20260810-048과 같은 연결 작업 트리 공용 Git index 잠금 경로가 현재 권한 범위 밖인 것으로 보임 +- 조치: 실패 기록 뒤 동일한 명시 경로만 공용 Git 메타데이터에 접근 가능한 권한으로 한 번 재실행했다. `.claude/`와 생성물은 계속 제외했다. +- 검증: 권한 재실행 뒤 cached diff 검사에 오류가 없었고, staged 목록은 Task 8 tutorial·tracked 문서만, 작업 트리에는 사용자 소유 `.claude/`만 남았다. +- 배운 점: 최종 문서 태스크도 worktree 파일 수정과 Git index lock 쓰기는 다른 권한 경계이므로, staging 실패 뒤 일부 파일이 추가됐다고 가정하지 않는다. + +### L-20260811-070 — Task 8 DocC build가 두 개의 섹션에 Steps 지시문 누락 경고를 출력함 + +- 상태: 보류 +- 발생 태스크: Task 8 — 전체 회귀·실기기 검증과 DocC·인수인계 +- 재현: `cd PiggyEscape && xcodebuild docbuild -project PiggyEscape.xcodeproj -scheme PiggyEscape -destination 'platform=iOS Simulator,name=iPhone 17 Pro' -derivedDataPath /tmp/piggyescape-reality-docc` +- 관찰: DocC archive는 생성됐지만 카메라 권한 전환·실기기 확인 섹션에서 각각 `Missing 'Steps' child directive` 경고가 출력됨. Chapter의 Image child 경고도 기존 이미지 에셋을 추가하지 않는 범위에서 계속 출력됨. +- 영향: 문서는 생성됐지만 두 섹션이 튜토리얼 단계 구조를 충족하지 못하며, 경고 없는 DocC 결과로 해석할 수 없음 +- 원인/가설: 두 섹션이 `@ContentAndMedia`만 포함하고 `@Steps`를 선언하지 않았음. Chapter Image 경고는 새 이미지 에셋을 금지한 현재 C3 디자인 제약과 기존 카탈로그 구조의 충돌임. +- 조치: Steps 누락은 같은 튜토리얼 안에 짧은 관찰·복구 단계를 추가해 고쳤다. Image 경고는 존재하지 않는 이미지 파일을 참조하거나 새 이미지 에셋을 추가하지 않고, 제한 사항으로 남긴다. +- 검증: 수정 뒤 같은 `xcodebuild docbuild`를 재실행해 두 Steps 경고가 사라졌고 `** BUILD DOCUMENTATION SUCCEEDED **` 및 `/tmp/piggyescape-reality-docc/Build/Products/Debug-iphonesimulator/PiggyEscape.doccarchive` 존재를 확인했다. `@Chapter`의 Image child 경고 1건은 남아 있다. +- 배운 점: DocC가 archive를 생성해도 구조 경고를 성공으로 묶지 말고, 코드·에셋 범위 제약으로 남는 경고와 문서 구조 결함을 구분한다. + +### L-20260811-069 — Task 8 전체 XCTest가 Simulator 서비스 접근 제한으로 중단됨 + +- 상태: 해결 +- 발생 태스크: Task 8 — 전체 회귀·실기기 검증과 DocC·인수인계 +- 재현: `cd PiggyEscape && xcodebuild -project PiggyEscape.xcodeproj -scheme PiggyEscape -destination 'platform=iOS Simulator,name=iPhone 17 Pro' -derivedDataPath /tmp/piggyescape-reality-tests test` +- 관찰: `CoreSimulatorService connection became invalid`, `Error opening log file ... Operation not permitted`, `Unable to discover any Simulator runtimes`가 출력돼 XCTest 실행 전 Simulator 초기화가 중단됨 +- 영향: 전체 XCTest 수·실패 수를 현재 권한에서 확인할 수 없으며, 이 종료를 코드·DocC 실패로 해석할 수 없음 +- 원인/가설: L-20260811-059와 같은 Simulator 서비스·로그·기기 상태 경로가 작업 트리 밖에 있어 접근이 거부된 것으로 보임 +- 조치: 실패 기록 뒤 Simulator 서비스에 접근 가능한 권한으로 같은 명시 명령을 한 번 재실행했다. +- 검증: 권한 재실행에서 `PiggyEscapeTests.xctest` 79개가 0 failures로 끝났고 `** TEST SUCCEEDED **`를 확인했다. 기존 AppIntents·SceneKit·RealityKit Simulator 경고는 L-20260811-064·L-20260810-046과 같은 범위 밖 진단으로 별도 보류한다. +- 배운 점: 최종 회귀도 종료 코드나 사전 기록이 아니라, 현재 실행의 XCTest 수와 `TEST SUCCEEDED`를 근거로만 판정한다. + +### L-20260811-068 — Task 8 DocC 검증 준비의 Tuist 생성이 세션 상태 디렉터리 권한으로 중단됨 + +- 상태: 해결 +- 발생 태스크: Task 8 — 전체 회귀·실기기 검증과 DocC·인수인계 +- 재현: `cd PiggyEscape && tuist generate --no-open` +- 관찰: `Fatal error: Error raised at top level: Permission denied: /Users/yang-eunseo/.local/state/tuist/sessions/...`로 생성 프로젝트 갱신 전에 종료됨 +- 영향: 바뀐 DocC 원본과 전체 XCTest가 현재 생성 프로젝트에 반영됐는지 확인할 수 없음 +- 원인/가설: L-20260811-058과 같은 Tuist 사용자 세션 상태 경로가 작업 트리 밖에 있어 현재 권한에서 쓰기 거부된 것으로 보임 +- 조치: 이 실패를 기록한 뒤 사용자 세션 상태에 접근 가능한 권한으로 같은 명령을 한 번 재실행했다. +- 검증: 권한 재실행이 `✔ Success`로 끝나 `PiggyEscape.xcodeproj`·workspace를 새로 생성했다. XCTest·DocC 결과는 별도 명령으로 계속 검증한다. +- 배운 점: 최종 DocC 검증도 일반 Xcode 프로젝트가 최신이라고 가정하지 말고, Tuist 생성 실패와 문서 자체 오류를 분리해야 한다. + +### L-20260811-067 — Task 8 시작 전 원격 fetch가 연결 작업 트리 메타데이터 권한으로 중단됨 + +- 상태: 보류 +- 발생 태스크: Task 8 — 전체 회귀·실기기 검증과 DocC·인수인계 +- 재현: `git fetch --prune origin` +- 관찰: `error: cannot open '.../.git/worktrees/ch1-reality-escape/FETCH_HEAD': Operation not permitted`로 원격 참조 갱신 전에 종료됨 +- 영향: Task 8 문서·검증 시작 시점의 원격 변경 여부를 현재 권한 범위에서 확정할 수 없음 +- 원인/가설: L-20260811-049와 같은 연결 작업 트리 공용 Git 메타데이터 `FETCH_HEAD` 쓰기 경로의 권한 제약이 재발한 것으로 보임 +- 조치: 실패 근거를 먼저 남기고, 권한이 허용되는 환경에서 같은 명령을 한 번만 재실행해 결과를 확인한다. 그 전에는 원격 상태를 추정하지 않는다. +- 검증: 재실행 전 상태는 사용자 소유의 추적되지 않은 `.claude/`만 표시하며, Task 8 문서 변경은 아직 시작하지 않음 +- 배운 점: 최종 문서 태스크도 시작 순서의 fetch 실패를 이전 성공으로 대체하지 말고, 현재 재현·영향·보류 상태를 독립 항목으로 남긴다. + ### L-20260811-066 — 최종 `tuist test`가 성공 코드와 함께 테스트를 건너뜀 - 상태: 해결 diff --git a/docs/PROJECT_CONTEXT.md b/docs/PROJECT_CONTEXT.md new file mode 100644 index 0000000..a9cf672 --- /dev/null +++ b/docs/PROJECT_CONTEXT.md @@ -0,0 +1,42 @@ +# 프로젝트 컨텍스트 + +> 이 문서는 저장소를 함께 다루는 작업자의 공통 시작점이다. 로컬 대화 기록이 아니라, 이 문서와 `docs/WORK_LOG.md`·설계·실행 계획의 Git 추적 상태를 기준으로 작업한다. + +## 현재 승인 범위 + +- 범위: Chapter 1 `ClosedWorld`의 **C3 월드에서 RealityKit 현실 세계로 탈출하기** 경험. +- 기준선: 기존 방·가짜 소파 구현을 담은 Draft PR은 참고 기준선으로 보존한다. 현재 작업 브랜치는 그 기준선 위에서 C3 섬과 실제 물체 뒤 숨기를 추가한다. +- 진입 화면: `ContentView`는 `EscapeRootView`만 시작한다. 예전 `ClosedWorldSceneView`·`FakeSofa`는 참고 코드로 컴파일되지만 현재 경험을 시작하지 않는다. +- 제외: 금융 상태, SwiftData 저장, 용돈·작은 돼지, WatchConnectivity, Watch UI, Chapter 2 이후 구현. + +## 경험 계약 + +1. SceneKit의 C3 섬에서 돼지가 “아, 나 좀 그만 쳐다보지. 나 숨고 싶어…”라고 말한다. +2. 나레이션이 끝난 뒤 돼지를 탭하면 `Piggy_running`으로 기존 `Cylinder_Tree` 뒤를 향해 걷는다. +3. 카메라 yaw 변화와 실제 프러스텀 조건이 함께 충족되면 `Piggy_surprised`·“아, 들켰네… 제대로 숨고 싶은데.”·1.5배 후 1.0배 복귀가 한 번 실행된다. +4. 0.70초 페이드 뒤에만 시스템 카메라 권한을 요청한다. 허용되면 RealityKit AR 화면으로 자동 전환한다. +5. LiDAR 지원 기기에서 사용자가 실제 물체의 수직 옆면을 탭하면 돼지는 카메라 반대편 바닥으로 이동한다. 실제 재구성 메쉬가 돼지를 가리고, 사용자가 물리적으로 이동해 다시 볼 때 같은 놀람 반응과 화면 1.12배 후 1.0배 복귀가 한 번 실행된다. + +정확한 상태 전이·수치·에셋 경계는 [설계 명세](superpowers/specs/2026-08-10-ch1-reality-escape-design.md)와 [실행 계획](superpowers/plans/2026-08-10-ch1-reality-escape-implementation.md)을 우선한다. + +## 시작 순서와 협업 규칙 + +1. 이 문서 +2. `docs/WORK_LOG.md` +3. `씬킷에서_리얼리티킷으로_컨셉노트.md` (현재 브랜치에 없으면 부재 사실을 `docs/LEARNING_LOG.md`에 기록하고 승인된 설계 명세를 대체 근거로 사용) +4. 관련 설계 명세와 실행 계획 + +구현 전에는 `git fetch --prune origin`, `git status --short` 순서로 상태를 확인한다. 권한·환경 문제로 명령이 실패하면 재시도나 보류 전에 `docs/LEARNING_LOG.md`에 재현·관찰·영향을 기록한다. 다른 작업자가 만들었거나 추적하지 않은 파일은 요청 없이 이동·삭제·스테이징하지 않는다. + +의사결정, 검증 결과, 남은 위험, 다음 시작점은 `docs/WORK_LOG.md`에 결과물과 같은 커밋으로 기록한다. 실패·예상 밖 동작·실기기 한계는 `docs/LEARNING_LOG.md`에 남긴다. `.claude/`와 대용량 C3 참고 사본은 로컬 참고물이며 추적하지 않는다. + +커밋·PR에는 작업자·도구·모델·AI 생성 표기와 `Co-Authored-By`를 넣지 않는다. 생성된 Xcode 프로젝트, DerivedData, `/tmp` 산출물은 추적하지 않는다. + +## 검증 상태 + +- 자동 검증: Task 8에서 명시적인 `xcodebuild` 전체 XCTest, Simulator build, DocC build를 새로 실행해 `docs/WORK_LOG.md`에 실제 결과를 기록한다. +- 실기기 검증: 카메라 권한 문구·허용/거부·Settings 복구, C3 탭/카메라 발견, LiDAR 메쉬 오클루전, 실제 이동 재발견, 1.5배 돼지·1.12배 화면 반응은 관찰 전까지 `실기기 대기`다. 자동 테스트나 Simulator 결과로 대체하지 않는다. + +## C3 참고 원본 + +`/Users/yang-eunseo/Downloads/C3_Piggy/C3_Piggy`의 월드 표현을 참고했다. 지정된 C3 에셋은 바이트 변경 없이 앱 리소스에 두며, 재사용 범위는 섬·나무·돼지 포즈·궤도 카메라·조명·장식 배치다. 금융과 Watch 기능은 의도적으로 가져오지 않는다. diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 1d24adb..3102220 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -89,3 +89,27 @@ if !hasGeometry { **해결**: `tuist generate --no-open`으로 생성 프로젝트를 갱신한 뒤 같은 선택 테스트를 다시 실행했다. 이때 실제로 1개 테스트가 실행·통과하는 것을 확인했다. **주의**: 새 소스나 테스트 파일을 추가한 뒤에는 `xcodebuild`의 성공 여부뿐 아니라 **실행된 테스트 수**를 확인한다. 특히 `-only-testing`은 이름이 틀리거나 생성 프로젝트가 오래됐어도 0개 실행으로 끝날 수 있다. + +## 8. Simulator에서 실제 물체 뒤 오클루전을 확인할 수 없음 + +**증상**: 전체 XCTest와 Simulator build가 성공해도, 실제 물체를 LiDAR로 재구성해 돼지를 가리는 장면은 관찰할 수 없다. + +**원인**: `ARWorldTrackingConfiguration`의 실제 분류 메쉬와 물리 공간은 Simulator 입력이 아니다. 테스트는 수직 면·바닥·거리 계산과 blocked→visible 한 번 재발견 규칙만 결정론적으로 확인한다. + +**해결/주의**: Simulator 성공을 실제 오클루전 통과로 기록하지 않는다. LiDAR 지원 기기에서 실제 물체의 옆면을 탭하고, 초기 위치에서는 가려지며 사용자가 옆으로 이동한 뒤에만 다시 보이는지를 별도 확인한다. 관찰 전 상태는 `실기기 대기`다. + +## 9. 시스템 카메라 권한 화면은 실기기에서만 완전히 확인할 수 있음 + +**증상**: 주입한 권한 결과로 허용·거부·제한 상태 전이는 테스트할 수 있지만, 시스템 목적 문구와 실제 앱 복귀 흐름은 Simulator 또는 단위 테스트만으로 확정할 수 없다. + +**원인**: 시스템 권한 알림은 기기별 권한 상태와 운영체제 UI가 소유한다. + +**해결/주의**: C3 발견 뒤 0.70초 페이드가 끝난 후 한 번만 요청하도록 코드로 제한하고, 실제 기기에서 허용·거부·제한·Settings 복구를 관찰한다. 관찰 전에는 `실기기 대기`로 남긴다. + +## 10. ARView 화면 확대는 렌즈나 FOV를 바꾸지 않음 + +**증상**: 현실에서 다시 들킨 순간 화면이 1.12배로 커지지만, 물체 뒤 오클루전의 카메라 기하를 바꾸면 안 된다. + +**원인**: AR 카메라 transform·렌즈를 바꾸면 실제 메쉬와 돼지의 공간 관계가 달라져 “사용자가 몸을 움직여 찾아낸다”는 경험 계약이 무너질 수 있다. + +**해결/주의**: `ARView` 자체가 아닌 SwiftUI 컨테이너에만 1.12→1.0 `scaleEffect`를 적용한다. 실제 화면에서 체감 품질과 1.5배 돼지 확대의 동시성은 LiDAR 실기기에서 별도 확인한다. diff --git a/docs/WORK_LOG.md b/docs/WORK_LOG.md index 59c844d..22535df 100644 --- a/docs/WORK_LOG.md +++ b/docs/WORK_LOG.md @@ -4,13 +4,26 @@ ## 현재 인수인계 -- 상태: Task 7 — 자동 SceneKit→RealityKit 전환과 C3 스타일 안내 구현 완료, 독립 검토 대기 -- 진행 중 범위: 앱 진입점은 `EscapeRootView` 하나로 교체됐다. C3 발견 callback은 놀란 상태를 기록하고 0.70초 페이드가 끝난 뒤에만 카메라 권한을 한 번 요청한다. 허용 시 `RealityHideARView`를 자동으로 열고 Task 6의 스캔 준비·타깃 수락·돼지 도착·재발견·오류 callback을 Task 2 상태 순서로 연결한다. 거부·제한은 각각 안내한 뒤 사용자가 직접 누른 경우에만 앱 Settings를 연다. 현실 재발견에는 ARView 컨테이너 1.12→1.0 확대가 추가됐고 실제 AR camera transform은 변경하지 않으며 Reduce Motion에서는 화면 확대를 생략한다. +- 상태: Task 8 — C3→RealityKit DocC·공통 인수인계·자동 검증 기록 완료, 실기기 수용 검증 대기 +- 진행 중 범위: 앱 진입점은 `EscapeRootView` 하나다. C3 발견 callback은 놀란 상태를 기록하고 0.70초 페이드가 끝난 뒤에만 카메라 권한을 한 번 요청한다. 허용 시 `RealityHideARView`를 자동으로 열고 스캔 준비·타깃 수락·돼지 도착·재발견·오류 callback을 상태 순서로 연결한다. 현실 재발견에는 `ARView` 컨테이너 1.12→1.0 확대가 추가됐고 실제 AR camera transform은 변경하지 않으며 Reduce Motion에서는 화면 확대를 생략한다. DocC는 같은 흐름을 C3 섬·카메라 발견·권한 전환·실제 메쉬·물리적 재발견의 다섯 장면으로 설명한다. - 실패 기록: `docs/LEARNING_LOG.md`에 실패·검증 한계의 재현 조건·원인/가설·조치·재발 방지 근거를 기록한다. -- 마지막 완료 범위: Task 7 — 자동 SceneKit→RealityKit 전환과 C3 스타일 안내 구현. -- 마지막 검증: focused `EscapeRootCoordinatorTests` 8/8, 명시적 Xcode scheme 전체 XCTest 79/79, iPhone 17 Pro Simulator 대상 `xcodebuild ... build`가 `** BUILD SUCCEEDED **`로 완료됐다. 권한·Tuist·actor·Reduce Motion 학습은 L-20260811-058~066에 기록했다. -- 다음 시작점: Task 7 독립 검토 뒤 구현 계획의 Task 8 — DocC·프로젝트 컨텍스트·실기기 검증 정리. -- 차단 요소: 실제 카메라 권한 UI·0.70초 시각 전환·Settings 복귀·1.12 화면 확대와 기존 LiDAR 메쉬·물리 오클루전은 L-20260811-065 및 L-20260810-047에 `실기기 대기`로 남겼다. +- 마지막 완료 범위: Task 8 — C3→RealityKit DocC·프로젝트 컨텍스트·자동 회귀 검증 기록. +- 마지막 검증: `cd PiggyEscape && tuist generate --no-open` 성공. 이어 명시적인 `xcodebuild -project PiggyEscape.xcodeproj -scheme PiggyEscape -destination 'platform=iOS Simulator,name=iPhone 17 Pro' -derivedDataPath /tmp/piggyescape-reality-tests test`가 79/79, 0 failures 및 `** TEST SUCCEEDED **`; 같은 destination의 `/tmp/piggyescape-reality-build build`가 `** BUILD SUCCEEDED **`; `/tmp/piggyescape-reality-docc` `docbuild`가 archive 생성과 `** BUILD DOCUMENTATION SUCCEEDED **`로 완료됐다. DocC의 Chapter Image 경고 1건과 환경 재시도는 L-20260811-067~070에 기록했다. +- 다음 시작점: LiDAR 지원 실기기에서 아래 수용 목록을 관찰해 결과·기기·OS를 기록하고, 그 뒤 전체 변경을 독립 검토한다. +- 차단 요소: 실제 카메라 권한 UI·0.70초 전환·Settings 복귀·1.12 화면 확대·LiDAR 메쉬·물리 오클루전은 관찰 전까지 `실기기 대기`다. DocC는 새 이미지 에셋을 추가하지 않는 제약 때문에 Chapter Image 경고 1건을 남긴다. + +### 실기기 수용 목록 — 실기기 대기 + +- [ ] C3 섬·기존 나무·초기 나레이션이 보인다. +- [ ] 초기 나레이션이 끝나기 전 돼지 탭은 무시된다. +- [ ] 탭 뒤 걷는 돼지가 현재 카메라 반대편 나무 뒤로 이동한다. +- [ ] 카메라를 0.70 rad 이상 돌려 돼지가 보일 때 놀란 모델·자막·1.5배 확대 후 복귀가 한 번 실행된다. +- [ ] 페이드 뒤 시스템 카메라 권한 문구가 보이고, 허용 뒤 AR 스캔 안내가 열린다. +- [ ] 권한 거부·제한과 Settings 복구를 각각 관찰한다. +- [ ] 실제 물체의 수직 옆면을 탭하면 돼지가 반대편 바닥으로 걸어간다. +- [ ] 초기 시점에서 실제 물체의 LiDAR 메쉬가 돼지를 가린다. +- [ ] 사용자가 옆으로 이동해 다시 볼 때 놀란 모델·자막·1.5배 돼지 확대·1.12배 화면 확대가 한 번 실행되고 복귀한다. +- [ ] LiDAR 미지원, 수평면 탭, 너무 가까운 탭, 바닥 추적 부족, 돼지 에셋 로드 실패 안내와 재시도 경로를 관찰한다. ### Task 7에서 해결한 항목 @@ -22,6 +35,7 @@ | 날짜 | 작업 범위 | 결과 | 검증 | 다음 시작점 | | --- | --- | --- | --- | --- | +| 2026-08-11 | Task 8 — 전체 회귀·실기기 검증과 DocC·인수인계 | 방·가짜 소파 중심 DocC를 C3 섬·나무 뒤 숨기·카메라 발견·권한 전환·실제 메쉬 오클루전·물리적 재발견의 다섯 장면으로 교체하고, `PROJECT_CONTEXT`·트러블슈팅·실기기 수용 목록을 추가함. 실기기 항목은 관찰하지 않아 모두 `실기기 대기`로 유지함 | `tuist generate --no-open` 성공, 명시적 전체 XCTest 79/79·0 failures, iPhone 17 Pro Simulator build 성공, DocC archive 생성·documentation build 성공. 연결 worktree fetch·Simulator 권한 재시도와 Chapter Image 경고 1건은 L-20260811-067~070 | LiDAR 지원 실기기 수용 목록 관찰 후 전체 독립 검토 | | 2026-08-11 | Task 7 — 자동 SceneKit→RealityKit 전환과 C3 스타일 안내 | C3 발견 뒤 0.70초 페이드 완료 시점에만 시스템 카메라 권한을 한 번 요청하고, 허용 시 AR 화면을 자동으로 열어 Task 6의 스캔·선택·도착·재발견·오류 callback을 상태 기계에 연결함. 거부·제한 안내와 명시적 Settings 복구, C3 스타일 material 패널, AR 화면 1.12→1.0 확대와 Reduce Motion 대안을 추가하고 `ContentView`를 새 루트로 교체함 | 루트 전환 TDD RED 뒤 focused 8/8, 명시적 Xcode scheme 전체 XCTest 79/79, iOS Simulator build 성공. 실패·환경·접근성 학습은 L-20260811-058~066, 실제 기기 대기는 L-20260811-065 | Task 7 독립 검토 후 Task 8 | | 2026-08-11 | Task 6 독립 리뷰 2차 수정 | running·idle·surprised 에셋 실패를 `Result`로 종결해 대기 또는 숨김 상태로 복구하고 Task 7용 `onError`·고정 한국어 메시지를 추가함. 고정 카메라 전방 시작점 대신 선택 면의 카메라 쪽 0.28m·정확한 바닥 Y에서 시작해 반대편 목적지로 걷도록 함 | 결함별 focused RED 뒤 coordinator 10/10, visual 3/3, 전체 XCTest 71/71 및 iOS Simulator build 성공. 원인·학습은 L-20260811-055~057 | Task 6 재검토 후 Task 7 | | 2026-08-11 | Task 6 독립 리뷰 수정 | 화면 밖·카메라 뒤 관찰을 재발견 입력에서 제외하고, 놀란 모델 설치 뒤에만 확대·발견 콜백을 실행함. 타깃 수락과 돼지 도착 이벤트를 분리하고 도착 전 발견을 막았으며, 유효한 바닥 Y·카메라 전방 시작 위치가 생기기 전 돼지를 비활성화함 | 결함별 RED 확인 후 visual focused 3/3, coordinator focused 7/7, 전체 XCTest 68/68 및 iOS Simulator build 성공. 원인·학습은 L-20260811-050~054 | Task 6 재검토 후 Task 7 | diff --git a/docs/superpowers/plans/2026-08-10-ch1-reality-escape-implementation.md b/docs/superpowers/plans/2026-08-10-ch1-reality-escape-implementation.md index 68db76d..a33f93c 100644 --- a/docs/superpowers/plans/2026-08-10-ch1-reality-escape-implementation.md +++ b/docs/superpowers/plans/2026-08-10-ch1-reality-escape-implementation.md @@ -1077,7 +1077,7 @@ git commit -m "Connect C3 world to RealityKit escape" - Produces: Chapter 1 DocC that teaches the C3 closed world, camera-driven failure to hide, camera-permission transition, LiDAR mesh occlusion, and physical rediscovery. - Produces: Git-tracked shared context with actual automated and manual verification evidence, without claimed real-device results until they were observed. -- [ ] **Step 1: DocC snippets가 새 코드 계약을 설명하도록 실패 검증을 준비한다.** +- [x] **Step 1: DocC snippets가 새 코드 계약을 설명하도록 실패 검증을 준비한다.** Replace each existing room/fake-sofa snippet with a compilable focused excerpt: state gate, C3 named tree and model container, tree hide calculation/discovery reaction, `ARWorldTrackingConfiguration` + scene understanding options, and real mesh reveal monitor. Each snippet must define all names it uses inside the snippet or import them from the adjacent snippet's declared context. @@ -1093,7 +1093,7 @@ xcodebuild docbuild -project PiggyEscape.xcodeproj -scheme PiggyEscape \ Expected before updating snippets: DocC content still describes room/fake sofa and no longer matches the running app, even if `docbuild` technically succeeds. -- [ ] **Step 2: 튜토리얼을 다섯 장면으로 다시 작성한다.** +- [x] **Step 2: 튜토리얼을 다섯 장면으로 다시 작성한다.** Use this exact tutorial progression: @@ -1107,11 +1107,11 @@ Use this exact tutorial progression: Explain that `sceneReconstruction = .meshWithClassification` is guarded by `supportsSceneReconstruction`, and explain `.occlusion` as depth-only use of reconstructed geometry. Link the primary Apple documentation URLs already listed in the design spec; do not copy third-party descriptions. -- [ ] **Step 3: shared Markdown을 실제 상태로 갱신한다.** +- [x] **Step 3: shared Markdown을 실제 상태로 갱신한다.** Create `docs/PROJECT_CONTEXT.md` by reconciling the root workspace's collaboration rules with this branch's newer approved `reality-escape` design: the current approved scope is this C3→RealityKit Chapter 1 experience, the Draft PR remains the baseline, and Task 8 manual LiDAR verification is not completed until observed. In `docs/TROUBLESHOOTING.md`, record only observed limitations: ARKit cannot perform physical mesh occlusion in Simulator; camera permission can only be fully checked on device; and `ARView` screen scaling is deliberately not a lens/FOV change. In `docs/WORK_LOG.md`, state exact test commands and the remaining manual checks. -- [ ] **Step 4: 전체 자동 검증과 DocC 빌드를 실행한다.** +- [x] **Step 4: 전체 자동 검증과 DocC 빌드를 실행한다.** Run: @@ -1147,7 +1147,7 @@ Use the physical LiDAR-capable iPhone to verify this exact checklist, recording [ ] 권한 거부, LiDAR 미지원, 수평면 탭, 너무 가까운 탭, 바닥 추적 부족은 각각 안내와 복구 경로를 보인다. ``` -- [ ] **Step 6: 최종 인수인계를 기록하고 커밋한다.** +- [x] **Step 6: 최종 인수인계를 기록하고 커밋한다.** Only stage source-controlled Markdown and tutorial source files; never stage `/tmp` outputs or `.claude/`. From 488e99d46473834117e6363cd2bad2d10447fca4 Mon Sep 17 00:00:00 2001 From: eunseo-com Date: Tue, 11 Aug 2026 01:35:37 +0900 Subject: [PATCH 17/22] Restore camera permission recovery --- .../Escape/EscapeExperienceState.swift | 3 +- .../Sources/Escape/EscapeRootView.swift | 162 ++++++++++++++++-- .../EscapeRootCoordinatorTests.swift | 132 ++++++++++++++ docs/LEARNING_LOG.md | 84 +++++++++ docs/WORK_LOG.md | 13 +- 5 files changed, 372 insertions(+), 22 deletions(-) diff --git a/PiggyEscape/PiggyEscape/Sources/Escape/EscapeExperienceState.swift b/PiggyEscape/PiggyEscape/Sources/Escape/EscapeExperienceState.swift index 7b2bc09..b6ead38 100644 --- a/PiggyEscape/PiggyEscape/Sources/Escape/EscapeExperienceState.swift +++ b/PiggyEscape/PiggyEscape/Sources/Escape/EscapeExperienceState.swift @@ -26,7 +26,8 @@ struct EscapeExperienceMachine { case (.walkingBehindTree, .pigReachedTree): state = .hiddenInClosedWorld case (.hiddenInClosedWorld, .closedWorldPigDiscovered): state = .discoveredByCamera case (.discoveredByCamera, .closedWorldFadeFinished): state = .requestingCameraPermission - case (.requestingCameraPermission, .cameraAuthorized): state = .scanningReality + case (.requestingCameraPermission, .cameraAuthorized), + (.cameraDenied, .cameraAuthorized): state = .scanningReality case (.requestingCameraPermission, .cameraDenied): state = .cameraDenied case (.scanningReality, .meshSupported): state = .waitingForRealTarget case (.scanningReality, .meshUnsupported): state = .lidarUnavailable diff --git a/PiggyEscape/PiggyEscape/Sources/Escape/EscapeRootView.swift b/PiggyEscape/PiggyEscape/Sources/Escape/EscapeRootView.swift index 90b105e..c1dc8a5 100644 --- a/PiggyEscape/PiggyEscape/Sources/Escape/EscapeRootView.swift +++ b/PiggyEscape/PiggyEscape/Sources/Escape/EscapeRootView.swift @@ -6,26 +6,40 @@ enum CameraAuthorizationResult: Equatable { case authorized case denied case restricted + case notDetermined } @MainActor protocol CameraAuthorizing { + func currentVideoAuthorization() -> CameraAuthorizationResult func requestVideoAccess( _ completion: @escaping @MainActor (CameraAuthorizationResult) -> Void ) } struct SystemCameraAuthorizer: CameraAuthorizing { - func requestVideoAccess( - _ completion: @escaping @MainActor (CameraAuthorizationResult) -> Void - ) { + func currentVideoAuthorization() -> CameraAuthorizationResult { switch AVCaptureDevice.authorizationStatus(for: .video) { case .authorized: - completion(.authorized) + .authorized case .denied: - completion(.denied) + .denied case .restricted: - completion(.restricted) + .restricted + case .notDetermined: + .notDetermined + @unknown default: + .restricted + } + } + + func requestVideoAccess( + _ completion: @escaping @MainActor (CameraAuthorizationResult) -> Void + ) { + let authorization = currentVideoAuthorization() + switch authorization { + case .authorized, .denied, .restricted: + completion(authorization) case .notDetermined: AVCaptureDevice.requestAccess(for: .video) { granted in let result: CameraAuthorizationResult @@ -38,12 +52,69 @@ struct SystemCameraAuthorizer: CameraAuthorizing { } Task { @MainActor in completion(result) } } - @unknown default: - completion(.restricted) } } } +@MainActor +protocol MainActorCallbackDeferring { + @discardableResult + func enqueue(_ operation: @escaping @MainActor () -> Void) -> Task +} + +struct TaskMainActorCallbackDeferrer: MainActorCallbackDeferring { + @discardableResult + func enqueue(_ operation: @escaping @MainActor () -> Void) -> Task { + Task { @MainActor in + await Task.yield() + guard !Task.isCancelled else { return } + operation() + } + } +} + +@MainActor +final class RealityCallbackRelay: ObservableObject { + private let deferrer: any MainActorCallbackDeferring + private var generation = 0 + private var isActive = true + private var pendingTasks: [UUID: Task] = [:] + + init() { + deferrer = TaskMainActorCallbackDeferrer() + } + + init(deferrer: any MainActorCallbackDeferring) { + self.deferrer = deferrer + } + + func activate() { + guard !isActive else { return } + generation &+= 1 + isActive = true + } + + func invalidate() { + generation &+= 1 + isActive = false + pendingTasks.values.forEach { $0.cancel() } + pendingTasks.removeAll() + } + + func schedule(_ operation: @escaping @MainActor () -> Void) { + guard isActive else { return } + let callbackGeneration = generation + let identifier = UUID() + let task = deferrer.enqueue { [weak self] in + guard let self else { return } + self.pendingTasks[identifier] = nil + guard self.isActive, self.generation == callbackGeneration else { return } + operation() + } + pendingTasks[identifier] = task + } +} + @MainActor protocol AppSettingsOpening { func openAppSettings() @@ -174,6 +245,23 @@ final class EscapeRootCoordinator: ObservableObject { settingsOpener.openAppSettings() } + func applicationDidBecomeActive() { + guard machine.state == .cameraDenied else { return } + let result = cameraAuthorizer.currentVideoAuthorization() + cameraAuthorizationResult = result + switch result { + case .authorized: + guard machine.send(.cameraAuthorized) else { return } + message = RealityAvailabilityMessage.scanFirst + case .denied: + message = EscapeRootMessage.cameraDenied + case .restricted: + message = EscapeRootMessage.cameraRestricted + case .notDetermined: + message = EscapeRootMessage.cameraDenied + } + } + private func synchronizeClosedWorldProgress() { if machine.state == .openingNarration { _ = machine.send(.narrationFinished) @@ -199,13 +287,17 @@ final class EscapeRootCoordinator: ObservableObject { case .restricted: guard machine.send(.cameraDenied) else { return } message = EscapeRootMessage.cameraRestricted + case .notDetermined: + return } } } struct EscapeRootView: View { @Environment(\.accessibilityReduceMotion) private var accessibilityReduceMotion + @Environment(\.scenePhase) private var scenePhase @StateObject private var coordinator: EscapeRootCoordinator + @StateObject private var realityCallbacks: RealityCallbackRelay @State private var fadeTask: Task? @State private var realityScaleTask: Task? @State private var realityScreenScale: CGFloat = 1 @@ -213,6 +305,7 @@ struct EscapeRootView: View { @MainActor init() { _coordinator = StateObject(wrappedValue: EscapeRootCoordinator()) + _realityCallbacks = StateObject(wrappedValue: RealityCallbackRelay()) } @MainActor @@ -224,6 +317,7 @@ struct EscapeRootView: View { cameraAuthorizer: cameraAuthorizer, settingsOpener: settingsOpener )) + _realityCallbacks = StateObject(wrappedValue: RealityCallbackRelay()) } var body: some View { @@ -251,13 +345,27 @@ struct EscapeRootView: View { if coordinator.showsRealityView { RealityHideARView( - onScanningReady: coordinator.realityScanningDidBecomeReady, - onTargetAccepted: coordinator.realityTargetDidBecomeAccepted, - onPigReachedTarget: coordinator.realityPigDidReachTarget, - onRevealed: coordinator.realityPigDidBecomeRevealed, - onError: coordinator.realityErrorDidOccur, - onUnavailable: coordinator.realityMeshDidBecomeUnavailable, - onMessage: coordinator.realityMessageDidChange + onScanningReady: { + scheduleRealityCallback { $0.realityScanningDidBecomeReady() } + }, + onTargetAccepted: { + scheduleRealityCallback { $0.realityTargetDidBecomeAccepted() } + }, + onPigReachedTarget: { + scheduleRealityCallback { $0.realityPigDidReachTarget() } + }, + onRevealed: { + scheduleRealityCallback { $0.realityPigDidBecomeRevealed() } + }, + onError: { + scheduleRealityCallback { $0.realityErrorDidOccur() } + }, + onUnavailable: { + scheduleRealityCallback { $0.realityMeshDidBecomeUnavailable() } + }, + onMessage: { message in + scheduleRealityCallback { $0.realityMessageDidChange(message) } + } ) .scaleEffect(realityScreenScale) .clipped() @@ -271,9 +379,24 @@ struct EscapeRootView: View { guard sequence > 0 else { return } performRealitySurprise() } + .onChange(of: coordinator.showsRealityView) { _, showsRealityView in + if showsRealityView { + realityCallbacks.activate() + } else { + realityCallbacks.invalidate() + } + } + .onChange(of: scenePhase) { _, phase in + guard phase == .active else { return } + coordinator.applicationDidBecomeActive() + } + .onAppear { + realityCallbacks.activate() + } .onDisappear { fadeTask?.cancel() realityScaleTask?.cancel() + realityCallbacks.invalidate() } } @@ -351,4 +474,13 @@ struct EscapeRootView: View { } } } + + private func scheduleRealityCallback( + _ operation: @escaping @MainActor (EscapeRootCoordinator) -> Void + ) { + realityCallbacks.schedule { [weak coordinator] in + guard let coordinator else { return } + operation(coordinator) + } + } } diff --git a/PiggyEscape/PiggyEscapeTests/EscapeRootCoordinatorTests.swift b/PiggyEscape/PiggyEscapeTests/EscapeRootCoordinatorTests.swift index d6a73fc..6870c35 100644 --- a/PiggyEscape/PiggyEscapeTests/EscapeRootCoordinatorTests.swift +++ b/PiggyEscape/PiggyEscapeTests/EscapeRootCoordinatorTests.swift @@ -76,6 +76,80 @@ final class EscapeRootCoordinatorTests: XCTestCase { XCTAssertEqual(settings.openCount, 1) } + func test_activeAfterSettingsGrantStartsRealityOnceWithoutAnotherPrompt() { + let authorizer = FakeCameraAuthorizer() + let settings = FakeSettingsOpener() + let coordinator = makeCoordinator(authorizer: authorizer, settings: settings) + reachCameraRequest(coordinator) + authorizer.resolve(.denied) + coordinator.openSettingsForRecovery() + + authorizer.currentAuthorization = .authorized + coordinator.applicationDidBecomeActive() + + XCTAssertEqual(coordinator.machine.state, .scanningReality) + XCTAssertTrue(coordinator.showsRealityView) + XCTAssertEqual(coordinator.message, RealityAvailabilityMessage.scanFirst) + XCTAssertEqual(authorizer.requestCount, 1) + XCTAssertEqual(authorizer.authorizationQueryCount, 1) + XCTAssertEqual(settings.openCount, 1) + + coordinator.applicationDidBecomeActive() + XCTAssertEqual(coordinator.machine.state, .scanningReality) + XCTAssertEqual(authorizer.requestCount, 1) + XCTAssertEqual(authorizer.authorizationQueryCount, 1) + } + + func test_activeAfterClosingSettingsWithoutPermissionChangePreservesDeniedRecovery() { + let authorizer = FakeCameraAuthorizer() + let settings = FakeSettingsOpener() + let coordinator = makeCoordinator(authorizer: authorizer, settings: settings) + reachCameraRequest(coordinator) + authorizer.resolve(.denied) + coordinator.openSettingsForRecovery() + + authorizer.currentAuthorization = .denied + coordinator.applicationDidBecomeActive() + coordinator.applicationDidBecomeActive() + + XCTAssertEqual(coordinator.machine.state, .cameraDenied) + XCTAssertEqual(coordinator.message, EscapeRootMessage.cameraDenied) + XCTAssertTrue(coordinator.showsSettingsRecovery) + XCTAssertEqual(authorizer.requestCount, 1) + XCTAssertEqual(authorizer.authorizationQueryCount, 2) + XCTAssertEqual(settings.openCount, 1) + } + + func test_activeAfterSettingsRestrictionPreservesRestrictedRecovery() { + let authorizer = FakeCameraAuthorizer() + let coordinator = makeCoordinator(authorizer: authorizer) + reachCameraRequest(coordinator) + authorizer.resolve(.denied) + + authorizer.currentAuthorization = .restricted + coordinator.applicationDidBecomeActive() + + XCTAssertEqual(coordinator.machine.state, .cameraDenied) + XCTAssertEqual(coordinator.message, EscapeRootMessage.cameraRestricted) + XCTAssertEqual(authorizer.requestCount, 1) + XCTAssertEqual(authorizer.authorizationQueryCount, 1) + } + + func test_activeAfterInitialAuthorizationDoesNotReenterRealityOrQueryAgain() { + let authorizer = FakeCameraAuthorizer() + let coordinator = makeCoordinator(authorizer: authorizer) + reachCameraRequest(coordinator) + authorizer.resolve(.authorized) + + coordinator.applicationDidBecomeActive() + coordinator.applicationDidBecomeActive() + + XCTAssertEqual(coordinator.machine.state, .scanningReality) + XCTAssertTrue(coordinator.showsRealityView) + XCTAssertEqual(authorizer.requestCount, 1) + XCTAssertEqual(authorizer.authorizationQueryCount, 0) + } + func test_realityCallbacksAdvanceInPhysicalActionOrderAndRevealOnce() { let authorizer = FakeCameraAuthorizer() let coordinator = makeCoordinator(authorizer: authorizer) @@ -132,6 +206,37 @@ final class EscapeRootCoordinatorTests: XCTestCase { XCTAssertEqual(EscapeRootMotion.realityPeakScale(reduceMotion: true), 1.0, accuracy: 0.0001) } + func test_realityCallbackRelayDefersExternalCallbackUntilTheNextMainActorTurn() { + let deferrer = QueuedMainActorDeferrer() + let relay = RealityCallbackRelay(deferrer: deferrer) + var deliveryCount = 0 + + relay.schedule { + deliveryCount += 1 + } + + XCTAssertEqual(deliveryCount, 0) + XCTAssertEqual(deferrer.pendingCount, 1) + + deferrer.runNext() + XCTAssertEqual(deliveryCount, 1) + } + + func test_realityCallbackRelayDropsCallbackQueuedBeforeItsLifetimeEnds() { + let deferrer = QueuedMainActorDeferrer() + let relay = RealityCallbackRelay(deferrer: deferrer) + var deliveryCount = 0 + + relay.schedule { + deliveryCount += 1 + } + relay.invalidate() + relay.activate() + deferrer.runNext() + + XCTAssertEqual(deliveryCount, 0) + } + private func makeCoordinator( authorizer: FakeCameraAuthorizer, settings: FakeSettingsOpener? = nil @@ -152,8 +257,15 @@ final class EscapeRootCoordinatorTests: XCTestCase { @MainActor private final class FakeCameraAuthorizer: CameraAuthorizing { private(set) var requestCount = 0 + private(set) var authorizationQueryCount = 0 + var currentAuthorization: CameraAuthorizationResult = .denied private var completion: ((CameraAuthorizationResult) -> Void)? + func currentVideoAuthorization() -> CameraAuthorizationResult { + authorizationQueryCount += 1 + return currentAuthorization + } + func requestVideoAccess( _ completion: @escaping @MainActor (CameraAuthorizationResult) -> Void ) { @@ -162,11 +274,31 @@ private final class FakeCameraAuthorizer: CameraAuthorizing { } func resolve(_ result: CameraAuthorizationResult) { + currentAuthorization = result completion?(result) completion = nil } } +@MainActor +private final class QueuedMainActorDeferrer: MainActorCallbackDeferring { + private var pending: [@MainActor () -> Void] = [] + + var pendingCount: Int { + pending.count + } + + func enqueue(_ operation: @escaping @MainActor () -> Void) -> Task { + pending.append(operation) + return Task {} + } + + func runNext() { + guard !pending.isEmpty else { return } + pending.removeFirst()() + } +} + @MainActor private final class FakeSettingsOpener: AppSettingsOpening { private(set) var openCount = 0 diff --git a/docs/LEARNING_LOG.md b/docs/LEARNING_LOG.md index 0484507..b95ec4a 100644 --- a/docs/LEARNING_LOG.md +++ b/docs/LEARNING_LOG.md @@ -28,6 +28,90 @@ ## 항목 +### L-20260811-078 — 최종 focused XCTest가 Simulator 서비스 접근 제한으로 중단됨 + +- 상태: 해결 +- 발생 태스크: Task 7 보수 — 권한 복귀와 외부 콜백 수명 +- 재현: `PiggyEscape/`에서 `xcodebuild -project PiggyEscape.xcodeproj -scheme PiggyEscape -destination 'platform=iOS Simulator,name=iPhone 17 Pro' -derivedDataPath /tmp/piggyescape-task7-repair-green -only-testing:PiggyEscapeTests/EscapeRootCoordinatorTests test` 실행 +- 관찰: `CoreSimulatorService connection became invalid`, `Error opening log file ... Operation not permitted`, `Unable to discover any Simulator runtimes`가 출력돼 XCTest assertion 실행 전에 중단됨 +- 영향: 현재 source 수정 뒤 focused XCTest 결과를 sandbox 안에서 확인할 수 없으므로, 앞선 성공 결과만으로 이 재검증을 대체할 수 없음 +- 원인/가설: Simulator 서비스·로그·runtime 상태 경로가 worktree 밖에 있어 sandbox 접근이 거부된 것으로 보이며 L-20260811-069와 같은 환경 제약이 재발함 +- 조치: 실패 기록 뒤 동일한 명시 명령을 Simulator 서비스에 접근 가능한 권한으로 한 번만 재실행함 +- 검증: 권한 재실행에서 focused XCTest 14/14, 전체 XCTest 85/85·0 failures 및 iPhone 17 Pro Simulator build 성공을 확인함 +- 배운 점: 코드 경로가 아니라 Simulator 초기화에서 멈춘 실행은 새 회귀로 분류하지 말고, 현재 명령을 접근 가능한 환경에서 재검증한 뒤에만 완료 근거로 사용한다. + +### L-20260811-077 — worktree 루트에서 Xcode 프로젝트 경로를 찾지 못함 + +- 상태: 해결 +- 발생 태스크: Task 7 보수 — 권한 복귀와 외부 콜백 수명 +- 재현: worktree 루트에서 `xcodebuild -project PiggyEscape.xcodeproj -scheme PiggyEscape -destination 'platform=iOS Simulator,name=iPhone 17 Pro' -derivedDataPath /tmp/piggyescape-task7-repair-green -only-testing:PiggyEscapeTests/EscapeRootCoordinatorTests test` 실행 +- 관찰: Simulator 서비스 접근 경고 뒤 `xcodebuild: error: 'PiggyEscape.xcodeproj' does not exist.`로 테스트 실행 전에 종료됨 +- 영향: 앱 소스·테스트 컴파일과 assertion은 시작하지 않았으므로, 이 종료를 제품 회귀나 Simulator 결과로 해석할 수 없음 +- 원인/가설: 생성된 Xcode 프로젝트는 worktree 루트가 아닌 `PiggyEscape/` 하위 디렉터리에 있으며, 검증 명령의 작업 디렉터리를 그 위치로 지정하지 않은 것이 확인된 원인임 +- 조치: 임의 경로 탐색이나 프로젝트 복제 없이, 같은 명령을 `PiggyEscape/` 디렉터리에서 다시 실행함 +- 검증: 수정한 작업 디렉터리에서 focused XCTest 14/14, 전체 XCTest 85/85·0 failures 및 iPhone 17 Pro Simulator build 성공을 확인함 +- 배운 점: worktree 이름과 Xcode 프로젝트 디렉터리를 같다고 가정하지 말고, 검증 전 `Project.swift`·생성 `.xcodeproj`의 실제 위치를 기준으로 작업 디렉터리를 고정한다. + +### L-20260811-076 — AR 스캔 준비 안내는 세션 실행 직후이며 실제 재구성 준비 완료를 뜻하지 않음 + +- 상태: 보류 +- 발생 태스크: Task 7 보수 — 권한 복귀와 외부 콜백 수명 +- 재현: `RealityHideARView.Coordinator.attach(to:)`에서 `arView.session.run(...)`와 `onScanningReady()`의 순서를 대조함 +- 관찰: 현재 callback은 세션 실행 직후 발생하며 첫 AR frame·메쉬 재구성·분류된 바닥을 기다리지 않는다. 이번 보수는 SwiftUI update 중 발행을 막기 위해 루트에서 다음 turn으로 옮길 뿐, callback의 RealityKit 의미는 바꾸지 않는다. +- 영향: 사용자는 AR 화면이 열리자마자 옆면 선택 안내를 볼 수 있으나 실제 세로 메쉬·바닥이 아직 부족하면 기존 재스캔 안내를 받을 수 있다. +- 원인/가설: Task 6의 `onScanningReady`가 센서 데이터 가용성 대신 AR 세션 시작을 나타내도록 정의돼 있으며, 실제 재구성 준비 기준은 아직 별도 상태로 모델링되지 않았음 +- 조치: Task 7 범위를 넘는 RealityKit 세션·평면·재발견 로직은 변경하지 않는다. 실제 mesh/frame 준비 기준과 안내 시점의 개선은 다음 RealityKit 보수 태스크에서 독립적으로 설계·테스트한다. +- 검증: 이번 Task 7 repair는 callback 전달을 다음 MainActor turn으로 지연하고 stale callback을 폐기하는 focused XCTest로만 보호한다. 실제 mesh 준비 기준은 실기기 관찰과 별도 테스트가 필요함. +- 배운 점: AR 세션 시작과 사용자가 행동할 수 있는 공간 재구성 준비 완료를 같은 readiness 용어로 묶지 말고, 후속 설계에서 각각의 관찰 가능한 기준을 둔다. + +### L-20260811-075 — actor 격리된 callback deferrer를 relay 기본 인자에서 생성할 수 없음 + +- 상태: 해결 +- 발생 태스크: Task 7 보수 — 권한 복귀와 외부 콜백 수명 +- 재현: callback relay·현재 권한 조회의 최소 구현 뒤 `EscapeRootCoordinatorTests` focused XCTest 컴파일 +- 관찰: `RealityCallbackRelay.init(deferrer: any MainActorCallbackDeferring = TaskMainActorCallbackDeferrer())`에서 `Call to main actor-isolated initializer 'init()' in a synchronous nonisolated context`가 발생해 앱 모듈 생성이 중단됨 +- 영향: 권한 복귀·callback relay assertion까지 실행하지 못하며, 이 컴파일 실패를 기능 GREEN으로 해석할 수 없음 +- 원인/가설: `MainActorCallbackDeferring` 적합성으로 시스템 deferrer의 생성도 MainActor에 격리됐지만, relay initializer의 기본 인자 평가 지점은 동기 비격리로 처리된 것이 L-20260811-062와 같은 원인임 +- 조치: 기본 인자 생성식을 없애고 MainActor 무인자 initializer에서 시스템 deferrer를 만들며, 테스트용 주입 initializer는 의존성만 받게 분리함 +- 검증: 기본 인자를 제거한 뒤 `EscapeRootCoordinatorTests` focused XCTest 14/14, 전체 XCTest 85/85·0 failures 및 iPhone 17 Pro Simulator build 성공을 확인했고 같은 actor 격리 컴파일 오류는 다시 출력되지 않음 +- 배운 점: MainActor 의존성을 가진 relay·coordinator도 기본 인자 생성으로 편의화하지 말고, 격리된 무인자 경계와 명시적 주입 경계를 나눈다. + +### L-20260811-074 — RealityKit 준비 콜백이 SwiftUI 뷰 생성 중 루트 상태를 동기로 바꿈 + +- 상태: 해결 +- 발생 태스크: Task 7 보수 — 권한 복귀와 외부 콜백 수명 +- 재현: `RealityHideARView.makeUIView` → `Coordinator.attach(to:)` → `onScanningReady()` 호출과 `EscapeRootView`가 전달한 `coordinator.realityScanningDidBecomeReady`를 따라감 +- 관찰: `attach(to:)`는 `makeUIView`의 동기 호출 경로에서 지원 확인 뒤 `onScanningReady()`를 바로 실행한다. 루트는 이를 `@Published machine` 변경으로 연결하므로 SwiftUI가 UIKit 뷰를 생성·갱신하는 같은 turn에 상태를 발행할 수 있다. 미지원 경로의 `onUnavailable()`도 같은 외부 callback 경계를 가진다. +- 영향: SwiftUI 갱신 중 상태 발행 경고·재렌더링 순서 불안정·dismantle 뒤 이미 예약된 callback이 새 화면 상태를 덮는 위험이 있다. +- 원인/가설: Task 7 루트가 `UIViewRepresentable` 외부 callback을 UIKit 생성 경계 밖의 다음 MainActor turn으로 넘기지 않고 coordinator 메서드를 직접 전달한 것이 확인된 원인이다. +- 조치: `RealityHideARView` 내부의 세션·평면·재발견 로직은 변경하지 않는다. 루트 소유의 취소 가능한 callback relay를 추가해 모든 AR 외부 callback을 다음 MainActor turn으로 지연하고, AR 화면 해제·뷰 사라짐 뒤 세대가 다른 callback을 버리도록 한다. +- 검증: type 부재로 실패한 RED 뒤 relay의 동기 미발행·다음 turn 전달·해제 뒤 stale callback 폐기를 포함한 `EscapeRootCoordinatorTests` focused XCTest 14/14, 전체 XCTest 85/85·0 failures 및 iPhone 17 Pro Simulator build 성공을 확인함. 실제 mesh 준비 기준은 L-20260811-076으로 분리해 보류함. +- 배운 점: `UIViewRepresentable.makeUIView`에서 온 callback은 MainActor 위에 있더라도 SwiftUI update 중일 수 있으므로, 상태를 직접 발행하지 말고 수명 가드가 있는 다음 turn으로 넘긴다. + +### L-20260811-073 — Settings 복귀 후 카메라 허용 상태를 다시 읽지 못함 + +- 상태: 해결 +- 발생 태스크: Task 7 보수 — 권한 복귀와 외부 콜백 수명 +- 재현: C3 발견 뒤 권한을 거부하고 `설정 열기`를 눌러 앱 Settings에서 카메라를 허용한 다음 앱을 foreground/active로 복귀함 +- 관찰: `openSettingsForRecovery()`는 Settings URL만 열고, `EscapeRootView`에는 `scenePhase`/foreground 관찰이 없다. `EscapeExperienceMachine`도 `.cameraDenied`에서 `.cameraAuthorized`로 가는 전이가 없어, 현재 권한이 허용으로 바뀌어도 `.cameraDenied` 안내에 머문다. +- 영향: 사용자가 명시적으로 카메라를 허용해도 AR 화면으로 이어지지 않으며, 다시 권한 요청을 시도하는 임시 우회는 시스템 문구·Settings 재열기 규칙을 어길 수 있다. +- 원인/가설: 초기 권한 요청의 callback만 상태 기계에 연결하고, Settings 복귀라는 별도 lifecycle 경계의 현재 권한 조회와 합법적인 재진입 전이를 설계하지 않은 것이 확인된 원인이다. +- 조치: prompt-capable 요청과 현재 상태 조회를 `CameraAuthorizing`에서 분리한다. 앱이 active가 될 때 `.cameraDenied`에서만 현재 상태를 읽고 허용이면 한 번만 `.scanningReality`로 전환하며, 거부·제한이면 현재 차단 안내를 유지한다. 이 경로에서는 권한 요청·Settings 열기를 자동 실행하지 않는다. +- 검증: type 부재로 실패한 RED 뒤 Settings에서 허용 후 active, 변경 없이 Settings 닫기, 제한 상태, 반복 active, 이미 AR에 진입한 상태를 포함한 `EscapeRootCoordinatorTests` focused XCTest 14/14, 전체 XCTest 85/85·0 failures 및 iPhone 17 Pro Simulator build 성공을 확인함. +- 배운 점: Settings 복구는 URL을 여는 동작으로 끝나지 않으며, 복귀 lifecycle에서 비침습적인 현재 권한 조회와 중복 없는 상태 재진입을 함께 계약해야 한다. + +### L-20260811-072 — 공동 시작 문서가 현재 worktree에 없음 + +- 상태: 보류 +- 발생 태스크: Task 7 보수 — 권한 복귀와 외부 콜백 수명 +- 재현: 저장소 시작 순서에 따라 `씬킷에서_리얼리티킷으로_컨셉노트.md`를 읽으려 했음 +- 관찰: 현재 worktree 루트에 해당 파일이 없어 `sed: ... No such file or directory`로 종료됨 +- 영향: 별도 컨셉 노트를 보조 근거로 읽을 수 없으므로, 현재 승인 설계와 실행 계획만을 구현 근거로 사용해야 함 +- 원인/가설: `docs/PROJECT_CONTEXT.md`가 이미 허용한 현재 브랜치의 문서 부재 상태이며, 작업 시작 시점에는 파일이 제공되지 않았음 +- 조치: 파일을 새로 만들거나 다른 위치의 대화 기록으로 대체하지 않고, `docs/superpowers/specs/2026-08-10-ch1-reality-escape-design.md`와 실행 계획을 현재 범위의 기준으로 사용함 +- 검증: 이번 보수의 변경 범위는 Task 7 루트·상태 전이·테스트·인수인계·학습 기록에 한정함 +- 배운 점: 공동 시작 문서가 없으면 추정으로 복원하지 말고 승인된 설계 명세를 대체 근거로 명시한다. + ### L-20260811-071 — Task 8 문서 staging이 연결 작업 트리 index 잠금 권한으로 중단됨 - 상태: 해결 diff --git a/docs/WORK_LOG.md b/docs/WORK_LOG.md index 22535df..af7f1dc 100644 --- a/docs/WORK_LOG.md +++ b/docs/WORK_LOG.md @@ -4,13 +4,13 @@ ## 현재 인수인계 -- 상태: Task 8 — C3→RealityKit DocC·공통 인수인계·자동 검증 기록 완료, 실기기 수용 검증 대기 -- 진행 중 범위: 앱 진입점은 `EscapeRootView` 하나다. C3 발견 callback은 놀란 상태를 기록하고 0.70초 페이드가 끝난 뒤에만 카메라 권한을 한 번 요청한다. 허용 시 `RealityHideARView`를 자동으로 열고 스캔 준비·타깃 수락·돼지 도착·재발견·오류 callback을 상태 순서로 연결한다. 현실 재발견에는 `ARView` 컨테이너 1.12→1.0 확대가 추가됐고 실제 AR camera transform은 변경하지 않으며 Reduce Motion에서는 화면 확대를 생략한다. DocC는 같은 흐름을 C3 섬·카메라 발견·권한 전환·실제 메쉬·물리적 재발견의 다섯 장면으로 설명한다. +- 상태: Task 7 보수 — Settings 권한 복귀와 AR 외부 callback 수명 구현 완료, 독립 검토 대기 +- 진행 중 범위: 앱 진입점은 `EscapeRootView` 하나다. C3 발견 callback은 놀란 상태를 기록하고 0.70초 페이드가 끝난 뒤에만 카메라 권한을 한 번 요청한다. 허용 시 `RealityHideARView`를 자동으로 열고 스캔 준비·타깃 수락·돼지 도착·재발견·오류 callback을 상태 순서로 연결한다. 앱이 Settings에서 active로 복귀할 때는 `.cameraDenied` 상태에서만 현재 권한을 다시 읽고, 허용이면 요청·Settings 재열기 없이 한 번만 AR 스캔으로 전환한다. AR 외부 callback은 다음 MainActor turn의 취소 가능한 relay를 거쳐 SwiftUI 생성·갱신 중 루트 상태를 바꾸지 않으며, AR 화면 해제 뒤 예약된 callback은 폐기한다. 현실 재발견에는 `ARView` 컨테이너 1.12→1.0 확대가 추가됐고 실제 AR camera transform은 변경하지 않으며 Reduce Motion에서는 화면 확대를 생략한다. DocC는 같은 흐름을 C3 섬·카메라 발견·권한 전환·실제 메쉬·물리적 재발견의 다섯 장면으로 설명한다. - 실패 기록: `docs/LEARNING_LOG.md`에 실패·검증 한계의 재현 조건·원인/가설·조치·재발 방지 근거를 기록한다. -- 마지막 완료 범위: Task 8 — C3→RealityKit DocC·프로젝트 컨텍스트·자동 회귀 검증 기록. -- 마지막 검증: `cd PiggyEscape && tuist generate --no-open` 성공. 이어 명시적인 `xcodebuild -project PiggyEscape.xcodeproj -scheme PiggyEscape -destination 'platform=iOS Simulator,name=iPhone 17 Pro' -derivedDataPath /tmp/piggyescape-reality-tests test`가 79/79, 0 failures 및 `** TEST SUCCEEDED **`; 같은 destination의 `/tmp/piggyescape-reality-build build`가 `** BUILD SUCCEEDED **`; `/tmp/piggyescape-reality-docc` `docbuild`가 archive 생성과 `** BUILD DOCUMENTATION SUCCEEDED **`로 완료됐다. DocC의 Chapter Image 경고 1건과 환경 재시도는 L-20260811-067~070에 기록했다. -- 다음 시작점: LiDAR 지원 실기기에서 아래 수용 목록을 관찰해 결과·기기·OS를 기록하고, 그 뒤 전체 변경을 독립 검토한다. -- 차단 요소: 실제 카메라 권한 UI·0.70초 전환·Settings 복귀·1.12 화면 확대·LiDAR 메쉬·물리 오클루전은 관찰 전까지 `실기기 대기`다. DocC는 새 이미지 에셋을 추가하지 않는 제약 때문에 Chapter Image 경고 1건을 남긴다. +- 마지막 완료 범위: Task 7 보수 — Settings 권한 복귀와 AR 외부 callback 수명. +- 마지막 검증: `xcodebuild -project PiggyEscape.xcodeproj -scheme PiggyEscape -destination 'platform=iOS Simulator,name=iPhone 17 Pro' -derivedDataPath /tmp/piggyescape-task7-repair-green -only-testing:PiggyEscapeTests/EscapeRootCoordinatorTests test`가 14/14, 0 failures 및 `** TEST SUCCEEDED **`; 이어 `/tmp/piggyescape-task7-repair-tests test`가 전체 85/85, 0 failures 및 `** TEST SUCCEEDED **`; 같은 destination의 `/tmp/piggyescape-task7-repair-build build`가 `** BUILD SUCCEEDED **`로 완료됐다. +- 다음 시작점: 이 보수를 독립 검토한 뒤 LiDAR 지원 실기기에서 아래 수용 목록을 관찰해 결과·기기·OS를 기록한다. AR 세션 시작과 실제 공간 재구성 준비를 구분하는 L-20260811-076은 별도 RealityKit 보수 태스크로 설계한다. +- 차단 요소: 실제 카메라 권한 UI·0.70초 전환·Settings 복귀·1.12 화면 확대·LiDAR 메쉬·물리 오클루전은 관찰 전까지 `실기기 대기`다. `onScanningReady`는 세션 시작만 뜻하며 실제 mesh/frame 준비 기준은 L-20260811-076으로 보류한다. DocC는 새 이미지 에셋을 추가하지 않는 제약 때문에 Chapter Image 경고 1건을 남긴다. ### 실기기 수용 목록 — 실기기 대기 @@ -35,6 +35,7 @@ | 날짜 | 작업 범위 | 결과 | 검증 | 다음 시작점 | | --- | --- | --- | --- | --- | +| 2026-08-11 | Task 7 보수 — Settings 권한 복귀와 AR 외부 callback 수명 | Settings에서 카메라를 허용한 뒤 active로 돌아오면 현재 권한만 다시 읽어 한 번 AR 스캔으로 전환하고, 권한 미변경·제한·이미 AR 상태에서는 요청·Settings 재열기·중복 전환을 하지 않게 함. `UIViewRepresentable`에서 온 모든 AR callback은 다음 MainActor turn의 취소 가능한 relay로 보내고 화면 해제 뒤 stale callback을 버림 | type 부재 RED 뒤 `EscapeRootCoordinatorTests` focused 14/14, 전체 XCTest 85/85·0 failures, iPhone 17 Pro Simulator build 성공. 실패·해결·실제 mesh 준비 한계는 L-20260811-073~076 | 보수 독립 검토 후 L-20260811-076 RealityKit readiness 설계 및 실기기 수용 목록 관찰 | | 2026-08-11 | Task 8 — 전체 회귀·실기기 검증과 DocC·인수인계 | 방·가짜 소파 중심 DocC를 C3 섬·나무 뒤 숨기·카메라 발견·권한 전환·실제 메쉬 오클루전·물리적 재발견의 다섯 장면으로 교체하고, `PROJECT_CONTEXT`·트러블슈팅·실기기 수용 목록을 추가함. 실기기 항목은 관찰하지 않아 모두 `실기기 대기`로 유지함 | `tuist generate --no-open` 성공, 명시적 전체 XCTest 79/79·0 failures, iPhone 17 Pro Simulator build 성공, DocC archive 생성·documentation build 성공. 연결 worktree fetch·Simulator 권한 재시도와 Chapter Image 경고 1건은 L-20260811-067~070 | LiDAR 지원 실기기 수용 목록 관찰 후 전체 독립 검토 | | 2026-08-11 | Task 7 — 자동 SceneKit→RealityKit 전환과 C3 스타일 안내 | C3 발견 뒤 0.70초 페이드 완료 시점에만 시스템 카메라 권한을 한 번 요청하고, 허용 시 AR 화면을 자동으로 열어 Task 6의 스캔·선택·도착·재발견·오류 callback을 상태 기계에 연결함. 거부·제한 안내와 명시적 Settings 복구, C3 스타일 material 패널, AR 화면 1.12→1.0 확대와 Reduce Motion 대안을 추가하고 `ContentView`를 새 루트로 교체함 | 루트 전환 TDD RED 뒤 focused 8/8, 명시적 Xcode scheme 전체 XCTest 79/79, iOS Simulator build 성공. 실패·환경·접근성 학습은 L-20260811-058~066, 실제 기기 대기는 L-20260811-065 | Task 7 독립 검토 후 Task 8 | | 2026-08-11 | Task 6 독립 리뷰 2차 수정 | running·idle·surprised 에셋 실패를 `Result`로 종결해 대기 또는 숨김 상태로 복구하고 Task 7용 `onError`·고정 한국어 메시지를 추가함. 고정 카메라 전방 시작점 대신 선택 면의 카메라 쪽 0.28m·정확한 바닥 Y에서 시작해 반대편 목적지로 걷도록 함 | 결함별 focused RED 뒤 coordinator 10/10, visual 3/3, 전체 XCTest 71/71 및 iOS Simulator build 성공. 원인·학습은 L-20260811-055~057 | Task 6 재검토 후 Task 7 | From 1553534d58cfde24983d01ede037553045a181f7 Mon Sep 17 00:00:00 2001 From: eunseo-com Date: Tue, 11 Aug 2026 01:41:37 +0900 Subject: [PATCH 18/22] Gate camera recovery on settings return --- .../Sources/Escape/EscapeRootView.swift | 6 ++- .../EscapeRootCoordinatorTests.swift | 46 ++++++++++++++++++- docs/LEARNING_LOG.md | 16 ++++++- docs/WORK_LOG.md | 9 ++-- 4 files changed, 68 insertions(+), 9 deletions(-) diff --git a/PiggyEscape/PiggyEscape/Sources/Escape/EscapeRootView.swift b/PiggyEscape/PiggyEscape/Sources/Escape/EscapeRootView.swift index c1dc8a5..cc519d0 100644 --- a/PiggyEscape/PiggyEscape/Sources/Escape/EscapeRootView.swift +++ b/PiggyEscape/PiggyEscape/Sources/Escape/EscapeRootView.swift @@ -170,6 +170,7 @@ final class EscapeRootCoordinator: ObservableObject { private let cameraAuthorizer: any CameraAuthorizing private let settingsOpener: any AppSettingsOpening private var hasRequestedCamera = false + private var awaitingSettingsReturn = false init() { cameraAuthorizer = SystemCameraAuthorizer() @@ -242,11 +243,14 @@ final class EscapeRootCoordinator: ObservableObject { func openSettingsForRecovery() { guard showsSettingsRecovery else { return } + awaitingSettingsReturn = true settingsOpener.openAppSettings() } func applicationDidBecomeActive() { - guard machine.state == .cameraDenied else { return } + guard machine.state == .cameraDenied, + awaitingSettingsReturn else { return } + awaitingSettingsReturn = false let result = cameraAuthorizer.currentVideoAuthorization() cameraAuthorizationResult = result switch result { diff --git a/PiggyEscape/PiggyEscapeTests/EscapeRootCoordinatorTests.swift b/PiggyEscape/PiggyEscapeTests/EscapeRootCoordinatorTests.swift index 6870c35..3ded314 100644 --- a/PiggyEscape/PiggyEscapeTests/EscapeRootCoordinatorTests.swift +++ b/PiggyEscape/PiggyEscapeTests/EscapeRootCoordinatorTests.swift @@ -116,23 +116,65 @@ final class EscapeRootCoordinatorTests: XCTestCase { XCTAssertEqual(coordinator.message, EscapeRootMessage.cameraDenied) XCTAssertTrue(coordinator.showsSettingsRecovery) XCTAssertEqual(authorizer.requestCount, 1) - XCTAssertEqual(authorizer.authorizationQueryCount, 2) + XCTAssertEqual(authorizer.authorizationQueryCount, 1) XCTAssertEqual(settings.openCount, 1) } - func test_activeAfterSettingsRestrictionPreservesRestrictedRecovery() { + func test_activeWithoutSettingsRecoveryTapDoesNotQueryCurrentAuthorization() { let authorizer = FakeCameraAuthorizer() let coordinator = makeCoordinator(authorizer: authorizer) reachCameraRequest(coordinator) authorizer.resolve(.denied) + coordinator.applicationDidBecomeActive() + coordinator.applicationDidBecomeActive() + + XCTAssertEqual(coordinator.machine.state, .cameraDenied) + XCTAssertEqual(coordinator.message, EscapeRootMessage.cameraDenied) + XCTAssertEqual(authorizer.requestCount, 1) + XCTAssertEqual(authorizer.authorizationQueryCount, 0) + } + + func test_activeAfterSettingsRestrictionConsumesOneRecoveryAttempt() { + let authorizer = FakeCameraAuthorizer() + let settings = FakeSettingsOpener() + let coordinator = makeCoordinator(authorizer: authorizer, settings: settings) + reachCameraRequest(coordinator) + authorizer.resolve(.denied) + + coordinator.openSettingsForRecovery() authorizer.currentAuthorization = .restricted coordinator.applicationDidBecomeActive() + coordinator.applicationDidBecomeActive() XCTAssertEqual(coordinator.machine.state, .cameraDenied) XCTAssertEqual(coordinator.message, EscapeRootMessage.cameraRestricted) XCTAssertEqual(authorizer.requestCount, 1) XCTAssertEqual(authorizer.authorizationQueryCount, 1) + XCTAssertEqual(settings.openCount, 1) + } + + func test_secondSettingsRecoveryTapAllowsOneNewAuthorizationQuery() { + let authorizer = FakeCameraAuthorizer() + let settings = FakeSettingsOpener() + let coordinator = makeCoordinator(authorizer: authorizer, settings: settings) + reachCameraRequest(coordinator) + authorizer.resolve(.denied) + + coordinator.openSettingsForRecovery() + coordinator.applicationDidBecomeActive() + XCTAssertEqual(authorizer.authorizationQueryCount, 1) + + coordinator.openSettingsForRecovery() + authorizer.currentAuthorization = .authorized + coordinator.applicationDidBecomeActive() + coordinator.applicationDidBecomeActive() + + XCTAssertEqual(coordinator.machine.state, .scanningReality) + XCTAssertTrue(coordinator.showsRealityView) + XCTAssertEqual(authorizer.requestCount, 1) + XCTAssertEqual(authorizer.authorizationQueryCount, 2) + XCTAssertEqual(settings.openCount, 2) } func test_activeAfterInitialAuthorizationDoesNotReenterRealityOrQueryAgain() { diff --git a/docs/LEARNING_LOG.md b/docs/LEARNING_LOG.md index b95ec4a..caedeab 100644 --- a/docs/LEARNING_LOG.md +++ b/docs/LEARNING_LOG.md @@ -28,6 +28,18 @@ ## 항목 +### L-20260811-079 — 카메라 거부 상태를 Settings 복귀 신호로 잘못 해석해 일반 active마다 권한을 조회함 + +- 상태: 해결 +- 발생 태스크: Task 7 보수 2차 — 명시적 Settings 복귀 1회 조회 +- 재현: 카메라를 거부해 `.cameraDenied`가 된 뒤 `설정 열기`를 탭하지 않고 앱 lifecycle의 active 알림을 두 번 전달함. 이어 `설정 열기`를 한 번 탭한 뒤 active를 두 번 전달함. +- 관찰: 기존 `applicationDidBecomeActive()`는 `.cameraDenied`만 확인하고 매번 `currentVideoAuthorization()`을 호출한다. 이에 따라 Settings를 열지 않은 active에도 조회가 발생하고, Settings를 닫고 권한을 바꾸지 않은 뒤 반복 active에서는 두 번 조회한다. 이전 테스트도 이 두 번째 관찰을 기대값 `2`로 고정해 잘못된 계약을 강화했다. +- 영향: 사용자가 Settings 복구를 선택하지 않았어도 lifecycle 변화가 권한 상태를 읽으며, Settings 복귀 후에도 재시도 경계가 한 번으로 제한되지 않는다. +- 원인/가설: `.cameraDenied` 상태가 Settings를 실제로 열었다는 사용자 의도를 보존하지 못하는데도 그것만 lifecycle guard로 사용한 것이 확인된 원인이다. +- 조치: `openSettingsForRecovery()`의 명시적 사용자 동작에서만 1회 복귀 대기 상태를 설정한다. 다음 active는 이를 권한 조회 전에 소비하고, 이후 active는 거부·제한 상태를 유지하되 조회하지 않는다. 두 번째 명시적 Settings 탭은 새 1회 조회를 허용한다. +- 검증: `EscapeRootCoordinatorTests` focused XCTest는 기존 구현에서 Settings 미탭·반복 active의 조회 횟수 assertion 3건이 실패해 16개 중 3 failures로 RED를 확인했다. 최소 플래그 적용 뒤 focused 16/16, 전체 XCTest 87/87·0 failures 및 iPhone 17 Pro Simulator build 성공을 확인했다. +- 배운 점: lifecycle event 자체를 사용자 의도로 취급하지 말고, 외부 앱 전환을 시작한 명시적 UI action과 복귀 소비 횟수를 별도로 모델링한다. + ### L-20260811-078 — 최종 focused XCTest가 Simulator 서비스 접근 제한으로 중단됨 - 상태: 해결 @@ -96,8 +108,8 @@ - 관찰: `openSettingsForRecovery()`는 Settings URL만 열고, `EscapeRootView`에는 `scenePhase`/foreground 관찰이 없다. `EscapeExperienceMachine`도 `.cameraDenied`에서 `.cameraAuthorized`로 가는 전이가 없어, 현재 권한이 허용으로 바뀌어도 `.cameraDenied` 안내에 머문다. - 영향: 사용자가 명시적으로 카메라를 허용해도 AR 화면으로 이어지지 않으며, 다시 권한 요청을 시도하는 임시 우회는 시스템 문구·Settings 재열기 규칙을 어길 수 있다. - 원인/가설: 초기 권한 요청의 callback만 상태 기계에 연결하고, Settings 복귀라는 별도 lifecycle 경계의 현재 권한 조회와 합법적인 재진입 전이를 설계하지 않은 것이 확인된 원인이다. -- 조치: prompt-capable 요청과 현재 상태 조회를 `CameraAuthorizing`에서 분리한다. 앱이 active가 될 때 `.cameraDenied`에서만 현재 상태를 읽고 허용이면 한 번만 `.scanningReality`로 전환하며, 거부·제한이면 현재 차단 안내를 유지한다. 이 경로에서는 권한 요청·Settings 열기를 자동 실행하지 않는다. -- 검증: type 부재로 실패한 RED 뒤 Settings에서 허용 후 active, 변경 없이 Settings 닫기, 제한 상태, 반복 active, 이미 AR에 진입한 상태를 포함한 `EscapeRootCoordinatorTests` focused XCTest 14/14, 전체 XCTest 85/85·0 failures 및 iPhone 17 Pro Simulator build 성공을 확인함. +- 조치: prompt-capable 요청과 현재 상태 조회를 `CameraAuthorizing`에서 분리한다. `설정 열기`의 명시적 탭이 1회 복귀 대기를 남긴 경우에만 앱 active에서 현재 상태를 읽고, 허용이면 한 번 `.scanningReality`로 전환한다. 거부·제한이면 현재 차단 안내를 유지하며, 대기는 조회 전에 소비한다. 이 경로에서는 권한 요청·Settings 열기를 자동 실행하지 않는다. +- 검증: 1차 보수 뒤 일반 active마다 조회하던 회귀를 L-20260811-079로 확인했다. Settings 미탭 active, Settings에서 허용 후 active, 변경 없이 Settings 닫기, 제한 상태, 반복 active, 두 번째 Settings 탭, 이미 AR에 진입한 상태를 포함한 `EscapeRootCoordinatorTests` focused XCTest 16/16, 전체 XCTest 87/87·0 failures 및 iPhone 17 Pro Simulator build 성공을 확인함. - 배운 점: Settings 복구는 URL을 여는 동작으로 끝나지 않으며, 복귀 lifecycle에서 비침습적인 현재 권한 조회와 중복 없는 상태 재진입을 함께 계약해야 한다. ### L-20260811-072 — 공동 시작 문서가 현재 worktree에 없음 diff --git a/docs/WORK_LOG.md b/docs/WORK_LOG.md index af7f1dc..ed392d4 100644 --- a/docs/WORK_LOG.md +++ b/docs/WORK_LOG.md @@ -4,11 +4,11 @@ ## 현재 인수인계 -- 상태: Task 7 보수 — Settings 권한 복귀와 AR 외부 callback 수명 구현 완료, 독립 검토 대기 -- 진행 중 범위: 앱 진입점은 `EscapeRootView` 하나다. C3 발견 callback은 놀란 상태를 기록하고 0.70초 페이드가 끝난 뒤에만 카메라 권한을 한 번 요청한다. 허용 시 `RealityHideARView`를 자동으로 열고 스캔 준비·타깃 수락·돼지 도착·재발견·오류 callback을 상태 순서로 연결한다. 앱이 Settings에서 active로 복귀할 때는 `.cameraDenied` 상태에서만 현재 권한을 다시 읽고, 허용이면 요청·Settings 재열기 없이 한 번만 AR 스캔으로 전환한다. AR 외부 callback은 다음 MainActor turn의 취소 가능한 relay를 거쳐 SwiftUI 생성·갱신 중 루트 상태를 바꾸지 않으며, AR 화면 해제 뒤 예약된 callback은 폐기한다. 현실 재발견에는 `ARView` 컨테이너 1.12→1.0 확대가 추가됐고 실제 AR camera transform은 변경하지 않으며 Reduce Motion에서는 화면 확대를 생략한다. DocC는 같은 흐름을 C3 섬·카메라 발견·권한 전환·실제 메쉬·물리적 재발견의 다섯 장면으로 설명한다. +- 상태: Task 7 보수 2차 — 명시적 Settings 복귀 1회 조회 구현 완료, 독립 검토 대기 +- 진행 중 범위: 앱 진입점은 `EscapeRootView` 하나다. C3 발견 callback은 놀란 상태를 기록하고 0.70초 페이드가 끝난 뒤에만 카메라 권한을 한 번 요청한다. 허용 시 `RealityHideARView`를 자동으로 열고 스캔 준비·타깃 수락·돼지 도착·재발견·오류 callback을 상태 순서로 연결한다. 사용자가 `설정 열기`를 탭해 Settings 복귀 대기를 남긴 경우에만 다음 active가 현재 권한을 한 번 읽고, 허용이면 요청·Settings 재열기 없이 한 번 AR 스캔으로 전환한다. Settings를 열지 않은 active와 대기 소비 뒤의 active는 거부·제한 상태에서도 조회하지 않으며, 두 번째 명시적 Settings 탭은 새 1회 조회를 허용한다. AR 외부 callback은 다음 MainActor turn의 취소 가능한 relay를 거쳐 SwiftUI 생성·갱신 중 루트 상태를 바꾸지 않으며, AR 화면 해제 뒤 예약된 callback은 폐기한다. 현실 재발견에는 `ARView` 컨테이너 1.12→1.0 확대가 추가됐고 실제 AR camera transform은 변경하지 않으며 Reduce Motion에서는 화면 확대를 생략한다. DocC는 같은 흐름을 C3 섬·카메라 발견·권한 전환·실제 메쉬·물리적 재발견의 다섯 장면으로 설명한다. - 실패 기록: `docs/LEARNING_LOG.md`에 실패·검증 한계의 재현 조건·원인/가설·조치·재발 방지 근거를 기록한다. -- 마지막 완료 범위: Task 7 보수 — Settings 권한 복귀와 AR 외부 callback 수명. -- 마지막 검증: `xcodebuild -project PiggyEscape.xcodeproj -scheme PiggyEscape -destination 'platform=iOS Simulator,name=iPhone 17 Pro' -derivedDataPath /tmp/piggyescape-task7-repair-green -only-testing:PiggyEscapeTests/EscapeRootCoordinatorTests test`가 14/14, 0 failures 및 `** TEST SUCCEEDED **`; 이어 `/tmp/piggyescape-task7-repair-tests test`가 전체 85/85, 0 failures 및 `** TEST SUCCEEDED **`; 같은 destination의 `/tmp/piggyescape-task7-repair-build build`가 `** BUILD SUCCEEDED **`로 완료됐다. +- 마지막 완료 범위: Task 7 보수 2차 — 명시적 Settings 복귀 1회 조회. +- 마지막 검증: `xcodebuild -project PiggyEscape.xcodeproj -scheme PiggyEscape -destination 'platform=iOS Simulator,name=iPhone 17 Pro' -derivedDataPath /tmp/piggyescape-task7-repair2-green -only-testing:PiggyEscapeTests/EscapeRootCoordinatorTests test`가 16/16, 0 failures 및 `** TEST SUCCEEDED **`; 이어 `/tmp/piggyescape-task7-repair2-tests test`가 전체 87/87, 0 failures 및 `** TEST SUCCEEDED **`; 같은 destination의 `/tmp/piggyescape-task7-repair2-build build`가 `** BUILD SUCCEEDED **`로 완료됐다. - 다음 시작점: 이 보수를 독립 검토한 뒤 LiDAR 지원 실기기에서 아래 수용 목록을 관찰해 결과·기기·OS를 기록한다. AR 세션 시작과 실제 공간 재구성 준비를 구분하는 L-20260811-076은 별도 RealityKit 보수 태스크로 설계한다. - 차단 요소: 실제 카메라 권한 UI·0.70초 전환·Settings 복귀·1.12 화면 확대·LiDAR 메쉬·물리 오클루전은 관찰 전까지 `실기기 대기`다. `onScanningReady`는 세션 시작만 뜻하며 실제 mesh/frame 준비 기준은 L-20260811-076으로 보류한다. DocC는 새 이미지 에셋을 추가하지 않는 제약 때문에 Chapter Image 경고 1건을 남긴다. @@ -35,6 +35,7 @@ | 날짜 | 작업 범위 | 결과 | 검증 | 다음 시작점 | | --- | --- | --- | --- | --- | +| 2026-08-11 | Task 7 보수 2차 — 명시적 Settings 복귀 1회 조회 | 기존 `.cameraDenied`만으로 active마다 권한을 조회하던 회귀를 수정함. `설정 열기`의 명시적 탭에서만 1회 복귀 대기를 만들고, 다음 active가 이를 조회 전에 소비한다. Settings 미탭·소비 후 active는 무조회이며, 두 번째 탭은 새 1회 조회를 허용함 | focused RED 16개 중 3 failures 뒤 `EscapeRootCoordinatorTests` focused 16/16, 전체 XCTest 87/87·0 failures, iPhone 17 Pro Simulator build 성공. 원인·기존 잘못된 테스트 기대값·수정 근거는 L-20260811-079 | 보수 2차 독립 검토 후 L-20260811-076 RealityKit readiness 설계 및 실기기 수용 목록 관찰 | | 2026-08-11 | Task 7 보수 — Settings 권한 복귀와 AR 외부 callback 수명 | Settings에서 카메라를 허용한 뒤 active로 돌아오면 현재 권한만 다시 읽어 한 번 AR 스캔으로 전환하고, 권한 미변경·제한·이미 AR 상태에서는 요청·Settings 재열기·중복 전환을 하지 않게 함. `UIViewRepresentable`에서 온 모든 AR callback은 다음 MainActor turn의 취소 가능한 relay로 보내고 화면 해제 뒤 stale callback을 버림 | type 부재 RED 뒤 `EscapeRootCoordinatorTests` focused 14/14, 전체 XCTest 85/85·0 failures, iPhone 17 Pro Simulator build 성공. 실패·해결·실제 mesh 준비 한계는 L-20260811-073~076 | 보수 독립 검토 후 L-20260811-076 RealityKit readiness 설계 및 실기기 수용 목록 관찰 | | 2026-08-11 | Task 8 — 전체 회귀·실기기 검증과 DocC·인수인계 | 방·가짜 소파 중심 DocC를 C3 섬·나무 뒤 숨기·카메라 발견·권한 전환·실제 메쉬 오클루전·물리적 재발견의 다섯 장면으로 교체하고, `PROJECT_CONTEXT`·트러블슈팅·실기기 수용 목록을 추가함. 실기기 항목은 관찰하지 않아 모두 `실기기 대기`로 유지함 | `tuist generate --no-open` 성공, 명시적 전체 XCTest 79/79·0 failures, iPhone 17 Pro Simulator build 성공, DocC archive 생성·documentation build 성공. 연결 worktree fetch·Simulator 권한 재시도와 Chapter Image 경고 1건은 L-20260811-067~070 | LiDAR 지원 실기기 수용 목록 관찰 후 전체 독립 검토 | | 2026-08-11 | Task 7 — 자동 SceneKit→RealityKit 전환과 C3 스타일 안내 | C3 발견 뒤 0.70초 페이드 완료 시점에만 시스템 카메라 권한을 한 번 요청하고, 허용 시 AR 화면을 자동으로 열어 Task 6의 스캔·선택·도착·재발견·오류 callback을 상태 기계에 연결함. 거부·제한 안내와 명시적 Settings 복구, C3 스타일 material 패널, AR 화면 1.12→1.0 확대와 Reduce Motion 대안을 추가하고 `ContentView`를 새 루트로 교체함 | 루트 전환 TDD RED 뒤 focused 8/8, 명시적 Xcode scheme 전체 XCTest 79/79, iOS Simulator build 성공. 실패·환경·접근성 학습은 L-20260811-058~066, 실제 기기 대기는 L-20260811-065 | Task 7 독립 검토 후 Task 8 | From ce353d953305f4be54637f1337c7477fcdb24f61 Mon Sep 17 00:00:00 2001 From: eunseo-com Date: Tue, 11 Aug 2026 02:01:20 +0900 Subject: [PATCH 19/22] Stabilize RealityKit hiding flow --- .../Sources/Reality/RealityHideARView.swift | 89 +++++++++-- .../Sources/Reality/RealityHidePlanner.swift | 105 ++++++++++++- .../RealityHideARViewCoordinatorTests.swift | 60 ++++++-- .../RealityHidePlannerTests.swift | 143 +++++++++++++++++- docs/LEARNING_LOG.md | 60 ++++++++ docs/WORK_LOG.md | 19 ++- 6 files changed, 430 insertions(+), 46 deletions(-) diff --git a/PiggyEscape/PiggyEscape/Sources/Reality/RealityHideARView.swift b/PiggyEscape/PiggyEscape/Sources/Reality/RealityHideARView.swift index 48c335d..57070ee 100644 --- a/PiggyEscape/PiggyEscape/Sources/Reality/RealityHideARView.swift +++ b/PiggyEscape/PiggyEscape/Sources/Reality/RealityHideARView.swift @@ -148,6 +148,8 @@ struct RealityHideARView: UIViewRepresentable { private weak var arView: ARView? private var revealMonitor = RealityRevealMonitor() private var revealSubscription: (any Cancellable)? + private var scanningReadiness = RealityScanningReadiness() + private var scanningSubscription: (any Cancellable)? private var pigAnchor: AnchorEntity? private(set) var didStartMeshSession = false @@ -195,7 +197,7 @@ struct RealityHideARView: UIViewRepresentable { let tap = UITapGestureRecognizer(target: self, action: #selector(handleTap(_:))) tap.numberOfTouchesRequired = 1 arView.addGestureRecognizer(tap) - onScanningReady() + beginScanningReadiness(in: arView) } @discardableResult @@ -223,11 +225,16 @@ struct RealityHideARView: UIViewRepresentable { func processRevealFrame( isObservationValid: Bool, meshDistance: Float?, - pigDistance: Float + pigDistance: Float, + cameraPose: RealityCameraPose ) -> Bool { guard isObservationValid, status == .hidden, - revealMonitor.update(meshDistance: meshDistance, pigDistance: pigDistance) else { + revealMonitor.update( + meshDistance: meshDistance, + pigDistance: pigDistance, + cameraPose: cameraPose + ) else { return false } @@ -248,6 +255,17 @@ struct RealityHideARView: UIViewRepresentable { return true } + @discardableResult + func processScanningObservation(hasMesh: Bool, hasFloor: Bool) -> Bool { + guard scanningReadiness.observe(hasMesh: hasMesh, hasFloor: hasFloor) else { + return false + } + scanningSubscription?.cancel() + scanningSubscription = nil + onScanningReady() + return true + } + func acceptHideTarget(destination: SIMD3, initialPosition: SIMD3) { guard status == .waitingForTarget else { return } status = .walking @@ -272,6 +290,8 @@ struct RealityHideARView: UIViewRepresentable { func stop() { revealSubscription?.cancel() revealSubscription = nil + scanningSubscription?.cancel() + scanningSubscription = nil arView?.session.pause() didStartMeshSession = false } @@ -361,19 +381,39 @@ struct RealityHideARView: UIViewRepresentable { } private func nearestFloor(in frame: ARFrame, to surfacePoint: SIMD3) -> RealityFloor? { - frame.anchors - .compactMap { $0 as? ARPlaneAnchor } - .filter { $0.alignment == .horizontal && $0.classification == .floor } - .map { anchor in - let center = SIMD4(anchor.center.x, anchor.center.y, anchor.center.z, 1) - let world = anchor.transform * center - return RealityFloor(point: SIMD3(world.x, world.y, world.z)) + let planeAnchors = frame.anchors.compactMap { $0 as? ARPlaneAnchor } + let containingFloors = planeAnchors.compactMap { anchor -> RealityFloor? in + guard anchor.alignment == .horizontal, + anchor.classification == .floor else { + return nil } - .min { lhs, rhs in - let lhsOffset = SIMD2(lhs.point.x - surfacePoint.x, lhs.point.z - surfacePoint.z) - let rhsOffset = SIMD2(rhs.point.x - surfacePoint.x, rhs.point.z - surfacePoint.z) - return simd_length_squared(lhsOffset) < simd_length_squared(rhsOffset) + + let footprint = RealityFloorPlane( + transform: anchor.transform, + center: anchor.center, + extent: SIMD2(anchor.planeExtent.width, anchor.planeExtent.height), + rotationOnYAxis: anchor.planeExtent.rotationOnYAxis + ) + return footprint.floor(containing: surfacePoint) + } + + return containingFloors.min { lhs, rhs in + abs(lhs.point.y - surfacePoint.y) < abs(rhs.point.y - surfacePoint.y) + } + } + + private func beginScanningReadiness(in arView: ARView) { + scanningSubscription?.cancel() + scanningSubscription = arView.scene.subscribe(to: SceneEvents.Update.self) { [weak self, weak arView] _ in + guard let self, + let frame = arView?.session.currentFrame else { return } + let hasMesh = frame.anchors.contains { $0 is ARMeshAnchor } + let hasFloor = frame.anchors.contains { anchor in + guard let plane = anchor as? ARPlaneAnchor else { return false } + return plane.alignment == .horizontal && plane.classification == .floor } + self.processScanningObservation(hasMesh: hasMesh, hasFloor: hasFloor) + } } private func beginRevealMonitoring(in arView: ARView) { @@ -397,6 +437,11 @@ struct RealityHideARView: UIViewRepresentable { ), let screenPoint else { return } let cameraPosition = Self.position(from: cameraTransform) + let cameraForward = -SIMD3( + cameraTransform.columns.2.x, + cameraTransform.columns.2.y, + cameraTransform.columns.2.z + ) let meshDistance = arView.hitTest( screenPoint, query: .nearest, @@ -406,7 +451,11 @@ struct RealityHideARView: UIViewRepresentable { processRevealFrame( isObservationValid: true, meshDistance: meshDistance, - pigDistance: pigDistance + pigDistance: pigDistance, + cameraPose: RealityCameraPose( + position: cameraPosition, + forward: cameraForward + ) ) } @@ -415,3 +464,13 @@ struct RealityHideARView: UIViewRepresentable { } } } + +private struct RealityScanningReadiness { + private var hasReported = false + + mutating func observe(hasMesh: Bool, hasFloor: Bool) -> Bool { + guard !hasReported, hasMesh || hasFloor else { return false } + hasReported = true + return true + } +} diff --git a/PiggyEscape/PiggyEscape/Sources/Reality/RealityHidePlanner.swift b/PiggyEscape/PiggyEscape/Sources/Reality/RealityHidePlanner.swift index f0a095f..6fde01b 100644 --- a/PiggyEscape/PiggyEscape/Sources/Reality/RealityHidePlanner.swift +++ b/PiggyEscape/PiggyEscape/Sources/Reality/RealityHidePlanner.swift @@ -9,6 +9,54 @@ struct RealityFloor: Equatable { let point: SIMD3 } +struct RealityFloorPlane { + /// AR plane edges can jitter by a few centimeters while scanning, so only a 2 cm footprint margin is accepted. + static let footprintTolerance: Float = 0.02 + + let transform: simd_float4x4 + let center: SIMD3 + let extent: SIMD2 + let rotationOnYAxis: Float + + init( + transform: simd_float4x4, + center: SIMD3, + extent: SIMD2, + rotationOnYAxis: Float = 0 + ) { + self.transform = transform + self.center = center + self.extent = extent + self.rotationOnYAxis = rotationOnYAxis + } + + func floor(containing worldPoint: SIMD3) -> RealityFloor? { + guard extent.x.isFinite, + extent.y.isFinite, + extent.x > 0, + extent.y > 0 else { + return nil + } + + let localPoint = SIMD4(worldPoint.x, worldPoint.y, worldPoint.z, 1) + let local = simd_inverse(transform) * localPoint + let relativeX = local.x - center.x + let relativeZ = local.z - center.z + let cosine = cos(rotationOnYAxis) + let sine = sin(rotationOnYAxis) + let xOffset = abs(cosine * relativeX - sine * relativeZ) + let zOffset = abs(sine * relativeX + cosine * relativeZ) + guard xOffset <= extent.x / 2 + Self.footprintTolerance, + zOffset <= extent.y / 2 + Self.footprintTolerance else { + return nil + } + + let floorLocal = SIMD4(local.x, center.y, local.z, 1) + let floorWorld = transform * floorLocal + return RealityFloor(point: SIMD3(floorWorld.x, floorWorld.y, floorWorld.z)) + } +} + enum RealityHideRejection: Equatable { case selectVerticalSide case moveFartherAway @@ -24,6 +72,8 @@ enum RealityHidePlanner { static let verticalNormalMaximumY: Float = 0.35 static let minimumCameraDistance: Float = 0.45 static let objectClearance: Float = 0.28 + /// Keeps direct callers from pairing a selected surface with an unrelated floor point. + /// AR plane footprint projection supplies the selected point's local XZ, never an anchor center. static let maximumFloorDistance: Float = 1.2 static func plan( @@ -56,17 +106,66 @@ enum RealityHidePlanner { } } +struct RealityCameraPose: Equatable { + let position: SIMD3 + let forward: SIMD3 +} + struct RealityRevealMonitor { + /// 15 cm exceeds ordinary hand tremor while still representing a small physical step around an object. + static let minimumTranslation: Float = 0.15 + /// 15° is a deliberate device reframe; mesh visibility must still remain stable before revealing. + static let minimumRotation: Float = .pi / 12 + static let requiredStableVisibleObservations = 2 + private var hasObservedBlockingMesh = false private var hasReportedReveal = false + private var blockingPose: RealityCameraPose? + private var stableVisibleObservationCount = 0 - mutating func update(meshDistance: Float?, pigDistance: Float) -> Bool { + mutating func update( + meshDistance: Float?, + pigDistance: Float, + cameraPose: RealityCameraPose + ) -> Bool { let blocked = meshDistance.map { $0 + 0.03 < pigDistance } ?? false - hasObservedBlockingMesh = hasObservedBlockingMesh || blocked - guard hasObservedBlockingMesh, !blocked, !hasReportedReveal else { + if blocked { + hasObservedBlockingMesh = true + blockingPose = cameraPose + stableVisibleObservationCount = 0 + return false + } + + guard hasObservedBlockingMesh, + !hasReportedReveal, + let blockingPose, + hasMeaningfulViewpointChange(from: blockingPose, to: cameraPose) else { + stableVisibleObservationCount = 0 + return false + } + + stableVisibleObservationCount += 1 + guard stableVisibleObservationCount >= Self.requiredStableVisibleObservations else { return false } hasReportedReveal = true return true } + + private func hasMeaningfulViewpointChange( + from blockedPose: RealityCameraPose, + to currentPose: RealityCameraPose + ) -> Bool { + let translation = simd_distance(blockedPose.position, currentPose.position) + guard translation.isFinite else { return false } + if translation >= Self.minimumTranslation { + return true + } + + let blockedLength = simd_length(blockedPose.forward) + let currentLength = simd_length(currentPose.forward) + guard blockedLength > 0.0001, currentLength > 0.0001 else { return false } + let directionsDot = simd_dot(blockedPose.forward / blockedLength, currentPose.forward / currentLength) + return directionsDot <= cos(Self.minimumRotation) + 0.000_001 + } } diff --git a/PiggyEscape/PiggyEscapeTests/RealityHideARViewCoordinatorTests.swift b/PiggyEscape/PiggyEscapeTests/RealityHideARViewCoordinatorTests.swift index a6560e6..60a6b56 100644 --- a/PiggyEscape/PiggyEscapeTests/RealityHideARViewCoordinatorTests.swift +++ b/PiggyEscape/PiggyEscapeTests/RealityHideARViewCoordinatorTests.swift @@ -32,6 +32,21 @@ final class RealityHideARViewCoordinatorTests: XCTestCase { XCTAssertEqual(coordinator.message(for: .findFloor), RealityAvailabilityMessage.scanFirst) } + func test_scanningReadinessWaitsForTheFirstMeaningfulMeshOrFloorObservation() { + var readyCount = 0 + let coordinator = RealityHideARView.Coordinator( + meshSupport: FakeRealityMeshSupport(supportsMeshWithClassification: true), + onScanningReady: { readyCount += 1 } + ) + + XCTAssertFalse(coordinator.processScanningObservation(hasMesh: false, hasFloor: false)) + XCTAssertEqual(readyCount, 0) + XCTAssertTrue(coordinator.processScanningObservation(hasMesh: true, hasFloor: false)) + XCTAssertEqual(readyCount, 1) + XCTAssertFalse(coordinator.processScanningObservation(hasMesh: false, hasFloor: true)) + XCTAssertEqual(readyCount, 1) + } + func test_revealIsReportedOnceOnlyAfterARealBlockingFrame() { var revealCount = 0 let visualController = RealityPigVisualController.makeForTesting() @@ -41,11 +56,14 @@ final class RealityHideARViewCoordinatorTests: XCTestCase { onRevealed: { revealCount += 1 } ) coordinator.acceptHideTarget(destination: SIMD3(0, 0, -2), initialPosition: SIMD3(0, 0, -0.8)) - - XCTAssertFalse(coordinator.processRevealFrame(isObservationValid: true, meshDistance: nil, pigDistance: 2)) - XCTAssertFalse(coordinator.processRevealFrame(isObservationValid: true, meshDistance: 1, pigDistance: 2)) - XCTAssertTrue(coordinator.processRevealFrame(isObservationValid: true, meshDistance: nil, pigDistance: 2)) - XCTAssertFalse(coordinator.processRevealFrame(isObservationValid: true, meshDistance: nil, pigDistance: 2)) + let blockedPose = RealityCameraPose(position: .zero, forward: SIMD3(0, 0, -1)) + let movedPose = RealityCameraPose(position: SIMD3(0.15, 0, 0), forward: SIMD3(0, 0, -1)) + + XCTAssertFalse(coordinator.processRevealFrame(isObservationValid: true, meshDistance: nil, pigDistance: 2, cameraPose: blockedPose)) + XCTAssertFalse(coordinator.processRevealFrame(isObservationValid: true, meshDistance: 1, pigDistance: 2, cameraPose: blockedPose)) + XCTAssertFalse(coordinator.processRevealFrame(isObservationValid: true, meshDistance: nil, pigDistance: 2, cameraPose: movedPose)) + XCTAssertTrue(coordinator.processRevealFrame(isObservationValid: true, meshDistance: nil, pigDistance: 2, cameraPose: movedPose)) + XCTAssertFalse(coordinator.processRevealFrame(isObservationValid: true, meshDistance: nil, pigDistance: 2, cameraPose: movedPose)) XCTAssertEqual(revealCount, 1) XCTAssertEqual(visualController.currentPose, .surprised) XCTAssertEqual(visualController.surprisePeakScale, 1.5, accuracy: 0.0001) @@ -58,10 +76,13 @@ final class RealityHideARViewCoordinatorTests: XCTestCase { visualController: RealityPigVisualController.makeForTesting() ) coordinator.acceptHideTarget(destination: SIMD3(0, 0, -2), initialPosition: SIMD3(0, 0, -0.8)) + let blockedPose = RealityCameraPose(position: .zero, forward: SIMD3(0, 0, -1)) + let movedPose = RealityCameraPose(position: SIMD3(0.15, 0, 0), forward: SIMD3(0, 0, -1)) - XCTAssertFalse(coordinator.processRevealFrame(isObservationValid: true, meshDistance: 1, pigDistance: 2)) - XCTAssertFalse(coordinator.processRevealFrame(isObservationValid: false, meshDistance: nil, pigDistance: 2)) - XCTAssertTrue(coordinator.processRevealFrame(isObservationValid: true, meshDistance: nil, pigDistance: 2)) + XCTAssertFalse(coordinator.processRevealFrame(isObservationValid: true, meshDistance: 1, pigDistance: 2, cameraPose: blockedPose)) + XCTAssertFalse(coordinator.processRevealFrame(isObservationValid: false, meshDistance: nil, pigDistance: 2, cameraPose: movedPose)) + XCTAssertFalse(coordinator.processRevealFrame(isObservationValid: true, meshDistance: nil, pigDistance: 2, cameraPose: movedPose)) + XCTAssertTrue(coordinator.processRevealFrame(isObservationValid: true, meshDistance: nil, pigDistance: 2, cameraPose: movedPose)) } func test_projectionGateRequiresOnscreenPointAndPigInFrontOfCamera() { @@ -120,8 +141,10 @@ final class RealityHideARViewCoordinatorTests: XCTestCase { XCTAssertTrue(visualController.outerEntity.isEnabled) XCTAssertEqual(visualController.worldPosition.y, 0, accuracy: 0.0001) XCTAssertEqual(events, ["accepted"]) - XCTAssertFalse(coordinator.processRevealFrame(isObservationValid: true, meshDistance: 0.5, pigDistance: 2)) - XCTAssertFalse(coordinator.processRevealFrame(isObservationValid: true, meshDistance: nil, pigDistance: 2)) + let blockedPose = RealityCameraPose(position: .zero, forward: SIMD3(0, 0, -1)) + let movedPose = RealityCameraPose(position: SIMD3(0.15, 0, 0), forward: SIMD3(0, 0, -1)) + XCTAssertFalse(coordinator.processRevealFrame(isObservationValid: true, meshDistance: 0.5, pigDistance: 2, cameraPose: blockedPose)) + XCTAssertFalse(coordinator.processRevealFrame(isObservationValid: true, meshDistance: nil, pigDistance: 2, cameraPose: movedPose)) XCTAssertEqual(revealCount, 0) let runningLoad = pendingLoads.removeFirst() @@ -134,8 +157,9 @@ final class RealityHideARViewCoordinatorTests: XCTestCase { idleLoad.completion(.success(Entity())) XCTAssertEqual(events, ["accepted", "reached"]) - XCTAssertFalse(coordinator.processRevealFrame(isObservationValid: true, meshDistance: 0.5, pigDistance: 2)) - XCTAssertTrue(coordinator.processRevealFrame(isObservationValid: true, meshDistance: nil, pigDistance: 2)) + XCTAssertFalse(coordinator.processRevealFrame(isObservationValid: true, meshDistance: 0.5, pigDistance: 2, cameraPose: blockedPose)) + XCTAssertFalse(coordinator.processRevealFrame(isObservationValid: true, meshDistance: nil, pigDistance: 2, cameraPose: movedPose)) + XCTAssertTrue(coordinator.processRevealFrame(isObservationValid: true, meshDistance: nil, pigDistance: 2, cameraPose: movedPose)) XCTAssertEqual(revealCount, 0) let surprisedLoad = pendingLoads.removeFirst() XCTAssertEqual(surprisedLoad.asset, "Piggy_surprised") @@ -243,9 +267,12 @@ final class RealityHideARViewCoordinatorTests: XCTestCase { loader.succeedNext() loader.succeedNext() XCTAssertEqual(coordinator.status, .hidden) + let blockedPose = RealityCameraPose(position: .zero, forward: SIMD3(0, 0, -1)) + let movedPose = RealityCameraPose(position: SIMD3(0.15, 0, 0), forward: SIMD3(0, 0, -1)) - XCTAssertFalse(coordinator.processRevealFrame(isObservationValid: true, meshDistance: 0.5, pigDistance: 2)) - XCTAssertTrue(coordinator.processRevealFrame(isObservationValid: true, meshDistance: nil, pigDistance: 2)) + XCTAssertFalse(coordinator.processRevealFrame(isObservationValid: true, meshDistance: 0.5, pigDistance: 2, cameraPose: blockedPose)) + XCTAssertFalse(coordinator.processRevealFrame(isObservationValid: true, meshDistance: nil, pigDistance: 2, cameraPose: movedPose)) + XCTAssertTrue(coordinator.processRevealFrame(isObservationValid: true, meshDistance: nil, pigDistance: 2, cameraPose: movedPose)) XCTAssertEqual(coordinator.status, .revealing) loader.failNext() @@ -253,8 +280,9 @@ final class RealityHideARViewCoordinatorTests: XCTestCase { XCTAssertTrue(visualController.outerEntity.isEnabled) XCTAssertEqual(revealCount, 0) XCTAssertEqual(errorCount, 1) - XCTAssertFalse(coordinator.processRevealFrame(isObservationValid: true, meshDistance: 0.5, pigDistance: 2)) - XCTAssertTrue(coordinator.processRevealFrame(isObservationValid: true, meshDistance: nil, pigDistance: 2)) + XCTAssertFalse(coordinator.processRevealFrame(isObservationValid: true, meshDistance: 0.5, pigDistance: 2, cameraPose: blockedPose)) + XCTAssertFalse(coordinator.processRevealFrame(isObservationValid: true, meshDistance: nil, pigDistance: 2, cameraPose: movedPose)) + XCTAssertTrue(coordinator.processRevealFrame(isObservationValid: true, meshDistance: nil, pigDistance: 2, cameraPose: movedPose)) } } diff --git a/PiggyEscape/PiggyEscapeTests/RealityHidePlannerTests.swift b/PiggyEscape/PiggyEscapeTests/RealityHidePlannerTests.swift index 318f3a9..efe0782 100644 --- a/PiggyEscape/PiggyEscapeTests/RealityHidePlannerTests.swift +++ b/PiggyEscape/PiggyEscapeTests/RealityHidePlannerTests.swift @@ -69,17 +69,148 @@ final class RealityHidePlannerTests: XCTestCase { func test_revealMonitorFiresOnlyAfterFirstBlockedFrameThenVisibleFrame() { var monitor = RealityRevealMonitor() + let blockedPose = RealityCameraPose(position: .zero, forward: SIMD3(0, 0, -1)) + let movedPose = RealityCameraPose(position: SIMD3(0.15, 0, 0), forward: SIMD3(0, 0, -1)) - XCTAssertFalse(monitor.update(meshDistance: nil, pigDistance: 2)) - XCTAssertFalse(monitor.update(meshDistance: 1, pigDistance: 2)) - XCTAssertTrue(monitor.update(meshDistance: nil, pigDistance: 2)) - XCTAssertFalse(monitor.update(meshDistance: nil, pigDistance: 2)) + XCTAssertFalse(monitor.update(meshDistance: nil, pigDistance: 2, cameraPose: blockedPose)) + XCTAssertFalse(monitor.update(meshDistance: 1, pigDistance: 2, cameraPose: blockedPose)) + XCTAssertFalse(monitor.update(meshDistance: nil, pigDistance: 2, cameraPose: movedPose)) + XCTAssertTrue(monitor.update(meshDistance: nil, pigDistance: 2, cameraPose: movedPose)) + XCTAssertFalse(monitor.update(meshDistance: nil, pigDistance: 2, cameraPose: movedPose)) } func test_revealMonitorTreatsThreeCentimeterBoundaryAsVisible() { var monitor = RealityRevealMonitor() + let blockedPose = RealityCameraPose(position: .zero, forward: SIMD3(0, 0, -1)) + let movedPose = RealityCameraPose(position: SIMD3(0.15, 0, 0), forward: SIMD3(0, 0, -1)) - XCTAssertFalse(monitor.update(meshDistance: 1, pigDistance: 2)) - XCTAssertTrue(monitor.update(meshDistance: 1.97, pigDistance: 2)) + XCTAssertFalse(monitor.update(meshDistance: 1, pigDistance: 2, cameraPose: blockedPose)) + XCTAssertFalse(monitor.update(meshDistance: 1.97, pigDistance: 2, cameraPose: movedPose)) + XCTAssertTrue(monitor.update(meshDistance: 1.97, pigDistance: 2, cameraPose: movedPose)) + } + + func test_floorFootprintUsesTransformedExtentInsteadOfItsDistantCenter() { + var transform = matrix_identity_float4x4 + transform.columns.0 = SIMD4(0, 0, -1, 0) + transform.columns.2 = SIMD4(1, 0, 0, 0) + transform.columns.3 = SIMD4(10, 0.3, -4, 1) + let plane = RealityFloorPlane( + transform: transform, + center: SIMD3(1, 0, 2), + extent: SIMD2(8, 6) + ) + let selectedObjectPoint = SIMD3(11, 2, -7.5) + + guard let floor = plane.floor(containing: selectedObjectPoint) else { + return XCTFail("expected the selected point inside the rotated floor footprint") + } + XCTAssertEqual(floor.point.x, 11, accuracy: 0.0001) + XCTAssertEqual(floor.point.y, 0.3, accuracy: 0.0001) + XCTAssertEqual(floor.point.z, -7.5, accuracy: 0.0001) + + let result = RealityHidePlanner.plan( + hit: RealitySurfaceHit(point: selectedObjectPoint, normal: SIMD3(0, 0, 1)), + cameraPosition: SIMD3(11, 2, -6.5), + floor: floor + ) + guard case .accepted = result else { + return XCTFail("expected the floor directly under the selected object to be accepted") + } + } + + func test_floorFootprintRejectsPointOutsideNearbyAnchorCenter() { + let plane = RealityFloorPlane( + transform: matrix_identity_float4x4, + center: .zero, + extent: SIMD2(1, 1) + ) + + XCTAssertNil(plane.floor(containing: SIMD3(0.75, 1, 0))) + } + + func test_floorFootprintAllowsOnlyItsTwoCentimeterBoundaryTolerance() { + let plane = RealityFloorPlane( + transform: matrix_identity_float4x4, + center: .zero, + extent: SIMD2(1, 1) + ) + + XCTAssertNotNil(plane.floor(containing: SIMD3(0.52, 1, 0))) + XCTAssertNil(plane.floor(containing: SIMD3(0.521, 1, 0))) + } + + func test_floorFootprintAppliesThePlaneExtentRotation() { + let plane = RealityFloorPlane( + transform: matrix_identity_float4x4, + center: .zero, + extent: SIMD2(4, 2), + rotationOnYAxis: .pi / 2 + ) + + XCTAssertNotNil(plane.floor(containing: SIMD3(0, 0, -1.9))) + XCTAssertNil(plane.floor(containing: SIMD3(1.1, 0, 0))) + } + + func test_revealMonitorDoesNotRevealForZeroMovementNullHits() { + var monitor = RealityRevealMonitor() + let pose = RealityCameraPose(position: .zero, forward: SIMD3(0, 0, -1)) + + XCTAssertFalse(monitor.update(meshDistance: 1, pigDistance: 2, cameraPose: pose)) + XCTAssertFalse(monitor.update(meshDistance: nil, pigDistance: 2, cameraPose: pose)) + XCTAssertFalse(monitor.update(meshDistance: nil, pigDistance: 2, cameraPose: pose)) + } + + func test_revealMonitorRejectsSubthresholdTranslationAndRotation() { + var translationMonitor = RealityRevealMonitor() + let blockedPose = RealityCameraPose(position: .zero, forward: SIMD3(0, 0, -1)) + let shortMove = RealityCameraPose(position: SIMD3(0.149, 0, 0), forward: SIMD3(0, 0, -1)) + + XCTAssertFalse(translationMonitor.update(meshDistance: 1, pigDistance: 2, cameraPose: blockedPose)) + XCTAssertFalse(translationMonitor.update(meshDistance: nil, pigDistance: 2, cameraPose: shortMove)) + XCTAssertFalse(translationMonitor.update(meshDistance: nil, pigDistance: 2, cameraPose: shortMove)) + + var rotationMonitor = RealityRevealMonitor() + let shortTurn = RealityCameraPose(position: .zero, forward: SIMD3(0.2, 0, -0.9797959)) + XCTAssertFalse(rotationMonitor.update(meshDistance: 1, pigDistance: 2, cameraPose: blockedPose)) + XCTAssertFalse(rotationMonitor.update(meshDistance: nil, pigDistance: 2, cameraPose: shortTurn)) + XCTAssertFalse(rotationMonitor.update(meshDistance: nil, pigDistance: 2, cameraPose: shortTurn)) + } + + func test_revealMonitorRequiresTwoStableVisibleFramesAfterExactMovementThreshold() { + var monitor = RealityRevealMonitor() + let blockedPose = RealityCameraPose(position: .zero, forward: SIMD3(0, 0, -1)) + let exactMove = RealityCameraPose(position: SIMD3(0.15, 0, 0), forward: SIMD3(0, 0, -1)) + + XCTAssertFalse(monitor.update(meshDistance: 1, pigDistance: 2, cameraPose: blockedPose)) + XCTAssertFalse(monitor.update(meshDistance: nil, pigDistance: 2, cameraPose: exactMove)) + XCTAssertTrue(monitor.update(meshDistance: nil, pigDistance: 2, cameraPose: exactMove)) + XCTAssertFalse(monitor.update(meshDistance: nil, pigDistance: 2, cameraPose: exactMove)) + } + + func test_revealMonitorAcceptsExactRotationThresholdAfterStableFrames() { + var monitor = RealityRevealMonitor() + let blockedPose = RealityCameraPose(position: .zero, forward: SIMD3(0, 0, -1)) + let angle: Float = .pi / 12 + let exactTurn = RealityCameraPose( + position: .zero, + forward: SIMD3(sin(angle), 0, -cos(angle)) + ) + + XCTAssertFalse(monitor.update(meshDistance: 1, pigDistance: 2, cameraPose: blockedPose)) + XCTAssertFalse(monitor.update(meshDistance: nil, pigDistance: 2, cameraPose: exactTurn)) + XCTAssertTrue(monitor.update(meshDistance: nil, pigDistance: 2, cameraPose: exactTurn)) + } + + func test_revealMonitorResetsVisibleStabilityWhenBlockingReturns() { + var monitor = RealityRevealMonitor() + let firstBlockingPose = RealityCameraPose(position: .zero, forward: SIMD3(0, 0, -1)) + let firstMove = RealityCameraPose(position: SIMD3(0.15, 0, 0), forward: SIMD3(0, 0, -1)) + let secondMove = RealityCameraPose(position: SIMD3(0.3, 0, 0), forward: SIMD3(0, 0, -1)) + + XCTAssertFalse(monitor.update(meshDistance: 1, pigDistance: 2, cameraPose: firstBlockingPose)) + XCTAssertFalse(monitor.update(meshDistance: nil, pigDistance: 2, cameraPose: firstMove)) + XCTAssertFalse(monitor.update(meshDistance: 1, pigDistance: 2, cameraPose: firstMove)) + XCTAssertFalse(monitor.update(meshDistance: nil, pigDistance: 2, cameraPose: secondMove)) + XCTAssertTrue(monitor.update(meshDistance: nil, pigDistance: 2, cameraPose: secondMove)) } } diff --git a/docs/LEARNING_LOG.md b/docs/LEARNING_LOG.md index caedeab..59b3876 100644 --- a/docs/LEARNING_LOG.md +++ b/docs/LEARNING_LOG.md @@ -28,6 +28,66 @@ ## 항목 +### L-20260811-084 — focused XCTest가 Simulator 서비스 접근 제한으로 시작 전에 중단됨 + +- 상태: 해결 +- 발생 태스크: Task 6 최종 통합 리뷰 보수 — floor·reveal·readiness 검증 +- 재현: `cd PiggyEscape && xcodebuild -project PiggyEscape.xcodeproj -scheme PiggyEscape -destination 'platform=iOS Simulator,name=iPhone 17 Pro' -only-testing:PiggyEscapeTests/RealityHidePlannerTests -only-testing:PiggyEscapeTests/RealityHideARViewCoordinatorTests test` +- 관찰: `CoreSimulatorService connection became invalid`, `Error opening log file ... Operation not permitted`, `Unable to discover any Simulator runtimes`가 출력돼 XCTest 컴파일·assertion 전에 중단됨 +- 영향: source 수정의 focused 검증 결과를 sandbox 실행만으로 확정할 수 없음 +- 원인/가설: Simulator 서비스·runtime·로그 경로가 worktree 밖에 있어 현재 sandbox 접근이 거부된 것으로 보이며, L-20260811-078·069와 같은 환경 제약이 재발함 +- 조치: 같은 명시 명령만 Simulator 서비스에 접근 가능한 권한으로 한 번 재실행했다. +- 검증: 권한 재실행에서 `RealityHidePlannerTests` 15개와 `RealityHideARViewCoordinatorTests` 11개, 합계 26개가 0 failures 및 `** TEST SUCCEEDED **`로 끝났다. 이어 전체 XCTest 97/97·0 failures와 Simulator build `** BUILD SUCCEEDED **`를 확인했다. +- 배운 점: Simulator 초기화 실패는 source 회귀와 구분하고, 접근 가능한 동일 명령의 현재 실행 결과로만 검증 근거를 갱신한다. + +### L-20260811-083 — AR plane 변환 체인이 Swift 컴파일러 type-check 한계를 초과함 + +- 상태: 해결 +- 발생 태스크: Task 6 최종 통합 리뷰 보수 — floor footprint 구현 +- 재현: `cd PiggyEscape && xcodebuild -project PiggyEscape.xcodeproj -scheme PiggyEscape -destination 'platform=iOS Simulator,name=iPhone 17 Pro' -only-testing:PiggyEscapeTests/RealityHidePlannerTests -only-testing:PiggyEscapeTests/RealityHideARViewCoordinatorTests test` +- 관찰: `RealityHideARView.nearestFloor`의 `anchors → compactMap → filter → compactMap → min` 단일 식에서 `the compiler is unable to type-check this expression in reasonable time`가 발생해 XCTest 컴파일 전에 중단됨 +- 영향: footprint 동작을 검증할 focused XCTest 자체를 실행할 수 없음 +- 원인/가설: ARKit anchor downcast와 두 closure의 추론·`RealityFloorPlane` 생성·최솟값 비교를 하나의 generic chain에 결합해 Swift type checker가 과도한 추론을 해야 했음 +- 조치: plane anchor 수집, footprint 포함 floor 생성, 높이 기준 선택을 명시적 지역 값으로 분리했다. iOS 16 이후 API에서는 `planeExtent`의 width·height·Y축 회전을 사용하고, 회전된 extent도 pure footprint 검사로 처리한다. +- 검증: 같은 focused XCTest는 `RealityHidePlannerTests` 15개와 `RealityHideARViewCoordinatorTests` 11개, 합계 26개를 0 failures로 통과했으며 Swift type-check 오류와 `extent` 사용 중단 경고는 다시 출력되지 않았다. 전체 XCTest 97/97·0 failures 및 Simulator build 성공을 추가 확인했다. +- 배운 점: ARKit anchor 변환처럼 타입이 많은 pipeline은 짧은 체인보다 단계별 지역 값이 컴파일 진단과 유지보수에 안전하다. + +### L-20260811-082 — AR 스캔 준비 콜백이 실제 공간 관찰보다 먼저 발생함 + +- 상태: 해결 +- 발생 태스크: Task 6 최종 통합 리뷰 보수 — 스캔 준비 시점 +- 재현: `RealityHideARView.Coordinator.attach(to:)`에서 AR 세션 실행·anchor 연결·`onScanningReady` 호출 순서를 추적하고, frame anchor 관찰 전후 callback 수를 확인하는 seam을 준비함 +- 관찰: 현재 구현은 세션 시작 직후 `onScanningReady()`를 호출하며, 첫 `ARMeshAnchor` 또는 분류된 수평 floor anchor가 생겼는지 확인하지 않음 +- 영향: 상위 화면은 실제로 “숨을 물체의 옆면을 탭해줘.”라고 안내할 준비가 되지 않았는데도 target 선택 상태로 넘어갈 수 있어 `scanFirst` 안내와 실제 공간 상태가 어긋남 +- 원인/가설: 세션을 시작할 수 있다는 capability와 공간에서 의미 있는 floor/mesh 관찰을 마쳤다는 readiness를 같은 사건으로 취급한 것이 확인된 원인임 +- 조치: 세션 시작 직후 callback을 제거하고, 첫 `ARMeshAnchor` 또는 분류된 수평 floor anchor를 관찰했을 때 한 번만 readiness를 내보내는 상태를 추가했다. 관찰 뒤에는 subscription을 취소하고 `stop()`도 이를 취소한다. +- 검증: production 변경 전 focused coordinator XCTest는 새 seam 부재로 컴파일에 실패하는 RED를 보였다. 변경 뒤 mesh/floor 전에는 callback이 없고 첫 관찰 뒤 한 번만 발생하는 assertion을 포함한 focused 26/26, 전체 XCTest 97/97·0 failures 및 Simulator build 성공을 확인했다. +- 배운 점: AR 세션 실행 성공은 센서 데이터가 사용자 선택을 지원할 만큼 준비되었다는 신호가 아니다. + +### L-20260811-081 — 한 번의 mesh hit 누락이 카메라 정지 상태에서도 재발견을 발생시킴 + +- 상태: 해결 +- 발생 태스크: Task 6 최종 통합 리뷰 보수 — 물리적 재발견 gate +- 재현: `RealityRevealMonitor`에 blocked mesh 관찰 뒤 동일한 camera pose와 `meshDistance: nil`을 전달하는 수치 입력을 추적함 +- 관찰: 현재 monitor는 blocked 여부만 저장한 뒤 다음 nonblocking 입력 하나에서 `true`를 반환하며, camera pose·방향·연속 관찰 수를 받지 않음 +- 영향: 공간 메쉬 갱신이나 hit jitter만으로 돼지가 “들켰다” 상태가 될 수 있어 사용자가 실제로 카메라를 움직여 찾아낸다는 경험 계약을 깨뜨림 +- 원인/가설: 재발견 data-flow가 `ARView`의 mesh 거리만 전달하고, 같은 frame에서 이미 읽을 수 있는 AR camera transform을 monitor 경계에 전달하지 않은 것이 확인된 원인임 +- 조치: `RealityRevealMonitor` 입력에 실제 `ARFrame.camera.transform`에서 얻은 position·forward pose를 추가했다. 이전 blocking pose에서 0.15m 이상 이동하거나 15° 이상 회전한 뒤 연속된 두 nonblocking 관찰이 있어야 한 번만 재발견한다. 다시 block되면 안정 관찰 수를 초기화한다. +- 검증: production 변경 전 focused planner XCTest는 camera pose API 부재로 컴파일에 실패하는 RED를 보였다. 정지/null, 0.149m·15° 미만, 정확히 0.15m·15°, interrupted visibility, one-time 사례를 포함한 focused 26/26, 전체 XCTest 97/97·0 failures 및 Simulator build 성공을 확인했다. +- 배운 점: 센서의 가시성 변화는 사용자 시점 변화와 구분해야 하며, 한 frame의 null 결과만으로 경험 상태를 전이하면 안 된다. + +### L-20260811-080 — floor 선택이 평면 발자국 대신 anchor 중심 거리를 사용함 + +- 상태: 해결 +- 발생 태스크: Task 6 최종 통합 리뷰 보수 — 선택 물체 아래 바닥 판정 +- 재현: `RealityHideARView.nearestFloor`가 각 `ARPlaneAnchor`를 transform된 center 하나의 `RealityFloor`로 바꾸는 흐름과 Task 5의 1.2m XZ 검사를 대조함 +- 관찰: 큰 floor plane 안의 선택 점이 center에서 1.2m 넘게 떨어지면 실제 바닥이 있어도 거절될 수 있고, center 근처지만 footprint 밖인 점은 바닥으로 잘못 받아들일 수 있음 +- 영향: 돼지가 사용자가 선택한 실제 물체 아래 바닥이 아닌 anchor 중심 기준으로 숨기 좌표를 계산하거나 유효한 넓은 바닥을 불필요하게 거절할 수 있음 +- 원인/가설: ARPlaneAnchor의 local `center`·`extent`·`transform`을 사용해 선택 world point의 footprint 포함을 판정하지 않은 것이 확인된 원인임 +- 조치: world point를 anchor local 좌표로 변환해 center·width·height·Y축 extent 회전이 만든 footprint 안인지 검사하고, 같은 local XZ를 floor Y로 투영한다. 2cm만 가장자리 jitter 여유로 허용한다. 1.2m 제한은 anchor 중심과 무관한 selected-point/floor XZ 안전 경계로 문서화해 유지했다. +- 검증: production 변경 전 focused XCTest는 `RealityFloorPlane` 타입 부재로 컴파일에 실패하는 RED를 보였다. 회전된 큰 plane 안이지만 중심에서 1.2m 넘는 점의 수용, 가까운 중심 밖 점의 거절, 2cm 경계와 extent 회전을 포함한 focused 26/26, 전체 XCTest 97/97·0 failures 및 Simulator build 성공을 확인했다. +- 배운 점: AR 평면의 중심은 발자국 전체를 대표하지 않으므로, 실제 선택 좌표는 local footprint로 판정해야 한다. + ### L-20260811-079 — 카메라 거부 상태를 Settings 복귀 신호로 잘못 해석해 일반 active마다 권한을 조회함 - 상태: 해결 diff --git a/docs/WORK_LOG.md b/docs/WORK_LOG.md index ed392d4..8423cee 100644 --- a/docs/WORK_LOG.md +++ b/docs/WORK_LOG.md @@ -4,13 +4,13 @@ ## 현재 인수인계 -- 상태: Task 7 보수 2차 — 명시적 Settings 복귀 1회 조회 구현 완료, 독립 검토 대기 -- 진행 중 범위: 앱 진입점은 `EscapeRootView` 하나다. C3 발견 callback은 놀란 상태를 기록하고 0.70초 페이드가 끝난 뒤에만 카메라 권한을 한 번 요청한다. 허용 시 `RealityHideARView`를 자동으로 열고 스캔 준비·타깃 수락·돼지 도착·재발견·오류 callback을 상태 순서로 연결한다. 사용자가 `설정 열기`를 탭해 Settings 복귀 대기를 남긴 경우에만 다음 active가 현재 권한을 한 번 읽고, 허용이면 요청·Settings 재열기 없이 한 번 AR 스캔으로 전환한다. Settings를 열지 않은 active와 대기 소비 뒤의 active는 거부·제한 상태에서도 조회하지 않으며, 두 번째 명시적 Settings 탭은 새 1회 조회를 허용한다. AR 외부 callback은 다음 MainActor turn의 취소 가능한 relay를 거쳐 SwiftUI 생성·갱신 중 루트 상태를 바꾸지 않으며, AR 화면 해제 뒤 예약된 callback은 폐기한다. 현실 재발견에는 `ARView` 컨테이너 1.12→1.0 확대가 추가됐고 실제 AR camera transform은 변경하지 않으며 Reduce Motion에서는 화면 확대를 생략한다. DocC는 같은 흐름을 C3 섬·카메라 발견·권한 전환·실제 메쉬·물리적 재발견의 다섯 장면으로 설명한다. +- 상태: Task 6 최종 통합 리뷰 보수 완료, 독립 검토 대기 +- 진행 중 범위: 앱 진입점은 `EscapeRootView` 하나다. C3 발견 callback은 놀란 상태를 기록하고 0.70초 페이드가 끝난 뒤에만 카메라 권한을 한 번 요청한다. 허용 시 `RealityHideARView`를 자동으로 열고 스캔 준비·타깃 수락·돼지 도착·재발견·오류 callback을 상태 순서로 연결한다. RealityKit은 첫 `ARMeshAnchor` 또는 분류된 수평 floor anchor를 관찰한 뒤에만 스캔 준비 callback을 한 번 전달한다. 실제 물체 선택에는 회전된 AR floor footprint 안의 동일 XZ를 floor Y로 투영하며, anchor 중심이 아닌 `planeExtent`의 width·height·Y축 회전을 사용한다. 숨은 뒤 재발견은 실제 camera pose가 이전 block 시점에서 0.15m 이상 이동하거나 15° 이상 회전하고 두 frame 연속 nonblocking일 때 한 번만 발생한다. 사용자가 `설정 열기`를 탭해 Settings 복귀 대기를 남긴 경우에만 다음 active가 현재 권한을 한 번 읽고, 허용이면 요청·Settings 재열기 없이 한 번 AR 스캔으로 전환한다. Settings를 열지 않은 active와 대기 소비 뒤의 active는 거부·제한 상태에서도 조회하지 않으며, 두 번째 명시적 Settings 탭은 새 1회 조회를 허용한다. AR 외부 callback은 다음 MainActor turn의 취소 가능한 relay를 거쳐 SwiftUI 생성·갱신 중 루트 상태를 바꾸지 않으며, AR 화면 해제 뒤 예약된 callback은 폐기한다. 현실 재발견에는 `ARView` 컨테이너 1.12→1.0 확대가 추가됐고 실제 AR camera transform은 변경하지 않으며 Reduce Motion에서는 화면 확대를 생략한다. DocC는 같은 흐름을 C3 섬·카메라 발견·권한 전환·실제 메쉬·물리적 재발견의 다섯 장면으로 설명한다. - 실패 기록: `docs/LEARNING_LOG.md`에 실패·검증 한계의 재현 조건·원인/가설·조치·재발 방지 근거를 기록한다. -- 마지막 완료 범위: Task 7 보수 2차 — 명시적 Settings 복귀 1회 조회. -- 마지막 검증: `xcodebuild -project PiggyEscape.xcodeproj -scheme PiggyEscape -destination 'platform=iOS Simulator,name=iPhone 17 Pro' -derivedDataPath /tmp/piggyescape-task7-repair2-green -only-testing:PiggyEscapeTests/EscapeRootCoordinatorTests test`가 16/16, 0 failures 및 `** TEST SUCCEEDED **`; 이어 `/tmp/piggyescape-task7-repair2-tests test`가 전체 87/87, 0 failures 및 `** TEST SUCCEEDED **`; 같은 destination의 `/tmp/piggyescape-task7-repair2-build build`가 `** BUILD SUCCEEDED **`로 완료됐다. -- 다음 시작점: 이 보수를 독립 검토한 뒤 LiDAR 지원 실기기에서 아래 수용 목록을 관찰해 결과·기기·OS를 기록한다. AR 세션 시작과 실제 공간 재구성 준비를 구분하는 L-20260811-076은 별도 RealityKit 보수 태스크로 설계한다. -- 차단 요소: 실제 카메라 권한 UI·0.70초 전환·Settings 복귀·1.12 화면 확대·LiDAR 메쉬·물리 오클루전은 관찰 전까지 `실기기 대기`다. `onScanningReady`는 세션 시작만 뜻하며 실제 mesh/frame 준비 기준은 L-20260811-076으로 보류한다. DocC는 새 이미지 에셋을 추가하지 않는 제약 때문에 Chapter Image 경고 1건을 남긴다. +- 마지막 완료 범위: Task 6 최종 통합 리뷰 보수 — AR floor footprint, 실제 camera pose 기반 재발견, 공간 관찰 뒤 scan readiness. +- 마지막 검증: `xcodebuild -project PiggyEscape.xcodeproj -scheme PiggyEscape -destination 'platform=iOS Simulator,name=iPhone 17 Pro' -only-testing:PiggyEscapeTests/RealityHidePlannerTests -only-testing:PiggyEscapeTests/RealityHideARViewCoordinatorTests test`가 focused 26/26, 0 failures 및 `** TEST SUCCEEDED **`; 이어 `-derivedDataPath /tmp/piggyescape-task6-repair-tests test`가 전체 97/97, 0 failures 및 `** TEST SUCCEEDED **`; 같은 destination의 `-derivedDataPath /tmp/piggyescape-task6-repair-build build`가 `** BUILD SUCCEEDED **`로 완료됐다. +- 다음 시작점: 이 보수를 독립 검토한 뒤 LiDAR 지원 실기기에서 아래 수용 목록을 관찰해 결과·기기·OS를 기록한다. +- 차단 요소: 실제 카메라 권한 UI·0.70초 전환·Settings 복귀·1.12 화면 확대·LiDAR 메쉬·물리 오클루전은 관찰 전까지 `실기기 대기`다. 실제 floor 분류·planeExtent 회전·메쉬 가림·0.15m/15° 재발견 임계값은 자동 테스트로 계약을 보호하지만 실기기 체감과 센서 동작은 아직 검증하지 않았다. DocC는 새 이미지 에셋을 추가하지 않는 제약 때문에 Chapter Image 경고 1건을 남긴다. ### 실기기 수용 목록 — 실기기 대기 @@ -31,10 +31,17 @@ 2. **돼지 좌표계·바닥 정렬** — `Piggy`도 Blender Z-up 모델이므로 내부 모델에서 Y-up 회전·균일 스케일·바닥 정렬을 수행하고, 바깥 노드는 위치와 액션만 맡게 분리했다. 3. **초기 프레이밍** — 카메라와 너무 가까워 화면 밖으로 밀리던 돼지의 임의 하드코딩 z 좌표를 1에서 0으로 조정했다. 카메라 설정과 `allowsCameraControl`은 계획값을 유지한다. +### Task 6 최종 통합 리뷰에서 해결한 항목 + +1. **바닥 footprint** — floor anchor 중심 대신 선택 지점을 local footprint와 비교한다. 큰 바닥의 유효 지점은 수용하고, 중심 근처라도 footprint 밖인 지점은 거절한다. iOS 16 이후 `planeExtent`의 width·height·Y축 회전을 반영하며, 가장자리 2cm만 tracking jitter 여유로 둔다. +2. **물리적 재발견** — 한 frame의 mesh hit 누락만으로는 발견되지 않는다. 이전 block camera pose에서 0.15m 이동 또는 15° 회전 뒤 두 frame 연속 nonblocking일 때만 한 번 재발견하며, 중간 block은 안정 관찰을 초기화한다. +3. **스캔 준비 시점** — AR 세션 시작 성공이 아니라 첫 mesh 또는 분류된 수평 floor 관찰을 스캔 준비로 정의했다. readiness callback은 한 번만 전달하고 view stop 시 관찰 subscription을 취소한다. + ## 작업 이력 | 날짜 | 작업 범위 | 결과 | 검증 | 다음 시작점 | | --- | --- | --- | --- | --- | +| 2026-08-11 | Task 6 최종 통합 리뷰 보수 — floor footprint·물리적 재발견·scan readiness | AR floor를 anchor 중심이 아닌 회전된 local footprint로 판정하고 선택 XZ를 floor Y에 투영함. actual camera position/forward 기반으로 0.15m 또는 15° 이동 뒤 두 frame 연속 visible일 때만 재발견하며, 첫 mesh/floor 관찰 뒤에만 스캔 준비를 알림 | 타입 부재 RED와 compiler type-check·Simulator 접근 실패를 학습 기록으로 남긴 뒤 focused 26/26, 전체 XCTest 97/97·0 failures, iPhone 17 Pro Simulator build 성공. L-20260811-080~084 | 독립 검토 후 LiDAR 지원 실기기에서 floor footprint·mesh occlusion·재발견 임계값을 관찰 | | 2026-08-11 | Task 7 보수 2차 — 명시적 Settings 복귀 1회 조회 | 기존 `.cameraDenied`만으로 active마다 권한을 조회하던 회귀를 수정함. `설정 열기`의 명시적 탭에서만 1회 복귀 대기를 만들고, 다음 active가 이를 조회 전에 소비한다. Settings 미탭·소비 후 active는 무조회이며, 두 번째 탭은 새 1회 조회를 허용함 | focused RED 16개 중 3 failures 뒤 `EscapeRootCoordinatorTests` focused 16/16, 전체 XCTest 87/87·0 failures, iPhone 17 Pro Simulator build 성공. 원인·기존 잘못된 테스트 기대값·수정 근거는 L-20260811-079 | 보수 2차 독립 검토 후 L-20260811-076 RealityKit readiness 설계 및 실기기 수용 목록 관찰 | | 2026-08-11 | Task 7 보수 — Settings 권한 복귀와 AR 외부 callback 수명 | Settings에서 카메라를 허용한 뒤 active로 돌아오면 현재 권한만 다시 읽어 한 번 AR 스캔으로 전환하고, 권한 미변경·제한·이미 AR 상태에서는 요청·Settings 재열기·중복 전환을 하지 않게 함. `UIViewRepresentable`에서 온 모든 AR callback은 다음 MainActor turn의 취소 가능한 relay로 보내고 화면 해제 뒤 stale callback을 버림 | type 부재 RED 뒤 `EscapeRootCoordinatorTests` focused 14/14, 전체 XCTest 85/85·0 failures, iPhone 17 Pro Simulator build 성공. 실패·해결·실제 mesh 준비 한계는 L-20260811-073~076 | 보수 독립 검토 후 L-20260811-076 RealityKit readiness 설계 및 실기기 수용 목록 관찰 | | 2026-08-11 | Task 8 — 전체 회귀·실기기 검증과 DocC·인수인계 | 방·가짜 소파 중심 DocC를 C3 섬·나무 뒤 숨기·카메라 발견·권한 전환·실제 메쉬 오클루전·물리적 재발견의 다섯 장면으로 교체하고, `PROJECT_CONTEXT`·트러블슈팅·실기기 수용 목록을 추가함. 실기기 항목은 관찰하지 않아 모두 `실기기 대기`로 유지함 | `tuist generate --no-open` 성공, 명시적 전체 XCTest 79/79·0 failures, iPhone 17 Pro Simulator build 성공, DocC archive 생성·documentation build 성공. 연결 worktree fetch·Simulator 권한 재시도와 Chapter Image 경고 1건은 L-20260811-067~070 | LiDAR 지원 실기기 수용 목록 관찰 후 전체 독립 검토 | From 3695a37e4ee58bcb97839a6a898044c051507c33 Mon Sep 17 00:00:00 2001 From: eunseo-com Date: Tue, 11 Aug 2026 02:14:00 +0900 Subject: [PATCH 20/22] Stabilize RealityKit reveal monitoring --- .../Sources/Reality/RealityHideARView.swift | 25 ++++++++++--- .../Sources/Reality/RealityHidePlanner.swift | 8 ++++- .../RealityHideARViewCoordinatorTests.swift | 16 +++++++++ .../RealityHidePlannerTests.swift | 14 ++++++++ docs/LEARNING_LOG.md | 36 +++++++++++++++++++ docs/WORK_LOG.md | 14 +++++--- 6 files changed, 103 insertions(+), 10 deletions(-) diff --git a/PiggyEscape/PiggyEscape/Sources/Reality/RealityHideARView.swift b/PiggyEscape/PiggyEscape/Sources/Reality/RealityHideARView.swift index 57070ee..a6c67dd 100644 --- a/PiggyEscape/PiggyEscape/Sources/Reality/RealityHideARView.swift +++ b/PiggyEscape/PiggyEscape/Sources/Reality/RealityHideARView.swift @@ -228,8 +228,14 @@ struct RealityHideARView: UIViewRepresentable { pigDistance: Float, cameraPose: RealityCameraPose ) -> Bool { - guard isObservationValid, - status == .hidden, + guard status == .hidden else { + return false + } + guard isObservationValid else { + processInvalidRevealObservation() + return false + } + guard revealMonitor.update( meshDistance: meshDistance, pigDistance: pigDistance, @@ -427,14 +433,20 @@ struct RealityHideARView: UIViewRepresentable { private func evaluateReveal(in arView: ARView) { let pigPosition = visualController.worldPosition - guard let cameraTransform = arView.session.currentFrame?.camera.transform else { return } + guard let cameraTransform = arView.session.currentFrame?.camera.transform else { + processInvalidRevealObservation() + return + } let screenPoint = arView.project(pigPosition) guard RealityProjectionGate.canObserve( projectedPoint: screenPoint, viewportBounds: arView.bounds, pigPosition: pigPosition, cameraTransform: cameraTransform - ), let screenPoint else { return } + ), let screenPoint else { + processInvalidRevealObservation() + return + } let cameraPosition = Self.position(from: cameraTransform) let cameraForward = -SIMD3( @@ -459,6 +471,11 @@ struct RealityHideARView: UIViewRepresentable { ) } + private func processInvalidRevealObservation() { + guard status == .hidden else { return } + revealMonitor.recordInvalidObservation() + } + private static func position(from transform: simd_float4x4) -> SIMD3 { SIMD3(transform.columns.3.x, transform.columns.3.y, transform.columns.3.z) } diff --git a/PiggyEscape/PiggyEscape/Sources/Reality/RealityHidePlanner.swift b/PiggyEscape/PiggyEscape/Sources/Reality/RealityHidePlanner.swift index 6fde01b..87e68b3 100644 --- a/PiggyEscape/PiggyEscape/Sources/Reality/RealityHidePlanner.swift +++ b/PiggyEscape/PiggyEscape/Sources/Reality/RealityHidePlanner.swift @@ -131,7 +131,9 @@ struct RealityRevealMonitor { let blocked = meshDistance.map { $0 + 0.03 < pigDistance } ?? false if blocked { hasObservedBlockingMesh = true - blockingPose = cameraPose + if blockingPose == nil { + blockingPose = cameraPose + } stableVisibleObservationCount = 0 return false } @@ -152,6 +154,10 @@ struct RealityRevealMonitor { return true } + mutating func recordInvalidObservation() { + stableVisibleObservationCount = 0 + } + private func hasMeaningfulViewpointChange( from blockedPose: RealityCameraPose, to currentPose: RealityCameraPose diff --git a/PiggyEscape/PiggyEscapeTests/RealityHideARViewCoordinatorTests.swift b/PiggyEscape/PiggyEscapeTests/RealityHideARViewCoordinatorTests.swift index 60a6b56..47e548e 100644 --- a/PiggyEscape/PiggyEscapeTests/RealityHideARViewCoordinatorTests.swift +++ b/PiggyEscape/PiggyEscapeTests/RealityHideARViewCoordinatorTests.swift @@ -70,6 +70,22 @@ final class RealityHideARViewCoordinatorTests: XCTestCase { XCTAssertEqual(visualController.surpriseRestoreScale, 1.0, accuracy: 0.0001) } + func test_invalidRevealObservationResetsVisibleStabilityButKeepsTheBlockingPose() { + let coordinator = RealityHideARView.Coordinator( + meshSupport: FakeRealityMeshSupport(supportsMeshWithClassification: true), + visualController: RealityPigVisualController.makeForTesting() + ) + coordinator.acceptHideTarget(destination: SIMD3(0, 0, -2), initialPosition: SIMD3(0, 0, -0.8)) + let blockingPose = RealityCameraPose(position: .zero, forward: SIMD3(0, 0, -1)) + let movedPose = RealityCameraPose(position: SIMD3(0.15, 0, 0), forward: SIMD3(0, 0, -1)) + + XCTAssertFalse(coordinator.processRevealFrame(isObservationValid: true, meshDistance: 1, pigDistance: 2, cameraPose: blockingPose)) + XCTAssertFalse(coordinator.processRevealFrame(isObservationValid: true, meshDistance: nil, pigDistance: 2, cameraPose: movedPose)) + XCTAssertFalse(coordinator.processRevealFrame(isObservationValid: false, meshDistance: nil, pigDistance: 2, cameraPose: movedPose)) + XCTAssertFalse(coordinator.processRevealFrame(isObservationValid: true, meshDistance: nil, pigDistance: 2, cameraPose: movedPose)) + XCTAssertTrue(coordinator.processRevealFrame(isObservationValid: true, meshDistance: nil, pigDistance: 2, cameraPose: movedPose)) + } + func test_invalidProjectionDoesNotConsumeVisibleFrameAfterBlock() { let coordinator = RealityHideARView.Coordinator( meshSupport: FakeRealityMeshSupport(supportsMeshWithClassification: true), diff --git a/PiggyEscape/PiggyEscapeTests/RealityHidePlannerTests.swift b/PiggyEscape/PiggyEscapeTests/RealityHidePlannerTests.swift index efe0782..82f0749 100644 --- a/PiggyEscape/PiggyEscapeTests/RealityHidePlannerTests.swift +++ b/PiggyEscape/PiggyEscapeTests/RealityHidePlannerTests.swift @@ -213,4 +213,18 @@ final class RealityHidePlannerTests: XCTestCase { XCTAssertFalse(monitor.update(meshDistance: nil, pigDistance: 2, cameraPose: secondMove)) XCTAssertTrue(monitor.update(meshDistance: nil, pigDistance: 2, cameraPose: secondMove)) } + + func test_revealMonitorLatchesTheFirstBlockingPoseForItsHideCycle() { + var monitor = RealityRevealMonitor() + let firstBlockingPose = RealityCameraPose(position: .zero, forward: SIMD3(0, 0, -1)) + let stillBlockedAfterMoving = RealityCameraPose( + position: SIMD3(0.15, 0, 0), + forward: SIMD3(0, 0, -1) + ) + + XCTAssertFalse(monitor.update(meshDistance: 1, pigDistance: 2, cameraPose: firstBlockingPose)) + XCTAssertFalse(monitor.update(meshDistance: 1, pigDistance: 2, cameraPose: stillBlockedAfterMoving)) + XCTAssertFalse(monitor.update(meshDistance: nil, pigDistance: 2, cameraPose: stillBlockedAfterMoving)) + XCTAssertTrue(monitor.update(meshDistance: nil, pigDistance: 2, cameraPose: stillBlockedAfterMoving)) + } } diff --git a/docs/LEARNING_LOG.md b/docs/LEARNING_LOG.md index 59b3876..72500e5 100644 --- a/docs/LEARNING_LOG.md +++ b/docs/LEARNING_LOG.md @@ -28,6 +28,42 @@ ## 항목 +### L-20260811-087 — worktree 루트에서 XCTest 프로젝트 경로를 찾지 못함 + +- 상태: 해결 +- 발생 태스크: Task 6 최종 monitor 보수 2차 — 커밋 전 전체 검증 +- 재현: worktree 루트에서 `xcodebuild -project PiggyEscape.xcodeproj -scheme PiggyEscape -destination 'platform=iOS Simulator,name=iPhone 17 Pro' -derivedDataPath /tmp/piggyescape-task6-monitor-repair2-tests test`를 실행한다. +- 관찰: XCTest가 시작되기 전에 `xcodebuild: error: 'PiggyEscape.xcodeproj' does not exist.`로 종료했다. +- 영향: 이 실행은 컴파일·테스트를 수행하지 못했으므로 커밋 전 검증 근거로 사용할 수 없다. +- 원인/가설: 프로젝트 파일은 worktree 루트가 아니라 `PiggyEscape/PiggyEscape.xcodeproj`에 있으며, 명령의 상대 경로 기준이 한 단계 달랐음이 `rg --files -g 'project.pbxproj' -g '*.xcodeproj' -g 'Project.swift'` 결과로 확인됐다. +- 조치: 프로젝트 디렉터리 `PiggyEscape`을 명시 workdir로 사용해 같은 scheme·destination·derived data 조건으로 검증을 다시 실행한다. +- 검증: `PiggyEscape` 디렉터리에서 같은 전체 XCTest가 99/99·0 failures 및 `** TEST SUCCEEDED **`로 끝났고, 같은 destination의 Simulator build도 `** BUILD SUCCEEDED **`로 끝났다. +- 배운 점: worktree 최상위와 Xcode 프로젝트 최상위를 구분하고, 검증 명령의 상대 경로는 실행 디렉터리와 함께 기록한다. + +### L-20260811-086 — invalid reveal 관찰이 직전 visible 안정 frame을 유지함 + +- 상태: 해결 +- 발생 태스크: Task 6 최종 monitor 보수 2차 — invalid projection 안정성 +- 재현: 최초 block 뒤 임계값을 넘긴 pose에서 valid nonblocking 관찰을 한 번 전달하고, screen-out·camera-behind·중단된 projection을 뜻하는 invalid 관찰 한 번 뒤 같은 pose의 valid nonblocking 관찰을 한 번 전달한다. +- 관찰: `RealityHideARView.Coordinator.processRevealFrame`의 `guard isObservationValid`가 monitor 호출보다 먼저 return하므로, `RealityRevealMonitor`의 `stableVisibleObservationCount`가 invalid 관찰을 지나도 남는다. 실제 `evaluateReveal`도 projection gate 실패에서 monitor에 아무 신호를 주지 않고 return한다. +- 영향: 유효 관찰 두 frame이 연속이어야 한다는 계약이 끊긴 projection을 사이에 두고도 충족돼 돼지가 너무 이르게 발견될 수 있다. +- 원인/가설: invalid을 “monitor 입력 없음”으로 취급해, 기존 blocking pose는 보존해야 하지만 transient visible 안정성만 초기화해야 하는 상태 경계를 모델링하지 않은 것이 원인으로 보인다. +- 조치: `RealityRevealMonitor.recordInvalidObservation()`이 stable visible count만 0으로 만들고 최초 blocking pose는 보존하게 했다. `processRevealFrame(isObservationValid: false)`와 camera transform·projection gate의 early-return 경로가 이를 명시 호출한다. +- 검증: RED에서 새 focused 28개 중 이 사례의 두 assertion이 실패했고, 최초 pose latch만 고친 중간 실행에서도 같은 두 failure가 남아 원인을 분리했다. 최종 focused 28/28, 전체 XCTest 99/99·0 failures 및 Simulator build 성공을 확인했다. +- 배운 점: 관찰을 건너뛰는 것은 상태가 없다는 뜻이 아니다. 연속성 조건이 있으면 invalid frame도 안정성 상태를 명시적으로 갱신해야 한다. + +### L-20260811-085 — 계속 가려진 frame이 최초 blocking camera pose를 덮어씀 + +- 상태: 해결 +- 발생 태스크: Task 6 최종 monitor 보수 2차 — blocking pose cycle 기준 +- 재현: pose A에서 실제 mesh block을 관찰한 뒤, pose B로 0.15m 이상 이동했지만 여전히 block인 frame을 전달하고, B에서 nonblocking 관찰을 두 frame 전달한다. +- 관찰: 현재 `RealityRevealMonitor.update`는 모든 blocked frame에서 `blockingPose = cameraPose`를 다시 대입한다. 따라서 B가 새 기준이 되어, 사용자가 A→B로 이미 움직였어도 B에서 다시 이동하기 전에는 재발견할 수 없다. +- 영향: 실제 물체를 따라 움직이며 시야를 찾는 사용자가 가림이 계속되는 동안의 유효한 이동을 잃고 추가 이동을 강요받을 수 있다. +- 원인/가설: `hasObservedBlockingMesh`가 hide cycle의 최초 block 여부를 표현하지만 `blockingPose` 대입은 이를 guard하지 않아, cycle 시작 기준과 후속 block의 visible 안정성 reset을 한 분기로 처리한 것이 원인으로 보인다. +- 조치: 최초 실제 block에서만 `blockingPose`를 latch하고 후속 block은 stable visible count만 reset하도록 바꿨다. 새 hide cycle은 기존 `RealityRevealMonitor()` 재생성 경계에서 latch를 비운다. +- 검증: RED에서 새 focused 28개 중 A block → B block → B visible 두 frame 사례의 assertion 1건이 실패했다. latch만 적용한 중간 실행에서 이 사례는 통과했고 invalid 관찰의 두 failure만 남아 변경 효과를 분리했다. 최종 focused 28/28, 전체 XCTest 99/99·0 failures 및 Simulator build 성공을 확인했다. +- 배운 점: cycle의 기준 pose와 frame마다 초기화해야 하는 안정성 counter는 별도 상태다. 하나의 block 분기에서 함께 갱신하면 사용자의 이미 수행한 이동을 잃을 수 있다. + ### L-20260811-084 — focused XCTest가 Simulator 서비스 접근 제한으로 시작 전에 중단됨 - 상태: 해결 diff --git a/docs/WORK_LOG.md b/docs/WORK_LOG.md index 8423cee..87fac5f 100644 --- a/docs/WORK_LOG.md +++ b/docs/WORK_LOG.md @@ -4,13 +4,13 @@ ## 현재 인수인계 -- 상태: Task 6 최종 통합 리뷰 보수 완료, 독립 검토 대기 -- 진행 중 범위: 앱 진입점은 `EscapeRootView` 하나다. C3 발견 callback은 놀란 상태를 기록하고 0.70초 페이드가 끝난 뒤에만 카메라 권한을 한 번 요청한다. 허용 시 `RealityHideARView`를 자동으로 열고 스캔 준비·타깃 수락·돼지 도착·재발견·오류 callback을 상태 순서로 연결한다. RealityKit은 첫 `ARMeshAnchor` 또는 분류된 수평 floor anchor를 관찰한 뒤에만 스캔 준비 callback을 한 번 전달한다. 실제 물체 선택에는 회전된 AR floor footprint 안의 동일 XZ를 floor Y로 투영하며, anchor 중심이 아닌 `planeExtent`의 width·height·Y축 회전을 사용한다. 숨은 뒤 재발견은 실제 camera pose가 이전 block 시점에서 0.15m 이상 이동하거나 15° 이상 회전하고 두 frame 연속 nonblocking일 때 한 번만 발생한다. 사용자가 `설정 열기`를 탭해 Settings 복귀 대기를 남긴 경우에만 다음 active가 현재 권한을 한 번 읽고, 허용이면 요청·Settings 재열기 없이 한 번 AR 스캔으로 전환한다. Settings를 열지 않은 active와 대기 소비 뒤의 active는 거부·제한 상태에서도 조회하지 않으며, 두 번째 명시적 Settings 탭은 새 1회 조회를 허용한다. AR 외부 callback은 다음 MainActor turn의 취소 가능한 relay를 거쳐 SwiftUI 생성·갱신 중 루트 상태를 바꾸지 않으며, AR 화면 해제 뒤 예약된 callback은 폐기한다. 현실 재발견에는 `ARView` 컨테이너 1.12→1.0 확대가 추가됐고 실제 AR camera transform은 변경하지 않으며 Reduce Motion에서는 화면 확대를 생략한다. DocC는 같은 흐름을 C3 섬·카메라 발견·권한 전환·실제 메쉬·물리적 재발견의 다섯 장면으로 설명한다. +- 상태: Task 6 최종 monitor 보수 2차 완료, 독립 검토 대기 +- 진행 중 범위: 앱 진입점은 `EscapeRootView` 하나다. C3 발견 callback은 놀란 상태를 기록하고 0.70초 페이드가 끝난 뒤에만 카메라 권한을 한 번 요청한다. 허용 시 `RealityHideARView`를 자동으로 열고 스캔 준비·타깃 수락·돼지 도착·재발견·오류 callback을 상태 순서로 연결한다. RealityKit은 첫 `ARMeshAnchor` 또는 분류된 수평 floor anchor를 관찰한 뒤에만 스캔 준비 callback을 한 번 전달한다. 실제 물체 선택에는 회전된 AR floor footprint 안의 동일 XZ를 floor Y로 투영하며, anchor 중심이 아닌 `planeExtent`의 width·height·Y축 회전을 사용한다. 숨은 뒤 재발견은 최초로 실제 가림이 확인된 camera pose를 hide cycle 동안 기준으로 삼고, 실제 camera pose가 그 지점에서 0.15m 이상 이동하거나 15° 이상 회전한 뒤 두 frame 연속 nonblocking일 때 한 번만 발생한다. 이후 blocked frame은 visible 안정 count만 초기화하며, camera transform 부재·screen-out·camera-behind 같은 invalid 관찰도 그 count만 초기화하고 최초 pose는 보존한다. 사용자가 `설정 열기`를 탭해 Settings 복귀 대기를 남긴 경우에만 다음 active가 현재 권한을 한 번 읽고, 허용이면 요청·Settings 재열기 없이 한 번 AR 스캔으로 전환한다. Settings를 열지 않은 active와 대기 소비 뒤의 active는 거부·제한 상태에서도 조회하지 않으며, 두 번째 명시적 Settings 탭은 새 1회 조회를 허용한다. AR 외부 callback은 다음 MainActor turn의 취소 가능한 relay를 거쳐 SwiftUI 생성·갱신 중 루트 상태를 바꾸지 않으며, AR 화면 해제 뒤 예약된 callback은 폐기한다. 현실 재발견에는 `ARView` 컨테이너 1.12→1.0 확대가 추가됐고 실제 AR camera transform은 변경하지 않으며 Reduce Motion에서는 화면 확대를 생략한다. DocC는 같은 흐름을 C3 섬·카메라 발견·권한 전환·실제 메쉬·물리적 재발견의 다섯 장면으로 설명한다. - 실패 기록: `docs/LEARNING_LOG.md`에 실패·검증 한계의 재현 조건·원인/가설·조치·재발 방지 근거를 기록한다. -- 마지막 완료 범위: Task 6 최종 통합 리뷰 보수 — AR floor footprint, 실제 camera pose 기반 재발견, 공간 관찰 뒤 scan readiness. -- 마지막 검증: `xcodebuild -project PiggyEscape.xcodeproj -scheme PiggyEscape -destination 'platform=iOS Simulator,name=iPhone 17 Pro' -only-testing:PiggyEscapeTests/RealityHidePlannerTests -only-testing:PiggyEscapeTests/RealityHideARViewCoordinatorTests test`가 focused 26/26, 0 failures 및 `** TEST SUCCEEDED **`; 이어 `-derivedDataPath /tmp/piggyescape-task6-repair-tests test`가 전체 97/97, 0 failures 및 `** TEST SUCCEEDED **`; 같은 destination의 `-derivedDataPath /tmp/piggyescape-task6-repair-build build`가 `** BUILD SUCCEEDED **`로 완료됐다. +- 마지막 완료 범위: Task 6 최종 monitor 보수 2차 — 최초 blocking pose latch와 invalid 관찰 안정성 reset. +- 마지막 검증: `cd PiggyEscape && xcodebuild -project PiggyEscape.xcodeproj -scheme PiggyEscape -destination 'platform=iOS Simulator,name=iPhone 17 Pro' -only-testing:PiggyEscapeTests/RealityHidePlannerTests -only-testing:PiggyEscapeTests/RealityHideARViewCoordinatorTests test`가 focused 28/28, 0 failures 및 `** TEST SUCCEEDED **`; 이어 `-derivedDataPath /tmp/piggyescape-task6-monitor-repair2-tests test`가 전체 99/99, 0 failures 및 `** TEST SUCCEEDED **`; 같은 destination의 `-derivedDataPath /tmp/piggyescape-task6-monitor-repair2-build build`가 `** BUILD SUCCEEDED **`로 완료됐다. worktree 루트에서 프로젝트 상대 경로를 지정한 재실행 1회는 테스트 시작 전 중단됐고, 올바른 `PiggyEscape` 디렉터리에서 전체 XCTest 99/99와 build를 다시 성공시켰다(L-20260811-087). - 다음 시작점: 이 보수를 독립 검토한 뒤 LiDAR 지원 실기기에서 아래 수용 목록을 관찰해 결과·기기·OS를 기록한다. -- 차단 요소: 실제 카메라 권한 UI·0.70초 전환·Settings 복귀·1.12 화면 확대·LiDAR 메쉬·물리 오클루전은 관찰 전까지 `실기기 대기`다. 실제 floor 분류·planeExtent 회전·메쉬 가림·0.15m/15° 재발견 임계값은 자동 테스트로 계약을 보호하지만 실기기 체감과 센서 동작은 아직 검증하지 않았다. DocC는 새 이미지 에셋을 추가하지 않는 제약 때문에 Chapter Image 경고 1건을 남긴다. +- 차단 요소: 실제 카메라 권한 UI·0.70초 전환·Settings 복귀·1.12 화면 확대·LiDAR 메쉬·물리 오클루전은 관찰 전까지 `실기기 대기`다. 실제 floor 분류·planeExtent 회전·메쉬 가림·최초 block 기준 0.15m/15° 재발견·시야 이탈 뒤 안정 관찰 reset은 자동 테스트로 계약을 보호하지만 실기기 체감과 센서 동작은 아직 검증하지 않았다. DocC는 새 이미지 에셋을 추가하지 않는 제약 때문에 Chapter Image 경고 1건을 남긴다. ### 실기기 수용 목록 — 실기기 대기 @@ -23,6 +23,8 @@ - [ ] 실제 물체의 수직 옆면을 탭하면 돼지가 반대편 바닥으로 걸어간다. - [ ] 초기 시점에서 실제 물체의 LiDAR 메쉬가 돼지를 가린다. - [ ] 사용자가 옆으로 이동해 다시 볼 때 놀란 모델·자막·1.5배 돼지 확대·1.12배 화면 확대가 한 번 실행되고 복귀한다. +- [ ] 가려진 채로 0.15m 이상 이동하거나 15° 이상 회전한 뒤 돼지가 보이면, 추가 이동 없이 연속된 두 유효 관찰 뒤에만 한 번 발견된다. +- [ ] 돼지가 화면 밖·카메라 뒤로 잠시 벗어난 뒤 다시 보이면, 그 전후의 유효 관찰을 합쳐 조기 발견하지 않고 복귀 뒤 두 유효 관찰을 새로 요구한다. - [ ] LiDAR 미지원, 수평면 탭, 너무 가까운 탭, 바닥 추적 부족, 돼지 에셋 로드 실패 안내와 재시도 경로를 관찰한다. ### Task 7에서 해결한 항목 @@ -36,11 +38,13 @@ 1. **바닥 footprint** — floor anchor 중심 대신 선택 지점을 local footprint와 비교한다. 큰 바닥의 유효 지점은 수용하고, 중심 근처라도 footprint 밖인 지점은 거절한다. iOS 16 이후 `planeExtent`의 width·height·Y축 회전을 반영하며, 가장자리 2cm만 tracking jitter 여유로 둔다. 2. **물리적 재발견** — 한 frame의 mesh hit 누락만으로는 발견되지 않는다. 이전 block camera pose에서 0.15m 이동 또는 15° 회전 뒤 두 frame 연속 nonblocking일 때만 한 번 재발견하며, 중간 block은 안정 관찰을 초기화한다. 3. **스캔 준비 시점** — AR 세션 시작 성공이 아니라 첫 mesh 또는 분류된 수평 floor 관찰을 스캔 준비로 정의했다. readiness callback은 한 번만 전달하고 view stop 시 관찰 subscription을 취소한다. +4. **재발견 cycle 기준** — 최초 실제 block의 camera pose를 hide cycle 동안 latch한다. 후속 block과 invalid projection은 visible 안정 count만 초기화하므로, 이미 가려진 상태에서 사용자가 한 이동과 최초 block 기준을 잃지 않으면서도 끊긴 frame을 연속 관찰로 세지 않는다. ## 작업 이력 | 날짜 | 작업 범위 | 결과 | 검증 | 다음 시작점 | | --- | --- | --- | --- | --- | +| 2026-08-11 | Task 6 최종 monitor 보수 2차 — 최초 block pose·invalid 관찰 | 최초 실제 block pose를 cycle 기준으로 latch하고, 후속 block은 visible 안정성만 reset하도록 수정함. camera transform 부재·projection 실패·명시 invalid 관찰도 기준 pose를 보존하면서 안정 count만 초기화함 | RED focused 28개 중 3 assertion failures, latch만 적용한 중간 실행에서 invalid 2 failures만 남는 것을 확인한 뒤 focused 28/28, 전체 XCTest 99/99·0 failures, iPhone 17 Pro Simulator build 성공. worktree 루트의 프로젝트 경로 오류는 테스트 시작 전 중단됐으며, 올바른 디렉터리에서 전체 검증을 다시 성공시킴. L-20260811-085~087 | 독립 검토 후 LiDAR 지원 실기기에서 최초 block 기준 이동·시야 이탈 reset을 포함한 수용 목록 관찰 | | 2026-08-11 | Task 6 최종 통합 리뷰 보수 — floor footprint·물리적 재발견·scan readiness | AR floor를 anchor 중심이 아닌 회전된 local footprint로 판정하고 선택 XZ를 floor Y에 투영함. actual camera position/forward 기반으로 0.15m 또는 15° 이동 뒤 두 frame 연속 visible일 때만 재발견하며, 첫 mesh/floor 관찰 뒤에만 스캔 준비를 알림 | 타입 부재 RED와 compiler type-check·Simulator 접근 실패를 학습 기록으로 남긴 뒤 focused 26/26, 전체 XCTest 97/97·0 failures, iPhone 17 Pro Simulator build 성공. L-20260811-080~084 | 독립 검토 후 LiDAR 지원 실기기에서 floor footprint·mesh occlusion·재발견 임계값을 관찰 | | 2026-08-11 | Task 7 보수 2차 — 명시적 Settings 복귀 1회 조회 | 기존 `.cameraDenied`만으로 active마다 권한을 조회하던 회귀를 수정함. `설정 열기`의 명시적 탭에서만 1회 복귀 대기를 만들고, 다음 active가 이를 조회 전에 소비한다. Settings 미탭·소비 후 active는 무조회이며, 두 번째 탭은 새 1회 조회를 허용함 | focused RED 16개 중 3 failures 뒤 `EscapeRootCoordinatorTests` focused 16/16, 전체 XCTest 87/87·0 failures, iPhone 17 Pro Simulator build 성공. 원인·기존 잘못된 테스트 기대값·수정 근거는 L-20260811-079 | 보수 2차 독립 검토 후 L-20260811-076 RealityKit readiness 설계 및 실기기 수용 목록 관찰 | | 2026-08-11 | Task 7 보수 — Settings 권한 복귀와 AR 외부 callback 수명 | Settings에서 카메라를 허용한 뒤 active로 돌아오면 현재 권한만 다시 읽어 한 번 AR 스캔으로 전환하고, 권한 미변경·제한·이미 AR 상태에서는 요청·Settings 재열기·중복 전환을 하지 않게 함. `UIViewRepresentable`에서 온 모든 AR callback은 다음 MainActor turn의 취소 가능한 relay로 보내고 화면 해제 뒤 stale callback을 버림 | type 부재 RED 뒤 `EscapeRootCoordinatorTests` focused 14/14, 전체 XCTest 85/85·0 failures, iPhone 17 Pro Simulator build 성공. 실패·해결·실제 mesh 준비 한계는 L-20260811-073~076 | 보수 독립 검토 후 L-20260811-076 RealityKit readiness 설계 및 실기기 수용 목록 관찰 | From a9e42de2ecf9c7db1eb45b12e5989d303594d4e3 Mon Sep 17 00:00:00 2001 From: eunseo-com Date: Tue, 11 Aug 2026 02:18:24 +0900 Subject: [PATCH 21/22] Record final escape verification --- docs/LEARNING_LOG.md | 12 ++++++++++++ docs/WORK_LOG.md | 8 ++++---- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/docs/LEARNING_LOG.md b/docs/LEARNING_LOG.md index 72500e5..9fe02ad 100644 --- a/docs/LEARNING_LOG.md +++ b/docs/LEARNING_LOG.md @@ -28,6 +28,18 @@ ## 항목 +### L-20260811-088 — 최종 인수인계 커밋이 worktree Git index 잠금 권한으로 중단됨 + +- 상태: 조사 중 +- 발생 태스크: 전체 브랜치 최종 검증 — 인수인계 결과를 Git으로 공유 +- 재현: worktree에서 `git add docs/WORK_LOG.md && git commit -m 'Record final escape verification'`를 실행한다. +- 관찰: `fatal: Unable to create '.../.git/worktrees/ch1-reality-escape/index.lock': Operation not permitted`로 스테이징 전에 종료했다. +- 영향: 최신 전체 검증 결과를 추적 커밋으로 공유하지 못했으며, 코드·문서 내용은 변경되지 않았다. +- 원인/가설: 연결 worktree의 공용 Git 메타데이터에 현재 실행 권한으로 `index.lock`를 만들 수 없다. +- 조치: 실패 자체를 먼저 기록하고, 공용 Git 메타데이터 쓰기가 허용된 동일 명령으로 재실행한다. +- 검증: 재실행 결과를 이 항목에 갱신한다. +- 배운 점: 연결 worktree에서 최종 문서 커밋도 소스 변경과 동일하게 Git 메타데이터 쓰기 권한을 사전 확인해야 한다. + ### L-20260811-087 — worktree 루트에서 XCTest 프로젝트 경로를 찾지 못함 - 상태: 해결 diff --git a/docs/WORK_LOG.md b/docs/WORK_LOG.md index 87fac5f..602e78c 100644 --- a/docs/WORK_LOG.md +++ b/docs/WORK_LOG.md @@ -4,12 +4,12 @@ ## 현재 인수인계 -- 상태: Task 6 최종 monitor 보수 2차 완료, 독립 검토 대기 +- 상태: Chapter 1 C3 월드→RealityKit 숨기 흐름 구현·태스크별 검토·전체 브랜치 재검토 완료. 실기기 수용 목록만 `실기기 대기`다. - 진행 중 범위: 앱 진입점은 `EscapeRootView` 하나다. C3 발견 callback은 놀란 상태를 기록하고 0.70초 페이드가 끝난 뒤에만 카메라 권한을 한 번 요청한다. 허용 시 `RealityHideARView`를 자동으로 열고 스캔 준비·타깃 수락·돼지 도착·재발견·오류 callback을 상태 순서로 연결한다. RealityKit은 첫 `ARMeshAnchor` 또는 분류된 수평 floor anchor를 관찰한 뒤에만 스캔 준비 callback을 한 번 전달한다. 실제 물체 선택에는 회전된 AR floor footprint 안의 동일 XZ를 floor Y로 투영하며, anchor 중심이 아닌 `planeExtent`의 width·height·Y축 회전을 사용한다. 숨은 뒤 재발견은 최초로 실제 가림이 확인된 camera pose를 hide cycle 동안 기준으로 삼고, 실제 camera pose가 그 지점에서 0.15m 이상 이동하거나 15° 이상 회전한 뒤 두 frame 연속 nonblocking일 때 한 번만 발생한다. 이후 blocked frame은 visible 안정 count만 초기화하며, camera transform 부재·screen-out·camera-behind 같은 invalid 관찰도 그 count만 초기화하고 최초 pose는 보존한다. 사용자가 `설정 열기`를 탭해 Settings 복귀 대기를 남긴 경우에만 다음 active가 현재 권한을 한 번 읽고, 허용이면 요청·Settings 재열기 없이 한 번 AR 스캔으로 전환한다. Settings를 열지 않은 active와 대기 소비 뒤의 active는 거부·제한 상태에서도 조회하지 않으며, 두 번째 명시적 Settings 탭은 새 1회 조회를 허용한다. AR 외부 callback은 다음 MainActor turn의 취소 가능한 relay를 거쳐 SwiftUI 생성·갱신 중 루트 상태를 바꾸지 않으며, AR 화면 해제 뒤 예약된 callback은 폐기한다. 현실 재발견에는 `ARView` 컨테이너 1.12→1.0 확대가 추가됐고 실제 AR camera transform은 변경하지 않으며 Reduce Motion에서는 화면 확대를 생략한다. DocC는 같은 흐름을 C3 섬·카메라 발견·권한 전환·실제 메쉬·물리적 재발견의 다섯 장면으로 설명한다. - 실패 기록: `docs/LEARNING_LOG.md`에 실패·검증 한계의 재현 조건·원인/가설·조치·재발 방지 근거를 기록한다. -- 마지막 완료 범위: Task 6 최종 monitor 보수 2차 — 최초 blocking pose latch와 invalid 관찰 안정성 reset. -- 마지막 검증: `cd PiggyEscape && xcodebuild -project PiggyEscape.xcodeproj -scheme PiggyEscape -destination 'platform=iOS Simulator,name=iPhone 17 Pro' -only-testing:PiggyEscapeTests/RealityHidePlannerTests -only-testing:PiggyEscapeTests/RealityHideARViewCoordinatorTests test`가 focused 28/28, 0 failures 및 `** TEST SUCCEEDED **`; 이어 `-derivedDataPath /tmp/piggyescape-task6-monitor-repair2-tests test`가 전체 99/99, 0 failures 및 `** TEST SUCCEEDED **`; 같은 destination의 `-derivedDataPath /tmp/piggyescape-task6-monitor-repair2-build build`가 `** BUILD SUCCEEDED **`로 완료됐다. worktree 루트에서 프로젝트 상대 경로를 지정한 재실행 1회는 테스트 시작 전 중단됐고, 올바른 `PiggyEscape` 디렉터리에서 전체 XCTest 99/99와 build를 다시 성공시켰다(L-20260811-087). -- 다음 시작점: 이 보수를 독립 검토한 뒤 LiDAR 지원 실기기에서 아래 수용 목록을 관찰해 결과·기기·OS를 기록한다. +- 마지막 완료 범위: 전체 브랜치 최종 재검토 — Settings 복귀 권한 토큰, floor footprint, 물리적 재발견 안정성까지 보수하고 재검토 승인. +- 마지막 검증: `cd PiggyEscape && xcodebuild -project PiggyEscape.xcodeproj -scheme PiggyEscape -destination 'platform=iOS Simulator,name=iPhone 17 Pro' -derivedDataPath /tmp/piggyescape-final-tests test`가 전체 XCTest 99/99, 0 failures 및 `** TEST SUCCEEDED **`; 같은 destination의 `-derivedDataPath /tmp/piggyescape-final-build build`가 `** BUILD SUCCEEDED **`; `-derivedDataPath /tmp/piggyescape-final-docc docbuild`가 `** BUILD DOCUMENTATION SUCCEEDED **`로 완료됐다. `tuist test`는 test action을 비워 실제 테스트를 건너뛸 수 있으므로 통과 근거로 사용하지 않는다(L-20260811-066). +- 다음 시작점: LiDAR 지원 실기기에서 아래 수용 목록을 관찰해 결과·기기·OS를 기록하고, 실제 체감과 센서 동작이 다르면 재현 조건을 `LEARNING_LOG.md`에 추가한다. - 차단 요소: 실제 카메라 권한 UI·0.70초 전환·Settings 복귀·1.12 화면 확대·LiDAR 메쉬·물리 오클루전은 관찰 전까지 `실기기 대기`다. 실제 floor 분류·planeExtent 회전·메쉬 가림·최초 block 기준 0.15m/15° 재발견·시야 이탈 뒤 안정 관찰 reset은 자동 테스트로 계약을 보호하지만 실기기 체감과 센서 동작은 아직 검증하지 않았다. DocC는 새 이미지 에셋을 추가하지 않는 제약 때문에 Chapter Image 경고 1건을 남긴다. ### 실기기 수용 목록 — 실기기 대기 From bb73d74fedbbc9c90c527c33932c98f35b01a0ae Mon Sep 17 00:00:00 2001 From: eunseo-com Date: Tue, 11 Aug 2026 02:18:41 +0900 Subject: [PATCH 22/22] Close verification log entry --- docs/LEARNING_LOG.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/LEARNING_LOG.md b/docs/LEARNING_LOG.md index 9fe02ad..da40525 100644 --- a/docs/LEARNING_LOG.md +++ b/docs/LEARNING_LOG.md @@ -30,14 +30,14 @@ ### L-20260811-088 — 최종 인수인계 커밋이 worktree Git index 잠금 권한으로 중단됨 -- 상태: 조사 중 +- 상태: 해결 - 발생 태스크: 전체 브랜치 최종 검증 — 인수인계 결과를 Git으로 공유 - 재현: worktree에서 `git add docs/WORK_LOG.md && git commit -m 'Record final escape verification'`를 실행한다. - 관찰: `fatal: Unable to create '.../.git/worktrees/ch1-reality-escape/index.lock': Operation not permitted`로 스테이징 전에 종료했다. - 영향: 최신 전체 검증 결과를 추적 커밋으로 공유하지 못했으며, 코드·문서 내용은 변경되지 않았다. - 원인/가설: 연결 worktree의 공용 Git 메타데이터에 현재 실행 권한으로 `index.lock`를 만들 수 없다. -- 조치: 실패 자체를 먼저 기록하고, 공용 Git 메타데이터 쓰기가 허용된 동일 명령으로 재실행한다. -- 검증: 재실행 결과를 이 항목에 갱신한다. +- 조치: 실패 자체를 먼저 기록한 뒤, 공용 Git 메타데이터 쓰기가 허용된 동일 명령으로 재실행했다. +- 검증: `a9e42de Record final escape verification`이 생성됐고, `docs/WORK_LOG.md`와 이 항목만 포함했다. - 배운 점: 연결 worktree에서 최종 문서 커밋도 소스 변경과 동일하게 Git 메타데이터 쓰기 권한을 사전 확인해야 한다. ### L-20260811-087 — worktree 루트에서 XCTest 프로젝트 경로를 찾지 못함