diff --git a/RoomPlanCatalogGenerator.xcodeproj/project.pbxproj b/RoomPlanCatalogGenerator.xcodeproj/project.pbxproj index 978c9cb..dcf346d 100644 --- a/RoomPlanCatalogGenerator.xcodeproj/project.pbxproj +++ b/RoomPlanCatalogGenerator.xcodeproj/project.pbxproj @@ -3,16 +3,27 @@ archiveVersion = 1; classes = { }; - objectVersion = 56; + objectVersion = 70; objects = { /* Begin PBXBuildFile section */ 070B96A62F8644FA00C0E62A /* RoomCaptureViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 070B96A52F8644FA00C0E62A /* RoomCaptureViewController.swift */; }; 070B96A82F86451700C0E62A /* RoomViewerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 070B96A72F86451700C0E62A /* RoomViewerView.swift */; }; 070B96AA2F8646D900C0E62A /* RoomOptimizerService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 070B96A92F8646D900C0E62A /* RoomOptimizerService.swift */; }; + AB01000000000000AB010000 /* ScanViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB01000000000000AB010001 /* ScanViewModel.swift */; }; + AB02000000000000AB020000 /* MainView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB02000000000000AB020001 /* MainView.swift */; }; + AB03000000000000AB030000 /* ScanGuideView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB03000000000000AB030001 /* ScanGuideView.swift */; }; + AB04000000000000AB040000 /* CountdownView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB04000000000000AB040001 /* CountdownView.swift */; }; + AB05000000000000AB050000 /* ScanningView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB05000000000000AB050001 /* ScanningView.swift */; }; + AB06000000000000AB060000 /* RoomResultView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB06000000000000AB060001 /* RoomResultView.swift */; }; + AB07000000000000AB070000 /* VOButtonStyles.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB07000000000000AB070001 /* VOButtonStyles.swift */; }; + AB08000000000000AB080000 /* LoadingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB08000000000000AB080001 /* LoadingView.swift */; }; + AB09000000000000AB090000 /* UploadCompleteView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB09000000000000AB090001 /* UploadCompleteView.swift */; }; + AB10000000000000AB100000 /* OptimizedResultView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB10000000000000AB100001 /* OptimizedResultView.swift */; }; + AB11000000000000AB110000 /* InquiryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB11000000000000AB110001 /* InquiryView.swift */; }; + AB12000000000000AB120000 /* InquiryResultView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB12000000000000AB120001 /* InquiryResultView.swift */; }; C427F92429F3703800930252 /* RoomPlanExporterApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = C427F92329F3703800930252 /* RoomPlanExporterApp.swift */; }; C427F92629F3703800930252 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C427F92529F3703800930252 /* ContentView.swift */; }; - C427F92829F3703800930252 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = C427F92729F3703800930252 /* Assets.xcassets */; }; C427F92B29F3703800930252 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = C427F92A29F3703800930252 /* Preview Assets.xcassets */; }; C48B87B629EE07A600D29743 /* RoomPlanCatalog.swift in Sources */ = {isa = PBXBuildFile; fileRef = C48B87B329EE07A600D29743 /* RoomPlanCatalog.swift */; }; C48B87B729EE07A600D29743 /* RoomPlanCatalogGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C48B87B429EE07A600D29743 /* RoomPlanCatalogGenerator.swift */; }; @@ -40,10 +51,21 @@ 070B96A72F86451700C0E62A /* RoomViewerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomViewerView.swift; sourceTree = ""; }; 070B96A92F8646D900C0E62A /* RoomOptimizerService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomOptimizerService.swift; sourceTree = ""; }; 070B96AB2F86475100C0E62A /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; + AB01000000000000AB010001 /* ScanViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanViewModel.swift; sourceTree = ""; }; + AB02000000000000AB020001 /* MainView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainView.swift; sourceTree = ""; }; + AB03000000000000AB030001 /* ScanGuideView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanGuideView.swift; sourceTree = ""; }; + AB04000000000000AB040001 /* CountdownView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CountdownView.swift; sourceTree = ""; }; + AB05000000000000AB050001 /* ScanningView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanningView.swift; sourceTree = ""; }; + AB06000000000000AB060001 /* RoomResultView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomResultView.swift; sourceTree = ""; }; + AB07000000000000AB070001 /* VOButtonStyles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VOButtonStyles.swift; sourceTree = ""; }; + AB08000000000000AB080001 /* LoadingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingView.swift; sourceTree = ""; }; + AB09000000000000AB090001 /* UploadCompleteView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UploadCompleteView.swift; sourceTree = ""; }; + AB10000000000000AB100001 /* OptimizedResultView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OptimizedResultView.swift; sourceTree = ""; }; + AB11000000000000AB110001 /* InquiryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InquiryView.swift; sourceTree = ""; }; + AB12000000000000AB120001 /* InquiryResultView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InquiryResultView.swift; sourceTree = ""; }; C427F92129F3703800930252 /* RoomPlanExporter.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = RoomPlanExporter.app; sourceTree = BUILT_PRODUCTS_DIR; }; C427F92329F3703800930252 /* RoomPlanExporterApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomPlanExporterApp.swift; sourceTree = ""; }; C427F92529F3703800930252 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; - C427F92729F3703800930252 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; C427F92A29F3703800930252 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; C47199DE2A05D856007C3501 /* README.md */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; C48B87A929EE075100D29743 /* RoomPlanCatalogGenerator */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = RoomPlanCatalogGenerator; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -56,6 +78,10 @@ F0023E5E5C3F6BC1636361AD /* SampleCode.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = SampleCode.xcconfig; path = Configuration/SampleCode.xcconfig; sourceTree = ""; }; /* End PBXFileReference section */ +/* Begin PBXFileSystemSynchronizedRootGroup section */ + 0707F8022FD98F3E000A5749 /* Resources */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Resources; sourceTree = ""; }; +/* End PBXFileSystemSynchronizedRootGroup section */ + /* Begin PBXFrameworksBuildPhase section */ C427F91E29F3703800930252 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; @@ -93,18 +119,62 @@ name = Configuration; sourceTree = ""; }; + AB21000000000000AB210000 /* Views */ = { + isa = PBXGroup; + children = ( + C427F92529F3703800930252 /* ContentView.swift */, + 070B96A72F86451700C0E62A /* RoomViewerView.swift */, + AB02000000000000AB020001 /* MainView.swift */, + AB03000000000000AB030001 /* ScanGuideView.swift */, + AB04000000000000AB040001 /* CountdownView.swift */, + AB05000000000000AB050001 /* ScanningView.swift */, + AB06000000000000AB060001 /* RoomResultView.swift */, + AB07000000000000AB070001 /* VOButtonStyles.swift */, + AB08000000000000AB080001 /* LoadingView.swift */, + AB09000000000000AB090001 /* UploadCompleteView.swift */, + AB10000000000000AB100001 /* OptimizedResultView.swift */, + AB11000000000000AB110001 /* InquiryView.swift */, + AB12000000000000AB120001 /* InquiryResultView.swift */, + ); + path = Views; + sourceTree = ""; + }; + AB22000000000000AB220000 /* ViewModels */ = { + isa = PBXGroup; + children = ( + AB01000000000000AB010001 /* ScanViewModel.swift */, + ); + path = ViewModels; + sourceTree = ""; + }; + AB23000000000000AB230000 /* Controllers */ = { + isa = PBXGroup; + children = ( + 070B96A52F8644FA00C0E62A /* RoomCaptureViewController.swift */, + ); + path = Controllers; + sourceTree = ""; + }; + AB24000000000000AB240000 /* Services */ = { + isa = PBXGroup; + children = ( + 070B96A92F8646D900C0E62A /* RoomOptimizerService.swift */, + ); + path = Services; + sourceTree = ""; + }; C427F92229F3703800930252 /* RoomPlanExporter */ = { isa = PBXGroup; children = ( + 0707F8022FD98F3E000A5749 /* Resources */, 070B96AB2F86475100C0E62A /* Info.plist */, C499B2B029F375A000E99C6C /* RoomPlanCatalog.bundle */, C427F92329F3703800930252 /* RoomPlanExporterApp.swift */, - C427F92529F3703800930252 /* ContentView.swift */, - C427F92729F3703800930252 /* Assets.xcassets */, C427F92929F3703800930252 /* Preview Content */, - 070B96A52F8644FA00C0E62A /* RoomCaptureViewController.swift */, - 070B96A72F86451700C0E62A /* RoomViewerView.swift */, - 070B96A92F8646D900C0E62A /* RoomOptimizerService.swift */, + AB21000000000000AB210000 /* Views */, + AB22000000000000AB220000 /* ViewModels */, + AB23000000000000AB230000 /* Controllers */, + AB24000000000000AB240000 /* Services */, ); path = RoomPlanExporter; sourceTree = ""; @@ -172,6 +242,9 @@ ); dependencies = ( ); + fileSystemSynchronizedGroups = ( + 0707F8022FD98F3E000A5749 /* Resources */, + ); name = RoomPlanExporter; productName = RoomPlanExporter; productReference = C427F92129F3703800930252 /* RoomPlanExporter.app */; @@ -246,7 +319,6 @@ files = ( C427F92B29F3703800930252 /* Preview Assets.xcassets in Resources */, C499B2B129F375A000E99C6C /* RoomPlanCatalog.bundle in Resources */, - C427F92829F3703800930252 /* Assets.xcassets in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -263,6 +335,18 @@ 070B96A82F86451700C0E62A /* RoomViewerView.swift in Sources */, 070B96AA2F8646D900C0E62A /* RoomOptimizerService.swift in Sources */, C427F92429F3703800930252 /* RoomPlanExporterApp.swift in Sources */, + AB01000000000000AB010000 /* ScanViewModel.swift in Sources */, + AB02000000000000AB020000 /* MainView.swift in Sources */, + AB03000000000000AB030000 /* ScanGuideView.swift in Sources */, + AB04000000000000AB040000 /* CountdownView.swift in Sources */, + AB05000000000000AB050000 /* ScanningView.swift in Sources */, + AB06000000000000AB060000 /* RoomResultView.swift in Sources */, + AB07000000000000AB070000 /* VOButtonStyles.swift in Sources */, + AB08000000000000AB080000 /* LoadingView.swift in Sources */, + AB09000000000000AB090000 /* UploadCompleteView.swift in Sources */, + AB10000000000000AB100000 /* OptimizedResultView.swift in Sources */, + AB11000000000000AB110000 /* InquiryView.swift in Sources */, + AB12000000000000AB120000 /* InquiryResultView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -291,7 +375,7 @@ DEVELOPMENT_TEAM = 87QYF25CFF; ENABLE_PREVIEWS = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; - GENERATE_INFOPLIST_FILE = YES; + GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = RoomPlanExporter/Info.plist; INFOPLIST_KEY_NSCalendarsUsageDescription = "This app does not use calendar access."; INFOPLIST_KEY_NSCameraUsageDescription = "방을 스캔하기 위해 카메라가 필요합니다."; @@ -332,7 +416,7 @@ DEVELOPMENT_TEAM = 87QYF25CFF; ENABLE_PREVIEWS = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; - GENERATE_INFOPLIST_FILE = YES; + GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = RoomPlanExporter/Info.plist; INFOPLIST_KEY_NSCalendarsUsageDescription = "This app does not use calendar access."; INFOPLIST_KEY_NSCameraUsageDescription = "방을 스캔하기 위해 카메라가 필요합니다."; diff --git a/RoomPlanExporter/Assets.xcassets/AppIcon.appiconset/100.png b/RoomPlanExporter/Assets.xcassets/AppIcon.appiconset/100.png deleted file mode 100644 index fb59690..0000000 Binary files a/RoomPlanExporter/Assets.xcassets/AppIcon.appiconset/100.png and /dev/null differ diff --git a/RoomPlanExporter/Assets.xcassets/AppIcon.appiconset/1024.png b/RoomPlanExporter/Assets.xcassets/AppIcon.appiconset/1024.png deleted file mode 100644 index 1b5d574..0000000 Binary files a/RoomPlanExporter/Assets.xcassets/AppIcon.appiconset/1024.png and /dev/null differ diff --git a/RoomPlanExporter/Assets.xcassets/AppIcon.appiconset/114.png b/RoomPlanExporter/Assets.xcassets/AppIcon.appiconset/114.png deleted file mode 100644 index 471f761..0000000 Binary files a/RoomPlanExporter/Assets.xcassets/AppIcon.appiconset/114.png and /dev/null differ diff --git a/RoomPlanExporter/Assets.xcassets/AppIcon.appiconset/120.png b/RoomPlanExporter/Assets.xcassets/AppIcon.appiconset/120.png deleted file mode 100644 index eb79f99..0000000 Binary files a/RoomPlanExporter/Assets.xcassets/AppIcon.appiconset/120.png and /dev/null differ diff --git a/RoomPlanExporter/Assets.xcassets/AppIcon.appiconset/144.png b/RoomPlanExporter/Assets.xcassets/AppIcon.appiconset/144.png deleted file mode 100644 index 915fa7b..0000000 Binary files a/RoomPlanExporter/Assets.xcassets/AppIcon.appiconset/144.png and /dev/null differ diff --git a/RoomPlanExporter/Assets.xcassets/AppIcon.appiconset/152.png b/RoomPlanExporter/Assets.xcassets/AppIcon.appiconset/152.png deleted file mode 100644 index 17050a2..0000000 Binary files a/RoomPlanExporter/Assets.xcassets/AppIcon.appiconset/152.png and /dev/null differ diff --git a/RoomPlanExporter/Assets.xcassets/AppIcon.appiconset/167.png b/RoomPlanExporter/Assets.xcassets/AppIcon.appiconset/167.png deleted file mode 100644 index fd78f92..0000000 Binary files a/RoomPlanExporter/Assets.xcassets/AppIcon.appiconset/167.png and /dev/null differ diff --git a/RoomPlanExporter/Assets.xcassets/AppIcon.appiconset/180.png b/RoomPlanExporter/Assets.xcassets/AppIcon.appiconset/180.png deleted file mode 100644 index e56099e..0000000 Binary files a/RoomPlanExporter/Assets.xcassets/AppIcon.appiconset/180.png and /dev/null differ diff --git a/RoomPlanExporter/Assets.xcassets/AppIcon.appiconset/20.png b/RoomPlanExporter/Assets.xcassets/AppIcon.appiconset/20.png deleted file mode 100644 index c69d735..0000000 Binary files a/RoomPlanExporter/Assets.xcassets/AppIcon.appiconset/20.png and /dev/null differ diff --git a/RoomPlanExporter/Assets.xcassets/AppIcon.appiconset/29.png b/RoomPlanExporter/Assets.xcassets/AppIcon.appiconset/29.png deleted file mode 100644 index 9a0b7e1..0000000 Binary files a/RoomPlanExporter/Assets.xcassets/AppIcon.appiconset/29.png and /dev/null differ diff --git a/RoomPlanExporter/Assets.xcassets/AppIcon.appiconset/40.png b/RoomPlanExporter/Assets.xcassets/AppIcon.appiconset/40.png deleted file mode 100644 index 0330925..0000000 Binary files a/RoomPlanExporter/Assets.xcassets/AppIcon.appiconset/40.png and /dev/null differ diff --git a/RoomPlanExporter/Assets.xcassets/AppIcon.appiconset/50.png b/RoomPlanExporter/Assets.xcassets/AppIcon.appiconset/50.png deleted file mode 100644 index 8167178..0000000 Binary files a/RoomPlanExporter/Assets.xcassets/AppIcon.appiconset/50.png and /dev/null differ diff --git a/RoomPlanExporter/Assets.xcassets/AppIcon.appiconset/57.png b/RoomPlanExporter/Assets.xcassets/AppIcon.appiconset/57.png deleted file mode 100644 index 4f0a050..0000000 Binary files a/RoomPlanExporter/Assets.xcassets/AppIcon.appiconset/57.png and /dev/null differ diff --git a/RoomPlanExporter/Assets.xcassets/AppIcon.appiconset/58.png b/RoomPlanExporter/Assets.xcassets/AppIcon.appiconset/58.png deleted file mode 100644 index 8c49a84..0000000 Binary files a/RoomPlanExporter/Assets.xcassets/AppIcon.appiconset/58.png and /dev/null differ diff --git a/RoomPlanExporter/Assets.xcassets/AppIcon.appiconset/60.png b/RoomPlanExporter/Assets.xcassets/AppIcon.appiconset/60.png deleted file mode 100644 index 881dc1a..0000000 Binary files a/RoomPlanExporter/Assets.xcassets/AppIcon.appiconset/60.png and /dev/null differ diff --git a/RoomPlanExporter/Assets.xcassets/AppIcon.appiconset/72.png b/RoomPlanExporter/Assets.xcassets/AppIcon.appiconset/72.png deleted file mode 100644 index d661000..0000000 Binary files a/RoomPlanExporter/Assets.xcassets/AppIcon.appiconset/72.png and /dev/null differ diff --git a/RoomPlanExporter/Assets.xcassets/AppIcon.appiconset/76.png b/RoomPlanExporter/Assets.xcassets/AppIcon.appiconset/76.png deleted file mode 100644 index 0cc2d4d..0000000 Binary files a/RoomPlanExporter/Assets.xcassets/AppIcon.appiconset/76.png and /dev/null differ diff --git a/RoomPlanExporter/Assets.xcassets/AppIcon.appiconset/80.png b/RoomPlanExporter/Assets.xcassets/AppIcon.appiconset/80.png deleted file mode 100644 index e939398..0000000 Binary files a/RoomPlanExporter/Assets.xcassets/AppIcon.appiconset/80.png and /dev/null differ diff --git a/RoomPlanExporter/Assets.xcassets/AppIcon.appiconset/87.png b/RoomPlanExporter/Assets.xcassets/AppIcon.appiconset/87.png deleted file mode 100644 index 2925a87..0000000 Binary files a/RoomPlanExporter/Assets.xcassets/AppIcon.appiconset/87.png and /dev/null differ diff --git a/RoomPlanExporter/ContentView.swift b/RoomPlanExporter/ContentView.swift deleted file mode 100644 index 3775af0..0000000 --- a/RoomPlanExporter/ContentView.swift +++ /dev/null @@ -1,389 +0,0 @@ -/* -See the LICENSE.txt file for this sample's licensing information. - -Abstract: -The sample app's primary view. -*/ - -import SwiftUI -import RoomPlan -import simd - -// MARK: - AppState - -enum AppState { - case idle - case scanning - case viewing(CapturedRoom) // USDZ 보여주기 - case uploading(CapturedRoom) // 저장 중 - case readyToOptimize(CapturedRoom, confirmCode: String) // 저장 완료 - case optimizing(CapturedRoom, confirmCode: String) // 최적화 중 - case optimized(CapturedRoom, [OptimizedObject]) // 최적화 완료 - case error(String) -} - -// MARK: - ModelProvider Extension - -extension CapturedRoom.ModelProvider { - enum CatalogError: LocalizedError { - case cannotFindCatalog - var errorDescription: String? { - switch self { - case .cannotFindCatalog: return "Cannot Find Catalog" - } - } - } - static func load() throws -> CapturedRoom.ModelProvider { - guard let catalogURL = Bundle.main.url(forResource: "RoomPlanCatalog", withExtension: "bundle") else { - throw CatalogError.cannotFindCatalog - } - return try RoomPlanCatalog.load(at: catalogURL) - } -} - -// MARK: - ContentView - -struct ContentView: View { - - @State private var appState: AppState = .idle - private let optimizer = RoomOptimizerService() - - // MARK: - Body - - var body: some View { - switch appState { - - case .idle: - idleView - - case .scanning: - RoomCaptureViewControllerRepresentable( - onFinish: { appState = .viewing($0) }, - onCancel: { appState = .idle } - ) - .ignoresSafeArea() - - case .error(let msg): - Text("").alert(msg, isPresented: .constant(true)) { - Button("확인") { appState = .idle } - } - - default: - if let room = currentRoom { - ZStack(alignment: .topLeading) { - RoomViewerView(capturedRoom: room, optimizedObjects: currentOptimizedObjects) - - // 다시 스캔하기 버튼 - Button(action: { appState = .idle }) { - HStack(spacing: 6) { - Image(systemName: "chevron.left") - Text("다시 스캔하기") - } - .font(.subheadline.weight(.semibold)) - .foregroundStyle(.white) - .padding(.horizontal, 14).padding(.vertical, 9) - .background(.ultraThinMaterial, in: Capsule()) - } - .padding(.top, 56).padding(.leading, 16) - - // 확인 코드 뱃지 - if let code = currentConfirmCode { - VStack { - HStack(spacing: 6) { - Image(systemName: "checkmark.seal.fill").foregroundStyle(.green) - Text("코드: \(code)") - .font(.system(.caption, design: .monospaced).weight(.semibold)) - } - .padding(.horizontal, 12).padding(.vertical, 6) - .background(.ultraThinMaterial, in: Capsule()) - .padding(.top, 56) - Spacer() - } - .frame(maxWidth: .infinity, alignment: .center) - } - - // 하단 버튼 - VStack { - Spacer() - bottomButtons(room: room).padding(.bottom, 48) - } - .frame(maxWidth: .infinity) - } - } - } - } - - // MARK: - 하단 버튼 (상태별) - - @ViewBuilder - private func bottomButtons(room: CapturedRoom) -> some View { - switch appState { - - case .viewing: - Button(action: { saveAndUpload(room: room) }) { - Label("저장하기", systemImage: "square.and.arrow.up") - .font(.body.weight(.semibold)) - .foregroundStyle(.white) - .padding(.horizontal, 40).padding(.vertical, 16) - .background(.green, in: Capsule()) - } - - case .uploading: - HStack(spacing: 8) { - ProgressView().tint(.white) - Text("저장 중...").foregroundStyle(.white).font(.subheadline) - } - .padding(.horizontal, 24).padding(.vertical, 14) - .background(.ultraThinMaterial, in: Capsule()) - - case .readyToOptimize(_, let code): - Button(action: { requestOptimize(room: room, confirmCode: code) }) { - Label("최적화 요청", systemImage: "sparkles") - .font(.body.weight(.semibold)) - .foregroundStyle(.white) - .padding(.horizontal, 40).padding(.vertical, 16) - .background(.blue, in: Capsule()) - } - - case .optimizing: - HStack(spacing: 8) { - ProgressView().tint(.white) - Text("최적화 중...").foregroundStyle(.white).font(.subheadline) - } - .padding(.horizontal, 24).padding(.vertical, 14) - .background(.ultraThinMaterial, in: Capsule()) - - case .optimized: - Label("최적화 완료", systemImage: "checkmark.circle.fill") - .foregroundStyle(.green) - .font(.subheadline.weight(.semibold)) - .padding(.horizontal, 24).padding(.vertical, 14) - .background(.ultraThinMaterial, in: Capsule()) - - default: - EmptyView() - } - } - - // MARK: - 시작 화면 - - var idleView: some View { - VStack(spacing: 20) { - Spacer() - Image(systemName: "camera.metering.matrix") - .font(.system(size: 60)).foregroundStyle(.blue) - Text("공간 스캔 & 배치 최적화") - .font(.title2.weight(.semibold)) - Text("LiDAR로 방을 스캔하면\n가구 배치를 자동으로 최적화해드려요") - .font(.subheadline).foregroundStyle(.secondary) - .multilineTextAlignment(.center) - Spacer() - Button(action: { appState = .scanning }) { - Label("스캔 시작", systemImage: "camera.fill") - .font(.body.weight(.semibold)) - .frame(maxWidth: .infinity).padding(.vertical, 16) - } - .buttonStyle(.borderedProminent).buttonBorderShape(.capsule) - .controlSize(.large).padding(.horizontal, 40) - Spacer() - } - .padding() - } - - // MARK: - AppState 헬퍼 - - private var currentRoom: CapturedRoom? { - switch appState { - case .viewing(let r): return r - case .uploading(let r): return r - case .readyToOptimize(let r, _): return r - case .optimizing(let r, _): return r - case .optimized(let r, _): return r - default: return nil - } - } - - private var currentOptimizedObjects: [OptimizedObject]? { - if case .optimized(_, let objs) = appState { return objs } - return nil - } - - private var currentConfirmCode: String? { - switch appState { - case .readyToOptimize(_, let code): return code - case .optimizing(_, let code): return code - case .optimized: return nil // 최적화 완료 후엔 숨김 - default: return nil - } - } - - // MARK: - 저장 & 업로드 - - private func saveAndUpload(room: CapturedRoom) { - appState = .uploading(room) - Task { - do { - let folderURL = try saveRoomData(room: room) - let response = try await optimizer.uploadScanZip(folderURL: folderURL) - print("✅ 업로드 완료 | confirm_code: \(response.confirmCode)") - appState = .readyToOptimize(room, confirmCode: response.confirmCode) - } catch { - print("❌ 저장 실패: \(error.localizedDescription)") - appState = .error("저장 실패: \(error.localizedDescription)") - } - } - } - - // MARK: - 최적화 요청 - - private func requestOptimize(room: CapturedRoom, confirmCode: String) { - appState = .optimizing(room, confirmCode: confirmCode) - Task { - do { - let objects = try await optimizer.requestOptimize(confirmCode: confirmCode) - appState = .optimized(room, objects) - } catch { - appState = .error("최적화 실패: \(error.localizedDescription)") - } - } - } - - // MARK: - 로컬 파일 저장 - - private func saveRoomData(room: CapturedRoom) throws -> URL { - let fm = FileManager.default - let ts = Int(Date().timeIntervalSince1970) - let exportFolder = URL(filePath: NSTemporaryDirectory()).appending(path: "ScanExport_\(ts)") - let modelsFolder = exportFolder.appending(path: "Models") - - try fm.createDirectory(at: exportFolder, withIntermediateDirectories: true) - try fm.createDirectory(at: modelsFolder, withIntermediateDirectories: true) - - // ModelProvider 한 번만 로드 - let mp = try? CapturedRoom.ModelProvider.load() - - // 가구별 usdc 복사 - var copiedModels: [String: String] = [:] - if let mp { - for obj in room.objects { - guard let src = try? mp.modelFileURL(for: obj) else { continue } - let dst = modelsFolder.appending(path: src.lastPathComponent) - if !fm.fileExists(atPath: dst.path()) { try? fm.copyItem(at: src, to: dst) } - copiedModels[obj.identifier.uuidString] = src.lastPathComponent - } - } - - // Room.usdz - let usdzURL = exportFolder.appending(path: "Room.usdz") - try? room.export(to: usdzURL, modelProvider: mp, exportOptions: [.parametric, .mesh, .model]) - - // Room_empty.usdz (가구 제거) - // CapturedRoom은 Codable을 공식 지원하지 않으므로 실패 시 건너뜀 - let emptyURL = exportFolder.appending(path: "Room_empty.usdz") - do { - let data = try JSONEncoder().encode(room) - if var json = (try JSONSerialization.jsonObject(with: data)) as? [String: Any] { - json["objects"] = [] - let d2 = try JSONSerialization.data(withJSONObject: json) - let emptyRoom = try JSONDecoder().decode(CapturedRoom.self, from: d2) - try emptyRoom.export(to: emptyURL, exportOptions: .parametric) - print("✅ Room_empty.usdz 저장") - } - } catch { - print("⚠️ Room_empty.usdz 생성 실패: \(error)") - } - - // room_data.json - let objects = room.objects.map { obj -> FurnitureData in - let t = obj.transform - return FurnitureData( - identifier: obj.identifier.uuidString, - category: String(describing: obj.category), - modelFileName: copiedModels[obj.identifier.uuidString], - center: [t.columns.3.x, t.columns.3.y, t.columns.3.z], - dimensions: [obj.dimensions.x, obj.dimensions.y, obj.dimensions.z], - frontVector: [-t.columns.2.x, -t.columns.2.y, -t.columns.2.z], - backVector: [ t.columns.2.x, t.columns.2.y, t.columns.2.z], - rightVector: [ t.columns.0.x, t.columns.0.y, t.columns.0.z], - leftVector: [-t.columns.0.x, -t.columns.0.y, -t.columns.0.z], - upVector: [ t.columns.1.x, t.columns.1.y, t.columns.1.z], - obbVertices: calcOBBVertices(obj), - transform: matrixToArray(t) - ) - } - - let floorY = room.floors.first?.transform.columns.3.y ?? 0 - let payload = RoomPayload( - scannedAt: ISO8601DateFormatter().string(from: Date()), - coordinateSystem: "RoomPlan (Y-up, meters, floor Y≈\(String(format: "%.2f", floorY))", - objectCount: objects.count, - objects: objects, - walls: room.walls.map { sdata($0) }, - floors: room.floors.map { sdata($0) }, - doors: room.doors.map { sdata($0) } - ) - let encoder = JSONEncoder() - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - try encoder.encode(payload).write(to: exportFolder.appending(path: "room_data.json")) - print("✅ room_data.json 저장") - - return exportFolder - } - - private func sdata(_ s: CapturedRoom.Surface) -> SurfaceData { - let t = s.transform - return SurfaceData( - center: [t.columns.3.x, t.columns.3.y, t.columns.3.z], - dimensions: [s.dimensions.x, s.dimensions.y], - transform: matrixToArray(t) - ) - } - - private func calcOBBVertices(_ obj: CapturedRoom.Object) -> [[Float]] { - let e = obj.dimensions; let t = obj.transform - let (hx, hy, hz) = (e.x/2, e.y/2, e.z/2) - let corners: [SIMD4] = [ - [-hx,-hy,-hz,1],[ hx,-hy,-hz,1],[-hx, hy,-hz,1],[ hx, hy,-hz,1], - [-hx,-hy, hz,1],[ hx,-hy, hz,1],[-hx, hy, hz,1],[ hx, hy, hz,1] - ] - return corners.map { v in let w = t*v; return [w.x, w.y, w.z] } - } - - private func matrixToArray(_ m: simd_float4x4) -> [[Float]] { - [ - [m.columns.0.x, m.columns.0.y, m.columns.0.z, m.columns.0.w], - [m.columns.1.x, m.columns.1.y, m.columns.1.z, m.columns.1.w], - [m.columns.2.x, m.columns.2.y, m.columns.2.z, m.columns.2.w], - [m.columns.3.x, m.columns.3.y, m.columns.3.z, m.columns.3.w] - ] - } -} - -// MARK: - JSON 모델 (room_data.json 구조) - -private struct FurnitureData: Encodable { - let identifier, category: String - let modelFileName: String? - let center, dimensions: [Float] - let frontVector, backVector, rightVector, leftVector, upVector: [Float] - let obbVertices: [[Float]] - let transform: [[Float]] -} - -private struct SurfaceData: Encodable { - let center, dimensions: [Float] - let transform: [[Float]] -} - -private struct RoomPayload: Encodable { - let scannedAt, coordinateSystem: String - let objectCount: Int - let objects: [FurnitureData] - let walls, floors, doors: [SurfaceData] -} - -// MARK: - Preview - -struct ContentView_Previews: PreviewProvider { - static var previews: some View { ContentView() } -} diff --git a/RoomPlanExporter/RoomCaptureViewController.swift b/RoomPlanExporter/Controllers/RoomCaptureViewController.swift similarity index 99% rename from RoomPlanExporter/RoomCaptureViewController.swift rename to RoomPlanExporter/Controllers/RoomCaptureViewController.swift index 2c06651..9d98f15 100644 --- a/RoomPlanExporter/RoomCaptureViewController.swift +++ b/RoomPlanExporter/Controllers/RoomCaptureViewController.swift @@ -69,7 +69,7 @@ class RoomCaptureViewController: UIViewController { let doneButton = UIButton(type: .system) doneButton.setTitle("스캔 완료", for: .normal) doneButton.titleLabel?.font = .systemFont(ofSize: 17, weight: .semibold) - doneButton.backgroundColor = .systemBlue + doneButton.backgroundColor = .voBlue doneButton.setTitleColor(.white, for: .normal) doneButton.layer.cornerRadius = 22 doneButton.translatesAutoresizingMaskIntoConstraints = false diff --git a/RoomPlanExporter/Info.plist b/RoomPlanExporter/Info.plist index fc0f798..316ecea 100644 --- a/RoomPlanExporter/Info.plist +++ b/RoomPlanExporter/Info.plist @@ -2,10 +2,71 @@ + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleLocalizations + + ko + en + + CFBundleDisplayName + V-O + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) NSAppTransportSecurity - NSAllowsLocalNetworking + NSAllowsArbitraryLoads + NSCameraUsageDescription + 방을 스캔하기 위해 카메라가 필요합니다. + NSCalendarsUsageDescription + This app does not use calendar access. + UIApplicationSupportsIndirectInputEvents + + UILaunchScreen + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + + UISupportedInterfaceOrientations~iphone + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UIAppFonts + + Pretendard-Black.otf + Pretendard-Bold.otf + Pretendard-ExtraBold.otf + Pretendard-ExtraLight.otf + Pretendard-Light.otf + Pretendard-Medium.otf + Pretendard-Regular.otf + Pretendard-SemiBold.otf + Pretendard-Thin.otf + diff --git a/RoomPlanExporter/Resources/Assets.xcassets/AccentColor.colorset/Contents.json b/RoomPlanExporter/Resources/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..3cd3805 --- /dev/null +++ b/RoomPlanExporter/Resources/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xE7", + "green" : "0xC7", + "red" : "0xB3" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xE7", + "green" : "0xC7", + "red" : "0xB3" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/RoomPlanExporter/Resources/Assets.xcassets/AppIcon.appiconset/100.png b/RoomPlanExporter/Resources/Assets.xcassets/AppIcon.appiconset/100.png new file mode 100644 index 0000000..44616c5 Binary files /dev/null and b/RoomPlanExporter/Resources/Assets.xcassets/AppIcon.appiconset/100.png differ diff --git a/RoomPlanExporter/Resources/Assets.xcassets/AppIcon.appiconset/1024.png b/RoomPlanExporter/Resources/Assets.xcassets/AppIcon.appiconset/1024.png new file mode 100644 index 0000000..13567b5 Binary files /dev/null and b/RoomPlanExporter/Resources/Assets.xcassets/AppIcon.appiconset/1024.png differ diff --git a/RoomPlanExporter/Resources/Assets.xcassets/AppIcon.appiconset/114.png b/RoomPlanExporter/Resources/Assets.xcassets/AppIcon.appiconset/114.png new file mode 100644 index 0000000..f277955 Binary files /dev/null and b/RoomPlanExporter/Resources/Assets.xcassets/AppIcon.appiconset/114.png differ diff --git a/RoomPlanExporter/Resources/Assets.xcassets/AppIcon.appiconset/120.png b/RoomPlanExporter/Resources/Assets.xcassets/AppIcon.appiconset/120.png new file mode 100644 index 0000000..8a8c7e3 Binary files /dev/null and b/RoomPlanExporter/Resources/Assets.xcassets/AppIcon.appiconset/120.png differ diff --git a/RoomPlanExporter/Resources/Assets.xcassets/AppIcon.appiconset/144.png b/RoomPlanExporter/Resources/Assets.xcassets/AppIcon.appiconset/144.png new file mode 100644 index 0000000..4593867 Binary files /dev/null and b/RoomPlanExporter/Resources/Assets.xcassets/AppIcon.appiconset/144.png differ diff --git a/RoomPlanExporter/Resources/Assets.xcassets/AppIcon.appiconset/152.png b/RoomPlanExporter/Resources/Assets.xcassets/AppIcon.appiconset/152.png new file mode 100644 index 0000000..5e54e8b Binary files /dev/null and b/RoomPlanExporter/Resources/Assets.xcassets/AppIcon.appiconset/152.png differ diff --git a/RoomPlanExporter/Resources/Assets.xcassets/AppIcon.appiconset/167.png b/RoomPlanExporter/Resources/Assets.xcassets/AppIcon.appiconset/167.png new file mode 100644 index 0000000..ca12913 Binary files /dev/null and b/RoomPlanExporter/Resources/Assets.xcassets/AppIcon.appiconset/167.png differ diff --git a/RoomPlanExporter/Resources/Assets.xcassets/AppIcon.appiconset/180.png b/RoomPlanExporter/Resources/Assets.xcassets/AppIcon.appiconset/180.png new file mode 100644 index 0000000..a473e7a Binary files /dev/null and b/RoomPlanExporter/Resources/Assets.xcassets/AppIcon.appiconset/180.png differ diff --git a/RoomPlanExporter/Resources/Assets.xcassets/AppIcon.appiconset/20.png b/RoomPlanExporter/Resources/Assets.xcassets/AppIcon.appiconset/20.png new file mode 100644 index 0000000..842c1c7 Binary files /dev/null and b/RoomPlanExporter/Resources/Assets.xcassets/AppIcon.appiconset/20.png differ diff --git a/RoomPlanExporter/Resources/Assets.xcassets/AppIcon.appiconset/29.png b/RoomPlanExporter/Resources/Assets.xcassets/AppIcon.appiconset/29.png new file mode 100644 index 0000000..09f1ec2 Binary files /dev/null and b/RoomPlanExporter/Resources/Assets.xcassets/AppIcon.appiconset/29.png differ diff --git a/RoomPlanExporter/Resources/Assets.xcassets/AppIcon.appiconset/40.png b/RoomPlanExporter/Resources/Assets.xcassets/AppIcon.appiconset/40.png new file mode 100644 index 0000000..563bef0 Binary files /dev/null and b/RoomPlanExporter/Resources/Assets.xcassets/AppIcon.appiconset/40.png differ diff --git a/RoomPlanExporter/Resources/Assets.xcassets/AppIcon.appiconset/50.png b/RoomPlanExporter/Resources/Assets.xcassets/AppIcon.appiconset/50.png new file mode 100644 index 0000000..3516858 Binary files /dev/null and b/RoomPlanExporter/Resources/Assets.xcassets/AppIcon.appiconset/50.png differ diff --git a/RoomPlanExporter/Resources/Assets.xcassets/AppIcon.appiconset/57.png b/RoomPlanExporter/Resources/Assets.xcassets/AppIcon.appiconset/57.png new file mode 100644 index 0000000..52823a4 Binary files /dev/null and b/RoomPlanExporter/Resources/Assets.xcassets/AppIcon.appiconset/57.png differ diff --git a/RoomPlanExporter/Resources/Assets.xcassets/AppIcon.appiconset/58.png b/RoomPlanExporter/Resources/Assets.xcassets/AppIcon.appiconset/58.png new file mode 100644 index 0000000..034b458 Binary files /dev/null and b/RoomPlanExporter/Resources/Assets.xcassets/AppIcon.appiconset/58.png differ diff --git a/RoomPlanExporter/Resources/Assets.xcassets/AppIcon.appiconset/60.png b/RoomPlanExporter/Resources/Assets.xcassets/AppIcon.appiconset/60.png new file mode 100644 index 0000000..ceea950 Binary files /dev/null and b/RoomPlanExporter/Resources/Assets.xcassets/AppIcon.appiconset/60.png differ diff --git a/RoomPlanExporter/Resources/Assets.xcassets/AppIcon.appiconset/72.png b/RoomPlanExporter/Resources/Assets.xcassets/AppIcon.appiconset/72.png new file mode 100644 index 0000000..6980774 Binary files /dev/null and b/RoomPlanExporter/Resources/Assets.xcassets/AppIcon.appiconset/72.png differ diff --git a/RoomPlanExporter/Resources/Assets.xcassets/AppIcon.appiconset/76.png b/RoomPlanExporter/Resources/Assets.xcassets/AppIcon.appiconset/76.png new file mode 100644 index 0000000..6643a59 Binary files /dev/null and b/RoomPlanExporter/Resources/Assets.xcassets/AppIcon.appiconset/76.png differ diff --git a/RoomPlanExporter/Resources/Assets.xcassets/AppIcon.appiconset/80.png b/RoomPlanExporter/Resources/Assets.xcassets/AppIcon.appiconset/80.png new file mode 100644 index 0000000..73344d0 Binary files /dev/null and b/RoomPlanExporter/Resources/Assets.xcassets/AppIcon.appiconset/80.png differ diff --git a/RoomPlanExporter/Resources/Assets.xcassets/AppIcon.appiconset/87.png b/RoomPlanExporter/Resources/Assets.xcassets/AppIcon.appiconset/87.png new file mode 100644 index 0000000..1d76e3b Binary files /dev/null and b/RoomPlanExporter/Resources/Assets.xcassets/AppIcon.appiconset/87.png differ diff --git a/RoomPlanExporter/Assets.xcassets/AppIcon.appiconset/Contents.json b/RoomPlanExporter/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json similarity index 100% rename from RoomPlanExporter/Assets.xcassets/AppIcon.appiconset/Contents.json rename to RoomPlanExporter/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json diff --git a/RoomPlanExporter/Assets.xcassets/Contents.json b/RoomPlanExporter/Resources/Assets.xcassets/Contents.json similarity index 100% rename from RoomPlanExporter/Assets.xcassets/Contents.json rename to RoomPlanExporter/Resources/Assets.xcassets/Contents.json diff --git a/RoomPlanExporter/Resources/Assets.xcassets/VOIcon.imageset/Contents.json b/RoomPlanExporter/Resources/Assets.xcassets/VOIcon.imageset/Contents.json new file mode 100644 index 0000000..dcc6fe0 --- /dev/null +++ b/RoomPlanExporter/Resources/Assets.xcassets/VOIcon.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "로고1024_W.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git "a/RoomPlanExporter/Resources/Assets.xcassets/VOIcon.imageset/\341\204\205\341\205\251\341\204\200\341\205\2511024_W.png" "b/RoomPlanExporter/Resources/Assets.xcassets/VOIcon.imageset/\341\204\205\341\205\251\341\204\200\341\205\2511024_W.png" new file mode 100644 index 0000000..efbb69d Binary files /dev/null and "b/RoomPlanExporter/Resources/Assets.xcassets/VOIcon.imageset/\341\204\205\341\205\251\341\204\200\341\205\2511024_W.png" differ diff --git a/RoomPlanExporter/Resources/Assets.xcassets/logo.imageset/98960103-C05F-43C7-8E9D-E0A1F80AD03B.png b/RoomPlanExporter/Resources/Assets.xcassets/logo.imageset/98960103-C05F-43C7-8E9D-E0A1F80AD03B.png new file mode 100644 index 0000000..d2a3b57 Binary files /dev/null and b/RoomPlanExporter/Resources/Assets.xcassets/logo.imageset/98960103-C05F-43C7-8E9D-E0A1F80AD03B.png differ diff --git a/RoomPlanExporter/Resources/Assets.xcassets/logo.imageset/Contents.json b/RoomPlanExporter/Resources/Assets.xcassets/logo.imageset/Contents.json new file mode 100644 index 0000000..9d87fcb --- /dev/null +++ b/RoomPlanExporter/Resources/Assets.xcassets/logo.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "98960103-C05F-43C7-8E9D-E0A1F80AD03B.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/RoomPlanExporter/Resources/Assets.xcassets/logo_white.imageset/Contents.json b/RoomPlanExporter/Resources/Assets.xcassets/logo_white.imageset/Contents.json new file mode 100644 index 0000000..d275739 --- /dev/null +++ b/RoomPlanExporter/Resources/Assets.xcassets/logo_white.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "로고_w.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git "a/RoomPlanExporter/Resources/Assets.xcassets/logo_white.imageset/\341\204\205\341\205\251\341\204\200\341\205\251_w.png" "b/RoomPlanExporter/Resources/Assets.xcassets/logo_white.imageset/\341\204\205\341\205\251\341\204\200\341\205\251_w.png" new file mode 100644 index 0000000..db15285 Binary files /dev/null and "b/RoomPlanExporter/Resources/Assets.xcassets/logo_white.imageset/\341\204\205\341\205\251\341\204\200\341\205\251_w.png" differ diff --git a/RoomPlanExporter/Assets.xcassets/AccentColor.colorset/Contents.json b/RoomPlanExporter/Resources/Color.xcassets/Contents.json similarity index 51% rename from RoomPlanExporter/Assets.xcassets/AccentColor.colorset/Contents.json rename to RoomPlanExporter/Resources/Color.xcassets/Contents.json index eb87897..73c0059 100644 --- a/RoomPlanExporter/Assets.xcassets/AccentColor.colorset/Contents.json +++ b/RoomPlanExporter/Resources/Color.xcassets/Contents.json @@ -1,9 +1,4 @@ { - "colors" : [ - { - "idiom" : "universal" - } - ], "info" : { "author" : "xcode", "version" : 1 diff --git a/RoomPlanExporter/Resources/Color.xcassets/Gray.colorset/Contents.json b/RoomPlanExporter/Resources/Color.xcassets/Gray.colorset/Contents.json new file mode 100644 index 0000000..5b5786d --- /dev/null +++ b/RoomPlanExporter/Resources/Color.xcassets/Gray.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x8B", + "green" : "0x8B", + "red" : "0x8B" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x8B", + "green" : "0x8B", + "red" : "0x8B" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/RoomPlanExporter/Resources/Color.xcassets/voBlue.colorset/Contents.json b/RoomPlanExporter/Resources/Color.xcassets/voBlue.colorset/Contents.json new file mode 100644 index 0000000..3cd3805 --- /dev/null +++ b/RoomPlanExporter/Resources/Color.xcassets/voBlue.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xE7", + "green" : "0xC7", + "red" : "0xB3" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xE7", + "green" : "0xC7", + "red" : "0xB3" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/RoomPlanExporter/Resources/Fonts/FontManager.swift b/RoomPlanExporter/Resources/Fonts/FontManager.swift new file mode 100644 index 0000000..aade4c7 --- /dev/null +++ b/RoomPlanExporter/Resources/Fonts/FontManager.swift @@ -0,0 +1,107 @@ +// +// FontManager.swift +// RoomPlanExporter +// +// Created by Jung Hyun Han on 6/10/26. +// Copyright © 2026 Apple. All rights reserved. +// + +import SwiftUI + +extension Font { + public enum Pretendard { + case semibold + case medium + case regular + case bold + case extraBold + case light + case custom(String) + + var value: String { + switch self { + case .semibold: + return "Pretendard-SemiBold" + case .medium: + return "Pretendard-Medium" + case .regular: + return "Pretendard-Regular" + case .bold: + return "Pretendard-Bold" + case .extraBold: + return "Pretendard-ExtraBold" + case .light: + return "Pretendard-Light" + case .custom(let name): + return name + } + } + } + + public enum Paperlogy { + case onboarding1 + case onboarding2 + case onboarding3 + + var value: String { + switch self { + case .onboarding1: + return "Paperlogy-6SemiBold" + case .onboarding2: + return "Paperlogy-7Bold" + case .onboarding3: + return "Paperlogy-5Medium" + } + } + } + + static func pretendard(_ type: Pretendard, size: CGFloat = 17) -> Font { + return .custom(type.value, size: size) + } + + static func paperlogy(_ type: Paperlogy, size: CGFloat = 17) -> Font { + return .custom(type.value, size: size) + } + + // MARK: - ExtraBold + static var extraBold24: Font { .pretendard(.extraBold, size: 24) } + + // MARK: - Bold + static var bold18: Font { .pretendard(.bold, size: 18) } + static var bold22: Font { .pretendard(.bold, size: 22) } + static var bold24: Font { .pretendard(.bold, size: 24) } + + // MARK: - SemiBold + static var semiBold38: Font { .pretendard(.semibold, size: 38) } + static var semiBold24: Font { .pretendard(.semibold, size: 24) } + static var semiBold20: Font { .pretendard(.semibold, size: 20) } //추가 + static var semiBold18: Font { .pretendard(.semibold, size: 18) } + static var semiBold16: Font { .pretendard(.semibold, size: 16) } + static var semiBold14: Font { .pretendard(.semibold, size: 14) } + static var semiBold13: Font { .pretendard(.semibold, size: 13) } + static var semiBold12: Font { .pretendard(.semibold, size: 12) } + static var semiBold10: Font { .pretendard(.semibold, size: 10) } + + // MARK: - Regular + static var regular20: Font { .pretendard(.regular, size: 20) } + static var regular18: Font { .pretendard(.regular, size: 18) } + static var regular14: Font { .pretendard(.regular, size: 14) } + static var regular13: Font { .pretendard(.regular, size: 13) } + static var regular12: Font { .pretendard(.regular, size: 12) } + static var regular11: Font { .pretendard(.regular, size: 11) } + static var regular10: Font { .pretendard(.regular, size: 10) } + static var regular09: Font { .pretendard(.regular, size: 9) } + + // MARK: - Medium + static var medium24: Font { .pretendard(.medium, size: 24) } //추가 + static var medium18: Font { .pretendard(.medium, size: 18) } + static var medium16: Font { .pretendard(.medium, size: 16) } + static var medium14: Font { .pretendard(.medium, size: 14) } + static var medium13: Font { .pretendard(.medium, size: 13) } + static var medium12: Font { .pretendard(.medium, size: 12) } + static var medium10: Font { .pretendard(.medium, size: 10) } + static var medium8: Font { .pretendard(.medium, size: 8) } + + // MARK: - Light + static var light14: Font { .pretendard(.light, size: 14) } +} diff --git a/RoomPlanExporter/Resources/Fonts/Pretendard-Black.otf b/RoomPlanExporter/Resources/Fonts/Pretendard-Black.otf new file mode 100644 index 0000000..a0d849e Binary files /dev/null and b/RoomPlanExporter/Resources/Fonts/Pretendard-Black.otf differ diff --git a/RoomPlanExporter/Resources/Fonts/Pretendard-Bold.otf b/RoomPlanExporter/Resources/Fonts/Pretendard-Bold.otf new file mode 100644 index 0000000..8e5e30a Binary files /dev/null and b/RoomPlanExporter/Resources/Fonts/Pretendard-Bold.otf differ diff --git a/RoomPlanExporter/Resources/Fonts/Pretendard-ExtraBold.otf b/RoomPlanExporter/Resources/Fonts/Pretendard-ExtraBold.otf new file mode 100644 index 0000000..388f3ca Binary files /dev/null and b/RoomPlanExporter/Resources/Fonts/Pretendard-ExtraBold.otf differ diff --git a/RoomPlanExporter/Resources/Fonts/Pretendard-ExtraLight.otf b/RoomPlanExporter/Resources/Fonts/Pretendard-ExtraLight.otf new file mode 100644 index 0000000..40c8b69 Binary files /dev/null and b/RoomPlanExporter/Resources/Fonts/Pretendard-ExtraLight.otf differ diff --git a/RoomPlanExporter/Resources/Fonts/Pretendard-Light.otf b/RoomPlanExporter/Resources/Fonts/Pretendard-Light.otf new file mode 100644 index 0000000..228679e Binary files /dev/null and b/RoomPlanExporter/Resources/Fonts/Pretendard-Light.otf differ diff --git a/RoomPlanExporter/Resources/Fonts/Pretendard-Medium.otf b/RoomPlanExporter/Resources/Fonts/Pretendard-Medium.otf new file mode 100644 index 0000000..0575069 Binary files /dev/null and b/RoomPlanExporter/Resources/Fonts/Pretendard-Medium.otf differ diff --git a/RoomPlanExporter/Resources/Fonts/Pretendard-Regular.otf b/RoomPlanExporter/Resources/Fonts/Pretendard-Regular.otf new file mode 100644 index 0000000..08bf4cf Binary files /dev/null and b/RoomPlanExporter/Resources/Fonts/Pretendard-Regular.otf differ diff --git a/RoomPlanExporter/Resources/Fonts/Pretendard-SemiBold.otf b/RoomPlanExporter/Resources/Fonts/Pretendard-SemiBold.otf new file mode 100644 index 0000000..e7e36ab Binary files /dev/null and b/RoomPlanExporter/Resources/Fonts/Pretendard-SemiBold.otf differ diff --git a/RoomPlanExporter/Resources/Fonts/Pretendard-Thin.otf b/RoomPlanExporter/Resources/Fonts/Pretendard-Thin.otf new file mode 100644 index 0000000..77e792d Binary files /dev/null and b/RoomPlanExporter/Resources/Fonts/Pretendard-Thin.otf differ diff --git a/RoomPlanExporter/RoomOptimizerService.swift b/RoomPlanExporter/RoomOptimizerService.swift deleted file mode 100644 index 5bedc61..0000000 --- a/RoomPlanExporter/RoomOptimizerService.swift +++ /dev/null @@ -1,230 +0,0 @@ -// -// Roomoptimizerservice.swift -// RoomPlanExporter -// -// Created by Jung Hyun Han on 4/8/26. -// Copyright © 2026 Apple. All rights reserved. -// - -import Foundation -import RoomPlan -import simd -import zlib - -// MARK: - OptimizedObject (서버 응답 모델) - -struct OptimizedObject: Codable, Identifiable { - var id: UUID { identifier } - let identifier: UUID - let category: String - let center: SIMD3 - let rotation: simd_quatf - - enum CodingKeys: String, CodingKey { - case identifier, category, center, rotation - } - - init(identifier: UUID, category: String, center: SIMD3, rotation: simd_quatf) { - self.identifier = identifier - self.category = category - self.center = center - self.rotation = rotation - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - identifier = try container.decode(UUID.self, forKey: .identifier) - category = try container.decode(String.self, forKey: .category) - - let centerArr = try container.decode([Float].self, forKey: .center) - center = SIMD3(centerArr[0], centerArr[1], centerArr[2]) - - let rotArr = try container.decode([Float].self, forKey: .rotation) - rotation = simd_quatf(ix: rotArr[0], iy: rotArr[1], iz: rotArr[2], r: rotArr[3]) - } - - func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(identifier, forKey: .identifier) - try container.encode(category, forKey: .category) - try container.encode([center.x, center.y, center.z], forKey: .center) - try container.encode([rotation.imag.x, rotation.imag.y, rotation.imag.z, rotation.real], forKey: .rotation) - } -} - -// MARK: - 서버 응답 모델 - -/// POST /api/v1/scans 업로드 응답 -struct UploadResponse: Codable { - let confirmCode: String - let objectCount: Int? - let uploadedFiles: [String]? - - enum CodingKeys: String, CodingKey { - case confirmCode = "confirm_code" - case objectCount = "object_count" - case uploadedFiles = "uploaded_files" - } -} - -/// POST /api/v1/scans/{confirm_code}/optimize 최적화 응답 -private struct OptimizeResponse: Codable { - let objects: [OptimizeObject] - - struct OptimizeObject: Codable { - let identifier: String - let category: String - let center: [Float] - let transform: [[Float]] - - func toOptimizedObject() -> OptimizedObject? { - guard let uuid = UUID(uuidString: identifier), center.count >= 3 else { return nil } - let pos = SIMD3(center[0], center[1], center[2]) - - let rotation: simd_quatf - if transform.count >= 4, - transform[0].count >= 4, transform[1].count >= 4, - transform[2].count >= 4, transform[3].count >= 4 { - let mat = simd_float4x4(columns: ( - SIMD4(transform[0][0], transform[0][1], transform[0][2], transform[0][3]), - SIMD4(transform[1][0], transform[1][1], transform[1][2], transform[1][3]), - SIMD4(transform[2][0], transform[2][1], transform[2][2], transform[2][3]), - SIMD4(transform[3][0], transform[3][1], transform[3][2], transform[3][3]) - )) - rotation = simd_quatf(mat) - } else { - rotation = simd_quatf(angle: 0, axis: [0, 1, 0]) - } - return OptimizedObject(identifier: uuid, category: category, center: pos, rotation: rotation) - } - } -} - -// MARK: - RoomOptimizerService - -actor RoomOptimizerService { - - private let baseURL = "http://172.30.1.62:8000/api/v1/scans" - - // MARK: - Scan Upload (zip 전송) - - func uploadScanZip(folderURL: URL) async throws -> UploadResponse { - let zipURL = folderURL.deletingLastPathComponent() - .appending(path: "\(folderURL.lastPathComponent).zip") - try createZip(from: folderURL, to: zipURL) - - let boundary = UUID().uuidString - var body = Data() - let zipData = try Data(contentsOf: zipURL) - - body += "--\(boundary)\r\n".data(using: .utf8)! - body += "Content-Disposition: form-data; name=\"scan_export\"; filename=\"\(zipURL.lastPathComponent)\"\r\n".data(using: .utf8)! - body += "Content-Type: application/zip\r\n\r\n".data(using: .utf8)! - body += zipData - body += "\r\n--\(boundary)--\r\n".data(using: .utf8)! - - var request = URLRequest(url: URL(string: baseURL)!) - request.httpMethod = "POST" - request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") - request.httpBody = body - request.timeoutInterval = 60 - - let (data, response) = try await URLSession.shared.data(for: request) - guard let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode) else { - print("❌ 서버 응답: \(String(data: data, encoding: .utf8) ?? "")") - throw OptimizerError.serverError - } - return try JSONDecoder().decode(UploadResponse.self, from: data) - } - - // MARK: - 배치 최적화 요청 - - func requestOptimize(confirmCode: String) async throws -> [OptimizedObject] { - let url = URL(string: "\(baseURL)/\(confirmCode)/optimize")! - var request = URLRequest(url: url) - request.httpMethod = "POST" - request.timeoutInterval = 60 - - let (data, response) = try await URLSession.shared.data(for: request) - guard let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode) else { - print("❌ 최적화 응답: \(String(data: data, encoding: .utf8) ?? "")") - throw OptimizerError.serverError - } - return try JSONDecoder().decode(OptimizeResponse.self, from: data).objects.compactMap { $0.toOptimizedObject() } - } - - // MARK: - Zip 생성 (Store, no compression) - - private func createZip(from folderURL: URL, to zipURL: URL) throws { - let fm = FileManager.default - guard let enumerator = fm.enumerator(at: folderURL, includingPropertiesForKeys: [.isDirectoryKey]) else { - throw OptimizerError.invalidResponse - } - - struct Entry { let name: Data; let data: Data; let crc: UInt32; let offset: UInt32 } - var zipData = Data() - var entries: [Entry] = [] - - for case let fileURL as URL in enumerator { - let isDir = (try? fileURL.resourceValues(forKeys: [.isDirectoryKey]))?.isDirectory == true - if isDir { continue } - let fileData = try Data(contentsOf: fileURL) - let relative = String(fileURL.path.dropFirst(folderURL.path.count + 1)) - guard let nameData = relative.data(using: .utf8) else { continue } - let crc = crc32Value(fileData) - let offset = UInt32(zipData.count) - - zipData += sig(0x04034b50) - zipData += u16(20); zipData += u16(0); zipData += u16(0) - zipData += u16(0); zipData += u16(0) - zipData += u32(crc); zipData += u32(UInt32(fileData.count)); zipData += u32(UInt32(fileData.count)) - zipData += u16(UInt16(nameData.count)); zipData += u16(0) - zipData += nameData; zipData += fileData - - entries.append(Entry(name: nameData, data: fileData, crc: crc, offset: offset)) - } - - let cdStart = UInt32(zipData.count) - for e in entries { - zipData += sig(0x02014b50) - zipData += u16(20); zipData += u16(20); zipData += u16(0); zipData += u16(0) - zipData += u16(0); zipData += u16(0) - zipData += u32(e.crc); zipData += u32(UInt32(e.data.count)); zipData += u32(UInt32(e.data.count)) - zipData += u16(UInt16(e.name.count)); zipData += u16(0); zipData += u16(0) - zipData += u16(0); zipData += u16(0); zipData += u32(0); zipData += u32(e.offset) - zipData += e.name - } - - let cdSize = UInt32(zipData.count) - cdStart - zipData += sig(0x06054b50) - zipData += u16(0); zipData += u16(0) - zipData += u16(UInt16(entries.count)); zipData += u16(UInt16(entries.count)) - zipData += u32(cdSize); zipData += u32(cdStart); zipData += u16(0) - - try zipData.write(to: zipURL) - } - - private func crc32Value(_ data: Data) -> UInt32 { - data.withUnsafeBytes { ptr in - UInt32(zlib.crc32(0, ptr.bindMemory(to: Bytef.self).baseAddress, uInt(data.count))) - } - } - - private func sig(_ v: UInt32) -> Data { u32(v) } - private func u16(_ v: UInt16) -> Data { Data([UInt8(v & 0xFF), UInt8(v >> 8)]) } - private func u32(_ v: UInt32) -> Data { Data([UInt8(v & 0xFF), UInt8((v>>8) & 0xFF), UInt8((v>>16) & 0xFF), UInt8(v>>24)]) } -} - -// MARK: - Error - -enum OptimizerError: LocalizedError { - case serverError - case invalidResponse - - var errorDescription: String? { - switch self { - case .serverError: return "서버 오류가 발생했습니다" - case .invalidResponse: return "응답 형식이 올바르지 않습니다" - } - } -} diff --git a/RoomPlanExporter/RoomViewerView.swift b/RoomPlanExporter/RoomViewerView.swift deleted file mode 100644 index a7fc176..0000000 --- a/RoomPlanExporter/RoomViewerView.swift +++ /dev/null @@ -1,309 +0,0 @@ -// -// RoomViewerView.swift -// RoomPlanExporter -// -// Created by Jung Hyun Han on 4/8/26. -// Copyright © 2026 Apple. All rights reserved. -// - -import SwiftUI -import RealityKit -import RoomPlan - -// MARK: - RoomViewerView (3D 뷰어 전용) - -struct RoomViewerView: View { - - let capturedRoom: CapturedRoom - var optimizedObjects: [OptimizedObject]? = nil - - @State private var usdzURL: URL? = nil - @State private var isGenerating = true - - var body: some View { - ZStack { - if isGenerating { - VStack(spacing: 16) { - ProgressView().progressViewStyle(.circular).scaleEffect(1.5) - Text("방 모델 생성 중...").font(.subheadline).foregroundStyle(.secondary) - } - } else { - RealityKitRoomView( - capturedRoom: capturedRoom, - usdzURL: usdzURL, - optimizedObjects: optimizedObjects - ) - .ignoresSafeArea() - } - } - .task { await generateUSDZ() } - } - - // MARK: - USDZ 생성 (시각화용) - - private func generateUSDZ() async { - isGenerating = true - defer { isGenerating = false } - - let tmp = URL(filePath: NSTemporaryDirectory()).appending(path: "RoomViewer") - try? FileManager.default.createDirectory(at: tmp, withIntermediateDirectories: true) - let outputURL = tmp.appending(path: "Room.usdz") - try? FileManager.default.removeItem(at: outputURL) - - do { - let mp = try CapturedRoom.ModelProvider.load() - try capturedRoom.export(to: outputURL, modelProvider: mp, exportOptions: .model) - await MainActor.run { usdzURL = outputURL } - print("✅ USDZ 생성 완료") - } catch { - print("⚠️ ModelProvider 실패: \(error) → 기본 메쉬") - try? capturedRoom.export(to: outputURL, exportOptions: [.parametric, .mesh]) - await MainActor.run { usdzURL = outputURL } - } - } -} - -// MARK: - RealityKit 뷰 - -struct RealityKitRoomView: UIViewRepresentable { - - let capturedRoom: CapturedRoom - var usdzURL: URL? - var optimizedObjects: [OptimizedObject]? - - func makeCoordinator() -> Coordinator { Coordinator() } - - func makeUIView(context: Context) -> ARView { - let arView = ARView(frame: .zero) - arView.environment.background = .color(UIColor(red: 0.96, green: 0.94, blue: 0.90, alpha: 1.0)) - arView.cameraMode = .nonAR - arView.autoresizingMask = [.flexibleWidth, .flexibleHeight] - - // 카메라 설정 - let camAnchor = AnchorEntity(world: .zero) - let camera = PerspectiveCamera() - camera.camera.fieldOfViewInDegrees = 60 - camera.position = SIMD3(0, 5, 5) - camera.look(at: .zero, from: camera.position, relativeTo: nil) - camAnchor.addChild(camera) - arView.scene.addAnchor(camAnchor) - - setupLighting(in: arView) - - let roomAnchor = AnchorEntity(world: .zero) - arView.scene.addAnchor(roomAnchor) - context.coordinator.roomAnchor = roomAnchor - context.coordinator.arView = arView - - // USDZ로 초기 씬 렌더링 - // (usdzURL은 RoomViewerView의 generateUSDZ 완료 후 RealityKitRoomView가 생성되므로 항상 설정됨) - if let url = usdzURL { - Task { await loadUSDZ(url: url, into: roomAnchor) } - } else { - renderScene(capturedRoom, in: roomAnchor) - } - - setupGestures(in: arView, coordinator: context.coordinator) - return arView - } - - func updateUIView(_ arView: ARView, context: Context) { - guard let optimizedObjects, - let roomAnchor = context.coordinator.roomAnchor, - !context.coordinator.hasAppliedOptimization else { return } - - context.coordinator.hasAppliedOptimization = true - - Task { @MainActor in - roomAnchor.children.removeAll() - await loadOptimizedScene(capturedRoom, objects: optimizedObjects, into: roomAnchor) - } - } - - // MARK: - USDZ 로드 - - @MainActor - private func loadUSDZ(url: URL, into anchor: AnchorEntity) async { - do { - let entity = try await Task.detached(priority: .userInitiated) { - try ModelEntity.loadModel(contentsOf: url) - }.value - anchor.addChild(entity) - print("✅ USDZ 로드 완료") - } catch { - print("❌ USDZ 로드 실패: \(error) → renderScene 폴백") - renderScene(capturedRoom, in: anchor) - } - } - - // MARK: - 최적화 씬 로드 (카탈로그 USDC 모델 사용) - - @MainActor - private func loadOptimizedScene(_ room: CapturedRoom, objects: [OptimizedObject], into anchor: AnchorEntity) async { - // 방 구조(벽/바닥/문/창문)만 먼저 렌더링 - renderSurfaces(room, in: anchor) - - let mp = try? CapturedRoom.ModelProvider.load() - - for opt in objects { - guard let original = room.objects.first(where: { $0.identifier == opt.identifier }) else { continue } - - var placed = false - - if let mp, let modelURL = try? mp.modelFileURL(for: original) { - do { - let model = try await Task.detached(priority: .userInitiated) { - try ModelEntity.loadModel(contentsOf: modelURL) - }.value - - // 모델 크기를 원본 dimensions에 맞게 스케일 조정 - let bounds = model.visualBounds(relativeTo: model) - let bSize = bounds.max - bounds.min - if bSize.x > 0.001 && bSize.y > 0.001 && bSize.z > 0.001 { - let d = original.dimensions - model.scale = SIMD3(d.x / bSize.x, d.y / bSize.y, d.z / bSize.z) - // pivot이 center가 아닐 경우 offset 보정 - let centerOffset = (bounds.max + bounds.min) / 2 - model.position = opt.center - centerOffset * model.scale - } else { - model.position = opt.center - } - model.orientation = opt.rotation - anchor.addChild(model) - placed = true - print("✅ 최적화 모델 배치: \(modelURL.lastPathComponent)") - } catch { - print("⚠️ 카탈로그 모델 로드 실패: \(error)") - } - } - - // 폴백: OBB 박스 - if !placed { - var mat = SimpleMaterial() - mat.color = .init(tint: colorForCategory(original.category)) - mat.roughness = 0.9; mat.metallic = 0.0 - let e = original.dimensions - let entity = ModelEntity( - mesh: .generateBox(width: e.x, height: e.y, depth: e.z, cornerRadius: 0.03), - materials: [mat] - ) - entity.position = opt.center - entity.orientation = opt.rotation - anchor.addChild(entity) - } - } - } - - // MARK: - 씬 렌더링 (named entities - 최적화 애니메이션용) - - private func renderSurfaces(_ room: CapturedRoom, in anchor: AnchorEntity) { - for s in room.walls { anchor.addChild(makeSurface(s, color: UIColor(white: 0.97, alpha: 1.0), depth: 0.04)) } - for s in room.floors { anchor.addChild(makeSurface(s, color: UIColor(red: 0.91, green: 0.87, blue: 0.80, alpha: 1.0), depth: 0.01)) } - for s in room.doors { anchor.addChild(makeSurface(s, color: UIColor(red: 0.75, green: 0.63, blue: 0.50, alpha: 1.0), depth: 0.04)) } - for s in room.windows { anchor.addChild(makeSurface(s, color: UIColor(red: 0.70, green: 0.85, blue: 0.95, alpha: 0.4), depth: 0.02)) } - } - - private func renderScene(_ room: CapturedRoom, in anchor: AnchorEntity) { - renderSurfaces(room, in: anchor) - for obj in room.objects { - var mat = SimpleMaterial() - mat.color = .init(tint: colorForCategory(obj.category)) - mat.roughness = 0.9; mat.metallic = 0.0 - let e = obj.dimensions - let entity = ModelEntity( - mesh: .generateBox(width: e.x, height: e.y, depth: e.z, cornerRadius: 0.03), - materials: [mat] - ) - entity.transform = Transform(matrix: obj.transform) - entity.name = "furniture_\(obj.identifier.uuidString)" - anchor.addChild(entity) - } - } - - private func makeSurface(_ s: CapturedRoom.Surface, color: UIColor, depth: Float) -> ModelEntity { - var mat = SimpleMaterial() - mat.color = .init(tint: color); mat.roughness = 0.9; mat.metallic = 0.0 - let entity = ModelEntity( - mesh: .generateBox(width: s.dimensions.x, height: s.dimensions.y, depth: depth), - materials: [mat] - ) - entity.transform = Transform(matrix: s.transform) - return entity - } - - // MARK: - 조명 - - private func setupLighting(in arView: ARView) { - let anchor = AnchorEntity(world: .zero) - let dir = DirectionalLight() - dir.light.intensity = 3000; dir.light.color = .white - dir.shadow = DirectionalLightComponent.Shadow(maximumDistance: 10) - dir.orientation = simd_quatf(angle: -.pi/3, axis: [1, 0, 0]) - anchor.addChild(dir) - let pt = PointLight() - pt.light.intensity = 1000; pt.position = [0, 4, 0] - anchor.addChild(pt) - arView.scene.addAnchor(anchor) - } - - // MARK: - 제스처 - - private func setupGestures(in arView: ARView, coordinator: Coordinator) { - arView.addGestureRecognizer(UIPinchGestureRecognizer(target: coordinator, action: #selector(Coordinator.handlePinch))) - arView.addGestureRecognizer(UIRotationGestureRecognizer(target: coordinator, action: #selector(Coordinator.handleRotation))) - arView.addGestureRecognizer(UIPanGestureRecognizer(target: coordinator, action: #selector(Coordinator.handlePan))) - } - - // MARK: - 카테고리별 색상 - - private func colorForCategory(_ category: CapturedRoom.Object.Category) -> UIColor { - switch category { - case .chair: return UIColor(red: 0.72, green: 0.83, blue: 0.90, alpha: 1.0) - case .sofa: return UIColor(red: 0.72, green: 0.83, blue: 0.90, alpha: 1.0) - case .table: return UIColor(red: 0.88, green: 0.82, blue: 0.72, alpha: 1.0) - case .bed: return UIColor(red: 0.95, green: 0.80, blue: 0.83, alpha: 1.0) - case .storage: return UIColor(red: 0.78, green: 0.82, blue: 0.76, alpha: 1.0) - case .television: return UIColor(red: 0.55, green: 0.55, blue: 0.58, alpha: 1.0) - case .refrigerator: return UIColor(red: 0.88, green: 0.90, blue: 0.92, alpha: 1.0) - case .washerDryer: return UIColor(red: 0.82, green: 0.88, blue: 0.92, alpha: 1.0) - default: return UIColor(red: 0.80, green: 0.78, blue: 0.75, alpha: 1.0) - } - } - - // MARK: - Coordinator - - class Coordinator: NSObject { - weak var arView: ARView? - var roomAnchor: AnchorEntity? - var hasAppliedOptimization: Bool = false - private var lastScale: Float = 1.0 - private var currentScale: Float = 1.0 - private var lastRotation: Float = 0 - - @objc func handlePinch(_ g: UIPinchGestureRecognizer) { - if g.state == .began { lastScale = currentScale } - currentScale = max(0.3, min(6.0, lastScale * Float(g.scale))) - roomAnchor?.scale = SIMD3(repeating: currentScale) - } - - @objc func handleRotation(_ g: UIRotationGestureRecognizer) { - if g.state == .began { lastRotation = 0 } - let delta = Float(g.rotation) - lastRotation - lastRotation = Float(g.rotation) - roomAnchor?.orientation *= simd_quatf(angle: -delta, axis: [0, 1, 0]) - } - - @objc func handlePan(_ g: UIPanGestureRecognizer) { - guard let arView else { return } - let delta = Float(g.translation(in: arView).x) * 0.005 - roomAnchor?.orientation *= simd_quatf(angle: delta, axis: [0, 1, 0]) - g.setTranslation(.zero, in: arView) - } - } -} - -// MARK: - SIMD 헬퍼 - -extension SIMD4 where Scalar == Float { - var xyz: SIMD3 { SIMD3(x, y, z) } -} diff --git a/RoomPlanExporter/Services/RoomOptimizerService.swift b/RoomPlanExporter/Services/RoomOptimizerService.swift new file mode 100644 index 0000000..16ca6e0 --- /dev/null +++ b/RoomPlanExporter/Services/RoomOptimizerService.swift @@ -0,0 +1,363 @@ +import Foundation +import RoomPlan +import simd + +// MARK: - 서버 요청 모델 (private) + +/// POST /rooms/start 요청 +private struct StartScanRequest: Encodable { + let includeRoomUsdz: Bool + let includeRoomEmptyUsdz: Bool + + enum CodingKeys: String, CodingKey { + case includeRoomUsdz = "include_room_usdz" + case includeRoomEmptyUsdz = "include_room_empty_usdz" + } +} + +/// POST /rooms/{confirm_code}/complete 요청 +private struct CompleteUploadRequest: Encodable { + let uploadedKeys: [String] + enum CodingKeys: String, CodingKey { case uploadedKeys = "uploaded_keys" } +} + +// MARK: - 서버 응답 모델 + +/// POST /rooms/start 응답 +struct StartScanResponse: Codable { + let confirmCode: String + let roomId: Int + let rawPrefix: String + let generatedPrefix: String + let expiresInSeconds: Int + let uploads: [UploadSlot] + + enum CodingKeys: String, CodingKey { + case confirmCode = "confirm_code" + case roomId = "room_id" + case rawPrefix = "raw_prefix" + case generatedPrefix = "generated_prefix" + case expiresInSeconds = "expires_in_seconds" + case uploads + } + + struct UploadSlot: Codable { + let logicalName: String + let s3Key: String + let presignedURL: String + let contentType: String + + enum CodingKeys: String, CodingKey { + case logicalName = "logical_name" + case s3Key = "s3_key" + case presignedURL = "presigned_url" + case contentType = "content_type" + } + } +} + +/// POST /rooms/{confirm_code}/complete 응답 +struct CompleteUploadResponse: Codable { + let confirmCode: String + let pipelineStarted: Bool + + enum CodingKeys: String, CodingKey { + case confirmCode = "confirm_code" + case pipelineStarted = "pipeline_started" + } +} + +/// GET /api/rooms/{confirm_code} 응답 (status 폴링) +struct RoomStatusResponse: Codable { + let roomId: Int? + let confirmCode: String + let status: String // "PENDING" | "PROCESSING" | "COMPLETED" | "ERROR" + + enum CodingKeys: String, CodingKey { + case roomId = "room_id" + case confirmCode = "confirm_code" + case status + } +} + +/// GET /api/rooms/{confirm_code}/versions 응답 – 버전 목록 +/// origin/optimized는 version_id 없음, vr_modified는 version_id 있음 +struct ScanVersion: Codable, Identifiable { + let versionType: String + let createdAt: String? + let versionId: String? // VR 수정본 등 삭제 가능한 버전의 식별자 + + /// Identifiable – version_type 을 id로 사용 (vr_modified는 versionId 우선) + var id: String { versionId ?? versionType } + + enum CodingKeys: String, CodingKey { + case versionType = "version_type" + case createdAt = "created_at" + case versionId = "version_id" + } + + /// 플레이스홀더 생성용 + init(placeholderType: String) { + self.versionType = placeholderType + self.createdAt = nil + self.versionId = nil + } + + var displayName: String { + switch versionType { + case "origin": return "원본" + case "optimized": return "최적화" + case "vr_modified": return "VR 수정본" + default: return versionType + } + } + + var systemIcon: String { + switch versionType { + case "origin": return "house" + case "optimized": return "sparkles" + case "vr_modified": return "visionpro" + default: return "doc" + } + } + + var supportsDownload: Bool { true } // placeholder는 DisplayVersion.isPlaceholder 로 처리 +} + +struct ScanDetail: Codable { + let roomId: Int? + let confirmCode: String + let createdAt: String? + var versions: [ScanVersion] + + enum CodingKeys: String, CodingKey { + case roomId = "room_id" + case confirmCode = "confirm_code" + case createdAt = "created_at" + case versions + } +} + +/// GET /api/rooms/{confirm_code}/origin|optimized 응답 +struct RoomVersionDetail: Codable { + let usdzUrl: String? // Room.usdz presigned URL (가구 포함 원본) + let usdzEmptyUrl: String? // Room_empty.usdz presigned URL (가구 없는 방 구조만, B안용) + let glbUrl: String? // output.glb presigned URL (Unity용) + let dataUrl: String? // room_data.roomplan_optimized.json (RoomPlan 좌표계, iOS용) + let unityDataUrl: String? // room_data.roomplan_optimized.unity.json (Unity 좌표계) — nullable + let modelUrls: [String: String]? // 카탈로그 모델 URL (key = modelFileName) + + enum CodingKeys: String, CodingKey { + case usdzUrl = "usdz_url" + case usdzEmptyUrl = "usdz_empty_url" + case glbUrl = "glb_url" + case dataUrl = "data_url" + case unityDataUrl = "unity_data_url" + case modelUrls = "model_urls" + } +} + +/// room_data.json / room_data.roomplan_optimized.json 파싱 모델 +struct RoomDataPayload: Codable { + let objects: [RoomObject] + let walls: [RoomSurface]? + let floors: [RoomSurface]? + let doors: [RoomSurface]? + let windows: [RoomSurface]? + + struct RoomObject: Codable { + let identifier: String + let category: String + let modelFileName: String? + let center: [Float] + let dimensions: [Float]? + let transform: [[Float]] + + var simdTransform: simd_float4x4? { + guard transform.count == 4, transform.allSatisfy({ $0.count >= 4 }) else { return nil } + return simd_float4x4(columns: ( + SIMD4(transform[0][0], transform[0][1], transform[0][2], transform[0][3]), + SIMD4(transform[1][0], transform[1][1], transform[1][2], transform[1][3]), + SIMD4(transform[2][0], transform[2][1], transform[2][2], transform[2][3]), + SIMD4(transform[3][0], transform[3][1], transform[3][2], transform[3][3]) + )) + } + } + + struct RoomSurface: Codable { + let center: [Float] + let dimensions: [Float] + let transform: [[Float]] + + var simdTransform: simd_float4x4? { + guard transform.count == 4, transform.allSatisfy({ $0.count >= 4 }) else { return nil } + return simd_float4x4(columns: ( + SIMD4(transform[0][0], transform[0][1], transform[0][2], transform[0][3]), + SIMD4(transform[1][0], transform[1][1], transform[1][2], transform[1][3]), + SIMD4(transform[2][0], transform[2][1], transform[2][2], transform[2][3]), + SIMD4(transform[3][0], transform[3][1], transform[3][2], transform[3][3]) + )) + } + } +} + +// MARK: - RoomOptimizerService + +actor RoomOptimizerService { + + private let apiBase = "http://43.201.10.153:8000/api" + private var roomsBase: String { "\(apiBase)/rooms" } + + // MARK: 📤 업로드 세션 시작 + + func startScanUpload(includeRoomUsdz: Bool = true, + includeRoomEmptyUsdz: Bool = false) async throws -> StartScanResponse { + let url = URL(string: "\(roomsBase)/start")! + var req = URLRequest(url: url) + req.httpMethod = "POST" + req.setValue("application/json", forHTTPHeaderField: "Content-Type") + req.httpBody = try JSONEncoder().encode( + StartScanRequest(includeRoomUsdz: includeRoomUsdz, + includeRoomEmptyUsdz: includeRoomEmptyUsdz) + ) + req.timeoutInterval = 30 + + let (data, response) = try await URLSession.shared.data(for: req) + guard let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode) else { + print("❌ start 응답: \(String(data: data, encoding: .utf8) ?? "")") + throw OptimizerError.serverError + } + return try JSONDecoder().decode(StartScanResponse.self, from: data) + } + + // MARK: S3 직접 업로드 (Presigned PUT) + + func uploadFileToS3(presignedURL: String, data: Data, contentType: String) async throws { + guard let url = URL(string: presignedURL) else { throw OptimizerError.invalidResponse } + var req = URLRequest(url: url) + req.httpMethod = "PUT" + req.setValue(contentType, forHTTPHeaderField: "Content-Type") + req.httpBody = data + req.timeoutInterval = 120 + + let (_, response) = try await URLSession.shared.data(for: req) + guard let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode) else { + print("❌ S3 PUT 실패: \((response as? HTTPURLResponse)?.statusCode ?? -1)") + throw OptimizerError.serverError + } + } + + // MARK: 업로드 완료 → 파이프라인 트리거 + + func completeScanUpload(confirmCode: String, + uploadedKeys: [String]) async throws -> CompleteUploadResponse { + // POST /api/rooms/{confirm_code}/complete (stale-connection 대비 최대 3회 재시도) + let url = URL(string: "\(roomsBase)/\(confirmCode)/complete")! + let body = try JSONEncoder().encode(CompleteUploadRequest(uploadedKeys: uploadedKeys)) + + var lastError: Error = OptimizerError.serverError + for attempt in 1...3 { + var req = URLRequest(url: url) + req.httpMethod = "POST" + req.setValue("application/json", forHTTPHeaderField: "Content-Type") + req.httpBody = body + req.timeoutInterval = 30 + + do { + let (data, response) = try await URLSession.shared.data(for: req) + guard let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode) else { + print("❌ complete 응답: \(String(data: data, encoding: .utf8) ?? "")") + throw OptimizerError.serverError + } + return try JSONDecoder().decode(CompleteUploadResponse.self, from: data) + } catch let err as NSError where err.code == NSURLErrorNetworkConnectionLost && attempt < 3 { + print("⚠️ complete 연결 끊김 (\(attempt)/3) – 재시도") + lastError = err + try await Task.sleep(for: .seconds(1)) + } + } + throw lastError + } + + // MARK: 🔍 룸 상태 조회 (최적화 완료 폴링) + + func fetchRoomStatus(confirmCode: String) async throws -> String { + let url = URL(string: "\(roomsBase)/\(confirmCode)")! + let (data, response) = try await URLSession.shared.data(from: url) + guard let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode) else { + throw OptimizerError.serverError + } + return try JSONDecoder().decode(RoomStatusResponse.self, from: data).status + } + + // MARK: 🗂️ 버전 목록 조회 (rooms API) + + func fetchRoomVersions(confirmCode: String) async throws -> ScanDetail { + let url = URL(string: "\(roomsBase)/\(confirmCode)/versions")! + let (data, response) = try await URLSession.shared.data(from: url) + guard let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode) else { + print("❌ 버전 목록 응답: \(String(data: data, encoding: .utf8) ?? "")") + throw OptimizerError.serverError + } + print("📋 버전 목록 원본: \(String(data: data, encoding: .utf8) ?? "")") + return try JSONDecoder().decode(ScanDetail.self, from: data) + } + + // saveAndUpload 완료 후 결과 조회용 (rooms API로 전환) + func fetchScanDetail(confirmCode: String) async throws -> ScanDetail { + try await fetchRoomVersions(confirmCode: confirmCode) + } + + // MARK: 📦 버전 상세 조회 (가구 배치 + USDC URL) + // GET /api/rooms/{confirm_code}/origin 또는 /api/rooms/{confirm_code}/optimized + + func fetchVersionDetail(confirmCode: String, versionType: String) async throws -> RoomVersionDetail { + let url = URL(string: "\(roomsBase)/\(confirmCode)/\(versionType)")! + let (data, response) = try await URLSession.shared.data(from: url) + guard let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode) else { + print("❌ 버전 상세 응답: \(String(data: data, encoding: .utf8) ?? "")") + throw OptimizerError.serverError + } + print("📦 버전 상세 원본: \(String(data: data, encoding: .utf8) ?? "")") + return try JSONDecoder().decode(RoomVersionDetail.self, from: data) + } + + /// Presigned URL에서 USDZ 다운로드 → 로컬 임시 파일 URL 반환 + func downloadUSDZ(from urlString: String) async throws -> URL { + guard let remoteURL = URL(string: urlString) else { throw OptimizerError.invalidResponse } + let (tempURL, _) = try await URLSession.shared.download(from: remoteURL) + let destURL = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent(UUID().uuidString + ".usdz") + try FileManager.default.moveItem(at: tempURL, to: destURL) + return destURL + } + + // MARK: 🗑️ 버전 삭제 (VR 수정본 전용) + // DELETE /api/rooms/{confirm_code}/versions/{version_id} + func deleteVersion(confirmCode: String, versionId: String) async throws { + let url = URL(string: "\(roomsBase)/\(confirmCode)/versions/\(versionId)")! + var req = URLRequest(url: url) + req.httpMethod = "DELETE" + let (data, response) = try await URLSession.shared.data(for: req) + guard let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode) else { + print("❌ 삭제 응답: \(String(data: data, encoding: .utf8) ?? "")") + throw OptimizerError.serverError + } + } +} + +// MARK: - Error + +enum OptimizerError: LocalizedError { + case serverError + case invalidResponse + case timeout + + var errorDescription: String? { + switch self { + case .serverError: return "서버 오류가 발생했습니다" + case .invalidResponse: return "응답 형식이 올바르지 않습니다" + case .timeout: return "최적화 시간이 초과되었습니다" + } + } +} diff --git a/RoomPlanExporter/ViewModels/ScanViewModel.swift b/RoomPlanExporter/ViewModels/ScanViewModel.swift new file mode 100644 index 0000000..e79a1d5 --- /dev/null +++ b/RoomPlanExporter/ViewModels/ScanViewModel.swift @@ -0,0 +1,404 @@ +import Foundation +import RoomPlan +import SceneKit +import simd + +// MARK: - ScanPhase + +enum ScanPhase { + case main + case guide + case countdown + case scanning + case result(CapturedRoom) // 스캔 완료 – 저장 전 + case uploading(CapturedRoom) // S3 파일 업로드 중 + case uploadComplete(CapturedRoom, confirmCode: String) // 업로드 완료 – 확인 코드 표시 + case processing(CapturedRoom, confirmCode: String) // 최적화 파이프라인 폴링 중 + case optimized(CapturedRoom, RoomVersionDetail) // 최적화 완료 – 결과 비교 + case inquiry // 확인 코드 입력 + case inquiryLoading(confirmCode: String) // 공간 조회 중 + case inquiryResult(ScanDetail) // 조회 / 최적화 완료 결과 +} + +extension ScanPhase: Equatable { + static func == (lhs: ScanPhase, rhs: ScanPhase) -> Bool { + switch (lhs, rhs) { + case (.main, .main), (.guide, .guide), (.countdown, .countdown), + (.scanning, .scanning), (.inquiry, .inquiry): return true + case (.result, .result), (.uploading, .uploading), + (.uploadComplete, .uploadComplete), (.processing, .processing), + (.optimized, .optimized), (.inquiryLoading, .inquiryLoading), + (.inquiryResult, .inquiryResult): return true + default: return false + } + } +} + +// MARK: - ScanViewModel + +@MainActor +final class ScanViewModel: ObservableObject { + @Published var phase: ScanPhase = .main + @Published var countdownValue: Int = 3 + @Published var isOptimizing: Bool = false // 최적화 진행 중 여부 + + private let optimizer = RoomOptimizerService() + + /// 최적화 요청 시 필요한 S3 키 (새 스캔 후 저장 시 채워짐) + private(set) var pendingConfirmCode: String? = nil + private(set) var pendingUploadedKeys: [String] = [] + + // MARK: Navigation + + func showGuide() { phase = .guide } + + func startCountdown() { + countdownValue = 3 + phase = .countdown + Task { + for i in stride(from: 3, through: 1, by: -1) { + countdownValue = i + try? await Task.sleep(for: .seconds(1)) + } + phase = .scanning + } + } + + func scanCompleted(_ room: CapturedRoom) { phase = .result(room) } + func retake() { + pendingConfirmCode = nil + pendingUploadedKeys = [] + phase = .main + } + func showInquiry() { phase = .inquiry } + + func saveAndUpload(room: CapturedRoom) { + phase = .uploading(room) + Task { + do { + // 1. 로컬 저장 + print("📁 [1/4] 로컬 저장 시작") + let (folderURL, usdzExists, emptyUsdzExists) = try saveRoomData(room: room) + print("📁 [1/4] 로컬 저장 완료 – USDZ=\(usdzExists), Empty=\(emptyUsdzExists)") + + // 2. 업로드 세션 시작 + print("🌐 [2/4] 업로드 세션 시작 (includeRoomUsdz=\(usdzExists), includeEmpty=\(emptyUsdzExists))") + let startResp = try await optimizer.startScanUpload(includeRoomUsdz: usdzExists, + includeRoomEmptyUsdz: emptyUsdzExists) + let confirmCode = startResp.confirmCode + print("🌐 [2/4] 세션 시작 완료 – confirmCode=\(confirmCode), 슬롯 \(startResp.uploads.count)개") + + // 3. 각 슬롯 파일을 S3에 PUT (room_data_json, room_usdz 두 슬롯) + var uploadedKeys: [String] = [] + for (i, slot) in startResp.uploads.enumerated() { + print("☁️ [3/4] S3 업로드 [\(i+1)/\(startResp.uploads.count)] \(slot.logicalName)") + let data = try readFileData(logicalName: slot.logicalName, folderURL: folderURL) + try await optimizer.uploadFileToS3( + presignedURL: slot.presignedURL, + data: data, + contentType: slot.contentType + ) + uploadedKeys.append(slot.s3Key) + print("☁️ [3/4] 업로드 완료 \(slot.logicalName) (\(data.count) bytes)") + } + + pendingConfirmCode = confirmCode + pendingUploadedKeys = uploadedKeys + + print("🎉 [4/4] 업로드 완료 → UploadCompleteView") + phase = .uploadComplete(room, confirmCode: confirmCode) + + } catch { + print("❌ 업로드 실패 (\(type(of: error))): \(error)") + phase = .result(room) + } + } + } + + /// logical_name → 실제 파일 Data 매핑 (room_data_json, room_usdz 두 슬롯만) + private func readFileData(logicalName: String, folderURL: URL) throws -> Data { + let fileURL: URL + switch logicalName { + case "room_data_json": fileURL = folderURL.appending(path: "room_data.json") + case "room_usdz": fileURL = folderURL.appending(path: "Room.usdz") + case "room_empty_usdz": fileURL = folderURL.appending(path: "Room_empty.usdz") + default: throw OptimizerError.invalidResponse + } + return try Data(contentsOf: fileURL) + } + + /// GET /rooms/{code}/optimized 폴링 → data_url(normalized.json) 나오면 바로 완료 (5초 간격, 최대 10분) + private func pollUntilComplete(confirmCode: String) async throws { + for attempt in 1...120 { + try await Task.sleep(for: .seconds(5)) + do { + let detail = try await optimizer.fetchVersionDetail( + confirmCode: confirmCode, versionType: "optimized") + if detail.dataUrl != nil { + print("✅ 폴링 \(attempt)/120 – normalized.json 확인됨, GLB 안 기다리고 진행") + return + } + } catch { + // 아직 없음 – 계속 폴링 + } + print("⏳ 폴링 \(attempt)/120 – optimized 아직 없음") + } + throw OptimizerError.timeout + } + + + // MARK: Inquiry (공간 조회) + + func fetchByCode(confirmCode: String) { + let code = confirmCode.trimmingCharacters(in: .whitespaces) + guard !code.isEmpty else { return } + phase = .inquiryLoading(confirmCode: code) + Task { + do { + let detail = try await optimizer.fetchRoomVersions(confirmCode: code) + phase = .inquiryResult(detail) + } catch { + print("❌ 조회 실패: \(error)") + phase = .inquiry + } + } + } + + func deleteVersion(confirmCode: String, versionId: String, from detail: ScanDetail) { + Task { + do { + try await optimizer.deleteVersion(confirmCode: confirmCode, versionId: versionId) + let updated = ScanDetail( + roomId: nil, + confirmCode: detail.confirmCode, + createdAt: detail.createdAt, + versions: detail.versions.filter { $0.versionId != versionId } + ) + phase = .inquiryResult(updated) + } catch { + print("❌ 삭제 실패: \(error)") + } + } + } + + // MARK: 최적화 요청 (UploadCompleteView 버튼) + + /// /complete 호출 → 파이프라인 시작 → 폴링 → 최적화 JSON 파싱 → OptimizedResultView + func requestOptimization(room: CapturedRoom, confirmCode: String) { + guard !isOptimizing else { return } + isOptimizing = true + // 즉시 로딩 화면으로 전환 + phase = .processing(room, confirmCode: confirmCode) + Task { + defer { isOptimizing = false } + do { + // 1. POST /api/rooms/{confirmCode}/complete → 파이프라인 트리거 + print("🚀 최적화 요청 – confirmCode=\(confirmCode)") + let resp = try await optimizer.completeScanUpload( + confirmCode: confirmCode, + uploadedKeys: pendingUploadedKeys + ) + print("🚀 pipelineStarted=\(resp.pipelineStarted)") + + // pipelineStarted=false → 파이프라인 미시작 (BE ARN 미설정 등) + // 폴링해도 optimized가 /versions에 절대 안 뜨므로 즉시 복귀 + guard resp.pipelineStarted else { + print("⚠️ pipelineStarted=false – BE의 Step Functions ARN 설정 필요") + phase = .uploadComplete(room, confirmCode: confirmCode) + return + } + + // 2. optimized가 /versions에 뜰 때까지 폴링 (5초 간격, 최대 5분) + print("⏳ 최적화 폴링 시작...") + try await pollUntilComplete(confirmCode: confirmCode) + print("✅ 최적화 완료") + + // 3. optimized 버전 상세 → OptimizedResultView (FurnitureRealityKitView가 직접 렌더링) + let versionDetail = try await optimizer.fetchVersionDetail( + confirmCode: confirmCode, versionType: "optimized") + print("📦 최적화 버전 상세 취득 – OptimizedResultView로 이동") + phase = .optimized(room, versionDetail) + + } catch { + print("❌ 최적화 요청 실패: \(error)") + // 에러 시 UploadCompleteView로 복귀 + phase = .uploadComplete(room, confirmCode: confirmCode) + } + } + } + + // MARK: Room Data Export + + /// 로컬 임시 폴더에 스캔 데이터 저장 + /// - returns: (폴더 URL, Room.usdz 생성 여부, Room_empty.usdz 생성 여부) + private func saveRoomData(room: CapturedRoom) throws -> (folderURL: URL, usdzExists: Bool, emptyUsdzExists: Bool) { + let fm = FileManager.default + let ts = Int(Date().timeIntervalSince1970) + let exportFolder = URL(filePath: NSTemporaryDirectory()).appending(path: "ScanExport_\(ts)") + try fm.createDirectory(at: exportFolder, withIntermediateDirectories: true) + + let mp = try? CapturedRoom.ModelProvider.load() + + let usdzURL = exportFolder.appending(path: "Room.usdz") + try? room.export(to: usdzURL, modelProvider: mp, exportOptions: [.parametric, .mesh, .model]) + let usdzExists = fm.fileExists(atPath: usdzURL.path()) + if !usdzExists { print("⚠️ Room.usdz 익스포트 실패 – usdz 없이 업로드") } + + // 빈 방 USDZ: Room.usdz를 SceneKit으로 로드 → 가구 제거 + 문/창문 배경색 처리 + let emptyUsdzURL = exportFolder.appending(path: "Room_empty.usdz") + if usdzExists { + makeEmptyUSDZ(from: usdzURL, output: emptyUsdzURL, room: room) + } + let emptyUsdzExists = fm.fileExists(atPath: emptyUsdzURL.path()) + print(emptyUsdzExists ? "✅ Room_empty.usdz 생성 완료" : "⚠️ Room_empty.usdz 생성 실패") + + let objects = room.objects.map { obj -> FurnitureData in + let t = obj.transform + let modelFileName = (try? mp?.modelFileURL(for: obj))?.lastPathComponent + return FurnitureData( + identifier: obj.identifier.uuidString, + category: String(describing: obj.category), + modelFileName: modelFileName, + center: [t.columns.3.x, t.columns.3.y, t.columns.3.z], + dimensions: [obj.dimensions.x, obj.dimensions.y, obj.dimensions.z], + frontVector: [-t.columns.2.x, -t.columns.2.y, -t.columns.2.z], + backVector: [ t.columns.2.x, t.columns.2.y, t.columns.2.z], + rightVector: [ t.columns.0.x, t.columns.0.y, t.columns.0.z], + leftVector: [-t.columns.0.x, -t.columns.0.y, -t.columns.0.z], + upVector: [ t.columns.1.x, t.columns.1.y, t.columns.1.z], + obbVertices: calcOBBVertices(obj), + transform: matrixToArray(t) + ) + } + + let floorY = room.floors.first?.transform.columns.3.y ?? 0 + let payload = RoomPayload( + scannedAt: ISO8601DateFormatter().string(from: Date()), + coordinateSystem: "RoomPlan (Y-up, meters, floor Y≈\(String(format: "%.2f", floorY))", + objectCount: objects.count, + objects: objects, + walls: room.walls.map { surfaceData($0) }, + floors: room.floors.map { surfaceData($0) }, + doors: room.doors.map { surfaceData($0) }, + windows: room.windows.map { surfaceData($0) } + ) + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + try encoder.encode(payload).write(to: exportFolder.appending(path: "room_data.json")) + + return (exportFolder, usdzExists, emptyUsdzExists) + } + + private func surfaceData(_ s: CapturedRoom.Surface) -> SurfaceData { + let t = s.transform + return SurfaceData( + center: [t.columns.3.x, t.columns.3.y, t.columns.3.z], + dimensions: [s.dimensions.x, s.dimensions.y], + transform: matrixToArray(t) + ) + } + + private func calcOBBVertices(_ obj: CapturedRoom.Object) -> [[Float]] { + let e = obj.dimensions; let t = obj.transform + let (hx, hy, hz) = (e.x / 2, e.y / 2, e.z / 2) + let corners: [SIMD4] = [ + [-hx,-hy,-hz,1],[ hx,-hy,-hz,1],[-hx, hy,-hz,1],[ hx, hy,-hz,1], + [-hx,-hy, hz,1],[ hx,-hy, hz,1],[-hx, hy, hz,1],[ hx, hy, hz,1] + ] + return corners.map { v in let w = t * v; return [w.x, w.y, w.z] } + } + + private func matrixToArray(_ m: simd_float4x4) -> [[Float]] { + [ + [m.columns.0.x, m.columns.0.y, m.columns.0.z, m.columns.0.w], + [m.columns.1.x, m.columns.1.y, m.columns.1.z, m.columns.1.w], + [m.columns.2.x, m.columns.2.y, m.columns.2.z, m.columns.2.w], + [m.columns.3.x, m.columns.3.y, m.columns.3.z, m.columns.3.w] + ] + } + + // MARK: - Room_empty.usdz 생성 + // Room.usdz를 SceneKit으로 로드, 가구 노드를 위치/이름으로 제거하고 재저장 + + /// Room.usdz → SceneKit: 가구 제거 + 문/창문 배경색(베이지) 재질 교체 → room_empty.usdz 저장. + /// SceneKit 재저장 시 OcclusionMaterial이 깨져 검정이 되므로, 배경색으로 교체해 뚫린 것처럼 보이게. + private func makeEmptyUSDZ(from source: URL, output: URL, room: CapturedRoom) { + guard let scene = try? SCNScene(url: source, options: nil) else { + print("⚠️ Empty USDZ: SCNScene 로드 실패"); return + } + + let furnitureCenters: [SIMD3] = room.objects.map { + SIMD3($0.transform.columns.3.x, $0.transform.columns.3.y, $0.transform.columns.3.z) + } + + let structuralKeywords: Set = ["wall", "floor", "ceiling"] + let doorWindowKeywords: Set = ["door", "window", "opening"] + let furnitureKeywords: Set = [ + "chair", "table", "sofa", "bed", "storage", "television", "tv", + "refrigerator", "washer", "dryer", "washerdryer", "toilet", "bathtub", + "sink", "stove", "oven", "dishwasher", "fireplace", "stairs", "screen", "object" + ] + + let bgMat: SCNMaterial = { + let m = SCNMaterial() + m.diffuse.contents = UIColor(red: 0.96, green: 0.94, blue: 0.90, alpha: 1.0) + m.lightingModel = .constant + return m + }() + + func applyBgMaterial(to node: SCNNode) { + if node.geometry != nil { node.geometry?.materials = [bgMat] } + node.childNodes.forEach { applyBgMaterial(to: $0) } + } + + func process(_ parent: SCNNode) { + var toRemove: [SCNNode] = [] + for child in parent.childNodes { + let name = child.name?.lowercased() ?? "" + if structuralKeywords.contains(where: { name.contains($0) }) { + process(child) + } else if doorWindowKeywords.contains(where: { name.contains($0) }) { + applyBgMaterial(to: child) + process(child) + } else if furnitureKeywords.contains(where: { name.contains($0) }) { + toRemove.append(child) + } else { + let wp = child.worldPosition + let pos = SIMD3(Float(wp.x), Float(wp.y), Float(wp.z)) + let isFurniture = furnitureCenters.contains { fc in + let d = pos - fc; return d.x*d.x + d.y*d.y + d.z*d.z < 0.49 + } + if isFurniture { toRemove.append(child) } else { process(child) } + } + } + toRemove.forEach { $0.removeFromParentNode() } + } + + process(scene.rootNode) + let ok = scene.write(to: output, options: nil, delegate: nil, progressHandler: nil) + print(ok ? "✅ Room_empty.usdz 저장 완료" : "⚠️ Room_empty.usdz 저장 실패") + } +} + +// MARK: - Local JSON Models (private) + +private struct FurnitureData: Encodable { + let identifier, category: String + let modelFileName: String? + let center, dimensions: [Float] + let frontVector, backVector, rightVector, leftVector, upVector: [Float] + let obbVertices: [[Float]] + let transform: [[Float]] +} + +private struct SurfaceData: Encodable { + let center, dimensions: [Float] + let transform: [[Float]] +} + +private struct RoomPayload: Encodable { + let scannedAt, coordinateSystem: String + let objectCount: Int + let objects: [FurnitureData] + let walls, floors, doors, windows: [SurfaceData] +} diff --git a/RoomPlanExporter/Views/ContentView.swift b/RoomPlanExporter/Views/ContentView.swift new file mode 100644 index 0000000..86dc4fd --- /dev/null +++ b/RoomPlanExporter/Views/ContentView.swift @@ -0,0 +1,84 @@ +import SwiftUI +import RoomPlan + +struct ContentView: View { + @StateObject private var vm = ScanViewModel() + + var body: some View { + ZStack { + switch vm.phase { + case .main: + MainView(vm: vm) + .transition(.opacity) + + case .guide: + ScanGuideView(vm: vm) + .transition(.opacity) + + case .countdown: + CountdownView(vm: vm) + .transition(.opacity) + + case .scanning: + ScanningView(vm: vm) + .transition(.opacity) + + case .result(let room): + RoomResultView(room: room, vm: vm) + .transition(.opacity) + + case .uploading: + LoadingView( + statusText: "내 방 저장 중..", + tips: [ + "데이터를 안전하게\n서버로 보내고 있어요", + "공간 데이터를 서버에 전송하고 있어요", + "저장 중입니다, 화면을 끄지 마세요", + "저장이 끝나면 확인 코드를 확인해보세요" + ] + ) + .transition(.opacity) + + case .uploadComplete(let room, let confirmCode): + UploadCompleteView(room: room, confirmCode: confirmCode, vm: vm) + .transition(.opacity) + + case .processing: + LoadingView( + statusText: "최적화 중..", + tips: [ + "최적화 진행 중입니다\n화면을 끄지 마세요", + "가구 배치를 분석하고 있어요", + "원본과 최적화 결과를\n비교해볼 수 있어요", + "잠시 후 최적화 결과가 나와요" + ] + ) + .transition(.opacity) + + case .optimized(let room, let versionDetail): + OptimizedResultView(room: room, versionDetail: versionDetail, vm: vm) + .transition(.opacity) + + case .inquiry: + InquiryView(vm: vm) + .transition(.opacity) + + case .inquiryLoading: + InquiryLoadingView(tips: [ + "저장된 데이터를 찾아\n불러오고 있어요", + "저장된 공간 데이터를 불러오고 있어요", + "원본과 최적화 결과를\n모두 확인할 수 있어요", + "잠시 후 결과가 나와요" + ]) + .transition(.opacity) + + case .inquiryResult(let detail): + InquiryResultView(detail: detail, vm: vm) + .transition(.opacity) + } + } + .animation(.easeInOut(duration: 0.3), value: vm.phase) + } +} + +#Preview { ContentView() } diff --git a/RoomPlanExporter/Views/CountdownView.swift b/RoomPlanExporter/Views/CountdownView.swift new file mode 100644 index 0000000..681df53 --- /dev/null +++ b/RoomPlanExporter/Views/CountdownView.swift @@ -0,0 +1,30 @@ +import SwiftUI + +struct CountdownView: View { + @ObservedObject var vm: ScanViewModel + + var body: some View { + ZStack { + Color.black.ignoresSafeArea() + VStack(spacing: 20) { + Text("\(vm.countdownValue)") + .font(.system(size: 50, weight: .bold)) + .foregroundStyle(.white) + .monospacedDigit() + .contentTransition(.numericText(countsDown: true)) + .animation(.easeInOut(duration: 0.3), value: vm.countdownValue) + + Text("곧 촬영이 시작됩니다\n준비해주세요!") + .font(.body) + .foregroundStyle(.white.opacity(0.8)) + .multilineTextAlignment(.center) + } + } + } +} + +#Preview { + let vm = ScanViewModel() + vm.countdownValue = 3 + return CountdownView(vm: vm) +} diff --git a/RoomPlanExporter/Views/InquiryResultView.swift b/RoomPlanExporter/Views/InquiryResultView.swift new file mode 100644 index 0000000..96485ad --- /dev/null +++ b/RoomPlanExporter/Views/InquiryResultView.swift @@ -0,0 +1,289 @@ +import SwiftUI + +struct InquiryResultView: View { + let detail: ScanDetail + @ObservedObject var vm: ScanViewModel + + @State private var selectedIndex: Int = 0 + @State private var showDeleteAlert = false + @State private var versionToDelete: ScanVersion? = nil + @State private var isTransparent: Bool = false + + private enum ViewerState { case idle, loading, loaded(RoomVersionDetail), error } + @State private var viewerState: ViewerState = .idle + @State private var cachedData: [String: RoomVersionDetail] = [:] + + private let service = RoomOptimizerService() + + // MARK: 버전 목록 (실제 버전 + VR 플레이스홀더) + + private var displayVersions: [DisplayVersion] { + let real = detail.versions.map { DisplayVersion(version: $0, isPlaceholder: false) } + let hasVR = detail.versions.contains { $0.versionType == "vr_modified" } + if hasVR { return real } + let placeholder = ScanVersion(placeholderType: "vr_modified") + return real + [DisplayVersion(version: placeholder, isPlaceholder: true)] + } + + private var selectedDisplay: DisplayVersion? { + guard selectedIndex < displayVersions.count else { return nil } + return displayVersions[selectedIndex] + } + + private var isViewerLoaded: Bool { + if case .loaded = viewerState { return true } + return false + } + + // MARK: Body + + var body: some View { + VStack(spacing: 0) { + banner + + // 3D 뷰어 (메인 영역) + ZStack { + Color(.secondarySystemBackground) + .frame(maxWidth: .infinity, maxHeight: .infinity) + + viewerContent + } + .overlay(alignment: .bottomTrailing) { + RoomStyleToggle(isTransparent: $isTransparent) + .padding(12) + } + + Divider() + + // 버전 리스트 패널 + versionListPanel + + Divider() + + // 메인으로 돌아가기 + Button("메인으로") { vm.retake() } + .buttonStyle(VOFilledButtonStyle()) + .padding(.horizontal, 50) + .padding(.vertical, 10) + } + .confirmationDialog( + "이 버전을 삭제하시겠어요?", + isPresented: $showDeleteAlert, + titleVisibility: .visible + ) { + Button("삭제", role: .destructive) { + if let v = versionToDelete, let vid = v.versionId { + vm.deleteVersion(confirmCode: detail.confirmCode, versionId: vid, from: detail) + } + } + Button("취소", role: .cancel) {} + } message: { + Text("삭제된 버전은 복구할 수 없어요") + } + .onAppear { loadViewer() } + .onChange(of: selectedIndex) { _, _ in loadViewer() } + } + + // MARK: 배너 + + private var banner: some View { + HStack(spacing: 10) { + Image("logo_white") + .resizable() + .scaledToFit() + .frame(height: 32) + VStack(alignment: .leading, spacing: 1) { + Text("공간 조회 결과") + .font(.subheadline.weight(.medium)) + .foregroundStyle(.white) + Text(detail.confirmCode) + .font(.caption.bold()) + .tracking(2) + .foregroundStyle(.white.opacity(0.8)) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 12) + .padding(.horizontal, 20) + .background(Color.voBlue) + } + + // MARK: 뷰어 콘텐츠 + + @ViewBuilder + private var viewerContent: some View { + switch viewerState { + case .idle: + EmptyView() + case .loading: + VStack(spacing: 16) { + ProgressView().progressViewStyle(.circular).scaleEffect(1.5) + Text("3D 모델 불러오는 중...") + .font(.subheadline).foregroundStyle(.secondary) + } + case .loaded(let versionDetail): + FurnitureRealityKitView(detail: versionDetail, isTransparent: isTransparent) + .id("\(versionDetail.dataUrl ?? versionDetail.usdzUrl ?? "\(selectedIndex)")-\(isTransparent)") + .ignoresSafeArea() + case .error: + VStack(spacing: 12) { + Image(systemName: "exclamationmark.triangle") + .font(.system(size: 36)).foregroundStyle(.secondary) + Text("모델을 불러오지 못했어요") + .font(.subheadline).foregroundStyle(.secondary) + Button("다시 시도") { loadViewer(force: true) } + .font(.subheadline).foregroundStyle(Color.voBlue) + } + } + } + + // MARK: 버전 리스트 패널 + + private var versionListPanel: some View { + ScrollView(showsIndicators: false) { + VStack(spacing: 0) { + ForEach(Array(displayVersions.enumerated()), id: \.offset) { index, dv in + VersionRow( + version: dv.version, + isSelected: index == selectedIndex && !dv.isPlaceholder, + isPlaceholder: dv.isPlaceholder, + onSelect: { + withAnimation(.easeInOut(duration: 0.2)) { selectedIndex = index } + }, + onDelete: { + versionToDelete = dv.version + showDeleteAlert = true + } + ) + if index < displayVersions.count - 1 { + Divider().padding(.leading, 54) + } + } + } + } + .frame(maxHeight: 180) + .background(Color(.systemBackground)) + } + + // MARK: 버전 상세 다운로드 & 캐시 + + private func loadViewer(force: Bool = false) { + guard let dv = selectedDisplay, !dv.isPlaceholder else { + viewerState = .idle + return + } + let cacheKey = dv.version.versionType // "origin" | "optimized" + + if !force, let cached = cachedData[cacheKey] { + viewerState = .loaded(cached) + return + } + + viewerState = .loading + + Task { + do { + let versionDetail = try await service.fetchVersionDetail( + confirmCode: detail.confirmCode, + versionType: dv.version.versionType + ) + cachedData[cacheKey] = versionDetail + viewerState = .loaded(versionDetail) + } catch { + print("❌ 버전 상세 로드 실패: \(error)") + viewerState = .error + } + } + } +} + +// MARK: - DisplayVersion + +private struct DisplayVersion { + let version: ScanVersion + let isPlaceholder: Bool +} + +// MARK: - VersionRow + +private struct VersionRow: View { + let version: ScanVersion + let isSelected: Bool + let isPlaceholder: Bool + let onSelect: () -> Void + let onDelete: () -> Void + + // 삭제는 VR 수정본(vr_modified)만 가능 + private var canDelete: Bool { + !isPlaceholder && version.versionType == "vr_modified" && version.versionId != nil + } + + var body: some View { + HStack(spacing: 14) { + // 라디오 인디케이터 + Image(systemName: isSelected ? "largecircle.fill.circle" : "circle") + .font(.system(size: 20)) + .foregroundStyle(isSelected ? Color.voBlue : Color(.tertiaryLabel)) + + // 카테고리 아이콘 + Image(systemName: version.systemIcon) + .font(.subheadline) + .foregroundStyle(isSelected ? Color.voBlue : .secondary) + .frame(width: 20) + + // 이름 + 날짜 + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 6) { + Text(version.displayName) + .font(.subheadline.weight(isSelected ? .semibold : .regular)) + .foregroundStyle( + isPlaceholder ? .tertiary : + isSelected ? .primary : .secondary + ) + + if isPlaceholder { + Text("준비 중") + .font(.caption2.bold()) + .foregroundStyle(.white) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Color.secondary.opacity(0.4), in: Capsule()) + } + } + + if let date = version.createdAt { + Text(shortDate(date)) + .font(.caption2) + .foregroundStyle(.tertiary) + } + } + + Spacer() + + // 삭제 버튼 (원본 제외, 플레이스홀더 제외) + if canDelete { + Button(action: onDelete) { + Image(systemName: "trash") + .font(.subheadline) + .foregroundStyle(.red.opacity(0.75)) + .padding(8) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 20) + .padding(.vertical, 8) + .background(isSelected ? Color.voBlue.opacity(0.06) : Color.clear) + .contentShape(Rectangle()) + .onTapGesture { if !isPlaceholder { onSelect() } } + } + + private func shortDate(_ iso: String) -> String { + let f = ISO8601DateFormatter() + guard let d = f.date(from: iso) else { return "" } + let df = DateFormatter() + df.locale = Locale(identifier: "ko_KR") + df.dateFormat = "MM.dd HH:mm" + return df.string(from: d) + } +} diff --git a/RoomPlanExporter/Views/InquiryView.swift b/RoomPlanExporter/Views/InquiryView.swift new file mode 100644 index 0000000..4894b8a --- /dev/null +++ b/RoomPlanExporter/Views/InquiryView.swift @@ -0,0 +1,163 @@ +import SwiftUI + +struct InquiryView: View { + @ObservedObject var vm: ScanViewModel + + @State private var confirmCode = "" + @FocusState private var isFocused: Bool + + private var canSubmit: Bool { + !confirmCode.trimmingCharacters(in: .whitespaces).isEmpty + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + Button { vm.retake() } label: { + Image(systemName: "chevron.left") + .font(.semiBold20) + .foregroundStyle(.black) + .padding(.horizontal, 30) + .padding(.top, 20) + .padding(.bottom, 40) + } + + VStack(alignment: .leading, spacing: 10) { + Text("공간 조회 🔍") + .font(.title2.bold()) + Text("이전에 저장한 공간 및 최적화 결과를 확인하고 싶다면\n발행된 확인 코드를 입력해주세요") + .font(.subheadline) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 30) + .padding(.bottom, 48) + + // 언더라인 스타일 텍스트필드 + VStack(spacing: 0) { + TextField("확인 코드 입력", text: $confirmCode) + .textInputAutocapitalization(.characters) + .autocorrectionDisabled() + .font(.title3.bold()) + .tracking(4) + .focused($isFocused) + .padding(.bottom, 10) + .onSubmit { + if canSubmit { vm.fetchByCode(confirmCode: confirmCode) } + } + + Rectangle() + .frame(height: 1.5) + .foregroundStyle(Color.voBlue) + } + .padding(.horizontal, 30) + + Spacer() + + Button("조회하기") { + isFocused = false + vm.fetchByCode(confirmCode: confirmCode) + } + .buttonStyle(VOFilledButtonStyle()) + .disabled(!canSubmit) + .opacity(canSubmit ? 1 : 0.5) + .padding(.horizontal, 50) + .padding(.bottom, 52) + } + .onTapGesture { isFocused = false } + } +} + +// MARK: - 조회 중 로딩 화면 (InquiryView 헤더 + LoadingView 애니메이션) + +struct InquiryLoadingView: View { + let tips: [String] + + @State private var tipIndex = 0 + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + Image(systemName: "chevron.left") + .font(.semiBold20) + .foregroundStyle(.black) + .padding(.horizontal, 30) + .padding(.top, 20) + .padding(.bottom, 40) + + VStack(alignment: .leading, spacing: 10) { + Text("공간 조회 🔍") + .font(.title2.bold()) + Text("이전에 저장한 공간 및 최적화 결과를 확인하고 싶다면\n발행된 확인 코드를 입력해주세요") + .font(.subheadline) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 30) + + Spacer() + + VStack(spacing: 28) { + TimelineView(.animation) { context in + let t = context.date.timeIntervalSinceReferenceDate + let raw = CGFloat(t.truncatingRemainder(dividingBy: 2.2) / 2.2) + let scan = raw * 1.6 - 0.3 + + ZStack { + Image("logo_white").resizable().scaledToFit().frame(width: 110) + Image("logo").resizable().scaledToFit().frame(width: 110) + .mask( + LinearGradient( + stops: [ + .init(color: .clear, location: max(0, scan - 0.5)), + .init(color: .black, location: max(0, scan - 0.05)), + .init(color: .black, location: min(1, scan + 0.05)), + .init(color: .clear, location: min(1, scan + 0.4)) + ], + startPoint: .leading, + endPoint: .trailing + ) + ) + } + } + + Text("결과 불러오는 중..") + .font(.medium14) + .foregroundStyle(.voBlue) + .padding(.top, -40) + + VStack(spacing: 10) { + Text("Tip") + .font(.semiBold10) + .foregroundStyle(.voBlue) + .padding(.horizontal, 14) + .padding(.vertical, 1) + .overlay(Capsule().stroke(Color.voBlue, lineWidth: 1)) + + Text(tips[tipIndex]) + .font(.regular14) + .foregroundStyle(.gray) + .multilineTextAlignment(.center) + .id(tipIndex) + .transition(.opacity) + .animation(.easeInOut(duration: 0.4), value: tipIndex) + .padding(.top, 10) + } + } + .frame(maxWidth: .infinity) + .padding(.horizontal, 40) + + Spacer() + } + .task { + while !Task.isCancelled { + try? await Task.sleep(for: .seconds(3)) + withAnimation { tipIndex = (tipIndex + 1) % tips.count } + } + } + } +} + +#Preview { + InquiryView(vm: ScanViewModel()) +} + +#Preview("로딩") { + InquiryLoadingView(tips: ["저장된 데이터를 찾아\n불러오고 있어요", "잠시 후 결과가 나와요"]) +} diff --git a/RoomPlanExporter/Views/LoadingView.swift b/RoomPlanExporter/Views/LoadingView.swift new file mode 100644 index 0000000..aa228f3 --- /dev/null +++ b/RoomPlanExporter/Views/LoadingView.swift @@ -0,0 +1,94 @@ +import SwiftUI + +struct LoadingView: View { + let statusText: String + let tips: [String] + + @State private var colorProgress: Double = 0 + @State private var tipIndex: Int = 0 + + var body: some View { + VStack(spacing: 28) { + Spacer() + + // TimelineView로 끊김 없는 좌→우 wipe + TimelineView(.animation) { context in + let t = context.date.timeIntervalSinceReferenceDate + // -0.3 → 1.3 범위로 이동: 시작/끝이 둘 다 화면 밖 → 리셋이 안 보임 + let raw = CGFloat(t.truncatingRemainder(dividingBy: 2.2) / 2.2) + let scan = raw * 1.6 - 0.3 + + ZStack { + Image("logo_white") + .resizable() + .scaledToFit() + .frame(width: 110) + + Image("logo") + .resizable() + .scaledToFit() + .frame(width: 110) + .mask( + LinearGradient( + stops: [ + .init(color: .clear, location: max(0, scan - 0.5)), + .init(color: .black, location: max(0, scan - 0.05)), + .init(color: .black, location: min(1, scan + 0.05)), + .init(color: .clear, location: min(1, scan + 0.4)) + ], + startPoint: .leading, + endPoint: .trailing + ) + ) + } + } + + // 상태 텍스트 + Text(statusText) + .font(.medium14) + .foregroundStyle(.voBlue) + .padding(.top, -40) + + // 팁 영역 + VStack(spacing: 10) { + Text("Tip") + .font(.semiBold10) + .foregroundStyle(.voBlue) + .padding(.horizontal, 14) + .padding(.vertical, 1) + .overlay(Capsule().stroke(Color.voBlue, lineWidth: 1)) + + Text(tips[tipIndex]) + .font(.regular14) + .foregroundStyle(.gray) + .multilineTextAlignment(.center) + .id(tipIndex) + .transition(.opacity) + .animation(.easeInOut(duration: 0.4), value: tipIndex) + .padding(.top, 3) + } + + Spacer() + } + .padding(.horizontal, 40) + .task { + while !Task.isCancelled { + try? await Task.sleep(for: .seconds(3)) + withAnimation { + tipIndex = (tipIndex + 1) % tips.count + } + } + } + } +} + +#Preview { + LoadingView( + statusText: "내 방 저장 중..", + tips: [ + "데이터를 안전하게\n서버로 보내고 있어요", + "공간 데이터를 \n서버에 전송하고 있어요", + "저장이 끝나면 \n최적화를 실행해볼 수 있어요" + ] + ) +} diff --git a/RoomPlanExporter/Views/MainView.swift b/RoomPlanExporter/Views/MainView.swift new file mode 100644 index 0000000..d4f64f1 --- /dev/null +++ b/RoomPlanExporter/Views/MainView.swift @@ -0,0 +1,37 @@ +import SwiftUI + +struct MainView: View { + @ObservedObject var vm: ScanViewModel + + var body: some View { + VStack(spacing: 0) { + Spacer() + + VStack(spacing: 8) { + Text("보는 것을 넘어, 최적을 찾다") + .font(.semiBold14) + .foregroundStyle(.gray) + + Image("logo") + .resizable() + .scaledToFit() + .frame(width: 250) + .padding(.top, -60) + } + + + VStack(spacing: 18) { + Button("공간 촬영") { vm.showGuide() } + .buttonStyle(VOOutlineButtonStyle()) + + Button("결과 조회") { vm.showInquiry() } + .buttonStyle(VOFilledButtonStyle()) + } + .padding(.horizontal, 50) + + Spacer() + } + } +} + +#Preview { MainView(vm: ScanViewModel()) } diff --git a/RoomPlanExporter/Views/OptimizedResultView.swift b/RoomPlanExporter/Views/OptimizedResultView.swift new file mode 100644 index 0000000..b38b6ba --- /dev/null +++ b/RoomPlanExporter/Views/OptimizedResultView.swift @@ -0,0 +1,104 @@ +import SwiftUI +import RoomPlan + +struct OptimizedResultView: View { + let room: CapturedRoom + let versionDetail: RoomVersionDetail + @ObservedObject var vm: ScanViewModel + + @State private var showOptimized = true + @State private var isTransparent = false + + var body: some View { + VStack(spacing: 0) { + // ── 배너 ───────────────────────────────────────────────────────── + HStack(spacing: 10) { + Image("logo_white") + .resizable().scaledToFit().frame(height: 32) + Text("최적화 완료 !") + .font(.subheadline.weight(.medium)) + .foregroundStyle(.white) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 12).padding(.horizontal, 20) + .background(Color.voBlue) + + // ── 3D 뷰어 ────────────────────────────────────────────────────── + Group { + if showOptimized { + FurnitureRealityKitView(detail: versionDetail, capturedRoom: room, isTransparent: isTransparent) + } else { + RoomViewerView(capturedRoom: room, isTransparent: isTransparent) + } + } + .id("\(showOptimized)-\(isTransparent)") + .frame(maxWidth: .infinity, maxHeight: .infinity) + .overlay(alignment: .bottomTrailing) { + RoomStyleToggle(isTransparent: $isTransparent) + .padding(12) + } + + Divider() + + // ── 버전 전환 버튼 (가로 2개) ───────────────────────────────────── + HStack(spacing: 12) { + if showOptimized { + Button("최적화") { withAnimation(.easeInOut(duration: 0.2)) { showOptimized = true } } + .buttonStyle(VOFilledButtonStyle()) + Button("내 방") { withAnimation(.easeInOut(duration: 0.2)) { showOptimized = false } } + .buttonStyle(VOOutlineButtonStyle()) + } else { + Button("최적화") { withAnimation(.easeInOut(duration: 0.2)) { showOptimized = true } } + .buttonStyle(VOOutlineButtonStyle()) + Button("내 방") { withAnimation(.easeInOut(duration: 0.2)) { showOptimized = false } } + .buttonStyle(VOFilledButtonStyle()) + } + } + .padding(.horizontal, 50) + .padding(.top, 16) + + // ── 메인으로 ───────────────────────────────────────────────────── + Button("메인으로") { vm.retake() } + .buttonStyle(VOOutlineButtonStyle()) + .padding(.horizontal, 50) + .padding(.vertical, 12) + } + } +} + +// MARK: - 벽 스타일 토글 버튼 (흰색 / 투명) + +struct RoomStyleToggle: View { + @Binding var isTransparent: Bool + + var body: some View { + HStack(spacing: 0) { + Button { + withAnimation(.easeInOut(duration: 0.15)) { isTransparent = false } + } label: { + Text("흰색") + .font(.caption.weight(isTransparent ? .regular : .semibold)) + .foregroundStyle(isTransparent ? Color.secondary : Color.voBlue) + .padding(.horizontal, 14) + .padding(.vertical, 7) + .background(isTransparent ? Color.clear : Color.voBlue.opacity(0.13)) + } + + Divider() + .frame(height: 18) + + Button { + withAnimation(.easeInOut(duration: 0.15)) { isTransparent = true } + } label: { + Text("투명") + .font(.caption.weight(isTransparent ? .semibold : .regular)) + .foregroundStyle(isTransparent ? Color.voBlue : Color.secondary) + .padding(.horizontal, 14) + .padding(.vertical, 7) + .background(isTransparent ? Color.voBlue.opacity(0.13) : Color.clear) + } + } + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 8)) + .overlay(RoundedRectangle(cornerRadius: 8).stroke(Color.voBlue.opacity(0.25), lineWidth: 1)) + } +} diff --git a/RoomPlanExporter/Views/RoomResultView.swift b/RoomPlanExporter/Views/RoomResultView.swift new file mode 100644 index 0000000..17e9056 --- /dev/null +++ b/RoomPlanExporter/Views/RoomResultView.swift @@ -0,0 +1,42 @@ +import SwiftUI +import RoomPlan + +struct RoomResultView: View { + let room: CapturedRoom + @ObservedObject var vm: ScanViewModel + + var body: some View { + VStack(spacing: 0) { + // Banner + Text("내 방이 잘 스캔되었는지 확인해보세요") + .font(.subheadline.weight(.medium)) + .foregroundStyle(.white) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .background(Color.voBlue) + + // 3D Viewer + RoomViewerView(capturedRoom: room) + .frame(maxWidth: .infinity, maxHeight: .infinity) + + // Bottom controls + VStack(spacing: 12) { + HStack(spacing: 6) { + Image(systemName: "info.circle") + .font(.caption) + Text("스캔 결과를 저장하면 최적화 여부를 결정할 수 있어요") + .font(.caption) + } + .foregroundStyle(.secondary) + + Button("저장하기") { vm.saveAndUpload(room: room) } + .buttonStyle(VOFilledButtonStyle()) + + Button("다시 찍기") { vm.retake() } + .buttonStyle(VOOutlineButtonStyle()) + } + .padding(.horizontal, 50) + .padding(.vertical, 20) + } + } +} diff --git a/RoomPlanExporter/Views/RoomViewerView.swift b/RoomPlanExporter/Views/RoomViewerView.swift new file mode 100644 index 0000000..2000647 --- /dev/null +++ b/RoomPlanExporter/Views/RoomViewerView.swift @@ -0,0 +1,1100 @@ +// +// RoomViewerView.swift +// RoomPlanExporter +// +// Created by Jung Hyun Han on 4/8/26. +// Copyright © 2026 Apple. All rights reserved. +// + +import SwiftUI +import RealityKit +import RoomPlan + +extension CapturedRoom.ModelProvider { + enum CatalogError: LocalizedError { + case cannotFindCatalog + var errorDescription: String? { "Cannot Find Catalog" } + } + static func load() throws -> CapturedRoom.ModelProvider { + guard let url = Bundle.main.url(forResource: "RoomPlanCatalog", withExtension: "bundle") else { + throw CatalogError.cannotFindCatalog + } + return try RoomPlanCatalog.load(at: url) + } +} + +// MARK: - Entity 로딩 헬퍼 +// +// @available(*, noasync) 우회: non-async @MainActor 타입 메서드 안에서 load를 호출. +// - async 컨텍스트에서 loadSync를 '호출'하는 것은 허용 (loadSync 자체가 noasync가 아님). +// - loadSync 내부에서 ModelEntity.load를 호출하는 시점의 스코프는 non-async → OK. +// - iOS 18+ async init은 배포 타겟 제한으로 사용 불가. + +private extension Entity { + @MainActor + static func loadSync(contentsOf url: URL) throws -> Entity { + try ModelEntity.load(contentsOf: url) + } +} + +// MARK: - OptimizedObject + +struct OptimizedObject: Identifiable { + var id: UUID { identifier } + let identifier: UUID + let category: String + let center: SIMD3 + let rotation: simd_quatf +} + +// MARK: - RoomViewerView (3D 뷰어 전용) + +struct RoomViewerView: View { + + let capturedRoom: CapturedRoom + var optimizedObjects: [OptimizedObject]? = nil + var isTransparent: Bool = false + + @State private var usdzURL: URL? = nil + @State private var isGenerating = true + + var body: some View { + ZStack { + if isGenerating && !isTransparent { + VStack(spacing: 16) { + ProgressView().progressViewStyle(.circular).scaleEffect(1.5) + Text("방 모델 생성 중...").font(.subheadline).foregroundStyle(.secondary) + } + } else { + RealityKitRoomView( + capturedRoom: capturedRoom, + usdzURL: isTransparent ? nil : usdzURL, + optimizedObjects: optimizedObjects, + isTransparent: isTransparent + ) + .ignoresSafeArea() + } + } + .task { + if isTransparent { isGenerating = false; return } + await generateUSDZ() + } + } + + // MARK: - USDZ 생성 (시각화용) + + private func generateUSDZ() async { + isGenerating = true + defer { isGenerating = false } + + let tmp = URL(filePath: NSTemporaryDirectory()).appending(path: "RoomViewer") + try? FileManager.default.createDirectory(at: tmp, withIntermediateDirectories: true) + let outputURL = tmp.appending(path: "Room.usdz") + try? FileManager.default.removeItem(at: outputURL) + + do { + let mp = try CapturedRoom.ModelProvider.load() + try capturedRoom.export(to: outputURL, modelProvider: mp, exportOptions: .model) + await MainActor.run { usdzURL = outputURL } + print("✅ USDZ 생성 완료") + } catch { + print("⚠️ ModelProvider 실패: \(error) → 기본 메쉬") + try? capturedRoom.export(to: outputURL, exportOptions: [.parametric, .mesh]) + await MainActor.run { usdzURL = outputURL } + } + } +} + +// MARK: - RealityKit 뷰 + +struct RealityKitRoomView: UIViewRepresentable { + + let capturedRoom: CapturedRoom + var usdzURL: URL? + var optimizedObjects: [OptimizedObject]? + var isTransparent: Bool = false + + func makeCoordinator() -> Coordinator { Coordinator() } + + func makeUIView(context: Context) -> ARView { + let arView = ARView(frame: .zero) + arView.environment.background = .color(UIColor(red: 0.96, green: 0.94, blue: 0.90, alpha: 1.0)) + arView.cameraMode = .nonAR + arView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + + // 카메라 설정 + let camAnchor = AnchorEntity(world: .zero) + let camera = PerspectiveCamera() + camera.camera.fieldOfViewInDegrees = 60 + camera.position = SIMD3(0, 5, 5) + camera.look(at: .zero, from: camera.position, relativeTo: nil) + camAnchor.addChild(camera) + arView.scene.addAnchor(camAnchor) + + setupLighting(in: arView) + + let roomAnchor = AnchorEntity(world: .zero) + arView.scene.addAnchor(roomAnchor) + context.coordinator.roomAnchor = roomAnchor + context.coordinator.arView = arView + + if isTransparent { + Task { @MainActor in + let opts = capturedRoom.objects.map { obj in + OptimizedObject( + identifier: obj.identifier, + category: String(describing: obj.category), + center: SIMD3(obj.transform.columns.3.x, obj.transform.columns.3.y, obj.transform.columns.3.z), + rotation: simd_quatf(obj.transform) + ) + } + await loadOptimizedScene(capturedRoom, objects: opts, into: roomAnchor) + } + } else if let url = usdzURL { + Task { await loadUSDZ(url: url, into: roomAnchor) } + } else { + renderScene(capturedRoom, in: roomAnchor) + } + + setupGestures(in: arView, coordinator: context.coordinator) + return arView + } + + func updateUIView(_ arView: ARView, context: Context) { + guard let optimizedObjects, + let roomAnchor = context.coordinator.roomAnchor, + !context.coordinator.hasAppliedOptimization else { return } + + context.coordinator.hasAppliedOptimization = true + + Task { @MainActor in + roomAnchor.children.removeAll() + await loadOptimizedScene(capturedRoom, objects: optimizedObjects, into: roomAnchor) + } + } + + // MARK: - USDZ 로드 + + @MainActor + private func loadUSDZ(url: URL, into anchor: AnchorEntity) async { + do { + let entity = try Entity.loadSync(contentsOf: url) + anchor.addChild(entity) + // Apple USDZ의 바닥 재질이 흰색이므로 베이지 바닥으로 덮기 + for s in capturedRoom.floors { + anchor.addChild(makeSurface(s, + color: UIColor(red: 0.91, green: 0.87, blue: 0.80, alpha: 1.0), + depth: 0.025)) + } + print("✅ USDZ 로드 완료") + } catch { + print("❌ USDZ 로드 실패: \(error) → renderScene 폴백") + renderScene(capturedRoom, in: anchor) + } + } + + // MARK: - 최적화 씬 로드 (카탈로그 USDC 모델 사용) + + @MainActor + private func loadOptimizedScene(_ room: CapturedRoom, objects: [OptimizedObject], into anchor: AnchorEntity) async { + // 방 구조(벽/바닥/문/창문)만 먼저 렌더링 + renderSurfaces(room, in: anchor) + + let mp = try? CapturedRoom.ModelProvider.load() + + for opt in objects { + guard let original = room.objects.first(where: { $0.identifier == opt.identifier }) else { continue } + + var placed = false + + if let mp, let modelURL = try? mp.modelFileURL(for: original) { + do { + let model = try Entity.loadSync(contentsOf: modelURL) + + // 모델 크기를 원본 dimensions에 맞게 스케일 조정 + let bounds = model.visualBounds(relativeTo: model) + let bSize = bounds.max - bounds.min + if bSize.x > 0.001 && bSize.y > 0.001 && bSize.z > 0.001 { + let d = original.dimensions + model.scale = SIMD3(d.x / bSize.x, d.y / bSize.y, d.z / bSize.z) + // pivot이 center가 아닐 경우 offset 보정 + let centerOffset = (bounds.max + bounds.min) / 2 + model.position = opt.center - centerOffset * model.scale + } else { + model.position = opt.center + } + model.orientation = opt.rotation + anchor.addChild(model) + placed = true + print("✅ 최적화 모델 배치: \(modelURL.lastPathComponent)") + } catch { + print("⚠️ 카탈로그 모델 로드 실패: \(error)") + } + } + + // 폴백: OBB 박스 + if !placed { + var mat = SimpleMaterial() + mat.color = .init(tint: colorForCategory(original.category)) + mat.roughness = 0.9; mat.metallic = 0.0 + let e = original.dimensions + let entity = ModelEntity( + mesh: .generateBox(width: e.x, height: e.y, depth: e.z, cornerRadius: 0.03), + materials: [mat] + ) + entity.position = opt.center + entity.orientation = opt.rotation + anchor.addChild(entity) + } + } + } + + // MARK: - 씬 렌더링 (named entities - 최적화 애니메이션용) + + private func renderSurfaces(_ room: CapturedRoom, in anchor: AnchorEntity) { + let alpha: CGFloat = isTransparent ? 0.45 : 1.0 + let wallColor = UIColor(white: 1.0, alpha: alpha) + let floorColor = UIColor(red: 0.94, green: 0.92, blue: 0.90, alpha: 1.0) + for wall in room.walls { + let openings = capturedRoomOpenings(wall: wall, doors: room.doors, windows: room.windows) + wallSegments(wallTransform: wall.transform, wallW: wall.dimensions.x, wallH: wall.dimensions.y, + openings: openings, color: wallColor, depth: 0.04) + .forEach { anchor.addChild($0) } + } + for s in room.floors { anchor.addChild(makeSurface(s, color: floorColor, depth: 0.01)) } + } + + private func renderScene(_ room: CapturedRoom, in anchor: AnchorEntity) { + renderSurfaces(room, in: anchor) + for obj in room.objects { + var mat = SimpleMaterial() + mat.color = .init(tint: colorForCategory(obj.category)) + mat.roughness = 0.9; mat.metallic = 0.0 + let e = obj.dimensions + let entity = ModelEntity( + mesh: .generateBox(width: e.x, height: e.y, depth: e.z, cornerRadius: 0.03), + materials: [mat] + ) + entity.transform = Transform(matrix: obj.transform) + entity.name = "furniture_\(obj.identifier.uuidString)" + anchor.addChild(entity) + } + } + + private func makeSurface(_ s: CapturedRoom.Surface, color: UIColor, depth: Float) -> ModelEntity { + var mat = SimpleMaterial() + mat.color = .init(tint: color); mat.roughness = 0.9; mat.metallic = 0.0 + let entity = ModelEntity( + mesh: .generateBox(width: s.dimensions.x, height: s.dimensions.y, depth: depth), + materials: [mat] + ) + entity.transform = Transform(matrix: s.transform) + return entity + } + + // MARK: - 조명 + + private func setupLighting(in arView: ARView) { + let anchor = AnchorEntity(world: .zero) + let dir = DirectionalLight() + dir.light.intensity = 3000; dir.light.color = .white + dir.shadow = DirectionalLightComponent.Shadow(maximumDistance: 10) + dir.orientation = simd_quatf(angle: -.pi/3, axis: [1, 0, 0]) + anchor.addChild(dir) + let pt = PointLight() + pt.light.intensity = 1000; pt.position = [0, 4, 0] + anchor.addChild(pt) + arView.scene.addAnchor(anchor) + } + + // MARK: - 제스처 + + private func setupGestures(in arView: ARView, coordinator: Coordinator) { + arView.addGestureRecognizer(UIPinchGestureRecognizer(target: coordinator, action: #selector(Coordinator.handlePinch))) + arView.addGestureRecognizer(UIRotationGestureRecognizer(target: coordinator, action: #selector(Coordinator.handleRotation))) + arView.addGestureRecognizer(UIPanGestureRecognizer(target: coordinator, action: #selector(Coordinator.handlePan))) + } + + // MARK: - 카테고리별 색상 + + private func colorForCategory(_ category: CapturedRoom.Object.Category) -> UIColor { + switch category { + case .chair: return UIColor(red: 0.72, green: 0.83, blue: 0.90, alpha: 1.0) + case .sofa: return UIColor(red: 0.72, green: 0.83, blue: 0.90, alpha: 1.0) + case .table: return UIColor(red: 0.88, green: 0.82, blue: 0.72, alpha: 1.0) + case .bed: return UIColor(red: 0.95, green: 0.80, blue: 0.83, alpha: 1.0) + case .storage: return UIColor(red: 0.78, green: 0.82, blue: 0.76, alpha: 1.0) + case .television: return UIColor(red: 0.55, green: 0.55, blue: 0.58, alpha: 1.0) + case .refrigerator: return UIColor(red: 0.88, green: 0.90, blue: 0.92, alpha: 1.0) + case .washerDryer: return UIColor(red: 0.82, green: 0.88, blue: 0.92, alpha: 1.0) + default: return UIColor(red: 0.80, green: 0.78, blue: 0.75, alpha: 1.0) + } + } + + // MARK: - Coordinator + + class Coordinator: NSObject { + weak var arView: ARView? + var roomAnchor: AnchorEntity? + var hasAppliedOptimization: Bool = false + private var lastScale: Float = 1.0 + private var currentScale: Float = 1.0 + private var lastRotation: Float = 0 + + @objc func handlePinch(_ g: UIPinchGestureRecognizer) { + if g.state == .began { lastScale = currentScale } + currentScale = max(0.3, min(6.0, lastScale * Float(g.scale))) + roomAnchor?.scale = SIMD3(repeating: currentScale) + } + + @objc func handleRotation(_ g: UIRotationGestureRecognizer) { + if g.state == .began { lastRotation = 0 } + let delta = Float(g.rotation) - lastRotation + lastRotation = Float(g.rotation) + roomAnchor?.orientation *= simd_quatf(angle: -delta, axis: [0, 1, 0]) + } + + @objc func handlePan(_ g: UIPanGestureRecognizer) { + guard let arView else { return } + let delta = Float(g.translation(in: arView).x) * 0.005 + roomAnchor?.orientation *= simd_quatf(angle: delta, axis: [0, 1, 0]) + g.setTranslation(.zero, in: arView) + } + } +} + +// MARK: - JSON 데이터 기반 RealityKit 뷰 (공간 조회용) +// +// room_data.json / room_data.roomplan_optimized.json을 파싱한 RoomDataPayload를 받아 +// 벽·바닥·가구를 RealityKit 엔티티로 직접 렌더링한다. +// 가구: modelFileName이 있으면 카탈로그 번들에서 로드, 없으면 카테고리별 색상 박스 폴백. + +struct JsonRealityKitView: UIViewRepresentable { + let roomData: RoomDataPayload + + func makeCoordinator() -> Coordinator { Coordinator() } + + func makeUIView(context: Context) -> ARView { + let arView = ARView(frame: .zero) + arView.environment.background = .color(UIColor(red: 0.96, green: 0.94, blue: 0.90, alpha: 1.0)) + arView.cameraMode = .nonAR + arView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + + // 카메라 + let camAnchor = AnchorEntity(world: .zero) + let camera = PerspectiveCamera() + camera.camera.fieldOfViewInDegrees = 60 + camera.position = SIMD3(0, 5, 5) + camera.look(at: .zero, from: camera.position, relativeTo: nil) + camAnchor.addChild(camera) + arView.scene.addAnchor(camAnchor) + + // 조명 + let lightAnchor = AnchorEntity(world: .zero) + let dir = DirectionalLight() + dir.light.intensity = 3000; dir.light.color = .white + dir.shadow = DirectionalLightComponent.Shadow(maximumDistance: 10) + dir.orientation = simd_quatf(angle: -.pi / 3, axis: [1, 0, 0]) + lightAnchor.addChild(dir) + let pt = PointLight(); pt.light.intensity = 1000; pt.position = [0, 4, 0] + lightAnchor.addChild(pt) + arView.scene.addAnchor(lightAnchor) + + // 씬 앵커 + let roomAnchor = AnchorEntity(world: .zero) + arView.scene.addAnchor(roomAnchor) + context.coordinator.roomAnchor = roomAnchor + context.coordinator.arView = arView + + // 씬 빌드 + Task { @MainActor in await buildScene(into: roomAnchor) } + + // 제스처 + arView.addGestureRecognizer(UIPinchGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.handlePinch))) + arView.addGestureRecognizer(UIRotationGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.handleRotation))) + arView.addGestureRecognizer(UIPanGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.handlePan))) + + return arView + } + + func updateUIView(_ uiView: ARView, context: Context) {} + + // MARK: 씬 구성 + + @MainActor + private func buildScene(into anchor: AnchorEntity) async { + // 구조물 + // 벽: 반투명 흰색, 문/창문 스킵 → 자연 간극 + for s in roomData.walls ?? [] { anchor.addChild(makeSurface(s, color: UIColor(white: 1.0, alpha: 0.45), depth: 0.04)) } + for s in roomData.floors ?? [] { anchor.addChild(makeSurface(s, color: UIColor(red: 0.94, green: 0.92, blue: 0.90, alpha: 1.0), depth: 0.01)) } + + // 가구 + for obj in roomData.objects { + if let entity = await loadFurniture(obj) { + anchor.addChild(entity) + } + } + } + + /// 가구 엔티티 생성: 카탈로그 모델 → 색상 박스 순으로 시도 + @MainActor + private func loadFurniture(_ obj: RoomDataPayload.RoomObject) async -> Entity? { + // 1. 카탈로그 번들에서 modelFileName으로 로드 + if let fileName = obj.modelFileName, + let modelURL = findCatalogModel(named: fileName) { + do { + let model = try Entity.loadSync(contentsOf: modelURL) + applyTransform(to: model, obj: obj) + print("✅ 카탈로그 모델 로드: \(fileName)") + return model + } catch { + print("⚠️ 카탈로그 로드 실패 (\(fileName)): \(error)") + } + } + + // 2. 색상 박스 폴백 + var mat = SimpleMaterial() + mat.color = .init(tint: categoryColor(obj.category)) + mat.roughness = 0.9; mat.metallic = 0.0 + let dims = obj.dimensions ?? [0.5, 0.5, 0.5] + let entity = ModelEntity( + mesh: .generateBox( + width: dims.count >= 1 ? dims[0] : 0.5, + height: dims.count >= 2 ? dims[1] : 0.5, + depth: dims.count >= 3 ? dims[2] : 0.5, + cornerRadius: 0.03 + ), + materials: [mat] + ) + applyTransform(to: entity, obj: obj) + return entity + } + + private func applyTransform(to entity: Entity, obj: RoomDataPayload.RoomObject) { + if let mat = obj.simdTransform { + let bounds = entity.visualBounds(relativeTo: entity) + let bSize = bounds.max - bounds.min + let pos = SIMD3(mat.columns.3.x, mat.columns.3.y, mat.columns.3.z) + if let dims = obj.dimensions, dims.count >= 3, + bSize.x > 0.001, bSize.y > 0.001, bSize.z > 0.001 { + let scale = SIMD3(dims[0] / bSize.x, dims[1] / bSize.y, dims[2] / bSize.z) + let center = (bounds.max + bounds.min) / 2 + entity.scale = scale + entity.position = pos - center * scale + entity.orientation = simd_quatf(mat) + } else { + entity.transform = Transform(matrix: mat) + } + } else { + let c = obj.center + entity.position = c.count >= 3 ? SIMD3(c[0], c[1], c[2]) : .zero + } + } + + private func makeSurface(_ s: RoomDataPayload.RoomSurface, color: UIColor, depth: Float) -> ModelEntity { + let d = s.dimensions + var mat = SimpleMaterial() + mat.color = .init(tint: color); mat.roughness = 0.9; mat.metallic = 0.0 + let entity = ModelEntity( + mesh: .generateBox(width: d.count >= 1 ? d[0] : 1, + height: d.count >= 2 ? d[1] : 1, + depth: depth), + materials: [mat] + ) + if let t = s.simdTransform { entity.transform = Transform(matrix: t) } + return entity + } + + /// RoomPlanCatalog.bundle 내에서 파일명으로 모델 URL 검색 + private func findCatalogModel(named fileName: String) -> URL? { + guard let bundleURL = Bundle.main.url(forResource: "RoomPlanCatalog", withExtension: "bundle"), + let bundle = Bundle(url: bundleURL) else { return nil } + + let name = URL(fileURLWithPath: fileName).deletingPathExtension().lastPathComponent + let ext = URL(fileURLWithPath: fileName).pathExtension.isEmpty ? "usdc" + : URL(fileURLWithPath: fileName).pathExtension + + // 직접 리소스 조회 + if let url = bundle.url(forResource: name, withExtension: ext) { return url } + + // 번들 내 재귀 탐색 (서브폴더에 있는 경우) + let enumerator = FileManager.default.enumerator(at: bundleURL, includingPropertiesForKeys: nil) + while let fileURL = enumerator?.nextObject() as? URL { + if fileURL.lastPathComponent == fileName { return fileURL } + } + return nil + } + + private func categoryColor(_ cat: String) -> UIColor { + switch cat.lowercased() { + case "chair", "sofa": return UIColor(red: 0.72, green: 0.83, blue: 0.90, alpha: 1.0) + case "table": return UIColor(red: 0.88, green: 0.82, blue: 0.72, alpha: 1.0) + case "bed": return UIColor(red: 0.95, green: 0.80, blue: 0.83, alpha: 1.0) + case "storage": return UIColor(red: 0.78, green: 0.82, blue: 0.76, alpha: 1.0) + case "television": return UIColor(red: 0.55, green: 0.55, blue: 0.58, alpha: 1.0) + case "refrigerator": return UIColor(red: 0.88, green: 0.90, blue: 0.92, alpha: 1.0) + case "washerdryer": return UIColor(red: 0.82, green: 0.88, blue: 0.92, alpha: 1.0) + default: return UIColor(red: 0.80, green: 0.78, blue: 0.75, alpha: 1.0) + } + } + + // MARK: Coordinator + class Coordinator: NSObject { + weak var arView: ARView? + var roomAnchor: AnchorEntity? + private var lastScale: Float = 1.0 + private var currentScale: Float = 1.0 + private var lastRotation: Float = 0 + + @objc func handlePinch(_ g: UIPinchGestureRecognizer) { + if g.state == .began { lastScale = currentScale } + currentScale = max(0.3, min(6.0, lastScale * Float(g.scale))) + roomAnchor?.scale = SIMD3(repeating: currentScale) + } + + @objc func handleRotation(_ g: UIRotationGestureRecognizer) { + if g.state == .began { lastRotation = 0 } + let delta = Float(g.rotation) - lastRotation + lastRotation = Float(g.rotation) + roomAnchor?.orientation *= simd_quatf(angle: -delta, axis: [0, 1, 0]) + } + + @objc func handlePan(_ g: UIPanGestureRecognizer) { + guard let arView else { return } + let delta = Float(g.translation(in: arView).x) * 0.005 + roomAnchor?.orientation *= simd_quatf(angle: delta, axis: [0, 1, 0]) + g.setTranslation(.zero, in: arView) + } + } +} + +// MARK: - USDZ 전용 RealityKit 뷰 (공간 조회용 – CapturedRoom 불필요) + +struct UsdzRealityKitView: UIViewRepresentable { + let usdzURL: URL + + func makeCoordinator() -> Coordinator { Coordinator() } + + func makeUIView(context: Context) -> ARView { + let arView = ARView(frame: .zero) + arView.environment.background = .color(UIColor(red: 0.96, green: 0.94, blue: 0.90, alpha: 1.0)) + arView.cameraMode = .nonAR + arView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + + // 카메라 + let camAnchor = AnchorEntity(world: .zero) + let camera = PerspectiveCamera() + camera.camera.fieldOfViewInDegrees = 60 + camera.position = SIMD3(0, 5, 5) + camera.look(at: .zero, from: camera.position, relativeTo: nil) + camAnchor.addChild(camera) + arView.scene.addAnchor(camAnchor) + + // 조명 + let lightAnchor = AnchorEntity(world: .zero) + let dir = DirectionalLight() + dir.light.intensity = 3000; dir.light.color = .white + dir.shadow = DirectionalLightComponent.Shadow(maximumDistance: 10) + dir.orientation = simd_quatf(angle: -.pi / 3, axis: [1, 0, 0]) + lightAnchor.addChild(dir) + let pt = PointLight() + pt.light.intensity = 1000; pt.position = [0, 4, 0] + lightAnchor.addChild(pt) + arView.scene.addAnchor(lightAnchor) + + // 씬 앵커 + let roomAnchor = AnchorEntity(world: .zero) + arView.scene.addAnchor(roomAnchor) + context.coordinator.roomAnchor = roomAnchor + context.coordinator.arView = arView + + // USDZ 로드 + Task { @MainActor in + do { + let entity = try Entity.loadSync(contentsOf: usdzURL) + roomAnchor.addChild(entity) + print("✅ 조회 USDZ 로드 완료") + } catch { + print("❌ 조회 USDZ 로드 실패: \(error)") + } + } + + // 제스처 + arView.addGestureRecognizer(UIPinchGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.handlePinch))) + arView.addGestureRecognizer(UIRotationGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.handleRotation))) + arView.addGestureRecognizer(UIPanGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.handlePan))) + + return arView + } + + func updateUIView(_ uiView: ARView, context: Context) {} + + // MARK: Coordinator (핀치·회전·팬) + class Coordinator: NSObject { + weak var arView: ARView? + var roomAnchor: AnchorEntity? + private var lastScale: Float = 1.0 + private var currentScale: Float = 1.0 + private var lastRotation: Float = 0 + + @objc func handlePinch(_ g: UIPinchGestureRecognizer) { + if g.state == .began { lastScale = currentScale } + currentScale = max(0.3, min(6.0, lastScale * Float(g.scale))) + roomAnchor?.scale = SIMD3(repeating: currentScale) + } + + @objc func handleRotation(_ g: UIRotationGestureRecognizer) { + if g.state == .began { lastRotation = 0 } + let delta = Float(g.rotation) - lastRotation + lastRotation = Float(g.rotation) + roomAnchor?.orientation *= simd_quatf(angle: -delta, axis: [0, 1, 0]) + } + + @objc func handlePan(_ g: UIPanGestureRecognizer) { + guard let arView else { return } + let delta = Float(g.translation(in: arView).x) * 0.005 + roomAnchor?.orientation *= simd_quatf(angle: delta, axis: [0, 1, 0]) + g.setTranslation(.zero, in: arView) + } + } +} + +// MARK: - FurnitureRealityKitView (rooms API 버전 상세 기반 3D 뷰어) +// +// B안: capturedRoom이 제공되면 로컬 USDZ를 생성하여 방 shell을 렌더링하고 +// 가구는 숨긴 뒤, JSON의 최적화된 가구를 오버레이한다. +// 폴백: capturedRoom이 없으면 JSON 박스 기반 렌더링. + +struct FurnitureRealityKitView: UIViewRepresentable { + let detail: RoomVersionDetail + var capturedRoom: CapturedRoom? = nil // B안: 제공 시 로컬 USDZ로 방 shell 렌더링 + var isTransparent: Bool = false // 투명 모드: 반투명 벽 박스 + + func makeCoordinator() -> Coordinator { Coordinator() } + + func makeUIView(context: Context) -> ARView { + let arView = ARView(frame: .zero) + arView.environment.background = .color(UIColor(red: 0.96, green: 0.94, blue: 0.90, alpha: 1.0)) + arView.cameraMode = .nonAR + arView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + + // 카메라 (초기값 – buildScene 완료 후 방 크기에 맞게 재조정) + let camAnchor = AnchorEntity(world: .zero) + let camera = PerspectiveCamera() + camera.camera.fieldOfViewInDegrees = 60 + camera.position = SIMD3(0, 6, 6) + camera.look(at: .zero, from: camera.position, relativeTo: nil) + camAnchor.addChild(camera) + arView.scene.addAnchor(camAnchor) + context.coordinator.camera = camera // 동적 조정용 + + // 조명 + let lightAnchor = AnchorEntity(world: .zero) + let dir = DirectionalLight() + dir.light.intensity = 3000; dir.light.color = .white + dir.shadow = DirectionalLightComponent.Shadow(maximumDistance: 15) + dir.orientation = simd_quatf(angle: -.pi / 3, axis: [1, 0, 0]) + lightAnchor.addChild(dir) + let pt = PointLight(); pt.light.intensity = 1000; pt.position = [0, 4, 0] + lightAnchor.addChild(pt) + arView.scene.addAnchor(lightAnchor) + + // 씬 앵커 + let roomAnchor = AnchorEntity(world: .zero) + arView.scene.addAnchor(roomAnchor) + context.coordinator.roomAnchor = roomAnchor + context.coordinator.arView = arView + + // 씬 빌드 (비동기) – coordinator 전달로 카메라 거리 조정 가능 + let coord = context.coordinator + Task { @MainActor in await buildScene(into: roomAnchor, coordinator: coord) } + + // 제스처 + arView.addGestureRecognizer(UIPinchGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.handlePinch))) + arView.addGestureRecognizer(UIRotationGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.handleRotation))) + arView.addGestureRecognizer(UIPanGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.handlePan))) + + return arView + } + + func updateUIView(_ uiView: ARView, context: Context) {} + + // MARK: 씬 구성 + // 1) data_url → 가구 배치 JSON + // 2) JSON walls/floors/doors → 방 구조 박스 + // 3) model_urls USDC → 가구 모델, 없으면 카테고리 색상 박스 + // 4) 씬 전체를 카메라 시야 중앙으로 오도록 anchor 이동 + 카메라 거리 자동 조정 + + @MainActor + private func buildScene(into anchor: AnchorEntity, coordinator: Coordinator) async { + + let wallAlpha: Float = isTransparent ? 0.35 : 1.0 + let wallColor = UIColor(white: 1.0, alpha: CGFloat(wallAlpha)) + let floorColor = UIColor(red: 0.94, green: 0.92, blue: 0.90, alpha: 1.0) + + // ── capturedRoom이 있으면 방 구조 먼저 렌더링 (JSON 결과 기다리지 않음) ── + if let room = capturedRoom { + for wall in room.walls { + let openings = capturedRoomOpenings(wall: wall, doors: room.doors, windows: room.windows) + wallSegments(wallTransform: wall.transform, wallW: wall.dimensions.x, wallH: wall.dimensions.y, + openings: openings, color: wallColor, depth: 0.04) + .forEach { anchor.addChild($0) } + } + for floor in room.floors { + anchor.addChild(makeCapturedSurface(floor, color: floorColor, depth: 0.01)) + } + + // 카메라 거리: capturedRoom 벽 기준 + var allPos = room.walls.compactMap { w -> SIMD3? in w.transform.columns.3.xyz } + allPos += room.floors.map { $0.transform.columns.3.xyz } + adjustCamera(anchor: anchor, coordinator: coordinator, positions: allPos) + } + + // ── JSON 다운로드 ────────────────────────────────────────────────────── + guard let dataURLString = detail.dataUrl, + let dataURL = URL(string: dataURLString) else { + print("⚠️ data_url 없음 – 방 구조만 표시") + return + } + + let payload: RoomDataPayload + do { + let (jsonData, _) = try await URLSession.shared.data(from: dataURL) + payload = try JSONDecoder().decode(RoomDataPayload.self, from: jsonData) + print("✅ JSON 파싱 완료: 오브젝트 \(payload.objects.count)개") + } catch { + print("❌ data_url 파싱 실패: \(error) – 방 구조만 표시") + return + } + + // ── capturedRoom이 없을 때만 JSON으로 방 구조 렌더링 ────────────────── + if capturedRoom == nil { + var allPos: [SIMD3] = [] + for w in payload.walls ?? [] { if let t = w.simdTransform { allPos.append(t.columns.3.xyz) } } + for f in payload.floors ?? [] { if let t = f.simdTransform { allPos.append(t.columns.3.xyz) } } + for o in payload.objects { let c = o.center; if c.count >= 3 { allPos.append(SIMD3(c[0], c[1], c[2])) } } + adjustCamera(anchor: anchor, coordinator: coordinator, positions: allPos) + + for wall in payload.walls ?? [] { + guard let wallT = wall.simdTransform else { + anchor.addChild(makeSurfaceBox(surface: wall, color: wallColor, depth: 0.12)) + continue + } + let wallW = wall.dimensions.count > 0 ? wall.dimensions[0] : 1.0 + let wallH = wall.dimensions.count > 1 ? wall.dimensions[1] : 2.4 + let openings = payloadOpenings(wallTransform: wallT, wallW: wallW, wallH: wallH, + doors: payload.doors ?? [], windows: payload.windows ?? []) + wallSegments(wallTransform: wallT, wallW: wallW, wallH: wallH, + openings: openings, color: wallColor, depth: 0.12) + .forEach { anchor.addChild($0) } + } + for floor in payload.floors ?? [] { + anchor.addChild(makeSurfaceBox(surface: floor, color: floorColor, depth: 0.02)) + } + } + + // ── 가구 배치 ────────────────────────────────────────────────────────── + let modelUrls = detail.modelUrls ?? [:] + print("📦 model_urls 키: \(Array(modelUrls.keys))") + for obj in payload.objects { + guard let matrix = obj.simdTransform else { continue } + let worldPos = SIMD3(matrix.columns.3.x, matrix.columns.3.y, matrix.columns.3.z) + + var placed = false + + // 1. 로컬 RoomPlanCatalog.bundle 우선 로드 + if let fileName = obj.modelFileName, + let localURL = findCatalogModel(named: fileName) { + do { + let loaded = try Entity.loadSync(contentsOf: localURL) + applyTransform(to: loaded, matrix: matrix, worldPos: worldPos, obj: obj) + anchor.addChild(loaded) + placed = true + print(" ✅ \(obj.category) (\(fileName)) 로컬 카탈로그 로드 성공") + } catch { + print(" ⚠️ \(obj.category) (\(fileName)) 로컬 로드 실패: \(error)") + } + } + + // 2. 로컬 실패 시 박스 폴백 + if !placed { + print(" 📦 \(obj.category): 박스 폴백") + let box = makeFallbackBox(for: obj) + box.transform = Transform(matrix: matrix) + anchor.addChild(box) + } + } + } + + /// anchor 센터링 + 카메라 거리 조정 + private func adjustCamera(anchor: AnchorEntity, coordinator: Coordinator, positions: [SIMD3]) { + guard !positions.isEmpty else { return } + let xs = positions.map(\.x), ys = positions.map(\.y), zs = positions.map(\.z) + let minX = xs.min()!, maxX = xs.max()! + let minZ = zs.min()!, maxZ = zs.max()! + let minY = ys.min()! + anchor.position = SIMD3(-(minX + maxX) / 2, -minY, -(minZ + maxZ) / 2) + let roomSize = max(maxX - minX, maxZ - minZ) + let dist = min(max(roomSize * 0.75 + 2.5, 4.0), 14.0) + coordinator.camera?.position = SIMD3(0, dist, dist) + coordinator.camera?.look(at: .zero, from: SIMD3(0, dist, dist), relativeTo: nil) + } + + /// 모델에 transform 적용 (bounds 스케일링 포함) + private func applyTransform(to entity: Entity, + matrix: simd_float4x4, + worldPos: SIMD3, + obj: RoomDataPayload.RoomObject) { + let bounds = entity.visualBounds(relativeTo: entity) + let bSize = bounds.max - bounds.min + let dims = obj.dimensions ?? [] + + if dims.count >= 3, bSize.x > 0.001, bSize.y > 0.001, bSize.z > 0.001 { + let scale = SIMD3(dims[0] / bSize.x, dims[1] / bSize.y, dims[2] / bSize.z) + let pivot = (bounds.max + bounds.min) / 2 + entity.scale = scale + entity.position = worldPos - pivot * scale + entity.orientation = simd_quatf(matrix) + } else { + entity.transform = Transform(matrix: matrix) + } + } + + /// RoomPlanCatalog.bundle 내에서 파일명으로 모델 URL 검색 + private func findCatalogModel(named fileName: String) -> URL? { + guard let bundleURL = Bundle.main.url(forResource: "RoomPlanCatalog", withExtension: "bundle") else { return nil } + + // 번들 내 재귀 탐색 + let enumerator = FileManager.default.enumerator(at: bundleURL, includingPropertiesForKeys: nil) + while let fileURL = enumerator?.nextObject() as? URL { + if fileURL.lastPathComponent == fileName { return fileURL } + } + return nil + } + + /// CapturedRoom.Surface를 박스 ModelEntity로 변환 (바닥 덮기용) + private func makeCapturedSurface(_ s: CapturedRoom.Surface, color: UIColor, depth: Float) -> ModelEntity { + var mat = SimpleMaterial() + mat.color = .init(tint: color); mat.roughness = 0.9; mat.metallic = 0.0 + let entity = ModelEntity( + mesh: .generateBox(width: s.dimensions.x, height: s.dimensions.y, depth: depth), + materials: [mat] + ) + entity.transform = Transform(matrix: s.transform) + return entity + } + + private func makeSurfaceBox(surface: RoomDataPayload.RoomSurface, + color: UIColor, + depth: Float) -> ModelEntity { + let dims = surface.dimensions + let w = dims.count > 0 ? dims[0] : 1.0 + let h = dims.count > 1 ? dims[1] : 1.0 + var mat = SimpleMaterial() + mat.color = .init(tint: color); mat.roughness = 0.9 + let entity = ModelEntity( + mesh: .generateBox(size: SIMD3(w, h, depth), cornerRadius: 0.0), + materials: [mat] + ) + if let matrix = surface.simdTransform { + entity.transform = Transform(matrix: matrix) + } + return entity + } + + /// 카테고리별 색상 박스 (배경색과 겹치지 않는 뚜렷한 색상) + private func makeFallbackBox(for obj: RoomDataPayload.RoomObject) -> ModelEntity { + var mat = SimpleMaterial() + mat.color = .init(tint: colorForCategory(obj.category)) + mat.roughness = 0.9 + let dims = obj.dimensions ?? [0.5, 0.5, 0.5] + return ModelEntity( + mesh: .generateBox( + size: SIMD3(dims[0], dims[1], dims[2]), + cornerRadius: 0.03 + ), + materials: [mat] + ) + } + + private func colorForCategory(_ category: String) -> UIColor { + switch category.lowercased() { + case "bed": return UIColor(red: 0.88, green: 0.82, blue: 0.85, alpha: 1.0) // 연한 핑크베이지 + case "sofa", "chair": return UIColor(red: 0.80, green: 0.86, blue: 0.92, alpha: 1.0) // 연한 파랑 + case "table": return UIColor(red: 0.86, green: 0.84, blue: 0.80, alpha: 1.0) // 연한 베이지 + case "storage": return UIColor(red: 0.83, green: 0.83, blue: 0.88, alpha: 1.0) // 연한 라벤더 + case "television": return UIColor(red: 0.65, green: 0.65, blue: 0.68, alpha: 1.0) + case "refrigerator": return UIColor(red: 0.88, green: 0.90, blue: 0.92, alpha: 1.0) + default: return UIColor(red: 0.84, green: 0.83, blue: 0.82, alpha: 1.0) + } + } + + // MARK: Coordinator + class Coordinator: NSObject { + weak var arView: ARView? + var roomAnchor: AnchorEntity? + var camera: PerspectiveCamera? // 카메라 거리 동적 조정용 + private var lastScale: Float = 1.0 + private var currentScale: Float = 1.0 + private var lastRotation: Float = 0 + + @objc func handlePinch(_ g: UIPinchGestureRecognizer) { + if g.state == .began { lastScale = currentScale } + currentScale = max(0.3, min(6.0, lastScale * Float(g.scale))) + roomAnchor?.scale = SIMD3(repeating: currentScale) + } + + @objc func handleRotation(_ g: UIRotationGestureRecognizer) { + if g.state == .began { lastRotation = 0 } + let delta = Float(g.rotation) - lastRotation + lastRotation = Float(g.rotation) + roomAnchor?.orientation *= simd_quatf(angle: -delta, axis: [0, 1, 0]) + } + + @objc func handlePan(_ g: UIPanGestureRecognizer) { + guard let arView else { return } + let delta = Float(g.translation(in: arView).x) * 0.005 + roomAnchor?.orientation *= simd_quatf(angle: delta, axis: [0, 1, 0]) + g.setTranslation(.zero, in: arView) + } + } +} + +// MARK: - 벽 세그먼트 분할 헬퍼 (문/창문 실제 구멍 생성) +// +// 각 벽(CapturedRoom.Surface 또는 JSON RoomSurface)에 대해: +// 1) 해당 벽 평면에 속하는 문/창문 개구부를 벽 로컬 좌표로 변환 +// 2) 개구부를 피한 박스 세그먼트들을 생성 → 실제 기하학적 구멍 + +private struct WallOpening { + let localX: Float // 벽 중심 기준 로컬 X (폭 방향) + let localY: Float // 벽 중심 기준 로컬 Y (높이 방향) + let width: Float + let height: Float + let isFloorLevel: Bool // true = 문(바닥까지 내려옴, 창문턱 없음), false = 창문(창문턱 있음) +} + +/// CapturedRoom.Surface 기반 – 해당 벽에 속하는 개구부 목록 +private func capturedRoomOpenings(wall: CapturedRoom.Surface, + doors: [CapturedRoom.Surface], + windows: [CapturedRoom.Surface]) -> [WallOpening] { + let wallInv = simd_inverse(wall.transform) + let W = wall.dimensions.x, H = wall.dimensions.y + let tol: Float = 0.3 + var result: [WallOpening] = [] + let all: [(CapturedRoom.Surface, Bool)] = doors.map { ($0, true) } + windows.map { ($0, false) } + for (s, isDoor) in all { + let lp = wallInv * s.transform.columns.3 + guard abs(lp.z) < tol, abs(lp.x) < W/2 + tol, abs(lp.y) < H/2 + tol else { continue } + result.append(WallOpening(localX: lp.x, localY: lp.y, + width: s.dimensions.x, height: s.dimensions.y, + isFloorLevel: isDoor)) + } + return result +} + +/// JSON Payload 기반 – 해당 벽에 속하는 개구부 목록 +private func payloadOpenings(wallTransform: simd_float4x4, + wallW: Float, wallH: Float, + doors: [RoomDataPayload.RoomSurface], + windows: [RoomDataPayload.RoomSurface]) -> [WallOpening] { + let wallInv = simd_inverse(wallTransform) + let tol: Float = 0.3 + var result: [WallOpening] = [] + for door in doors { + guard let dt = door.simdTransform else { continue } + let lp = wallInv * dt.columns.3 + guard abs(lp.z) < tol, abs(lp.x) < wallW/2 + tol, abs(lp.y) < wallH/2 + tol else { continue } + let dW = door.dimensions.count > 0 ? door.dimensions[0] : 0.9 + let dH = door.dimensions.count > 1 ? door.dimensions[1] : 2.1 + result.append(WallOpening(localX: lp.x, localY: lp.y, width: dW, height: dH, isFloorLevel: true)) + } + for win in windows { + guard let wt = win.simdTransform else { continue } + let lp = wallInv * wt.columns.3 + guard abs(lp.z) < tol, abs(lp.x) < wallW/2 + tol, abs(lp.y) < wallH/2 + tol else { continue } + let wW = win.dimensions.count > 0 ? win.dimensions[0] : 1.0 + let wH = win.dimensions.count > 1 ? win.dimensions[1] : 1.2 + result.append(WallOpening(localX: lp.x, localY: lp.y, width: wW, height: wH, isFloorLevel: false)) + } + return result +} + +/// 개구부를 피해 벽을 분할한 ModelEntity 세그먼트 배열 생성 +private func wallSegments(wallTransform: simd_float4x4, + wallW: Float, wallH: Float, + openings: [WallOpening], + color: UIColor, + depth: Float) -> [ModelEntity] { + guard !openings.isEmpty else { + return [wallBox(lx: 0, ly: 0, w: wallW, h: wallH, + wallTransform: wallTransform, color: color, depth: depth)] + } + + let sorted = openings.sorted { $0.localX - $0.width/2 < $1.localX - $1.width/2 } + var result: [ModelEntity] = [] + var curX: Float = -wallW/2 + + for op in sorted { + let opL = op.localX - op.width/2 + let opR = op.localX + op.width/2 + let opT = op.localY + op.height/2 + let opB = op.localY - op.height/2 + + // 왼쪽 세그먼트 (개구부 왼쪽 전체 높이) + if opL > curX + 0.01 { + let sw = opL - curX + result.append(wallBox(lx: curX + sw/2, ly: 0, w: sw, h: wallH, + wallTransform: wallTransform, color: color, depth: depth)) + } + + // 인방 (개구부 위) + let lintelH = wallH/2 - opT + if lintelH > 0.01 { + result.append(wallBox(lx: op.localX, ly: wallH/2 - lintelH/2, + w: op.width, h: lintelH, + wallTransform: wallTransform, color: color, depth: depth)) + } + + // 창문턱 (창문만 – 문은 바닥까지 열려 있으므로 생략) + if !op.isFloorLevel { + let sillH = opB + wallH/2 + if sillH > 0.01 { + result.append(wallBox(lx: op.localX, ly: -wallH/2 + sillH/2, + w: op.width, h: sillH, + wallTransform: wallTransform, color: color, depth: depth)) + } + } + + curX = opR + } + + // 오른쪽 세그먼트 + if curX < wallW/2 - 0.01 { + let sw = wallW/2 - curX + result.append(wallBox(lx: curX + sw/2, ly: 0, w: sw, h: wallH, + wallTransform: wallTransform, color: color, depth: depth)) + } + + return result +} + +/// 벽 로컬 좌표 (lx, ly) 위치의 박스 ModelEntity – 벽 transform 적용 +private func wallBox(lx: Float, ly: Float, w: Float, h: Float, + wallTransform: simd_float4x4, + color: UIColor, depth: Float) -> ModelEntity { + var mat = SimpleMaterial() + mat.color = .init(tint: color); mat.roughness = 0.9; mat.metallic = 0.0 + let entity = ModelEntity(mesh: .generateBox(width: w, height: h, depth: depth), materials: [mat]) + // 벽 로컬 (lx, ly, 0) → 월드 위치로 변환, 회전은 벽과 동일 + var t = wallTransform + t.columns.3 = wallTransform * SIMD4(lx, ly, 0, 1) + entity.transform = Transform(matrix: t) + return entity +} + +// MARK: - SIMD 헬퍼 + +extension SIMD4 where Scalar == Float { + var xyz: SIMD3 { SIMD3(x, y, z) } +} diff --git a/RoomPlanExporter/Views/ScanGuideView.swift b/RoomPlanExporter/Views/ScanGuideView.swift new file mode 100644 index 0000000..58fd881 --- /dev/null +++ b/RoomPlanExporter/Views/ScanGuideView.swift @@ -0,0 +1,106 @@ +import SwiftUI + +struct ScanGuideView: View { + @ObservedObject var vm: ScanViewModel + + private let tips: [(String, String)] = [ + ("방은 환하게 유지하기", + "방의 모든 불을 켜주세요. 공간이 밝을수록\n가구의 형태와 색상이 더 선명하게 복제됩니다."), + ("이동은 최대한 천천히", + "비디오를 찍듯이 아주 천천히 움직여주세요.\n급격한 회전은 데이터가 누락되는 원인이 됩니다."), + ("시선은 위아래 골고루", + "가구의 윗면과 바닥까지 충분히 담아주세요.\n다양한 각도에서 찍어야 정확한 분석이 가능합니다."), + ("거울과 유리창 주의", + "거울이나 유리창은 가려주면 더 정확합니다.\n빛 반사가 적어야 왜곡 없이 데이터가 수집됩니다.") + ] + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + Button { vm.phase = .main } label: { + Image(systemName: "chevron.left") + .font(.semiBold20) + .foregroundStyle(.primary) + .padding(.horizontal, 30) + .padding(.top, 20) + .padding(.bottom, 40) + } + + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 0) { + VStack(alignment: .leading, spacing: 10) { + Text("공간 촬영 및 분석 📷\n이용 안내") + .font(.title2.bold()) + Text("원활하고 정확한 공간 촬영을 위해 확인해주세요!") + .font(.subheadline) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 40) + .padding(.bottom, 45) + + VStack(spacing: 0) { + ForEach(Array(tips.enumerated()), id: \.offset) { i, tip in + TipRow(number: i + 1, title: tip.0, detail: tip.1, + showConnector: i < tips.count - 1) + } + } + .padding(.horizontal, 40) + } + .padding(.bottom, 16) + } + + Button("촬영 시작") { vm.startCountdown() } + .buttonStyle(VOFilledButtonStyle()) + .padding(.horizontal, 40) + .padding(.top, 12) + .padding(.bottom, 48) + } + } +} + +private struct TipRow: View { + let number: Int + let title: String + let detail: String + let showConnector: Bool + + var body: some View { + HStack(alignment: .top, spacing: 20) { + VStack(spacing: 0) { + Circle() + .fill(Color.voBlue) + .frame(width: 28, height: 28) + .overlay( + Text("\(number)") + .font(.caption.bold()) + .foregroundStyle(.white) + ) + + if showConnector { + Canvas { ctx, size in + var path = Path() + path.move(to: CGPoint(x: size.width / 2, y: 0)) + path.addLine(to: CGPoint(x: size.width / 2, y: size.height)) + ctx.stroke(path, with: .color(.gray.opacity(0.4)), + style: StrokeStyle(lineWidth: 1.5, dash: [4, 4])) + } + .frame(width: 28, height: 44) + .padding(.top, 4) + } + } + .frame(width: 28, alignment: .top) + + VStack(alignment: .leading, spacing: 6) { + Text(title) + .font(.subheadline.bold()) + Text(detail) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.bottom, showConnector ? 16 : 0) + } + } +} + +#Preview { ScanGuideView(vm: ScanViewModel()) } diff --git a/RoomPlanExporter/Views/ScanningView.swift b/RoomPlanExporter/Views/ScanningView.swift new file mode 100644 index 0000000..3213b29 --- /dev/null +++ b/RoomPlanExporter/Views/ScanningView.swift @@ -0,0 +1,23 @@ +import SwiftUI +import RoomPlan + +struct ScanningView: View { + @ObservedObject var vm: ScanViewModel + + var body: some View { + ZStack(alignment: .top) { + RoomCaptureViewControllerRepresentable( + onFinish: { vm.scanCompleted($0) }, + onCancel: { vm.retake() } + ) + .ignoresSafeArea() + + Text("당신의 공간을 디지털 트윈으로 복제 중입니다") + .font(.subheadline.weight(.medium)) + .foregroundStyle(.white) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .background(Color.voBlue) + } + } +} diff --git a/RoomPlanExporter/Views/UploadCompleteView.swift b/RoomPlanExporter/Views/UploadCompleteView.swift new file mode 100644 index 0000000..47fc610 --- /dev/null +++ b/RoomPlanExporter/Views/UploadCompleteView.swift @@ -0,0 +1,81 @@ +import SwiftUI +import RoomPlan + +struct UploadCompleteView: View { + let room: CapturedRoom + let confirmCode: String + @ObservedObject var vm: ScanViewModel + + @State private var copied = false + + var body: some View { + VStack(spacing: 0) { + // 배너 + HStack(spacing: 10) { + Image("logo_white") + .resizable() + .scaledToFit() + .frame(height: 32) + Text("내 방 저장 및 확인 코드 생성 완료 !") + .font(.subheadline.weight(.medium)) + .foregroundStyle(.white) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .padding(.horizontal, 20) + .background(Color.voBlue) + + // 확인 코드 + VStack(spacing: 8) { + HStack(spacing: 10) { + Text(confirmCode) + .font(.title2.bold()) + .tracking(4) + .foregroundStyle(Color.voBlue) + + Button { + UIPasteboard.general.string = confirmCode + copied = true + Task { + try? await Task.sleep(for: .seconds(1.5)) + copied = false + } + } label: { + Image(systemName: copied ? "checkmark" : "square.on.square") + .font(.system(size: 16)) + .foregroundStyle(copied ? .green : Color.voBlue) + .animation(.easeInOut(duration: 0.2), value: copied) + } + } + + HStack(spacing: 4) { + Image(systemName: "info.circle") + Text("확인 코드를 복사해두면 다음에도 조회 및 최적화가 가능해요") + } + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(.vertical, 16) + .padding(.horizontal, 24) + + Divider() + + // 3D 뷰어 + RoomViewerView(capturedRoom: room) + .frame(maxWidth: .infinity, maxHeight: .infinity) + + // 하단 버튼 + VStack(spacing: 12) { + Button("최적화 하기") { + vm.requestOptimization(room: room, confirmCode: confirmCode) + } + .buttonStyle(VOFilledButtonStyle()) + + Button("메인으로") { vm.retake() } + .buttonStyle(VOOutlineButtonStyle()) + } + .padding(.horizontal, 50) + .padding(.vertical, 20) + } + } +} diff --git a/RoomPlanExporter/Views/VOButtonStyles.swift b/RoomPlanExporter/Views/VOButtonStyles.swift new file mode 100644 index 0000000..b5ae9fa --- /dev/null +++ b/RoomPlanExporter/Views/VOButtonStyles.swift @@ -0,0 +1,25 @@ +import SwiftUI + +struct VOFilledButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(.semiBold14) + .foregroundStyle(.white) + .frame(maxWidth: .infinity) + .padding(.vertical, 16) + .background(Color.voBlue.opacity(configuration.isPressed ? 0.7 : 1)) + .clipShape(Capsule()) + } +} + +struct VOOutlineButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(.semiBold14) + .foregroundStyle(Color.voBlue) + .frame(maxWidth: .infinity) + .padding(.vertical, 16) + .overlay(Capsule().stroke(Color.voBlue, lineWidth: 1.5)) + .opacity(configuration.isPressed ? 0.7 : 1) + } +}