From 6c8eebfd8501b933c13780992be4ed9440eeb54d Mon Sep 17 00:00:00 2001 From: eunseo-com Date: Mon, 10 Aug 2026 18:21:10 +0900 Subject: [PATCH 01/16] Scaffold PiggyEscape Xcode project via Tuist --- PiggyEscape/.gitignore | 3 +++ PiggyEscape/PiggyEscape/Resources/.gitkeep | 0 .../PiggyEscape/Sources/ContentView.swift | 7 +++++ .../PiggyEscape/Sources/PiggyEscapeApp.swift | 10 +++++++ .../PiggyEscapeTests/PiggyEscapeTests.swift | 7 +++++ PiggyEscape/Project.swift | 27 +++++++++++++++++++ 6 files changed, 54 insertions(+) create mode 100644 PiggyEscape/.gitignore create mode 100644 PiggyEscape/PiggyEscape/Resources/.gitkeep create mode 100644 PiggyEscape/PiggyEscape/Sources/ContentView.swift create mode 100644 PiggyEscape/PiggyEscape/Sources/PiggyEscapeApp.swift create mode 100644 PiggyEscape/PiggyEscapeTests/PiggyEscapeTests.swift create mode 100644 PiggyEscape/Project.swift diff --git a/PiggyEscape/.gitignore b/PiggyEscape/.gitignore new file mode 100644 index 0000000..2f81c2b --- /dev/null +++ b/PiggyEscape/.gitignore @@ -0,0 +1,3 @@ +*.xcodeproj +.build/ +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/Sources/ContentView.swift b/PiggyEscape/PiggyEscape/Sources/ContentView.swift new file mode 100644 index 0000000..4630170 --- /dev/null +++ b/PiggyEscape/PiggyEscape/Sources/ContentView.swift @@ -0,0 +1,7 @@ +import SwiftUI + +struct ContentView: View { + var body: some View { + Text("PiggyEscape") + } +} 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/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/Project.swift b/PiggyEscape/Project.swift new file mode 100644 index 0000000..2f988f2 --- /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/**"], + 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")] + ) + ] +) From 3945db94ee3c06cd0a45b437f486ec340b551658 Mon Sep 17 00:00:00 2001 From: eunseo-com Date: Mon, 10 Aug 2026 18:24:38 +0900 Subject: [PATCH 02/16] Add C3_Piggy assets and AssetLoader --- .../PiggyEscape/Resources/Ground_Color.usdc | Bin 0 -> 4286 bytes PiggyEscape/PiggyEscape/Resources/Piggy.usdc | Bin 0 -> 79175 bytes .../PiggyEscape/Resources/Wood_Color.usdc | Bin 0 -> 19275 bytes .../Sources/ClosedWorld/AssetLoader.swift | 48 ++++++++++++++++++ .../PiggyEscapeTests/AssetLoaderTests.swift | 37 ++++++++++++++ 5 files changed, 85 insertions(+) create mode 100644 PiggyEscape/PiggyEscape/Resources/Ground_Color.usdc create mode 100644 PiggyEscape/PiggyEscape/Resources/Piggy.usdc create mode 100644 PiggyEscape/PiggyEscape/Resources/Wood_Color.usdc create mode 100644 PiggyEscape/PiggyEscape/Sources/ClosedWorld/AssetLoader.swift create mode 100644 PiggyEscape/PiggyEscapeTests/AssetLoaderTests.swift diff --git a/PiggyEscape/PiggyEscape/Resources/Ground_Color.usdc b/PiggyEscape/PiggyEscape/Resources/Ground_Color.usdc new file mode 100644 index 0000000000000000000000000000000000000000..17a99b2d8f375696a6a8620a85edc2752069a679 GIT binary patch literal 4286 zcmbtXeRNah8GqjUk)#Qw32m^T(lmTXu`Q_qKZ0#;TMA-H$`wrpw;W2@W20p_?+QKbLxm6RM%w-!(7qLSXyF({Wpm+Xiz zWZKjKSF&{@^&sXTDiN06i~L-~6^Lqt<#Rsr)Gr-ZA+AO^seq#fL3KVvEy9mjh@g2b zMl3;GgRp!pMc(qY4EbvjJc5pTL<6D`u^bUVSohIfeU=bJ!E$0H>p~qJD-qWtZb00K zxCs$LtU}z3XhyUkXc1Q70`jehHUzb?;?PD}ajfQ`iiWV%XAf^b3Xn5_d>DBX)-#|X zX2Lc@rabKv9TtU2LS1*@5XCqKB?T7#G?jPe^LdsOfLtg5Yu-`3P-EIZtA{joI!|L# z|8~R??ctU?&wSW&wyUGLvH5sQa_RLg`k4dG!EgV%Wh7D_uDj-`=0Rn$xVXl_=_%i*m&r{iFr|204T5N|_w)54aS!q%C>?PgSfStVK|=9(|X zF0TJlEm%*^s)h35k{)fR^_0gsI*RYn44&HKJXrP?Z|U(q(+|~CKK%7MG<}*nsuxAk zmP(cn(#;0P zF3afi9jhAZ00v}3*7S(1iD|`vq?*VM$Z10|6g3TPDLI{#HQ(UM#z12eWHYTpiVj;L zqbURJ{YomS$!XA374MUKrEJQG-~{LyIid6_vIfS^jJ#1AkYP)&strIQs~hUTYRQnc z!i6g6lhd*$8FCWN-@otNPY<1YV7NOFXoBtmRBDQp(&xbh19Ez>JEipX8xTvV85tLK zTT;??xPagEsNB#7lYYS9>4cI=$;s}v*y=TyC7Nr7BI}@LjZD_iLwZ*0l@c;! zby~82&(uSPYcfjOqLmo1+njGp?QZfwD9(H9yh9;%; zlw`~SUzausTn+5jRD59amTzorf-XHFrJ9MYNA0;&P8eY_2^(a+AEb;DOZ3YFlD?0O zJKL0WQc3r{RvfxFhBSwhwyV8#X|4clix8k1PT ze9B^GxJ*FL_9T@-MaR{m!6F$pGuTrN#&98Jds1>tNy^FPuuqqbHPV2R+8M&*#803$ z)LR9Q#Bp`NT3H>4)qwzVO0H)K9u&F&z6Z=QaBr+<-sn-)6ku#c>QPb#VHdL!c6o=K zeg`H2dQ;fe;I{4fpp0$X_X1?$IEZ>OqRE4b{H@qthBSf?5`E0!U@u5IrU8x1Pmo%D z`8)`82ih6sj5H@&drNaUA03&F10CB_t z%m|4&1l-f{`1kHrs&@Fx(b`C2g$+4~uPZ3Snphr*H%C{Y=m)*nPnC zGx$`#=>{%9nyQ8UyBJIt8$Ykx2TfN9W7QD60`91W+}rf139j64DZ%mQBEiK|K_4}# zibPvGL~lnNyk0R{75C!dRuntLxE-QVGWth(BNVtUtf(na;zV%+sChQ=QzQPX1u0?aA1I81qDo9o@WtGxONi zldB#bELCdJyR6Dj(3edV1rT{IVu$f&TCrD*i&3mkE$=?q;5~W=zFN#fb{oS4Y))it`y9?|$0)YXhv**EUx%%K%;u~=hES&i zUn&#|7|J7xo+$Pg!wMpSNnQ{|p4iLy*9teh{AIz;Fl8%Vb-|{l*9#rLm%B*$e@S>RKH=S5A@tTnf#lshOkml_@t5)o{!$?PRG!R%J18M}tAkfs9tFB|I4EZz z7!kcy@B^9_{V;u&$vFMH2yv{|>v4OCh~BZ?9EU30sXPdg*m#bg;_3cW#_@KZzu@6G zmSs8i5)TZ4&+O;s*igP?!!O4&d49_Ag^T3~ zOxeFQmp{(MyE;2I#!OrDa=tY2cP{F9D9{iv<1&^0ks* literal 0 HcmV?d00001 diff --git a/PiggyEscape/PiggyEscape/Resources/Piggy.usdc b/PiggyEscape/PiggyEscape/Resources/Piggy.usdc new file mode 100644 index 0000000000000000000000000000000000000000..7370b33c49db6f0a18ad1e99b895a0aa6da81831 GIT binary patch literal 79175 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

C6+Selg4qcA{0jP1;aiX={dYotsc;nR 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 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/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) + } +} From 3520c4ba8dbf0cd7b7da2e627f7abbebfb41a933 Mon Sep 17 00:00:00 2001 From: eunseo-com Date: Mon, 10 Aug 2026 18:28:12 +0900 Subject: [PATCH 03/16] Add RoomBuilder: SCNBox walls + reused Ground_Color floor --- .../Sources/ClosedWorld/RoomBuilder.swift | 43 +++++++++++++++++++ .../PiggyEscapeTests/RoomBuilderTests.swift | 34 +++++++++++++++ 2 files changed, 77 insertions(+) create mode 100644 PiggyEscape/PiggyEscape/Sources/ClosedWorld/RoomBuilder.swift create mode 100644 PiggyEscape/PiggyEscapeTests/RoomBuilderTests.swift diff --git a/PiggyEscape/PiggyEscape/Sources/ClosedWorld/RoomBuilder.swift b/PiggyEscape/PiggyEscape/Sources/ClosedWorld/RoomBuilder.swift new file mode 100644 index 0000000..0d7ed95 --- /dev/null +++ b/PiggyEscape/PiggyEscape/Sources/ClosedWorld/RoomBuilder.swift @@ -0,0 +1,43 @@ +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 + + @MainActor + static func build() -> SCNNode { + let room = SCNNode() + room.name = "Room" + room.position = SCNVector3(0, 0, 0) // 좌표 원점을 여기서 임의로 선언한다 + + let floor = AssetLoader.object(named: "Ground_Color") { + AssetLoader.voxelBox(width: CGFloat(roomWidth), height: 0.1, length: CGFloat(roomDepth), + color: UIColor(white: 0.8, alpha: 1)) + } + floor.name = "Floor" + floor.position = SCNVector3(0, 0, 0) + room.addChildNode(floor) + + 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 + } +} diff --git a/PiggyEscape/PiggyEscapeTests/RoomBuilderTests.swift b/PiggyEscape/PiggyEscapeTests/RoomBuilderTests.swift new file mode 100644 index 0000000..9cb9139 --- /dev/null +++ b/PiggyEscape/PiggyEscapeTests/RoomBuilderTests.swift @@ -0,0 +1,34 @@ +import XCTest +import SceneKit +@testable import PiggyEscape + +final class RoomBuilderTests: XCTestCase { + @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) + } +} From e4e2927853ba02e6797a070a14f2e8667c427109 Mon Sep 17 00:00:00 2001 From: eunseo-com Date: Mon, 10 Aug 2026 18:52:27 +0900 Subject: [PATCH 04/16] Place Piggy and the fake sofa at hardcoded coordinates --- .../Sources/ClosedWorld/FakeSofa.swift | 18 +++++++ .../Sources/ClosedWorld/PigPlacement.swift | 52 +++++++++++++++++++ .../PiggyEscapeTests/FakeSofaTests.swift | 24 +++++++++ .../PiggyEscapeTests/PigPlacementTests.swift | 26 ++++++++++ 4 files changed, 120 insertions(+) create mode 100644 PiggyEscape/PiggyEscape/Sources/ClosedWorld/FakeSofa.swift create mode 100644 PiggyEscape/PiggyEscape/Sources/ClosedWorld/PigPlacement.swift create mode 100644 PiggyEscape/PiggyEscapeTests/FakeSofaTests.swift create mode 100644 PiggyEscape/PiggyEscapeTests/PigPlacementTests.swift diff --git a/PiggyEscape/PiggyEscape/Sources/ClosedWorld/FakeSofa.swift b/PiggyEscape/PiggyEscape/Sources/ClosedWorld/FakeSofa.swift new file mode 100644 index 0000000..9c55f57 --- /dev/null +++ b/PiggyEscape/PiggyEscape/Sources/ClosedWorld/FakeSofa.swift @@ -0,0 +1,18 @@ +import SceneKit +import UIKit + +/// "가짜 소파" — 개발자가 코드로 선언한 숨는 지점. 실제 방의 진짜 소파와는 +/// 아무 관계가 없다. 스텝 5의 "숨어봐" 인터랙션이 이동시키는 목적지가 바로 이 좌표다. +enum FakeSofa { + static let hardcodedPosition = SCNVector3(1.2, 0, -1.2) + + @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) + } + model.name = "FakeSofa" + model.position = hardcodedPosition + return model + } +} diff --git a/PiggyEscape/PiggyEscape/Sources/ClosedWorld/PigPlacement.swift b/PiggyEscape/PiggyEscape/Sources/ClosedWorld/PigPlacement.swift new file mode 100644 index 0000000..71636d2 --- /dev/null +++ b/PiggyEscape/PiggyEscape/Sources/ClosedWorld/PigPlacement.swift @@ -0,0 +1,52 @@ +import SceneKit + +/// 돼지를 하드코딩된 좌표에 배치한다. 이 좌표는 개발자가 정한 것일 뿐, +/// 방 안의 어떤 실제 기준(가구 위치 등)과도 연결되어 있지 않다. +enum PigPlacement { + static let hardcodedPosition = SCNVector3(0, 0, 1) + private static let standardHeight: Float = 0.6 + + @MainActor + static func makePigNode() -> SCNNode { + var model = AssetLoader.object(named: "Piggy") { + AssetLoader.voxelBox(width: 0.4, height: 0.4, length: 0.6, color: .systemPink) + } + // If the loaded model doesn't have geometry at the expected level, use fallback + let hasGeometry = model.geometry != nil || model.childNodes.contains { $0.geometry != nil } + if !hasGeometry { + model = AssetLoader.voxelBox(width: 0.4, height: 0.4, length: 0.6, color: .systemPink) + } + normalize(model, toHeight: standardHeight) + model.name = "Piggy" + model.position = hardcodedPosition + return model + } + + @MainActor + private 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) + } + + private 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) + } +} diff --git a/PiggyEscape/PiggyEscapeTests/FakeSofaTests.swift b/PiggyEscape/PiggyEscapeTests/FakeSofaTests.swift new file mode 100644 index 0000000..ba618b8 --- /dev/null +++ b/PiggyEscape/PiggyEscapeTests/FakeSofaTests.swift @@ -0,0 +1,24 @@ +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) + } +} diff --git a/PiggyEscape/PiggyEscapeTests/PigPlacementTests.swift b/PiggyEscape/PiggyEscapeTests/PigPlacementTests.swift new file mode 100644 index 0000000..43261a5 --- /dev/null +++ b/PiggyEscape/PiggyEscapeTests/PigPlacementTests.swift @@ -0,0 +1,26 @@ +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_hasGeometryOrChildGeometry() { + let pig = PigPlacement.makePigNode() + let hasGeometry = pig.geometry != nil || pig.childNodes.contains { $0.geometry != nil } + XCTAssertTrue(hasGeometry) + } +} From bd0b058de77e1430ac54beb0242a6c5e5c2bd6ee Mon Sep 17 00:00:00 2001 From: eunseo-com Date: Mon, 10 Aug 2026 18:59:51 +0900 Subject: [PATCH 05/16] Fix makePigNode discarding the real Piggy model The shallow hasGeometry check only looked one level into the loaded node's children, but Piggy.usdc nests its meshes two levels down (wrapper -> root -> body/eyes/tail), so the check always evaluated false and silently overwrote the real model with the voxel fallback. Removed the redundant check and trust AssetLoader.object(named:fallback:)'s existing non-nil contract instead. Strengthened the geometry test with a deep enumerateHierarchy traversal (matching the pattern already used in boundingBox(of:)) and added a test asserting multiple meshes are placed, not the single-box fallback. --- .../Sources/ClosedWorld/PigPlacement.swift | 7 +--- .../PiggyEscapeTests/PigPlacementTests.swift | 38 ++++++++++++++++++- 2 files changed, 37 insertions(+), 8 deletions(-) diff --git a/PiggyEscape/PiggyEscape/Sources/ClosedWorld/PigPlacement.swift b/PiggyEscape/PiggyEscape/Sources/ClosedWorld/PigPlacement.swift index 71636d2..3ca3796 100644 --- a/PiggyEscape/PiggyEscape/Sources/ClosedWorld/PigPlacement.swift +++ b/PiggyEscape/PiggyEscape/Sources/ClosedWorld/PigPlacement.swift @@ -8,14 +8,9 @@ enum PigPlacement { @MainActor static func makePigNode() -> SCNNode { - var model = AssetLoader.object(named: "Piggy") { + let model = AssetLoader.object(named: "Piggy") { AssetLoader.voxelBox(width: 0.4, height: 0.4, length: 0.6, color: .systemPink) } - // If the loaded model doesn't have geometry at the expected level, use fallback - let hasGeometry = model.geometry != nil || model.childNodes.contains { $0.geometry != nil } - if !hasGeometry { - model = AssetLoader.voxelBox(width: 0.4, height: 0.4, length: 0.6, color: .systemPink) - } normalize(model, toHeight: standardHeight) model.name = "Piggy" model.position = hardcodedPosition diff --git a/PiggyEscape/PiggyEscapeTests/PigPlacementTests.swift b/PiggyEscape/PiggyEscapeTests/PigPlacementTests.swift index 43261a5..c2dfa87 100644 --- a/PiggyEscape/PiggyEscapeTests/PigPlacementTests.swift +++ b/PiggyEscape/PiggyEscapeTests/PigPlacementTests.swift @@ -20,7 +20,41 @@ final class PigPlacementTests: XCTestCase { @MainActor func test_makePigNode_hasGeometryOrChildGeometry() { let pig = PigPlacement.makePigNode() - let hasGeometry = pig.geometry != nil || pig.childNodes.contains { $0.geometry != nil } - XCTAssertTrue(hasGeometry) + 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 the pattern used by + /// PigPlacement.boundingBox(of:)'s enumerateHierarchy usage, 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 } } From dfc9f4514ce5b11ccf69fba9afb55c61bde500d9 Mon Sep 17 00:00:00 2001 From: eunseo-com Date: Mon, 10 Aug 2026 19:03:11 +0900 Subject: [PATCH 06/16] Add NodeInspector demonstrating SceneKit's node-bundled structure --- .../Sources/ClosedWorld/NodeInspector.swift | 23 +++++++++++++++ .../PiggyEscapeTests/NodeInspectorTests.swift | 28 +++++++++++++++++++ 2 files changed, 51 insertions(+) create mode 100644 PiggyEscape/PiggyEscape/Sources/ClosedWorld/NodeInspector.swift create mode 100644 PiggyEscape/PiggyEscapeTests/NodeInspectorTests.swift 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/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") }) + } +} From 38e2485ae33fbc14938ba87f40f8c3db9aa89143 Mon Sep 17 00:00:00 2001 From: eunseo-com Date: Mon, 10 Aug 2026 19:13:11 +0900 Subject: [PATCH 07/16] Add HideAction: tap-to-hide moves the pig to the fake sofa --- .../Sources/ClosedWorld/HideAction.swift | 10 +++++ .../PiggyEscapeTests/HideActionTests.swift | 38 +++++++++++++++++++ 2 files changed, 48 insertions(+) create mode 100644 PiggyEscape/PiggyEscape/Sources/ClosedWorld/HideAction.swift create mode 100644 PiggyEscape/PiggyEscapeTests/HideActionTests.swift 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/PiggyEscapeTests/HideActionTests.swift b/PiggyEscape/PiggyEscapeTests/HideActionTests.swift new file mode 100644 index 0000000..ac378d1 --- /dev/null +++ b/PiggyEscape/PiggyEscapeTests/HideActionTests.swift @@ -0,0 +1,38 @@ +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 + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 16, height: 16)) + window.addSubview(view) + window.makeKeyAndVisible() + + let expectation = expectation(description: "pig reaches fake sofa position") + + pig.runAction(HideAction.makeMoveAction()) { + expectation.fulfill() + } + + 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) + } +} From 048a68a7caaa2f439bfe34570fad852b4e0568b8 Mon Sep 17 00:00:00 2001 From: eunseo-com Date: Mon, 10 Aug 2026 19:20:17 +0900 Subject: [PATCH 08/16] Fix HideActionTests: teardown window/view, document timing tradeoff Adds a defer block to resign key status and stop the SCNView's display link after the test runs, so the offscreen UIWindow/SCNView never leaks across tests in the same process. Also documents why the test relies on the real display link + a generous 2.0s/0.5s timeout margin instead of a fully deterministic clock (the SCNRenderer manual-pump alternative crashes on this simulator runtime), and notes the expected makeKeyAndVisible() deprecation warning from the windowScene-less test window. --- .../PiggyEscapeTests/HideActionTests.swift | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/PiggyEscape/PiggyEscapeTests/HideActionTests.swift b/PiggyEscape/PiggyEscapeTests/HideActionTests.swift index ac378d1..43a64d0 100644 --- a/PiggyEscape/PiggyEscapeTests/HideActionTests.swift +++ b/PiggyEscape/PiggyEscapeTests/HideActionTests.swift @@ -20,9 +20,22 @@ final class HideActionTests: XCTestCase { 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") @@ -30,6 +43,15 @@ final class HideActionTests: XCTestCase { 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 + // task-6-report.md), 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) From 933e3a69aee164f462ed82e8a26c660c15f79ac0 Mon Sep 17 00:00:00 2001 From: eunseo-com Date: Mon, 10 Aug 2026 19:23:22 +0900 Subject: [PATCH 09/16] Record Chapter 1 troubleshooting log --- docs/TROUBLESHOOTING.md | 69 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 docs/TROUBLESHOOTING.md diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md new file mode 100644 index 0000000..5738f1c --- /dev/null +++ b/docs/TROUBLESHOOTING.md @@ -0,0 +1,69 @@ +# 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`). 에디터 진단을 무시하는 습관을 들이더라도, 평소와 다른 종류의 에러(모듈을 못 찾는다는 것과 타입이 안 맞는다는 것은 성격이 다르다)가 섞이면 한 번은 직접 눈으로 확인하는 편이 안전하다. From 6c0ce56542d426bfab6254d90200bfba4cf9ca58 Mon Sep 17 00:00:00 2001 From: eunseo-com Date: Mon, 10 Aug 2026 19:36:05 +0900 Subject: [PATCH 10/16] Wire RoomBuilder, PigPlacement, FakeSofa, and HideAction into the app --- .../ClosedWorld/ClosedWorldSceneView.swift | 53 +++++++++++++++++++ .../PiggyEscape/Sources/ContentView.swift | 3 +- 2 files changed, 55 insertions(+), 1 deletion(-) create mode 100644 PiggyEscape/PiggyEscape/Sources/ClosedWorld/ClosedWorldSceneView.swift diff --git a/PiggyEscape/PiggyEscape/Sources/ClosedWorld/ClosedWorldSceneView.swift b/PiggyEscape/PiggyEscape/Sources/ClosedWorld/ClosedWorldSceneView.swift new file mode 100644 index 0000000..eb59248 --- /dev/null +++ b/PiggyEscape/PiggyEscape/Sources/ClosedWorld/ClosedWorldSceneView.swift @@ -0,0 +1,53 @@ +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 + 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/ContentView.swift b/PiggyEscape/PiggyEscape/Sources/ContentView.swift index 4630170..c033c69 100644 --- a/PiggyEscape/PiggyEscape/Sources/ContentView.swift +++ b/PiggyEscape/PiggyEscape/Sources/ContentView.swift @@ -2,6 +2,7 @@ import SwiftUI struct ContentView: View { var body: some View { - Text("PiggyEscape") + ClosedWorldSceneView() + .ignoresSafeArea() } } From 5bc198571dc3f5756c01433fdecdef78acfc8075 Mon Sep 17 00:00:00 2001 From: eunseo-com Date: Mon, 10 Aug 2026 20:01:48 +0900 Subject: [PATCH 11/16] Normalize FakeSofa's scale like PigPlacement already does Wood_Color.usdc loads several meters larger than the 4x4x2.5m room, and FakeSofa.makeSofaNode() never rescaled it (unlike PigPlacement, which normalizes Piggy to a 0.6m standard height). Factor the bounding-box-based normalize helper out of PigPlacement into a shared SceneKitGeometry enum, and use it in FakeSofa to normalize the sofa to a 0.45m furniture-scale height. RoomBuilder's floor (Ground_Color.usdc) was also checked: its raw footprint is undersized relative to the 4x4m room, but its "thin" axis doesn't line up with the room's y-up convention the way the sofa/pig's does. Applying the same toHeight/toFootprintWidth scale uniformly inflates the wrong axis and ends up enveloping the camera (verified in the simulator), so it's left unnormalized rather than guess-fixed. --- .../Sources/ClosedWorld/FakeSofa.swift | 3 + .../Sources/ClosedWorld/PigPlacement.swift | 30 +--------- .../ClosedWorld/SceneKitGeometry.swift | 49 ++++++++++++++++ .../PiggyEscapeTests/FakeSofaTests.swift | 15 +++++ .../SceneKitGeometryTests.swift | 57 +++++++++++++++++++ 5 files changed, 125 insertions(+), 29 deletions(-) create mode 100644 PiggyEscape/PiggyEscape/Sources/ClosedWorld/SceneKitGeometry.swift create mode 100644 PiggyEscape/PiggyEscapeTests/SceneKitGeometryTests.swift diff --git a/PiggyEscape/PiggyEscape/Sources/ClosedWorld/FakeSofa.swift b/PiggyEscape/PiggyEscape/Sources/ClosedWorld/FakeSofa.swift index 9c55f57..84f3ea7 100644 --- a/PiggyEscape/PiggyEscape/Sources/ClosedWorld/FakeSofa.swift +++ b/PiggyEscape/PiggyEscape/Sources/ClosedWorld/FakeSofa.swift @@ -5,12 +5,15 @@ 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/PigPlacement.swift b/PiggyEscape/PiggyEscape/Sources/ClosedWorld/PigPlacement.swift index 3ca3796..66db1ca 100644 --- a/PiggyEscape/PiggyEscape/Sources/ClosedWorld/PigPlacement.swift +++ b/PiggyEscape/PiggyEscape/Sources/ClosedWorld/PigPlacement.swift @@ -11,37 +11,9 @@ enum PigPlacement { let model = AssetLoader.object(named: "Piggy") { AssetLoader.voxelBox(width: 0.4, height: 0.4, length: 0.6, color: .systemPink) } - normalize(model, toHeight: standardHeight) + SceneKitGeometry.normalize(model, toHeight: standardHeight) model.name = "Piggy" model.position = hardcodedPosition return model } - - @MainActor - private 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) - } - - private 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) - } } 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/PiggyEscapeTests/FakeSofaTests.swift b/PiggyEscape/PiggyEscapeTests/FakeSofaTests.swift index ba618b8..030a56a 100644 --- a/PiggyEscape/PiggyEscapeTests/FakeSofaTests.swift +++ b/PiggyEscape/PiggyEscapeTests/FakeSofaTests.swift @@ -21,4 +21,19 @@ final class FakeSofaTests: XCTestCase { 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/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) + } +} From dc1feac2c7ba09c94859fd3820752ec87f7a20ea Mon Sep 17 00:00:00 2001 From: eunseo-com Date: Mon, 10 Aug 2026 20:02:00 +0900 Subject: [PATCH 12/16] Set view.pointOfView so the configured camera actually renders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit While re-verifying the FakeSofa scale fix in the simulator, the screenshot still showed a scene dominated by giant close-up polygons regardless of any scale change to the sofa or floor. Bisecting by removing walls/floor/sofa one at a time proved the artifact wasn't any of those nodes — it was SceneKit's own auto-framing default camera, because view.pointOfView was never set to the manually positioned cameraNode. Without it, the camera placed in the code (0, 2, 1.7 looking at 0, 0.3, -0.5) was never actually used for rendering. Confirmed via screenshot: with pointOfView set, the scene renders as a wall/floor plane with the sofa visible as a separate object, instead of one giant unrecognizable polygon. --- .../Sources/ClosedWorld/ClosedWorldSceneView.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/PiggyEscape/PiggyEscape/Sources/ClosedWorld/ClosedWorldSceneView.swift b/PiggyEscape/PiggyEscape/Sources/ClosedWorld/ClosedWorldSceneView.swift index eb59248..0a76f7c 100644 --- a/PiggyEscape/PiggyEscape/Sources/ClosedWorld/ClosedWorldSceneView.swift +++ b/PiggyEscape/PiggyEscape/Sources/ClosedWorld/ClosedWorldSceneView.swift @@ -29,6 +29,10 @@ struct ClosedWorldSceneView: UIViewRepresentable { scene.rootNode.addChildNode(light) view.scene = scene + // pointOfView가 비어 있으면 SceneKit이 전체 씬을 자동으로 프레이밍하는 + // 기본 카메라를 대신 사용한다 — 위에서 공들여 배치한 cameraNode는 + // 렌더링에 전혀 쓰이지 않고 무시된다. 반드시 명시적으로 지정해야 한다. + view.pointOfView = cameraNode view.allowsCameraControl = true view.autoenablesDefaultLighting = true From 3bacc01e1f8998c73d62672c7ac7d3eeb0f0349c Mon Sep 17 00:00:00 2001 From: eunseo-com Date: Mon, 10 Aug 2026 20:04:47 +0900 Subject: [PATCH 13/16] Record Chapter 1 handoff: Task 7 paused with 3 open items --- docs/WORK_LOG.md | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 docs/WORK_LOG.md diff --git a/docs/WORK_LOG.md b/docs/WORK_LOG.md new file mode 100644 index 0000000..f10208e --- /dev/null +++ b/docs/WORK_LOG.md @@ -0,0 +1,37 @@ +# 작업 인수인계 기록 + +> 다음 작업을 시작하기 전에 **현재 인수인계**를 먼저 읽는다. 의미 있는 작업 단위가 끝나면 결과물과 같은 커밋에 이 문서를 갱신한다. 사용한 도구나 모델 이름은 쓰지 않는다. + +## 현재 인수인계 + +- 상태: Chapter 1 Task 7 진행 중 (일시 중지) +- 진행 중 범위: `docs/superpowers/plans/2026-08-10-ch1-closed-world-implementation.md`의 Task 7 — ClosedWorldSceneView. 구현과 1차 수정은 끝났지만 재검토 전. +- 마지막 완료 범위: Task 1~6 (Tuist 스캐폴드, 에셋+AssetLoader, RoomBuilder, PigPlacement+FakeSofa, NodeInspector, HideAction) — 전부 검증·검토 완료. +- 마지막 검증: `xcodebuild ... test` 23/23 통과. Task 7 커밋 범위(`6c0ce56..dc1feac`)는 아직 검토자 재검토를 받지 않음. +- 다음 시작점: 아래 "Task 7 미해결 항목" 3가지를 먼저 처리하고, Task 7을 완료 처리한 뒤 Task 8(DocC 카탈로그)을 시작한다. +- 차단 요소: 시뮬레이터 화면 접근 권한이 이번 세션 내내 승인되지 않아 탭 인터랙션 검증을 못 했다. 사용자가 시뮬레이터 패널에서 접근을 허용해야 풀린다. + +### Task 7 미해결 항목 + +1. **RoomBuilder 바닥 방향 문제** — `Ground_Color` 에셋이 4×4m 방에 비해 축소가 필요한데, 얇은 축이 로컬 y가 아니라 z라 `PigPlacement`/`FakeSofa`와 같은 높이 기준 스케일링을 그대로 적용하면 바닥이 카메라를 삼켜버린다. 회전을 먼저 맞추고 나서 스케일링해야 한다. 의도적으로 손대지 않고 남겨둠(짐작으로 고치지 않기 위해). +2. **돼지 가시성 미확인** — `view.pointOfView` 누락 버그를 고친 뒤 화면 구성은 방/바닥/소파로 나아졌지만, 그 프레임 안에 돼지가 실제로 보이는지는 확인하지 못했다. +3. **탭 인터랙션 미검증** — 시뮬레이터 접근 권한 문제로 "탭하면 돼지가 가짜 소파로 이동" 동작을 실제로 눌러보지 못했다. + +## 작업 이력 + +| 날짜 | 작업 범위 | 결과 | 검증 | 다음 시작점 | +| --- | --- | --- | --- | --- | +| 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 | + +## 기록 형식 + +새 항목은 작업 이력 표의 첫 행에 추가한다. 각 항목에는 아래 정보만 기록한다. + +- 날짜 +- 작업 범위 +- 결과 +- 검증 근거 +- 다음 시작점 + +기록에 사람·도구·모델 이름, 대화 내용, 비밀 정보는 넣지 않는다. From 3ba27b05216b69293541a9572f22448bfd37227c Mon Sep 17 00:00:00 2001 From: eunseo-com Date: Mon, 10 Aug 2026 20:36:57 +0900 Subject: [PATCH 14/16] Fix ClosedWorld scene transforms and verification --- PiggyEscape/.gitignore | 2 + .../Sources/ClosedWorld/PigPlacement.swift | 33 +++++++++++--- .../Sources/ClosedWorld/RoomBuilder.swift | 45 ++++++++++++++++--- .../ClosedWorldSceneViewTests.swift | 16 +++++++ .../PiggyEscapeTests/HideActionTests.swift | 2 +- .../PiggyEscapeTests/PigPlacementTests.swift | 16 +++++-- .../PiggyEscapeTests/RoomBuilderTests.swift | 45 +++++++++++++++++++ docs/TROUBLESHOOTING.md | 22 +++++++++ docs/WORK_LOG.md | 20 +++++---- 9 files changed, 175 insertions(+), 26 deletions(-) create mode 100644 PiggyEscape/PiggyEscapeTests/ClosedWorldSceneViewTests.swift diff --git a/PiggyEscape/.gitignore b/PiggyEscape/.gitignore index 2f81c2b..6f76e36 100644 --- a/PiggyEscape/.gitignore +++ b/PiggyEscape/.gitignore @@ -1,3 +1,5 @@ *.xcodeproj +*.xcworkspace/ .build/ +Derived/ DerivedData/ diff --git a/PiggyEscape/PiggyEscape/Sources/ClosedWorld/PigPlacement.swift b/PiggyEscape/PiggyEscape/Sources/ClosedWorld/PigPlacement.swift index 66db1ca..800055a 100644 --- a/PiggyEscape/PiggyEscape/Sources/ClosedWorld/PigPlacement.swift +++ b/PiggyEscape/PiggyEscape/Sources/ClosedWorld/PigPlacement.swift @@ -3,17 +3,38 @@ import SceneKit /// 돼지를 하드코딩된 좌표에 배치한다. 이 좌표는 개발자가 정한 것일 뿐, /// 방 안의 어떤 실제 기준(가구 위치 등)과도 연결되어 있지 않다. enum PigPlacement { - static let hardcodedPosition = SCNVector3(0, 0, 1) + static let hardcodedPosition = SCNVector3(0, 0, 0) private static let standardHeight: Float = 0.6 @MainActor static func makePigNode() -> SCNNode { - let model = AssetLoader.object(named: "Piggy") { - AssetLoader.voxelBox(width: 0.4, height: 0.4, length: 0.6, color: .systemPink) + 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) - model.name = "Piggy" - model.position = hardcodedPosition - return model + 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 index 0d7ed95..ab1e419 100644 --- a/PiggyEscape/PiggyEscape/Sources/ClosedWorld/RoomBuilder.swift +++ b/PiggyEscape/PiggyEscape/Sources/ClosedWorld/RoomBuilder.swift @@ -8,6 +8,7 @@ enum RoomBuilder { 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 { @@ -15,13 +16,7 @@ enum RoomBuilder { room.name = "Room" room.position = SCNVector3(0, 0, 0) // 좌표 원점을 여기서 임의로 선언한다 - let floor = AssetLoader.object(named: "Ground_Color") { - AssetLoader.voxelBox(width: CGFloat(roomWidth), height: 0.1, length: CGFloat(roomDepth), - color: UIColor(white: 0.8, alpha: 1)) - } - floor.name = "Floor" - floor.position = SCNVector3(0, 0, 0) - room.addChildNode(floor) + room.addChildNode(makeFloor()) let wallSpecs: [(name: String, position: SCNVector3, eulerY: Float, width: Float)] = [ ("Wall_0", SCNVector3(0, wallHeight / 2, -roomDepth / 2), 0, roomWidth), @@ -40,4 +35,40 @@ enum RoomBuilder { 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/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/HideActionTests.swift b/PiggyEscape/PiggyEscapeTests/HideActionTests.swift index 43a64d0..7654d50 100644 --- a/PiggyEscape/PiggyEscapeTests/HideActionTests.swift +++ b/PiggyEscape/PiggyEscapeTests/HideActionTests.swift @@ -50,7 +50,7 @@ final class HideActionTests: XCTestCase { // 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 - // task-6-report.md), so the real-display-link + generous-timeout approach + // 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) diff --git a/PiggyEscape/PiggyEscapeTests/PigPlacementTests.swift b/PiggyEscape/PiggyEscapeTests/PigPlacementTests.swift index c2dfa87..06f405c 100644 --- a/PiggyEscape/PiggyEscapeTests/PigPlacementTests.swift +++ b/PiggyEscape/PiggyEscapeTests/PigPlacementTests.swift @@ -17,6 +17,17 @@ final class PigPlacementTests: XCTestCase { 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() @@ -43,9 +54,8 @@ final class PigPlacementTests: XCTestCase { XCTAssertGreaterThan(geometryNodeCount, 1, "expected multiple meshes from the real Piggy model, not the single-box fallback") } - /// Deep traversal of the node hierarchy, mirroring the pattern used by - /// PigPlacement.boundingBox(of:)'s enumerateHierarchy usage, instead of a - /// shallow one-level check. + /// 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 diff --git a/PiggyEscape/PiggyEscapeTests/RoomBuilderTests.swift b/PiggyEscape/PiggyEscapeTests/RoomBuilderTests.swift index 9cb9139..07d99f3 100644 --- a/PiggyEscape/PiggyEscapeTests/RoomBuilderTests.swift +++ b/PiggyEscape/PiggyEscapeTests/RoomBuilderTests.swift @@ -3,6 +3,35 @@ 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() @@ -31,4 +60,20 @@ final class RoomBuilderTests: XCTestCase { 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/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 5738f1c..1d24adb 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -67,3 +67,25 @@ if !hasGeometry { **해결**: 파일을 추가·수정한 뒤에는 반드시 `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 index f10208e..17dd219 100644 --- a/docs/WORK_LOG.md +++ b/docs/WORK_LOG.md @@ -4,23 +4,25 @@ ## 현재 인수인계 -- 상태: Chapter 1 Task 7 진행 중 (일시 중지) -- 진행 중 범위: `docs/superpowers/plans/2026-08-10-ch1-closed-world-implementation.md`의 Task 7 — ClosedWorldSceneView. 구현과 1차 수정은 끝났지만 재검토 전. +- 상태: Chapter 1 Task 7 완료 (최종 재검토 승인) +- 진행 중 범위: `docs/superpowers/plans/2026-08-10-ch1-closed-world-implementation.md`의 Task 7 — ClosedWorldSceneView. 기존 구현과 1차 수정 뒤 실제 화면 검증에서 발견된 바닥·돼지 배치 문제까지 보정했다. - 마지막 완료 범위: Task 1~6 (Tuist 스캐폴드, 에셋+AssetLoader, RoomBuilder, PigPlacement+FakeSofa, NodeInspector, HideAction) — 전부 검증·검토 완료. -- 마지막 검증: `xcodebuild ... test` 23/23 통과. Task 7 커밋 범위(`6c0ce56..dc1feac`)는 아직 검토자 재검토를 받지 않음. -- 다음 시작점: 아래 "Task 7 미해결 항목" 3가지를 먼저 처리하고, Task 7을 완료 처리한 뒤 Task 8(DocC 카탈로그)을 시작한다. -- 차단 요소: 시뮬레이터 화면 접근 권한이 이번 세션 내내 승인되지 않아 탭 인터랙션 검증을 못 했다. 사용자가 시뮬레이터 패널에서 접근을 허용해야 풀린다. +- 마지막 검증: `xcodebuild ... test` 26/26 통과. Simulator에서 수평 바닥·돼지·가짜 소파가 한 프레임에 보임을 확인했다. 탭은 `Coordinator`가 HideAction을 시작하는 단위 테스트와, 표시된 씬에서 실제 액션 완료를 확인하는 HideAction 테스트로 검증했다. +- 다음 시작점: Task 8(DocC 카탈로그)을 시작한다. +- 차단 요소: 없음. -### Task 7 미해결 항목 +### Task 7에서 해결한 항목 -1. **RoomBuilder 바닥 방향 문제** — `Ground_Color` 에셋이 4×4m 방에 비해 축소가 필요한데, 얇은 축이 로컬 y가 아니라 z라 `PigPlacement`/`FakeSofa`와 같은 높이 기준 스케일링을 그대로 적용하면 바닥이 카메라를 삼켜버린다. 회전을 먼저 맞추고 나서 스케일링해야 한다. 의도적으로 손대지 않고 남겨둠(짐작으로 고치지 않기 위해). -2. **돼지 가시성 미확인** — `view.pointOfView` 누락 버그를 고친 뒤 화면 구성은 방/바닥/소파로 나아졌지만, 그 프레임 안에 돼지가 실제로 보이는지는 확인하지 못했다. -3. **탭 인터랙션 미검증** — 시뮬레이터 접근 권한 문제로 "탭하면 돼지가 가짜 소파로 이동" 동작을 실제로 눌러보지 못했다. +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 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 | From 36e66fcb139b3395329b5b520b00be3866ff9709 Mon Sep 17 00:00:00 2001 From: eunseo-com Date: Mon, 10 Aug 2026 20:44:30 +0900 Subject: [PATCH 15/16] Add Chapter 1 DocC tutorial catalog --- .../SceneKitToRealityKit.tutorial | 15 +++ .../Tutorials/01-ClosedWorld.tutorial | 101 ++++++++++++++++++ .../Resources/01-ClosedWorld-01-01.swift | 57 ++++++++++ .../Resources/01-ClosedWorld-02-01.swift | 74 +++++++++++++ .../Resources/01-ClosedWorld-03-01.swift | 40 +++++++ .../Resources/01-ClosedWorld-04-01.swift | 23 ++++ .../Resources/01-ClosedWorld-05-01.swift | 10 ++ PiggyEscape/Project.swift | 2 +- docs/WORK_LOG.md | 11 +- 9 files changed, 327 insertions(+), 6 deletions(-) create mode 100644 PiggyEscape/PiggyEscape/Tutorials/SceneKitToRealityKit.docc/SceneKitToRealityKit.tutorial create mode 100644 PiggyEscape/PiggyEscape/Tutorials/SceneKitToRealityKit.docc/Tutorials/01-ClosedWorld.tutorial create mode 100644 PiggyEscape/PiggyEscape/Tutorials/SceneKitToRealityKit.docc/Tutorials/Resources/01-ClosedWorld-01-01.swift create mode 100644 PiggyEscape/PiggyEscape/Tutorials/SceneKitToRealityKit.docc/Tutorials/Resources/01-ClosedWorld-02-01.swift create mode 100644 PiggyEscape/PiggyEscape/Tutorials/SceneKitToRealityKit.docc/Tutorials/Resources/01-ClosedWorld-03-01.swift create mode 100644 PiggyEscape/PiggyEscape/Tutorials/SceneKitToRealityKit.docc/Tutorials/Resources/01-ClosedWorld-04-01.swift create mode 100644 PiggyEscape/PiggyEscape/Tutorials/SceneKitToRealityKit.docc/Tutorials/Resources/01-ClosedWorld-05-01.swift 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/Project.swift b/PiggyEscape/Project.swift index 2f988f2..489bdcb 100644 --- a/PiggyEscape/Project.swift +++ b/PiggyEscape/Project.swift @@ -10,7 +10,7 @@ let project = Project( bundleId: "com.techmap.piggyescape", deploymentTargets: .iOS("17.0"), infoPlist: .default, - sources: ["PiggyEscape/Sources/**"], + sources: ["PiggyEscape/Sources/**", "PiggyEscape/Tutorials/**"], resources: ["PiggyEscape/Resources/**"] ), .target( diff --git a/docs/WORK_LOG.md b/docs/WORK_LOG.md index 17dd219..abb9695 100644 --- a/docs/WORK_LOG.md +++ b/docs/WORK_LOG.md @@ -4,11 +4,11 @@ ## 현재 인수인계 -- 상태: Chapter 1 Task 7 완료 (최종 재검토 승인) -- 진행 중 범위: `docs/superpowers/plans/2026-08-10-ch1-closed-world-implementation.md`의 Task 7 — ClosedWorldSceneView. 기존 구현과 1차 수정 뒤 실제 화면 검증에서 발견된 바닥·돼지 배치 문제까지 보정했다. -- 마지막 완료 범위: Task 1~6 (Tuist 스캐폴드, 에셋+AssetLoader, RoomBuilder, PigPlacement+FakeSofa, NodeInspector, HideAction) — 전부 검증·검토 완료. -- 마지막 검증: `xcodebuild ... test` 26/26 통과. Simulator에서 수평 바닥·돼지·가짜 소파가 한 프레임에 보임을 확인했다. 탭은 `Coordinator`가 HideAction을 시작하는 단위 테스트와, 표시된 씬에서 실제 액션 완료를 확인하는 HideAction 테스트로 검증했다. -- 다음 시작점: Task 8(DocC 카탈로그)을 시작한다. +- 상태: Chapter 1 Task 8 완료 +- 진행 중 범위: 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 **`로 완료했다. +- 다음 시작점: Chapter 1 결과물을 검토하거나 다음 챕터의 별도 설계를 시작한다. - 차단 요소: 없음. ### Task 7에서 해결한 항목 @@ -21,6 +21,7 @@ | 날짜 | 작업 범위 | 결과 | 검증 | 다음 시작점 | | --- | --- | --- | --- | --- | +| 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 | From 567578213730c4227c1ee7790897744ee8ae10ed Mon Sep 17 00:00:00 2001 From: eunseo-com Date: Mon, 10 Aug 2026 21:01:33 +0900 Subject: [PATCH 16/16] Record Chapter 1 review follow-ups --- docs/WORK_LOG.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/WORK_LOG.md b/docs/WORK_LOG.md index abb9695..1118ee5 100644 --- a/docs/WORK_LOG.md +++ b/docs/WORK_LOG.md @@ -4,11 +4,11 @@ ## 현재 인수인계 -- 상태: Chapter 1 Task 8 완료 -- 진행 중 범위: Chapter 1 `ClosedWorld` 구현과 DocC 카탈로그(Task 1~8)를 완료했다. +- 상태: 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 **`로 완료했다. -- 다음 시작점: Chapter 1 결과물을 검토하거나 다음 챕터의 별도 설계를 시작한다. +- 다음 시작점: 가짜 소파 바닥 정렬, 실제 장면의 NodeInspector 실행 예시, DocC의 가짜 소파 코드·화면 연결 설명, 공통 인수인계 문서 통합을 보완하고 재검토한다. - 차단 요소: 없음. ### Task 7에서 해결한 항목 @@ -21,6 +21,7 @@ | 날짜 | 작업 범위 | 결과 | 검증 | 다음 시작점 | | --- | --- | --- | --- | --- | +| 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 |