diff --git a/PiggyEscape/.gitignore b/PiggyEscape/.gitignore new file mode 100644 index 0000000..6f76e36 --- /dev/null +++ b/PiggyEscape/.gitignore @@ -0,0 +1,5 @@ +*.xcodeproj +*.xcworkspace/ +.build/ +Derived/ +DerivedData/ diff --git a/PiggyEscape/PiggyEscape/Resources/.gitkeep b/PiggyEscape/PiggyEscape/Resources/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/PiggyEscape/PiggyEscape/Resources/Ground_Color.usdc b/PiggyEscape/PiggyEscape/Resources/Ground_Color.usdc new file mode 100644 index 0000000..17a99b2 Binary files /dev/null and b/PiggyEscape/PiggyEscape/Resources/Ground_Color.usdc differ diff --git a/PiggyEscape/PiggyEscape/Resources/Piggy.usdc b/PiggyEscape/PiggyEscape/Resources/Piggy.usdc new file mode 100644 index 0000000..7370b33 Binary files /dev/null and b/PiggyEscape/PiggyEscape/Resources/Piggy.usdc differ diff --git a/PiggyEscape/PiggyEscape/Resources/Wood_Color.usdc b/PiggyEscape/PiggyEscape/Resources/Wood_Color.usdc new file mode 100644 index 0000000..e47a1db Binary files /dev/null and b/PiggyEscape/PiggyEscape/Resources/Wood_Color.usdc differ diff --git a/PiggyEscape/PiggyEscape/Sources/ClosedWorld/AssetLoader.swift b/PiggyEscape/PiggyEscape/Sources/ClosedWorld/AssetLoader.swift new file mode 100644 index 0000000..4bad02f --- /dev/null +++ b/PiggyEscape/PiggyEscape/Sources/ClosedWorld/AssetLoader.swift @@ -0,0 +1,48 @@ +import SceneKit +import UIKit + +/// 3D 모델 파일(.usdz/.usdc/.usda/.obj)을 불러오는 도우미 모음. +/// 모델을 못 찾으면 단순한 정육면체(복셀) 박스로 대신 채운다("폴백"). +enum AssetLoader { + @MainActor + static func object(named name: String, fallback: @MainActor () -> SCNNode) -> SCNNode { + object(named: name) ?? fallback() + } + + @MainActor + static func object(named name: String) -> SCNNode? { + for ext in ["usdz", "usdc", "usda"] { + if let url = Bundle.main.url(forResource: name, withExtension: ext), + let scene = try? SCNScene(url: url, options: nil) { + return wrap(scene) + } + } + if let url = Bundle.main.url(forResource: name, withExtension: "obj"), + let scene = try? SCNScene(url: url, options: [ + .convertToYUp: true, + .createNormalsIfAbsent: true + ]) { + return wrap(scene) + } + return nil + } + + private static func wrap(_ scene: SCNScene) -> SCNNode { + let node = SCNNode() + scene.rootNode.childNodes.forEach { node.addChildNode($0.clone()) } + return node + } + + static func voxelBox(width: CGFloat, height: CGFloat, length: CGFloat, + color: UIColor) -> SCNNode { + let box = SCNBox(width: width * 0.96, + height: height * 0.96, + length: length * 0.96, + chamferRadius: 0.02) + let mat = SCNMaterial() + mat.diffuse.contents = color + mat.lightingModel = .blinn + box.materials = [mat] + return SCNNode(geometry: box) + } +} diff --git a/PiggyEscape/PiggyEscape/Sources/ClosedWorld/ClosedWorldSceneView.swift b/PiggyEscape/PiggyEscape/Sources/ClosedWorld/ClosedWorldSceneView.swift new file mode 100644 index 0000000..0a76f7c --- /dev/null +++ b/PiggyEscape/PiggyEscape/Sources/ClosedWorld/ClosedWorldSceneView.swift @@ -0,0 +1,57 @@ +import SwiftUI +import SceneKit + +/// SwiftUI ↔ SceneKit(SCNView)을 잇는 다리. 방·돼지·가짜 소파를 씬에 담고, +/// 탭하면 돼지가 가짜 소파로 "숨는" 하나의 인터랙션만 처리한다. +struct ClosedWorldSceneView: UIViewRepresentable { + func makeUIView(context: Context) -> SCNView { + let view = SCNView() + let scene = SCNScene() + + 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() + 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()) + } + } +} diff --git a/PiggyEscape/PiggyEscape/Sources/ClosedWorld/FakeSofa.swift b/PiggyEscape/PiggyEscape/Sources/ClosedWorld/FakeSofa.swift new file mode 100644 index 0000000..84f3ea7 --- /dev/null +++ b/PiggyEscape/PiggyEscape/Sources/ClosedWorld/FakeSofa.swift @@ -0,0 +1,21 @@ +import SceneKit +import UIKit + +/// "가짜 소파" — 개발자가 코드로 선언한 숨는 지점. 실제 방의 진짜 소파와는 +/// 아무 관계가 없다. 스텝 5의 "숨어봐" 인터랙션이 이동시키는 목적지가 바로 이 좌표다. +enum FakeSofa { + static let hardcodedPosition = SCNVector3(1.2, 0, -1.2) + /// 방(벽 높이 2.5m)과 돼지(0.6m) 사이, "작은 가구 한 점" 정도의 눈대중 높이. + private static let standardHeight: Float = 0.45 + + @MainActor + static func makeSofaNode() -> SCNNode { + let model = AssetLoader.object(named: "Wood_Color") { + AssetLoader.voxelBox(width: 0.8, height: 0.4, length: 0.5, color: .brown) + } + SceneKitGeometry.normalize(model, toHeight: standardHeight) + model.name = "FakeSofa" + model.position = hardcodedPosition + return model + } +} diff --git a/PiggyEscape/PiggyEscape/Sources/ClosedWorld/HideAction.swift b/PiggyEscape/PiggyEscape/Sources/ClosedWorld/HideAction.swift new file mode 100644 index 0000000..bbb1730 --- /dev/null +++ b/PiggyEscape/PiggyEscape/Sources/ClosedWorld/HideAction.swift @@ -0,0 +1,10 @@ +import SceneKit + +/// "숨어봐" 인터랙션의 핵심: 하드코딩된 가짜 소파 좌표로 이동하는 액션 하나. +/// 이 액션은 실제 방에 있는 진짜 소파가 어디 있든 상관하지 않는다 — +/// 목적지는 오직 FakeSofa.hardcodedPosition, 즉 개발자가 선언한 좌표뿐이다. +enum HideAction { + static func makeMoveAction() -> SCNAction { + .move(to: FakeSofa.hardcodedPosition, duration: 0.5) + } +} diff --git a/PiggyEscape/PiggyEscape/Sources/ClosedWorld/NodeInspector.swift b/PiggyEscape/PiggyEscape/Sources/ClosedWorld/NodeInspector.swift new file mode 100644 index 0000000..acbe62f --- /dev/null +++ b/PiggyEscape/PiggyEscape/Sources/ClosedWorld/NodeInspector.swift @@ -0,0 +1,23 @@ +import SceneKit + +/// 노드 하나를 들여다보고, 생김새(geometry)·물리(physicsBody)·행동(action)이 +/// 전부 같은 SCNNode 객체 위에 얹혀 있다는 걸 텍스트로 보여준다. +/// RealityKit의 ECS는 이 세 가지를 각각 다른 Component로 분리하지만, +/// SceneKit은 분리하지 않는다 — 이걸 실행 결과로 확인하기 위한 디버그 도구. +enum NodeInspector { + static func describe(_ node: SCNNode) -> [String] { + var lines: [String] = [] + + 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")") + } + + return lines + } +} diff --git a/PiggyEscape/PiggyEscape/Sources/ClosedWorld/PigPlacement.swift b/PiggyEscape/PiggyEscape/Sources/ClosedWorld/PigPlacement.swift new file mode 100644 index 0000000..800055a --- /dev/null +++ b/PiggyEscape/PiggyEscape/Sources/ClosedWorld/PigPlacement.swift @@ -0,0 +1,40 @@ +import SceneKit + +/// 돼지를 하드코딩된 좌표에 배치한다. 이 좌표는 개발자가 정한 것일 뿐, +/// 방 안의 어떤 실제 기준(가구 위치 등)과도 연결되어 있지 않다. +enum PigPlacement { + static let hardcodedPosition = SCNVector3(0, 0, 0) + private static let standardHeight: Float = 0.6 + + @MainActor + static func makePigNode() -> SCNNode { + let pig = SCNNode() + let model = SCNNode() + let art: SCNNode + + 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) + } + model.addChildNode(art) + + // 바깥 `pig`은 위치와 행동을 맡고, 안쪽 `model`은 에셋의 회전·크기·바닥 정렬만 + // 맡는다. 그래서 나중에 이동 액션을 실행해도 모델 보정값이 섞이지 않는다. + SceneKitGeometry.normalize(model, toHeight: standardHeight) + pig.addChildNode(model) + + 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 + } +} diff --git a/PiggyEscape/PiggyEscape/Sources/ClosedWorld/RoomBuilder.swift b/PiggyEscape/PiggyEscape/Sources/ClosedWorld/RoomBuilder.swift new file mode 100644 index 0000000..ab1e419 --- /dev/null +++ b/PiggyEscape/PiggyEscape/Sources/ClosedWorld/RoomBuilder.swift @@ -0,0 +1,74 @@ +import SceneKit +import UIKit + +/// 방을 짓는 빌더. 이 세계에 있는 모든 것 — 벽이든 바닥이든 —은 +/// 여기 코드로 써넣은 것만 존재한다. 좌표 원점(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()) + + 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) + } + + return room + } + + /// `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) + + 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 } + + // `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 + ) + return floor + } +} diff --git a/PiggyEscape/PiggyEscape/Sources/ClosedWorld/SceneKitGeometry.swift b/PiggyEscape/PiggyEscape/Sources/ClosedWorld/SceneKitGeometry.swift new file mode 100644 index 0000000..4204e75 --- /dev/null +++ b/PiggyEscape/PiggyEscape/Sources/ClosedWorld/SceneKitGeometry.swift @@ -0,0 +1,49 @@ +import SceneKit + +/// 로드된 3D 모델은 원본 제작 스케일이 제각각이라, 방 좌표계(미터 단위)에 +/// 맞춰 다시 정규화해야 한다. `PigPlacement`가 먼저 쓰던 "바운딩 박스를 재고 +/// 목표 크기에 맞춰 균일하게 스케일한다" 패턴을 여러 자리(돼지, 가짜 소파, +/// 바닥)에서 공유하기 위한 도우미. +enum SceneKitGeometry { + /// 노드(및 하위 계층 전체)의 로컬 좌표계 기준 바운딩 박스. + static func boundingBox(of node: SCNNode) -> (SCNVector3, SCNVector3) { + var lo = SCNVector3(Float.greatestFiniteMagnitude, .greatestFiniteMagnitude, .greatestFiniteMagnitude) + var hi = SCNVector3(-Float.greatestFiniteMagnitude, -.greatestFiniteMagnitude, -.greatestFiniteMagnitude) + node.enumerateHierarchy { child, _ in + guard let geometry = child.geometry else { return } + let (minB, maxB) = geometry.boundingBox + for x in [minB.x, maxB.x] { + for y in [minB.y, maxB.y] { + for z in [minB.z, maxB.z] { + let p = child.convertPosition(SCNVector3(x, y, z), to: node) + lo = SCNVector3(min(lo.x, p.x), min(lo.y, p.y), min(lo.z, p.z)) + hi = SCNVector3(max(hi.x, p.x), max(hi.y, p.y), max(hi.z, p.z)) + } + } + } + } + return (lo, hi) + } + + /// 세로(y) 치수를 재서 `targetHeight`에 맞도록 균일하게(x/y/z 동일 비율) 스케일한다. + /// 돼지·가짜 소파처럼 "세워서 놓는" 소품에 쓴다. + @MainActor + static func normalize(_ node: SCNNode, toHeight targetHeight: Float) { + let (lo, hi) = boundingBox(of: node) + let height = hi.y - lo.y + guard height > 0.0001 else { return } + let scale = targetHeight / height + node.scale = SCNVector3(scale, scale, scale) + } + + /// 바닥면(x/z) 중 더 큰 치수를 재서 `targetWidth`에 맞도록 균일하게 스케일한다. + /// 바닥 타일처럼 "가로로 깔아 놓는" 소품에 쓴다. + @MainActor + static func normalize(_ node: SCNNode, toFootprintWidth targetWidth: Float) { + let (lo, hi) = boundingBox(of: node) + let footprint = max(hi.x - lo.x, hi.z - lo.z) + guard footprint > 0.0001 else { return } + let scale = targetWidth / footprint + node.scale = SCNVector3(scale, scale, scale) + } +} diff --git a/PiggyEscape/PiggyEscape/Sources/ContentView.swift b/PiggyEscape/PiggyEscape/Sources/ContentView.swift new file mode 100644 index 0000000..c033c69 --- /dev/null +++ b/PiggyEscape/PiggyEscape/Sources/ContentView.swift @@ -0,0 +1,8 @@ +import SwiftUI + +struct ContentView: View { + var body: some View { + ClosedWorldSceneView() + .ignoresSafeArea() + } +} diff --git a/PiggyEscape/PiggyEscape/Sources/PiggyEscapeApp.swift b/PiggyEscape/PiggyEscape/Sources/PiggyEscapeApp.swift new file mode 100644 index 0000000..3d38349 --- /dev/null +++ b/PiggyEscape/PiggyEscape/Sources/PiggyEscapeApp.swift @@ -0,0 +1,10 @@ +import SwiftUI + +@main +struct PiggyEscapeApp: App { + var body: some Scene { + WindowGroup { + ContentView() + } + } +} diff --git a/PiggyEscape/PiggyEscape/Tutorials/SceneKitToRealityKit.docc/SceneKitToRealityKit.tutorial b/PiggyEscape/PiggyEscape/Tutorials/SceneKitToRealityKit.docc/SceneKitToRealityKit.tutorial new file mode 100644 index 0000000..fd90e30 --- /dev/null +++ b/PiggyEscape/PiggyEscape/Tutorials/SceneKitToRealityKit.docc/SceneKitToRealityKit.tutorial @@ -0,0 +1,15 @@ +@Tutorials(name: "씬킷에서 리얼리티킷으로") { + @Intro(title: "갇힌 캐릭터의 탈출") { + 개발자가 만든 가짜 세계에 갇혀 살던 돼지가 균열을 뚫고 진짜 세계로 도망친다. + 이 튜토리얼은 그 탈출과 술래잡기를 직접 만들어보며, + SceneKit이 가짜로 짓는 세계와 RealityKit이 진짜로 읽는 세계의 차이를 체험한다. + } + + @Chapter(name: "Chapter 1: 갇힌 세계") { + SceneKit만으로 방을 짓고 돼지를 그 안에 가둔다. + 이 세계에 있는 모든 것은 코드로 선언한 것의 총합일 뿐이라는 걸, + "숨어봐"가 실패하는 순간으로 직접 확인한다. + + @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 new file mode 100644 index 0000000..389f926 --- /dev/null +++ b/PiggyEscape/PiggyEscape/Tutorials/SceneKitToRealityKit.docc/Tutorials/01-ClosedWorld.tutorial @@ -0,0 +1,101 @@ +@Tutorial(time: 20) { + @Intro(title: "갇힌 세계 짓기") { + SceneKit 세계는 두 가지 의미에서 닫혀 있다. 이 세계에 있는 모든 것은 + 개발자가 코드로 선언한 것의 총합이고, 그 존재 방식조차 SCNNode 하나가 + 생김새·물리·행동을 전부 짊어지는 구조다. 이 장에서는 그걸 설명이 아니라 + 캐릭터가 실패를 겪는 과정으로 직접 확인한다. + } + + @Section(title: "프로젝트 세팅") { + @ContentAndMedia { + SwiftUI 앱에서 SceneKit의 SCNView를 UIViewRepresentable로 감싸는 + 껍데기를 만든다. 이 단계는 서사적 의미 없는 빌드 준비다. + } + + @Steps { + @Step { + `ClosedWorldSceneView`를 만들어 SwiftUI에서 SceneKit 씬을 호스팅할 + 준비를 한다. + + @Code(name: "ClosedWorldSceneView.swift", file: "01-ClosedWorld-01-01.swift") + } + } + } + + @Section(title: "방 짓기") { + @ContentAndMedia { + `SCNBox`로 벽을 세우고, `Ground_Color.usdc`를 바닥으로 재사용한다. + 좌표 원점(0,0,0)도 여기서 임의로 선언한다. 이 세계에 있는 모든 것 — + 벽이든 바닥이든 — 은 지금 이 코드로 써넣은 것만 존재한다. + } + + @Steps { + @Step { + `RoomBuilder`를 만들어 방을 짓는다. 벽 4개는 `SCNBox`로, 바닥은 + `AssetLoader`로 `Ground_Color` 에셋을 불러와 만든다. + + @Code(name: "RoomBuilder.swift", file: "01-ClosedWorld-02-01.swift") + } + } + } + + @Section(title: "돼지 배치") { + @ContentAndMedia { + 하드코딩된 좌표에 돼지를 놓는다. 이 좌표는 방 안 어떤 실제 기준과도 + 연결되어 있지 않다 — 그냥 숫자다. + } + + @Steps { + @Step { + `PigPlacement`가 `Piggy.usdc`를 불러와 표준 높이로 정규화하고 + 하드코딩된 위치에 놓는다. + + @Code(name: "PigPlacement.swift", file: "01-ClosedWorld-03-01.swift") + } + } + } + + @Section(title: "노드 구조 탐구") { + @ContentAndMedia { + 지금까지 만든 노드를 들여다보면, 생김새(geometry)·물리(physicsBody)· + 행동(action)이 전부 SCNNode 하나에 붙어있다는 걸 확인할 수 있다. + 책임이 분리되지 않는 구조다. RealityKit의 ECS는 이걸 쪼갠다 — + 자세한 대비는 4장에서 다룬다. + } + + @Steps { + @Step { + `NodeInspector`로 노드 하나에 얹힌 geometry·physicsBody·action을 + 모두 출력해 확인한다. + + @Code(name: "NodeInspector.swift", file: "01-ClosedWorld-04-01.swift") + } + } + } + + @Section(title: "\"숨어봐\" 시도") { + @ContentAndMedia { + `Wood_Color.usdc`를 가짜 소파로 미리 놓아두고, 탭하면 돼지가 그 + 좌표로 이동하는 액션을 실행한다. 진짜 소파가 화면 반대편(실제 방)에 + 있어도 이 동작은 그와 전혀 무관하게 실행된다 — 내가 선언한 가짜 + 소파로는 이동되지만, 진짜 소파는 이 세계에 아예 존재하지 않는다. + } + + @Steps { + @Step { + `FakeSofa`와 `HideAction`을 추가하고, 탭 제스처로 돼지를 + 가짜 소파 위치까지 이동시킨다. + + @Code(name: "HideAction.swift", file: "01-ClosedWorld-05-01.swift") + } + } + } + + @Section(title: "다음 장 예고") { + @ContentAndMedia { + 이 세계는 지금까지 내가 만든 것만으로 이루어져 있었다. 다음 장부터 + 이 세계는 더 이상 내가 만든 것만으로 이루어지지 않는다 — 균열 + 사이로 진짜 빛이 새어 든다. + } + } +} 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 new file mode 100644 index 0000000..0a76f7c --- /dev/null +++ b/PiggyEscape/PiggyEscape/Tutorials/SceneKitToRealityKit.docc/Tutorials/Resources/01-ClosedWorld-01-01.swift @@ -0,0 +1,57 @@ +import SwiftUI +import SceneKit + +/// SwiftUI ↔ SceneKit(SCNView)을 잇는 다리. 방·돼지·가짜 소파를 씬에 담고, +/// 탭하면 돼지가 가짜 소파로 "숨는" 하나의 인터랙션만 처리한다. +struct ClosedWorldSceneView: UIViewRepresentable { + func makeUIView(context: Context) -> SCNView { + let view = SCNView() + let scene = SCNScene() + + 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() + 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()) + } + } +} 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 new file mode 100644 index 0000000..ab1e419 --- /dev/null +++ b/PiggyEscape/PiggyEscape/Tutorials/SceneKitToRealityKit.docc/Tutorials/Resources/01-ClosedWorld-02-01.swift @@ -0,0 +1,74 @@ +import SceneKit +import UIKit + +/// 방을 짓는 빌더. 이 세계에 있는 모든 것 — 벽이든 바닥이든 —은 +/// 여기 코드로 써넣은 것만 존재한다. 좌표 원점(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()) + + 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) + } + + return room + } + + /// `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) + + 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 } + + // `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 + ) + 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 new file mode 100644 index 0000000..800055a --- /dev/null +++ b/PiggyEscape/PiggyEscape/Tutorials/SceneKitToRealityKit.docc/Tutorials/Resources/01-ClosedWorld-03-01.swift @@ -0,0 +1,40 @@ +import SceneKit + +/// 돼지를 하드코딩된 좌표에 배치한다. 이 좌표는 개발자가 정한 것일 뿐, +/// 방 안의 어떤 실제 기준(가구 위치 등)과도 연결되어 있지 않다. +enum PigPlacement { + static let hardcodedPosition = SCNVector3(0, 0, 0) + private static let standardHeight: Float = 0.6 + + @MainActor + static func makePigNode() -> SCNNode { + let pig = SCNNode() + let model = SCNNode() + let art: SCNNode + + 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) + } + model.addChildNode(art) + + // 바깥 `pig`은 위치와 행동을 맡고, 안쪽 `model`은 에셋의 회전·크기·바닥 정렬만 + // 맡는다. 그래서 나중에 이동 액션을 실행해도 모델 보정값이 섞이지 않는다. + SceneKitGeometry.normalize(model, toHeight: standardHeight) + pig.addChildNode(model) + + 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 + } +} 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 new file mode 100644 index 0000000..acbe62f --- /dev/null +++ b/PiggyEscape/PiggyEscape/Tutorials/SceneKitToRealityKit.docc/Tutorials/Resources/01-ClosedWorld-04-01.swift @@ -0,0 +1,23 @@ +import SceneKit + +/// 노드 하나를 들여다보고, 생김새(geometry)·물리(physicsBody)·행동(action)이 +/// 전부 같은 SCNNode 객체 위에 얹혀 있다는 걸 텍스트로 보여준다. +/// RealityKit의 ECS는 이 세 가지를 각각 다른 Component로 분리하지만, +/// SceneKit은 분리하지 않는다 — 이걸 실행 결과로 확인하기 위한 디버그 도구. +enum NodeInspector { + static func describe(_ node: SCNNode) -> [String] { + var lines: [String] = [] + + 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")") + } + + return lines + } +} 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 new file mode 100644 index 0000000..bbb1730 --- /dev/null +++ b/PiggyEscape/PiggyEscape/Tutorials/SceneKitToRealityKit.docc/Tutorials/Resources/01-ClosedWorld-05-01.swift @@ -0,0 +1,10 @@ +import SceneKit + +/// "숨어봐" 인터랙션의 핵심: 하드코딩된 가짜 소파 좌표로 이동하는 액션 하나. +/// 이 액션은 실제 방에 있는 진짜 소파가 어디 있든 상관하지 않는다 — +/// 목적지는 오직 FakeSofa.hardcodedPosition, 즉 개발자가 선언한 좌표뿐이다. +enum HideAction { + static func makeMoveAction() -> SCNAction { + .move(to: FakeSofa.hardcodedPosition, duration: 0.5) + } +} diff --git a/PiggyEscape/PiggyEscapeTests/AssetLoaderTests.swift b/PiggyEscape/PiggyEscapeTests/AssetLoaderTests.swift new file mode 100644 index 0000000..d6ca83b --- /dev/null +++ b/PiggyEscape/PiggyEscapeTests/AssetLoaderTests.swift @@ -0,0 +1,37 @@ +import XCTest +import SceneKit +@testable import PiggyEscape + +final class AssetLoaderTests: XCTestCase { + @MainActor + func test_object_named_loadsBundledPiggyModel() { + let node = AssetLoader.object(named: "Piggy") + XCTAssertNotNil(node) + XCTAssertFalse(node?.childNodes.isEmpty ?? true) + } + + @MainActor + func test_object_named_returnsNilForMissingAsset() { + let node = AssetLoader.object(named: "DoesNotExist") + XCTAssertNil(node) + } + + @MainActor + func test_object_named_fallback_usesFallbackWhenMissing() { + let node = AssetLoader.object(named: "DoesNotExist") { + AssetLoader.voxelBox(width: 1, height: 1, length: 1, color: .red) + } + XCTAssertNotNil(node.geometry as? SCNBox) + } + + func test_voxelBox_producesBoxGeometryWithGivenColor() { + let node = AssetLoader.voxelBox(width: 2, height: 1, length: 3, color: .blue) + guard let box = node.geometry as? SCNBox else { + XCTFail("expected SCNBox geometry") + return + } + XCTAssertEqual(box.width, 2 * 0.96, accuracy: 0.0001) + XCTAssertEqual(box.height, 1 * 0.96, accuracy: 0.0001) + XCTAssertEqual(box.length, 3 * 0.96, accuracy: 0.0001) + } +} diff --git a/PiggyEscape/PiggyEscapeTests/ClosedWorldSceneViewTests.swift b/PiggyEscape/PiggyEscapeTests/ClosedWorldSceneViewTests.swift new file mode 100644 index 0000000..1a9d27f --- /dev/null +++ b/PiggyEscape/PiggyEscapeTests/ClosedWorldSceneViewTests.swift @@ -0,0 +1,16 @@ +import XCTest +import SceneKit +@testable import PiggyEscape + +final class ClosedWorldSceneViewTests: XCTestCase { + @MainActor + func test_tapCoordinator_startsHideActionOnPig() { + let coordinator = ClosedWorldSceneView.Coordinator() + let pig = SCNNode() + coordinator.pigNode = pig + + coordinator.handleTap() + + XCTAssertTrue(pig.hasActions, "a tap should start the pig's hide action") + } +} diff --git a/PiggyEscape/PiggyEscapeTests/FakeSofaTests.swift b/PiggyEscape/PiggyEscapeTests/FakeSofaTests.swift new file mode 100644 index 0000000..030a56a --- /dev/null +++ b/PiggyEscape/PiggyEscapeTests/FakeSofaTests.swift @@ -0,0 +1,39 @@ +import XCTest +import SceneKit +@testable import PiggyEscape + +final class FakeSofaTests: XCTestCase { + @MainActor + func test_makeSofaNode_isNamedFakeSofa() { + let sofa = FakeSofa.makeSofaNode() + XCTAssertEqual(sofa.name, "FakeSofa") + } + + @MainActor + func test_makeSofaNode_isPlacedAtHardcodedPosition() { + let sofa = FakeSofa.makeSofaNode() + XCTAssertEqual(sofa.position.x, FakeSofa.hardcodedPosition.x, accuracy: 0.0001) + XCTAssertEqual(sofa.position.z, FakeSofa.hardcodedPosition.z, accuracy: 0.0001) + } + + func test_hardcodedPosition_isInsideRoomBounds() { + // "가짜 소파"도 방 안 좌표일 뿐 — 실제 소파 위치와는 무관하다는 걸 좌표 자체로 보여준다. + XCTAssertLessThan(abs(FakeSofa.hardcodedPosition.x), RoomBuilder.roomWidth / 2) + XCTAssertLessThan(abs(FakeSofa.hardcodedPosition.z), RoomBuilder.roomDepth / 2) + } + + /// 회귀 방지: Wood_Color.usdc는 원본 스케일이 방보다 몇 배 큰 메시라서, + /// makeSofaNode()가 정규화를 빼먹으면 방(wallHeight 2.5m)보다 커지거나 + /// 돼지(0.6m)보다 비정상적으로 커져 화면을 뒤덮는다. 실제 렌더된 바운딩 + /// 박스 높이가 방/돼지 스케일에 비해 "작은 가구 하나" 수준인지 확인한다. + @MainActor + func test_makeSofaNode_isNormalizedToSmallFurnitureScale() { + let sofa = FakeSofa.makeSofaNode() + let (lo, hi) = SceneKitGeometry.boundingBox(of: sofa) + let height = (hi.y - lo.y) * sofa.scale.y + + XCTAssertGreaterThan(height, 0.05, "sofa should not collapse to zero size") + XCTAssertLessThan(height, RoomBuilder.wallHeight, "sofa should fit under the room's ceiling") + XCTAssertLessThan(height, 1.0, "a normalized sofa should be furniture-scale, not room-scale") + } +} diff --git a/PiggyEscape/PiggyEscapeTests/HideActionTests.swift b/PiggyEscape/PiggyEscapeTests/HideActionTests.swift new file mode 100644 index 0000000..7654d50 --- /dev/null +++ b/PiggyEscape/PiggyEscapeTests/HideActionTests.swift @@ -0,0 +1,60 @@ +import XCTest +import SceneKit +@testable import PiggyEscape + +final class HideActionTests: XCTestCase { + // Note: SCNAction completion handlers only fire while something is actually + // rendering/ticking the scene (an SCNView or SCNRenderer). A bare + // `pig.runAction(...) { }` + `wait(for:)` with no renderer never fires the + // handler and times out (verified: it did, in this environment). Instead, + // the pig is attached to a live SCNView added to a window with + // `isPlaying = true`, so the system's real display link ticks the action + // and the completion handler fires normally, matching the plan's original + // test shape as closely as possible. + @MainActor + func test_makeMoveAction_movesNodeToFakeSofaPosition() { + let pig = PigPlacement.makePigNode() + let scene = SCNScene() + scene.rootNode.addChildNode(pig) + + let view = SCNView(frame: CGRect(x: 0, y: 0, width: 16, height: 16)) + view.scene = scene + view.isPlaying = true + // No windowScene is attached here (this is an offscreen test window, not + // part of the app's real scene-based lifecycle), which triggers a soft + // makeKeyAndVisible() deprecation warning in the console. That warning is + // expected/harmless for this isolated rendering-driver setup and can be + // ignored. + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 16, height: 16)) + window.addSubview(view) + window.makeKeyAndVisible() + // Ensure the window/view never outlive this test, even if an assertion + // above throws: resign key status and stop the display link driving the + // scene so no state leaks into later tests in the same process. + defer { + view.isPlaying = false + window.isHidden = true + window.resignKey() + } + + let expectation = expectation(description: "pig reaches fake sofa position") + + pig.runAction(HideAction.makeMoveAction()) { + expectation.fulfill() + } + + // This test relies on the real system display link (via SCNView.isPlaying) + // to tick the SCNAction forward, so it depends on real wall-clock time + // rather than a fully deterministic clock. The action itself only takes + // 0.5s; a 2.0s timeout gives a 4x margin, which is generous enough that + // this is not expected to flake under normal CI/simulator load. A fully + // deterministic alternative (manually pumping SCNRenderer.render(atTime:)) + // was tried first and crashed on this iOS 26.5 simulator runtime (see + // docs/TROUBLESHOOTING.md §2), so the real-display-link + generous-timeout approach + // is an accepted, deliberate tradeoff rather than an oversight. + wait(for: [expectation], timeout: 2.0) + XCTAssertEqual(pig.position.x, FakeSofa.hardcodedPosition.x, accuracy: 0.01) + XCTAssertEqual(pig.position.y, FakeSofa.hardcodedPosition.y, accuracy: 0.01) + XCTAssertEqual(pig.position.z, FakeSofa.hardcodedPosition.z, accuracy: 0.01) + } +} diff --git a/PiggyEscape/PiggyEscapeTests/NodeInspectorTests.swift b/PiggyEscape/PiggyEscapeTests/NodeInspectorTests.swift new file mode 100644 index 0000000..a4ec7da --- /dev/null +++ b/PiggyEscape/PiggyEscapeTests/NodeInspectorTests.swift @@ -0,0 +1,28 @@ +import XCTest +import SceneKit +@testable import PiggyEscape + +final class NodeInspectorTests: XCTestCase { + @MainActor + func test_describe_reportsGeometryPhysicsAndActionOnSameNode() { + // 의도적으로 geometry·physicsBody·action을 전부 SCNNode 하나에 붙인다 — + // SceneKit이 이 셋을 분리하지 않는다는 걸 테스트로 증명하기 위해. + let node = SCNNode(geometry: SCNBox(width: 1, height: 1, length: 1, chamferRadius: 0)) + node.physicsBody = SCNPhysicsBody(type: .dynamic, shape: nil) + node.runAction(.moveBy(x: 1, y: 0, z: 0, duration: 1), forKey: "demo.move") + + let lines = NodeInspector.describe(node) + + XCTAssertTrue(lines.contains { $0.contains("geometry") }) + XCTAssertTrue(lines.contains { $0.contains("physicsBody") }) + XCTAssertTrue(lines.contains { $0.contains("demo.move") }) + } + + @MainActor + func test_describe_omitsAspectsNotPresent() { + let node = SCNNode() + let lines = NodeInspector.describe(node) + XCTAssertFalse(lines.contains { $0.contains("geometry") }) + XCTAssertFalse(lines.contains { $0.contains("physicsBody") }) + } +} diff --git a/PiggyEscape/PiggyEscapeTests/PigPlacementTests.swift b/PiggyEscape/PiggyEscapeTests/PigPlacementTests.swift new file mode 100644 index 0000000..06f405c --- /dev/null +++ b/PiggyEscape/PiggyEscapeTests/PigPlacementTests.swift @@ -0,0 +1,70 @@ +import XCTest +import SceneKit +@testable import PiggyEscape + +final class PigPlacementTests: XCTestCase { + @MainActor + func test_makePigNode_isNamedPiggy() { + let pig = PigPlacement.makePigNode() + XCTAssertEqual(pig.name, "Piggy") + } + + @MainActor + func test_makePigNode_isPlacedAtHardcodedPosition() { + let pig = PigPlacement.makePigNode() + XCTAssertEqual(pig.position.x, PigPlacement.hardcodedPosition.x, accuracy: 0.0001) + XCTAssertEqual(pig.position.y, PigPlacement.hardcodedPosition.y, accuracy: 0.0001) + XCTAssertEqual(pig.position.z, PigPlacement.hardcodedPosition.z, accuracy: 0.0001) + } + + @MainActor + func test_makePigNode_standsOnTheRoomFloor() { + let pig = PigPlacement.makePigNode() + let sceneRoot = SCNNode() + sceneRoot.addChildNode(pig) + let (lo, hi) = SceneKitGeometry.boundingBox(of: sceneRoot) + + XCTAssertEqual(lo.y, 0, accuracy: 0.001, "the pig's feet should meet the room floor") + XCTAssertEqual(hi.y - lo.y, 0.6, accuracy: 0.01) + } + + @MainActor + func test_makePigNode_hasGeometryOrChildGeometry() { + let pig = PigPlacement.makePigNode() + XCTAssertTrue(hasGeometryDeep(pig)) + } + + /// The bundled "Piggy" asset nests its geometry several levels below the + /// wrapper node AssetLoader.object(named:) returns (wrapper -> root -> + /// body/eyes/tail meshes). This test walks the full hierarchy to confirm + /// makePigNode() is actually placing the real model and not silently + /// falling back to the placeholder voxel box. + @MainActor + func test_makePigNode_placesRealModel_notVoxelFallback() { + let pig = PigPlacement.makePigNode() + XCTAssertTrue(hasGeometryDeep(pig), "expected geometry somewhere in the node's subtree") + + var geometryNodeCount = 0 + pig.enumerateHierarchy { node, _ in + if node.geometry != nil { geometryNodeCount += 1 } + } + // The real Piggy.usdc model is made of multiple meshes (body/eyes/tail), + // while the voxel fallback is a single SCNBox node. More than one + // geometry-bearing node means the real model was placed, not the fallback. + XCTAssertGreaterThan(geometryNodeCount, 1, "expected multiple meshes from the real Piggy model, not the single-box fallback") + } + + /// Deep traversal of the node hierarchy, mirroring + /// SceneKitGeometry.boundingBox(of:), instead of a shallow one-level check. + @MainActor + private func hasGeometryDeep(_ node: SCNNode) -> Bool { + var found = false + node.enumerateHierarchy { child, stop in + if child.geometry != nil { + found = true + stop.pointee = true + } + } + return found + } +} diff --git a/PiggyEscape/PiggyEscapeTests/PiggyEscapeTests.swift b/PiggyEscape/PiggyEscapeTests/PiggyEscapeTests.swift new file mode 100644 index 0000000..2c6e57e --- /dev/null +++ b/PiggyEscape/PiggyEscapeTests/PiggyEscapeTests.swift @@ -0,0 +1,7 @@ +import XCTest + +final class PiggyEscapeTests: XCTestCase { + func test_placeholder() { + XCTAssertTrue(true) + } +} diff --git a/PiggyEscape/PiggyEscapeTests/RoomBuilderTests.swift b/PiggyEscape/PiggyEscapeTests/RoomBuilderTests.swift new file mode 100644 index 0000000..07d99f3 --- /dev/null +++ b/PiggyEscape/PiggyEscapeTests/RoomBuilderTests.swift @@ -0,0 +1,79 @@ +import XCTest +import SceneKit +@testable import PiggyEscape + +final class RoomBuilderTests: XCTestCase { + private func bounds(of node: SCNNode, in referenceNode: SCNNode) -> (min: SCNVector3, max: SCNVector3) { + var minPoint = SCNVector3(Float.greatestFiniteMagnitude, + Float.greatestFiniteMagnitude, + Float.greatestFiniteMagnitude) + var maxPoint = SCNVector3(-Float.greatestFiniteMagnitude, + -Float.greatestFiniteMagnitude, + -Float.greatestFiniteMagnitude) + + node.enumerateHierarchy { child, _ in + guard let geometry = child.geometry else { return } + let (minBounds, maxBounds) = geometry.boundingBox + for x in [minBounds.x, maxBounds.x] { + for y in [minBounds.y, maxBounds.y] { + for z in [minBounds.z, maxBounds.z] { + let point = child.convertPosition(SCNVector3(x, y, z), to: referenceNode) + minPoint = SCNVector3(min(minPoint.x, point.x), + min(minPoint.y, point.y), + min(minPoint.z, point.z)) + maxPoint = SCNVector3(max(maxPoint.x, point.x), + max(maxPoint.y, point.y), + max(maxPoint.z, point.z)) + } + } + } + } + + return (minPoint, maxPoint) + } + + @MainActor + func test_build_returnsRoomWithFloorAndFourWalls() { + let room = RoomBuilder.build() + XCTAssertEqual(room.name, "Room") + + let floor = room.childNode(withName: "Floor", recursively: false) + XCTAssertNotNil(floor) + + let walls = (0..<4).compactMap { room.childNode(withName: "Wall_\($0)", recursively: false) } + XCTAssertEqual(walls.count, 4) + } + + @MainActor + func test_build_wallsAreDeclaredBoxGeometry() { + let room = RoomBuilder.build() + for i in 0..<4 { + let wall = room.childNode(withName: "Wall_\(i)", recursively: false) + XCTAssertTrue(wall?.geometry is SCNBox, "Wall_\(i) should be a plain SCNBox — C3_Piggy has no wall asset") + } + } + + @MainActor + func test_build_originIsDeclaredAtRoomCenter() { + let room = RoomBuilder.build() + XCTAssertEqual(room.position.x, 0, accuracy: 0.0001) + XCTAssertEqual(room.position.y, 0, accuracy: 0.0001) + XCTAssertEqual(room.position.z, 0, accuracy: 0.0001) + } + + @MainActor + func test_build_orientsAndFitsBundledFloorInsideRoom() { + let room = RoomBuilder.build() + guard let floor = room.childNode(withName: "Floor", recursively: false) else { + XCTFail("expected Floor node") + return + } + + let (minPoint, maxPoint) = bounds(of: floor, in: room) + XCTAssertEqual(maxPoint.x - minPoint.x, RoomBuilder.roomWidth, accuracy: 0.1) + XCTAssertEqual(maxPoint.z - minPoint.z, RoomBuilder.roomDepth, accuracy: 0.1) + XCTAssertLessThan(maxPoint.y - minPoint.y, 0.2, "floor must stay flat instead of filling the room vertically") + XCTAssertEqual(minPoint.y, 0, accuracy: 0.001, "floor must meet the room origin") + XCTAssertLessThanOrEqual(maxPoint.y, 0.2, "floor must not rise into the camera view") + } +} diff --git a/PiggyEscape/PiggyEscapeTests/SceneKitGeometryTests.swift b/PiggyEscape/PiggyEscapeTests/SceneKitGeometryTests.swift new file mode 100644 index 0000000..b670da2 --- /dev/null +++ b/PiggyEscape/PiggyEscapeTests/SceneKitGeometryTests.swift @@ -0,0 +1,57 @@ +import XCTest +import SceneKit +@testable import PiggyEscape + +final class SceneKitGeometryTests: XCTestCase { + /// 정확히 2 x 4 x 1 (x/y/z)인 합성 SCNBox 노드 — 실제 에셋과 무관하게 + /// 정규화 계산 자체를 결정론적으로 검증하기 위한 픽스처. + private func makeBoxNode(width: CGFloat = 2, height: CGFloat = 4, length: CGFloat = 1) -> SCNNode { + let box = SCNBox(width: width, height: height, length: length, chamferRadius: 0) + return SCNNode(geometry: box) + } + + func test_boundingBox_matchesGeometryExtent() { + let node = makeBoxNode(width: 2, height: 4, length: 1) + let (lo, hi) = SceneKitGeometry.boundingBox(of: node) + XCTAssertEqual(hi.x - lo.x, 2, accuracy: 0.001) + XCTAssertEqual(hi.y - lo.y, 4, accuracy: 0.001) + XCTAssertEqual(hi.z - lo.z, 1, accuracy: 0.001) + } + + @MainActor + func test_normalizeToHeight_scalesUniformlyToHitTargetHeight() { + let node = makeBoxNode(width: 2, height: 4, length: 1) + SceneKitGeometry.normalize(node, toHeight: 1) + + // height(4) -> 1 means scale factor 0.25, applied uniformly to x/y/z + XCTAssertEqual(node.scale.x, 0.25, accuracy: 0.0001) + XCTAssertEqual(node.scale.y, 0.25, accuracy: 0.0001) + XCTAssertEqual(node.scale.z, 0.25, accuracy: 0.0001) + + let (lo, hi) = SceneKitGeometry.boundingBox(of: node) + let scaledHeight = (hi.y - lo.y) * node.scale.y + XCTAssertEqual(scaledHeight, 1, accuracy: 0.001) + } + + @MainActor + func test_normalizeToFootprintWidth_scalesUniformlyUsingLargerOfXOrZ() { + // x=2, z=1 -> footprint is max(2,1) = 2 + let node = makeBoxNode(width: 2, height: 4, length: 1) + SceneKitGeometry.normalize(node, toFootprintWidth: 4) + + // footprint(2) -> 4 means scale factor 2.0 + XCTAssertEqual(node.scale.x, 2.0, accuracy: 0.0001) + XCTAssertEqual(node.scale.y, 2.0, accuracy: 0.0001) + XCTAssertEqual(node.scale.z, 2.0, accuracy: 0.0001) + } + + @MainActor + func test_normalize_doesNothingForDegenerateNode() { + // geometry-less node: bounding box collapses to a single point, height 0 + let node = SCNNode() + SceneKitGeometry.normalize(node, toHeight: 1) + XCTAssertEqual(node.scale.x, 1, accuracy: 0.0001) + XCTAssertEqual(node.scale.y, 1, accuracy: 0.0001) + XCTAssertEqual(node.scale.z, 1, accuracy: 0.0001) + } +} diff --git a/PiggyEscape/Project.swift b/PiggyEscape/Project.swift new file mode 100644 index 0000000..489bdcb --- /dev/null +++ b/PiggyEscape/Project.swift @@ -0,0 +1,27 @@ +import ProjectDescription + +let project = Project( + name: "PiggyEscape", + targets: [ + .target( + name: "PiggyEscape", + destinations: .iOS, + product: .app, + bundleId: "com.techmap.piggyescape", + deploymentTargets: .iOS("17.0"), + infoPlist: .default, + sources: ["PiggyEscape/Sources/**", "PiggyEscape/Tutorials/**"], + resources: ["PiggyEscape/Resources/**"] + ), + .target( + name: "PiggyEscapeTests", + destinations: .iOS, + product: .unitTests, + bundleId: "com.techmap.piggyescape.tests", + deploymentTargets: .iOS("17.0"), + infoPlist: .default, + sources: ["PiggyEscapeTests/**"], + dependencies: [.target(name: "PiggyEscape")] + ) + ] +) diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md new file mode 100644 index 0000000..1d24adb --- /dev/null +++ b/docs/TROUBLESHOOTING.md @@ -0,0 +1,91 @@ +# Chapter 1 구현 트러블슈팅 + +> Chapter 1(`ClosedWorld`)을 구현하면서 실제로 막혔던 지점과 그 원인·해결을 기록한다. 결과 코드만 보면 사라지는 "왜 이렇게 짰는가"를 남기기 위한 문서다. 형식은 `🐞 증상 → 추적 → 원인 → 해결 → 주의`. + +## 1. `PigPlacement.makePigNode()`가 실제 돼지 모델 대신 항상 핑크 박스를 반환함 + +**증상**: 빌드도 되고 테스트도 전부 통과하는데, 실제로 `makePigNode()`가 반환하는 노드는 항상 `Piggy.usdc`가 아니라 폴백용 분홍 박스였다. + +**추적**: `AssetLoader.object(named: "Piggy") { fallback }`이 성공적으로 모델을 로드했는지 확인하려고 넣은 방어 코드가 원인이었다. + +```swift +let hasGeometry = model.geometry != nil || model.childNodes.contains { $0.geometry != nil } +if !hasGeometry { + model = AssetLoader.voxelBox(...) +} +``` + +**원인**: `Piggy.usdc`를 로드하면 노드 계층이 `wrapper → root → {body, eyes, tail}` 순으로 3단계이고, 실제 geometry는 `root`의 자식(2단계 아래)에 있다. 위 체크는 `model`의 **직계 자식**까지만 본다. 즉 실제로 잘 로드된 모델도 이 체크를 통과하지 못해서, "모델이 없다"고 오판하고 매번 폴백으로 덮어썼다. 겉으로는 아무 에러도 나지 않고, 심지어 같은 방식(얕은 체크)으로 짠 테스트도 이 상태를 "정상"으로 보고 통과시켰다. + +**해결**: 방어 코드를 지우고, `AssetLoader.object(named:fallback:)`가 이미 "성공하면 실제 모델, 실패하면 폴백"을 보장한다는 계약을 그대로 신뢰했다. 대신 테스트를 `enumerateHierarchy`로 계층 전체를 훑는 깊은 탐색으로 바꾸고, "geometry를 가진 노드가 1개보다 많다"는 조건으로 실제 다중 메시 모델과 단일 박스 폴백을 구분하게 했다. + +**주의**: USD/USDZ로 불러온 모델은 지오메트리가 몇 단계 아래에 있는지 파일마다 다를 수 있다. "노드 하나만 보고 있다/없다"를 판단하는 코드는 항상 의심할 것 — 특히 그 판단 결과로 무언가를 덮어쓰는 코드라면, 판단이 틀렸을 때 결과가 조용히 나빠진다(에러가 안 남). + +## 2. 헤드리스 XCTest에서 `SCNAction`의 완료 핸들러가 발화하지 않음 + +**증상**: `pig.runAction(HideAction.makeMoveAction()) { expectation.fulfill() }`을 부르고 `wait(for:timeout:)`으로 기다리는 테스트가 항상 타임아웃됐다. + +**추적**: `SCNAction`은 실제로 씬이 렌더링(정확히는 매 프레임 틱)되고 있어야 진행된다. 노드를 씬에 붙이지 않거나, 씬을 아무도 렌더링하지 않으면 액션 자체가 진행되지 않고 완료 핸들러도 영원히 안 불린다. + +1차 시도: `SCNRenderer(device: nil, options: nil)`을 만들어 `render(atTime:)`을 수동으로 여러 번 호출해 시간을 흘려보내려 했다. → 이 시뮬레이터 런타임(iOS 26.5)에서 `[SceneKit] Assertion 'false' failed. Invalid pass parameter`로 크래시. `device: nil`이 요청하는 GLES 기반 렌더 경로가 더 이상 지원되지 않는 것으로 보인다. + +2차 시도: 명시적 Metal 디바이스 + 오프스크린 텍스처로 렌더 패스를 직접 구성. → 크래시는 사라졌지만 돼지 위치가 전혀 안 바뀜(원인 미확정 — Metal 렌더 패스 구성이 실제 화면 렌더링과 뭔가 다른 전제를 요구하는 것으로 추정되나 끝까지 규명하지는 못했다). + +**해결**: 실제 `UIWindow` + `SCNView`(`isPlaying = true`)를 만들어 씬에 노드를 붙이고, 시스템의 진짜 디스플레이 링크가 프레임을 틱하게 했다. 이러면 `SCNAction`이 정상적으로 진행되고 완료 핸들러도 예정대로 불린다. 테스트가 끝나면 `defer`로 `isPlaying = false`, `window.isHidden = true`, `window.resignKey()`를 확실히 정리한다. + +**주의**: +- 이 방식은 실제 시간이 흐르는 것에 의존한다(0.5초짜리 액션에 2초 타임아웃을 줌 — 4배 여유). CI 환경에서 시뮬레이터가 여러 개 동시에 도는 등 부하가 크면 이론적으로는 느려질 수 있으니, 나중에 이 테스트가 가끔 실패하기 시작하면 여기부터 의심할 것. +- `window.makeKeyAndVisible()`을 `windowScene` 없이 부르면 콘솔에 경고가 뜬다. 테스트 전용 오프스크린 윈도우라 의도된 것이지만, 나중에 iOS 버전이 올라가면 동작이 바뀔 수 있다. +- `SCNAction`을 다루는 테스트를 새로 짤 때는 처음부터 "이 액션을 실제로 누가 틱하는가"부터 확인하고 시작하면 위 두 번의 실패한 시도를 반복하지 않아도 된다. + +## 3. `git worktree` 생성용 내장 도구가 "Failed to resolve base branch 'HEAD'"로 실패 + +**증상**: 격리된 작업 공간을 만드는 내장 도구로 새 worktree를 만들려 했더니 `HEAD`를 못 찾는다는 에러가 났다. 정작 프로젝트 폴더에서 `git rev-parse HEAD`는 멀쩡히 커밋 해시를 반환했다. + +**추적**: 사용자 홈 디렉토리(`/Users/yang-eunseo`) 바로 밑에 커밋이 하나도 없는 별도의 `.git`이 이미 존재했다(아마 실수로 홈 디렉토리에서 `git init`이 실행된 것으로 보인다). 어떤 경로 해석이 프로젝트 폴더가 아니라 이 커밋 0개짜리 저장소를 대상으로 삼았을 가능성이 크다 — 커밋이 없는 저장소에서는 `HEAD`가 정말로 해석되지 않는다(`fatal: ambiguous argument 'HEAD'`). + +**해결**: 내장 도구 대신 `git worktree add .worktrees/ -b `를 프로젝트 폴더 경로를 명시해 직접 실행했다. 문제없이 생성됐다. + +**주의**: `/Users/yang-eunseo/.git`은 지우지 않고 그대로 남겨뒀다(사용자 확인 없이 삭제할 만한 일이 아니라서). 이 프로젝트 관련 git 작업은 항상 프로젝트 폴더 안의 `.git`을 명시적으로 대상 삼을 것 — 상위 디렉토리로 `cd`하거나 상대 경로로 git 명령을 실행하면 의도치 않게 엉뚱한 저장소를 건드릴 수 있다. + +## 4. 같은 구현 계획을 다른 세션이 동시에 진행 중이었음 + +**증상**: 격리된 worktree에서 Task 1~6까지 다 끝낸 뒤 `main`을 다시 보니, 그 사이 다른 세션이 `main`에 직접 커밋을 쌓아 `AGENTS.md`/`docs/PROJECT_CONTEXT.md`/`docs/WORK_LOG.md`라는 인수인계 체계를 새로 만들어 두었고, 거기에 "Task 1 진행 중 — 다른 세션에서 다시 시작하지 말 것"이라고 적혀 있었다. + +**추적**: 실제 `PiggyEscape/` 구현 코드는 `main`에 전혀 없었다 — 그쪽 세션은 조율 문서만 만든 상태였다. 그래서 코드 충돌은 없었지만, 진행 상태 기록이 완전히 어긋나 있었다. + +**해결**: 격리된 worktree 쪽 작업이 이미 더 앞서 있었으므로 그걸 기준으로 계속 진행하고, 작업이 끝나는 시점에 `docs/WORK_LOG.md`와 `docs/PROJECT_CONTEXT.md`를 실제 상태에 맞게 갱신해서 `main`에 반영하기로 했다. + +**주의**: 여러 세션이 같은 저장소를 동시에 건드릴 수 있는 환경이라면, 작업을 시작하기 전에 `docs/WORK_LOG.md`(또는 이에 준하는 인수인계 문서)의 "현재 인수인계"를 먼저 확인하는 습관이 필요하다. 격리된 작업 공간(worktree)은 코드 충돌은 막아주지만, 기록 차원의 조율까지 자동으로 막아주지는 않는다. + +## 5. 편집 직후 뜨는 에디터 진단이 실제 빌드 실패와 다름 + +**증상**: 파일을 새로 만들거나 수정한 직후 "No such module 'XCTest'", "Cannot find 'AssetLoader' in scope" 같은 에러가 표시됐다. 그런데 실제 `xcodebuild ... test`는 매번 정상적으로 통과했다. + +**원인**: Tuist로 프로젝트를 다시 생성(`tuist generate`)하기 전까지는 에디터의 코드 인덱스가 새 파일/새 타깃 구성을 모른다. 실제 컴파일러(`xcodebuild`)가 아니라 에디터 쪽 인덱싱이 뒤처진 것뿐이다. + +**해결**: 파일을 추가·수정한 뒤에는 반드시 `tuist generate`로 프로젝트를 재생성하고, 최종 판단은 항상 `xcodebuild` 빌드/테스트 결과로 내렸다. 에디터 진단은 참고만 하고 그 자체로 실패 신호로 취급하지 않았다. + +**주의**: 딱 한 번, 진짜 컴파일 에러일 수도 있는 진단(`Binary operator '/' cannot be applied to operands of type 'Float' and 'CGFloat'`)이 섞여 나온 적이 있었다. 무시하기 전에 해당 코드를 직접 열어 타입을 확인했고, 실제로는 문제가 없었다(SceneKit의 `SCNVector3` 구성 요소는 이 플랫폼에서 전부 `Float`). 에디터 진단을 무시하는 습관을 들이더라도, 평소와 다른 종류의 에러(모듈을 못 찾는다는 것과 타입이 안 맞는다는 것은 성격이 다르다)가 섞이면 한 번은 직접 눈으로 확인하는 편이 안전하다. + +## 6. Blender Z-up 에셋을 그대로 놓으면 바닥은 서고 돼지는 바닥 아래로 파묻힘 + +**증상**: `Ground_Color.usdc`를 방의 `Floor`로 바로 추가하면 높이 2m짜리 세로 판처럼 렌더링되어 카메라 시야를 가렸다. `Piggy.usdc`는 높이를 정규화했는데도 바닥면이 y=0보다 아래에 남아, 실제 화면에서 돼지가 프레임 하단에 거의 보이지 않았다. + +**추적**: USD 메타데이터에서 `Ground_Color`의 `upAxis`가 Z임을 확인했다. C3_Piggy의 `IslandBuilder.flatGroundMetrics`는 바닥을 X축 -90°로 눕힌 **뒤** 실제 경계를 재며, `PigController.loadPigModel`은 돼지에 X축 +90°·Z축 180° 회전을 적용한 뒤 바닥을 정렬한다. 둘 다 하위 메시의 꼭짓점을 컨테이너 좌표계로 변환해 경계를 구한다. + +**원인**: SceneKit은 에셋 파일의 제작 좌표계를 자동으로 월드의 Y-up 방향으로 바로잡아 주지 않는다. 또한 `SceneKitGeometry.boundingBox(of:)`는 전달받은 노드의 **자식** 변환을 반영해 측정하므로, 같은 노드에 회전·스케일을 적용한 뒤 다시 자기 자신을 측정하면 그 루트 변환은 측정에 포함되지 않는다. + +**해결**: 바닥은 이름 있는 컨테이너 안에 실제 모델을 넣고, 모델을 X축 -90° 회전한 다음 컨테이너 기준 경계를 측정했다. 컨테이너에 x/z는 방의 폭·깊이, y는 0.1m 두께가 되도록 스케일을 적용하고 하단이 y=0이 되도록 위치를 조정했다. 돼지는 바깥 노드(위치·HideAction)와 안쪽 모델 노드(회전·스케일·바닥 정렬)를 나눴다. 이렇게 하면 이동 액션이 모델 보정값을 덮어쓰지 않는다. 초기 카메라 프레임에는 돼지가 너무 가까웠으므로, 의미가 임의인 하드코딩 z 좌표만 1에서 0으로 조정했다. + +**주의**: "높이를 정규화했다"는 사실만으로 세워진 모델이나 바닥 접촉을 보장할 수 없다. 회전이 필요한 에셋은 **회전 → 경계 측정 → 스케일 → 바닥/중심 정렬** 순서를 지키고, 실제 씬 좌표계에서 폭·높이·깊이·하단 위치를 모두 검사하는 회귀 테스트를 둔다. + +## 7. 새 XCTest 파일을 만들었는데 선택 테스트가 0개 실행됨 + +**증상**: `ClosedWorldSceneViewTests.swift`를 추가한 직후 `-only-testing:...` 명령이 성공했지만, 결과는 `Executed 0 tests`였다. + +**원인**: Tuist가 생성해 둔 `PiggyEscape.xcodeproj`에는 새 파일이 아직 등록되지 않았다. 컴파일 오류가 아니어서 명령 종료 코드만 보면 테스트가 통과한 것처럼 보였다. + +**해결**: `tuist generate --no-open`으로 생성 프로젝트를 갱신한 뒤 같은 선택 테스트를 다시 실행했다. 이때 실제로 1개 테스트가 실행·통과하는 것을 확인했다. + +**주의**: 새 소스나 테스트 파일을 추가한 뒤에는 `xcodebuild`의 성공 여부뿐 아니라 **실행된 테스트 수**를 확인한다. 특히 `-only-testing`은 이름이 틀리거나 생성 프로젝트가 오래됐어도 0개 실행으로 끝날 수 있다. diff --git a/docs/WORK_LOG.md b/docs/WORK_LOG.md new file mode 100644 index 0000000..1118ee5 --- /dev/null +++ b/docs/WORK_LOG.md @@ -0,0 +1,41 @@ +# 작업 인수인계 기록 + +> 다음 작업을 시작하기 전에 **현재 인수인계**를 먼저 읽는다. 의미 있는 작업 단위가 끝나면 결과물과 같은 커밋에 이 문서를 갱신한다. 사용한 도구나 모델 이름은 쓰지 않는다. + +## 현재 인수인계 + +- 상태: Chapter 1 구현·DocC 카탈로그 완료, Draft PR의 최종 보완 대기 +- 진행 중 범위: Chapter 1 `ClosedWorld` 구현과 DocC 카탈로그(Task 1~8)를 완료했다. 최종 검토에서 확인한 보완 항목을 별도 커밋으로 처리한 뒤 병합 준비를 마친다. +- 마지막 완료 범위: 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의 가짜 소파 코드·화면 연결 설명, 공통 인수인계 문서 통합을 보완하고 재검토한다. +- 차단 요소: 없음. + +### Task 7에서 해결한 항목 + +1. **RoomBuilder 바닥 방향** — `Ground_Color`을 X축으로 -90° 회전하고, 변환된 경계를 재서 4×4×0.1m에 맞춘 뒤 바닥면을 y=0에 정렬했다. 회전 전 원본 경계로 스케일하는 실수를 회귀 테스트로 막는다. +2. **돼지 좌표계·바닥 정렬** — `Piggy`도 Blender Z-up 모델이므로 내부 모델에서 Y-up 회전·균일 스케일·바닥 정렬을 수행하고, 바깥 노드는 위치와 액션만 맡게 분리했다. +3. **초기 프레이밍** — 카메라와 너무 가까워 화면 밖으로 밀리던 돼지의 임의 하드코딩 z 좌표를 1에서 0으로 조정했다. 카메라 설정과 `allowsCameraControl`은 계획값을 유지한다. + +## 작업 이력 + +| 날짜 | 작업 범위 | 결과 | 검증 | 다음 시작점 | +| --- | --- | --- | --- | --- | +| 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 | +| 2026-08-10 | Chapter 1 Task 7 보정·재검증 | Blender 좌표계 보정으로 바닥·돼지를 방 바닥에 맞추고, 초기 프레임에 돼지를 표시. Task 7 탭 연결 테스트 추가 | `tuist generate --no-open`, `xcodebuild ... build` 성공, `xcodebuild ... test` 26/26 통과, Simulator 장면 확인 | Task 7 최종 재검토·커밋 후 Task 8 | +| 2026-08-10 | Chapter 1 Task 1~6 | Tuist 스캐폴드부터 HideAction까지 완료, 태스크마다 검토·필요 시 수정 라운드 거침 | 각 태스크 `xcodebuild test` 통과, 태스크별 검토 승인 | Task 7 | +| 2026-08-10 | Chapter 1 Task 7 (진행 중) | SwiftUI 화면 연결, 가짜 소파 스케일 버그와 `pointOfView` 누락 버그 발견·수정, 공용 지오메트리 헬퍼 분리 | `xcodebuild test` 23/23 통과. 위 3개 미해결 항목은 미검증 | 미해결 항목 처리 후 Task 7 완료, 이어서 Task 8 | + +## 기록 형식 + +새 항목은 작업 이력 표의 첫 행에 추가한다. 각 항목에는 아래 정보만 기록한다. + +- 날짜 +- 작업 범위 +- 결과 +- 검증 근거 +- 다음 시작점 + +기록에 사람·도구·모델 이름, 대화 내용, 비밀 정보는 넣지 않는다.