From 8aaa92f6dfd706ce432c007df97763d128632711 Mon Sep 17 00:00:00 2001 From: giljihun Date: Tue, 10 Feb 2026 21:24:18 +0900 Subject: [PATCH 01/17] =?UTF-8?q?chore:=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EC=9A=A9=20=EB=84=A4=EC=98=A8=EC=82=AC=EC=9D=B8=ED=82=A4?= =?UTF-8?q?=EB=A7=81=20=EA=B4=80=EB=A0=A8=20=ED=8C=8C=EC=9D=BC=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Keychy/Keychy/Core/Keyring/KeyringScale.swift | 15 + .../Scene/CellScene/KeyringScale.swift | 15 + .../Core/Keyring/Scene/KeyringScale.swift | 15 + .../ViewModels/NeonSignVM+Drawing.swift | 107 ------- .../ViewModels/NeonSignVM+Effect.swift | 163 ---------- .../NeonSign/ViewModels/NeonSignVM.swift | 302 ------------------ .../NeonSign/Views/DrawingCanvasView.swift | 112 ------- .../NeonSign/Views/DrawingToolsView.swift | 120 ------- .../NeonSign/Views/NeonSignPreview.swift | 33 -- 9 files changed, 45 insertions(+), 837 deletions(-) create mode 100644 Keychy/Keychy/Core/Keyring/KeyringScale.swift create mode 100644 Keychy/Keychy/Core/Keyring/Scene/CellScene/KeyringScale.swift create mode 100644 Keychy/Keychy/Core/Keyring/Scene/KeyringScale.swift delete mode 100644 Keychy/Keychy/Presentation/KeyringMaker/Templates/NeonSign/ViewModels/NeonSignVM+Drawing.swift delete mode 100644 Keychy/Keychy/Presentation/KeyringMaker/Templates/NeonSign/ViewModels/NeonSignVM+Effect.swift delete mode 100644 Keychy/Keychy/Presentation/KeyringMaker/Templates/NeonSign/ViewModels/NeonSignVM.swift delete mode 100644 Keychy/Keychy/Presentation/KeyringMaker/Templates/NeonSign/Views/DrawingCanvasView.swift delete mode 100644 Keychy/Keychy/Presentation/KeyringMaker/Templates/NeonSign/Views/DrawingToolsView.swift delete mode 100644 Keychy/Keychy/Presentation/KeyringMaker/Templates/NeonSign/Views/NeonSignPreview.swift diff --git a/Keychy/Keychy/Core/Keyring/KeyringScale.swift b/Keychy/Keychy/Core/Keyring/KeyringScale.swift new file mode 100644 index 000000000..20008afc8 --- /dev/null +++ b/Keychy/Keychy/Core/Keyring/KeyringScale.swift @@ -0,0 +1,15 @@ +// +// KeyringScale.swift +// Keychy +// +// Created by 길지훈 on 2/10/26. +// + +import Foundation + +/// 키링 스케일 중앙 관리 +/// - 화면별 기본 스케일 +/// - 템플릿별 커스텀 스케일 +enum KeyringScale { + +} diff --git a/Keychy/Keychy/Core/Keyring/Scene/CellScene/KeyringScale.swift b/Keychy/Keychy/Core/Keyring/Scene/CellScene/KeyringScale.swift new file mode 100644 index 000000000..20008afc8 --- /dev/null +++ b/Keychy/Keychy/Core/Keyring/Scene/CellScene/KeyringScale.swift @@ -0,0 +1,15 @@ +// +// KeyringScale.swift +// Keychy +// +// Created by 길지훈 on 2/10/26. +// + +import Foundation + +/// 키링 스케일 중앙 관리 +/// - 화면별 기본 스케일 +/// - 템플릿별 커스텀 스케일 +enum KeyringScale { + +} diff --git a/Keychy/Keychy/Core/Keyring/Scene/KeyringScale.swift b/Keychy/Keychy/Core/Keyring/Scene/KeyringScale.swift new file mode 100644 index 000000000..20008afc8 --- /dev/null +++ b/Keychy/Keychy/Core/Keyring/Scene/KeyringScale.swift @@ -0,0 +1,15 @@ +// +// KeyringScale.swift +// Keychy +// +// Created by 길지훈 on 2/10/26. +// + +import Foundation + +/// 키링 스케일 중앙 관리 +/// - 화면별 기본 스케일 +/// - 템플릿별 커스텀 스케일 +enum KeyringScale { + +} diff --git a/Keychy/Keychy/Presentation/KeyringMaker/Templates/NeonSign/ViewModels/NeonSignVM+Drawing.swift b/Keychy/Keychy/Presentation/KeyringMaker/Templates/NeonSign/ViewModels/NeonSignVM+Drawing.swift deleted file mode 100644 index 7904e76aa..000000000 --- a/Keychy/Keychy/Presentation/KeyringMaker/Templates/NeonSign/ViewModels/NeonSignVM+Drawing.swift +++ /dev/null @@ -1,107 +0,0 @@ -// -// NeonSignVM+Drawing.swift -// Keychy -// -// Created by Rundo on 11/8/25. -// 그리기 관련 로직 -// - -import SwiftUI - -extension NeonSignVM { - - // MARK: - Drawing Composition - - /// 그림을 bodyImage와 합성 - func composeDrawingWithBodyImage() { - guard let original = originalBodyImage, !drawingPaths.isEmpty else { - // 그림이 없으면 원본으로 복원 - if let original = originalBodyImage { - bodyImage = original - } - return - } - - let imageSize = original.size - let renderer = UIGraphicsImageRenderer(size: imageSize) - - // 화면 좌표 → 이미지 좌표 변환 비율 계산 - let scaleX = imageSize.width / imageFrame.width - let scaleY = imageSize.height / imageFrame.height - - let composedImage = renderer.image { context in - // 1. 원본 이미지 그리기 - original.draw(at: .zero) - - // 2. 그림 경로들 그리기 - let cgContext = context.cgContext - - for drawnPath in drawingPaths { - // 화면 좌표의 Path를 이미지 좌표로 변환 - var transformedPath = Path() - - drawnPath.path.forEach { element in - switch element { - case .move(to: let point): - let transformedPoint = CGPoint( - x: (point.x - imageFrame.origin.x) * scaleX, - y: (point.y - imageFrame.origin.y) * scaleY - ) - transformedPath.move(to: transformedPoint) - - case .line(to: let point): - let transformedPoint = CGPoint( - x: (point.x - imageFrame.origin.x) * scaleX, - y: (point.y - imageFrame.origin.y) * scaleY - ) - transformedPath.addLine(to: transformedPoint) - - case .quadCurve(to: let point, control: let control): - let transformedPoint = CGPoint( - x: (point.x - imageFrame.origin.x) * scaleX, - y: (point.y - imageFrame.origin.y) * scaleY - ) - let transformedControl = CGPoint( - x: (control.x - imageFrame.origin.x) * scaleX, - y: (control.y - imageFrame.origin.y) * scaleY - ) - transformedPath.addQuadCurve(to: transformedPoint, control: transformedControl) - - case .curve(to: let point, control1: let control1, control2: let control2): - let transformedPoint = CGPoint( - x: (point.x - imageFrame.origin.x) * scaleX, - y: (point.y - imageFrame.origin.y) * scaleY - ) - let transformedControl1 = CGPoint( - x: (control1.x - imageFrame.origin.x) * scaleX, - y: (control1.y - imageFrame.origin.y) * scaleY - ) - let transformedControl2 = CGPoint( - x: (control2.x - imageFrame.origin.x) * scaleX, - y: (control2.y - imageFrame.origin.y) * scaleY - ) - transformedPath.addCurve(to: transformedPoint, control1: transformedControl1, control2: transformedControl2) - - case .closeSubpath: - transformedPath.closeSubpath() - - @unknown default: - break - } - } - - let cgPath = transformedPath.cgPath - - cgContext.setStrokeColor(UIColor(drawnPath.color).cgColor) - cgContext.setLineWidth(drawnPath.lineWidth * scaleX) - cgContext.setLineCap(.round) - cgContext.setLineJoin(.round) - - cgContext.addPath(cgPath) - cgContext.strokePath() - } - } - - bodyImage = composedImage - } -} diff --git a/Keychy/Keychy/Presentation/KeyringMaker/Templates/NeonSign/ViewModels/NeonSignVM+Effect.swift b/Keychy/Keychy/Presentation/KeyringMaker/Templates/NeonSign/ViewModels/NeonSignVM+Effect.swift deleted file mode 100644 index b29504e3f..000000000 --- a/Keychy/Keychy/Presentation/KeyringMaker/Templates/NeonSign/ViewModels/NeonSignVM+Effect.swift +++ /dev/null @@ -1,163 +0,0 @@ -// -// NeonSignVM+Effect.swift -// Keychy -// -// Created by Rundo on 11/8/25. -// 사운드, 파티클 관련 로직 -// - -import Combine -import Foundation - -extension NeonSignVM { - - // MARK: - Sound & Particle Update - - /// 사운드 업데이트 - func updateSound(_ sound: Sound?) { - selectedSound = sound - soundId = sound?.id ?? "none" - effectSubject.send((soundId, particleId, .sound)) - } - - /// 파티클 업데이트 - func updateParticle(_ particle: Particle?) { - selectedParticle = particle - particleId = particle?.id ?? "none" - effectSubject.send((soundId, particleId, .particle)) - } - - // MARK: - Custom Sound (녹음) - - /// 커스텀 사운드 존재 여부 - var hasCustomSound: Bool { - customSoundURL != nil - } - - /// 커스텀 사운드 적용 - func applyCustomSound(_ url: URL) { - customSoundURL = url - - // 기존 사운드 선택 해제 - selectedSound = nil - - // soundId를 특별한 값으로 설정 (나중에 재생 시 구분) - soundId = "custom_recording" - effectSubject.send((soundId, particleId, .sound)) - } - - /// 커스텀 사운드 제거 - func removeCustomSound() { - customSoundURL = nil - soundId = "none" - effectSubject.send((soundId, particleId, .sound)) - } - - // MARK: - Ownership Check - - /// 사운드 소유 여부 확인 (Firebase 구매 기록) - func isOwned(soundId: String) -> Bool { - return EffectManager.shared.isOwned(soundId: soundId, userManager: userManager) - } - - /// 파티클 소유 여부 확인 (Firebase 구매 기록) - func isOwned(particleId: String) -> Bool { - return EffectManager.shared.isOwned(particleId: particleId, userManager: userManager) - } - - /// 사운드가 Bundle에 포함되어 있는지 (앱에 포함된 무료 아이템) - func isInBundle(soundId: String) -> Bool { - return EffectManager.shared.isInBundle(soundId: soundId) - } - - /// 파티클이 Bundle에 포함되어 있는지 (앱에 포함된 무료 아이템) - func isInBundle(particleId: String) -> Bool { - return EffectManager.shared.isInBundle(particleId: particleId) - } - - /// 사운드가 Cache에 다운로드되어 있는지 - func isInCache(soundId: String) -> Bool { - return EffectManager.shared.isInCache(soundId: soundId) - } - - /// 파티클이 Cache에 다운로드되어 있는지 - func isInCache(particleId: String) -> Bool { - return EffectManager.shared.isInCache(particleId: particleId) - } - - // MARK: - Download - - /// 사운드 다운로드 - func downloadSound(_ sound: Sound) async { - guard let soundId = sound.id else { return } - - // ViewModel 상태 시작 - await MainActor.run { - downloadingItemIds.insert(soundId) - downloadProgress[soundId] = 0.0 - } - - // Progress 모니터링 Task - let monitorTask = Task { - while !Task.isCancelled { - await MainActor.run { - // EffectManager의 progress를 ViewModel에 복사 - if let progress = EffectManager.shared.downloadProgress[soundId] { - downloadProgress[soundId] = progress - } - } - try? await Task.sleep(nanoseconds: 100_000_000) // 0.1초마다 확인 - } - } - - // EffectManager를 통해 다운로드 - await EffectManager.shared.downloadSound(sound, userManager: userManager) - - // 모니터링 중단 - monitorTask.cancel() - - // 다운로드 완료 후 상태 정리 및 자동 선택 - await MainActor.run { - downloadingItemIds.remove(soundId) - downloadProgress.removeValue(forKey: soundId) - updateSound(sound) - } - } - - /// 파티클 다운로드 - func downloadParticle(_ particle: Particle) async { - guard let particleId = particle.id else { return } - - // ViewModel 상태 시작 - await MainActor.run { - downloadingItemIds.insert(particleId) - downloadProgress[particleId] = 0.0 - } - - // Progress 모니터링 Task - let monitorTask = Task { - while !Task.isCancelled { - await MainActor.run { - // EffectManager의 progress를 ViewModel에 복사 - if let progress = EffectManager.shared.downloadProgress[particleId] { - downloadProgress[particleId] = progress - } - } - try? await Task.sleep(nanoseconds: 100_000_000) // 0.1초마다 확인 - } - } - - // EffectManager를 통해 다운로드 - await EffectManager.shared.downloadParticle(particle, userManager: userManager) - - // 모니터링 중단 - monitorTask.cancel() - - // 다운로드 완료 후 상태 정리 및 자동 선택 - await MainActor.run { - downloadingItemIds.remove(particleId) - downloadProgress.removeValue(forKey: particleId) - updateParticle(particle) - } - } -} diff --git a/Keychy/Keychy/Presentation/KeyringMaker/Templates/NeonSign/ViewModels/NeonSignVM.swift b/Keychy/Keychy/Presentation/KeyringMaker/Templates/NeonSign/ViewModels/NeonSignVM.swift deleted file mode 100644 index c6cc42a7a..000000000 --- a/Keychy/Keychy/Presentation/KeyringMaker/Templates/NeonSign/ViewModels/NeonSignVM.swift +++ /dev/null @@ -1,302 +0,0 @@ -// -// NeonSignVM.swift -// Keychy -// -// Created by Rundo on 11/8/25. -// 설명: 네온 사인 템플릿 뷰모델 -// 이미지 선택 없이 미리 정의된 bodyImage 사용 + 이펙트 선택 - -import SwiftUI -import Combine -import FirebaseFirestore - -@Observable -class NeonSignVM: KeyringViewModelProtocol { - // MARK: - Template Data (Firebase) - var template: KeyringTemplate? - var isLoadingTemplate = false - - // MARK: - Effect Data (Firebase) - var availableSounds: [Sound] = [] - var availableParticles: [Particle] = [] - - /// 정렬된 사운드 리스트 - var sortedAvailableSounds: [Sound] { - availableSounds.sorted { sound1, sound2 in - guard let id1 = sound1.id, let id2 = sound2.id else { return false } - - let downloaded1 = isInBundle(soundId: id1) || isInCache(soundId: id1) - let downloaded2 = isInBundle(soundId: id2) || isInCache(soundId: id2) - - let priority1 = getSortPriority( - isFree: sound1.isFree, - isDownloaded: downloaded1 - ) - let priority2 = getSortPriority( - isFree: sound2.isFree, - isDownloaded: downloaded2 - ) - - return priority1 < priority2 - } - } - - /// 정렬된 파티클 리스트 - var sortedAvailableParticles: [Particle] { - availableParticles.sorted { particle1, particle2 in - guard let id1 = particle1.id, let id2 = particle2.id else { return false } - - let downloaded1 = isInBundle(particleId: id1) || isInCache(particleId: id1) - let downloaded2 = isInBundle(particleId: id2) || isInCache(particleId: id2) - - let priority1 = getSortPriority( - isFree: particle1.isFree, - isDownloaded: downloaded1 - ) - let priority2 = getSortPriority( - isFree: particle2.isFree, - isDownloaded: downloaded2 - ) - - return priority1 < priority2 - } - } - - var selectedSound: Sound? = nil - var selectedParticle: Particle? = nil - - // MARK: - Custom Sound (녹음) - var customSoundURL: URL? = nil - - // MARK: - Download State - var downloadingItemIds: Set = [] - var downloadProgress: [String: Double] = [:] - - // MARK: - Scene 전달용 ID - var soundId: String = "none" - var particleId: String = "none" - - // MARK: - Combine Bridge - /// @Observable을 Combine에 사용하기 위한 브릿지 - /// KeyringScene에 soundId, particleId만 전달 - let effectSubject = PassthroughSubject<(soundId: String, particleId: String, type: KeyringUpdateType), Never>() - - // MARK: - UserManager - var userManager: UserManager - - // MARK: - Body Image (템플릿에 미리 정의된 이미지) - var bodyImage: UIImage? = nil - var originalBodyImage: UIImage? = nil // 원본 이미지 (합성 전) - var hookOffsetY: CGFloat = 0.0 - - /// 템플릿 ID - var templateId: String { - template?.id ?? "NeonSign" - } - - var errorMessage: String? - - // MARK: - Drawing State (그리기 모드) - var drawingPaths: [DrawnPath] = [] - var currentDrawingColor: Color = .white - var currentLineWidth: CGFloat = 3.0 - var imageFrame: CGRect = .zero // 그리기 영역 (DrawingCanvasView에서 설정) - - // MARK: - 정보 입력 - var nameText: String = "" - var maxTextCount: Int = 10 - var memoText: String = "" - var maxMemoCount: Int = 500 - var selectedTags: [String] = [] - var createdAt: Date = Date() - var savedKeyringDocumentId: String? - var packagedPostOfficeId: String? - var packagedShareLink: String? - - // MARK: - 초기화 - init(userManager: UserManager = UserManager.shared) { - self.userManager = userManager - } - - // MARK: - 생성일 Date formatter - func formattedDate(date: Date) -> String { - let formatter = DateFormatter() - formatter.locale = Locale(identifier: "ko_KR") - formatter.dateFormat = "yyyy년 MM월 dd일" - return formatter.string(from: createdAt) - } - - // MARK: - Firebase Template 가져오기 - func fetchTemplate() async { - isLoadingTemplate = true - - defer { isLoadingTemplate = false } - - do { - let document = try await Firestore.firestore() - .collection("Template") - .document("NeonSign") - .getDocument() - - template = try document.data(as: KeyringTemplate.self) - - // 네온사인 템플릿 전용 고정 이미지 (Assets) - let image = UIImage(named: "bangMark") - bodyImage = image - originalBodyImage = image // 원본 저장 - - } catch { - errorMessage = "템플릿을 불러오는데 실패했습니다." - } - } - - // MARK: - Firebase Effects 가져오기 (전체 - 소유/미소유 분리) - func fetchEffects() async { - guard let user = userManager.currentUser else { - errorMessage = "유저 정보를 불러올 수 없습니다." - return - } - - do { - // Sound 전체 가져오기 (isActive == true만) - let soundsSnapshot = try await Firestore.firestore() - .collection("Sound") - .whereField("isActive", isEqualTo: true) - .getDocuments() - - let allSounds = try soundsSnapshot.documents.compactMap { - try $0.data(as: Sound.self) - } - - // 소유/미소유 분리 및 정렬 - let ownedSounds = allSounds.filter { sound in - guard let id = sound.id else { return false } - return user.soundEffects.contains(id) - } - let notOwnedSounds = allSounds.filter { sound in - guard let id = sound.id else { return false } - return !user.soundEffects.contains(id) - } - - availableSounds = ownedSounds + notOwnedSounds - - // Particle 전체 가져오기 (isActive == true만) - let particlesSnapshot = try await Firestore.firestore() - .collection("Particle") - .whereField("isActive", isEqualTo: true) - .getDocuments() - - let allParticles = try particlesSnapshot.documents.compactMap { - try $0.data(as: Particle.self) - } - - // 소유/미소유 분리 및 정렬 - let ownedParticles = allParticles.filter { particle in - guard let id = particle.id else { return false } - return user.particleEffects.contains(id) - } - let notOwnedParticles = allParticles.filter { particle in - guard let id = particle.id else { return false } - return !user.particleEffects.contains(id) - } - - availableParticles = ownedParticles + notOwnedParticles - - } catch { - errorMessage = "이펙트 목록을 불러오는데 실패했습니다." - } - } - - // MARK: - Sorting Helper - private func getSortPriority(isFree: Bool, isDownloaded: Bool) -> Int { - if isFree && isDownloaded { return 1 } - if !isFree && isDownloaded { return 2 } - if isFree && !isDownloaded { return 3 } - if !isFree && !isDownloaded { return 4 } - return 99 - } - - // MARK: - Lifecycle Callbacks - /// 모드 변경 시 그리기 → 다른 모드로 전환되면 그림 합성 - func onModeChanged(from oldMode: CustomizingMode, to newMode: CustomizingMode) { - if oldMode == .drawing && newMode != .drawing { - composeDrawingWithBodyImage() - } - } - - /// 다음 화면으로 이동하기 전 그림 합성 - func beforeNavigateToNext() { - composeDrawingWithBodyImage() - } - - // MARK: - Customizing Modes - /// 커스터마이징 모드 (네온 사인은 그리기 + 이펙트 지원) - var availableCustomizingModes: [CustomizingMode] { - [.drawing, .effect] - } - - // MARK: - View Providers - /// 씬 뷰 제공 (모드별) - func sceneView(for mode: CustomizingMode, onSceneReady: @escaping () -> Void) -> AnyView { - switch mode { - case .effect: - return AnyView(KeyringSceneView(viewModel: self, onSceneReady: onSceneReady)) - case .drawing: - return AnyView(DrawingCanvasView(viewModel: self, onSceneReady: onSceneReady)) - default: - return AnyView(EmptyView()) - } - } - - /// 하단 콘텐츠 뷰 제공 (모드별) - func bottomContentView( - for mode: CustomizingMode, - showPurchaseSheet: Binding, - cartItems: Binding<[EffectItem]> - ) -> AnyView { - switch mode { - case .effect: - return AnyView(EffectSelectorView(viewModel: self, cartItems: cartItems)) - case .drawing: - return AnyView(DrawingToolsView(viewModel: self)) - default: - return AnyView(EmptyView()) - } - } - - // MARK: - Reset - /// 커스터마이징 데이터 초기화 (이펙트 + 그리기) - func resetCustomizingData() { - // 이펙트 초기화 - selectedSound = nil - selectedParticle = nil - customSoundURL = nil - soundId = "none" - particleId = "none" - downloadingItemIds.removeAll() - downloadProgress.removeAll() - - // 그리기 상태 초기화 - drawingPaths.removeAll() - currentDrawingColor = .white - currentLineWidth = 3.0 - - // 원본 이미지로 복원 - if let original = originalBodyImage { - bodyImage = original - } - } - - /// 정보 입력 데이터 초기화 - func resetInfoData() { - nameText = "" - memoText = "" - selectedTags = [] - } - - /// 완전 초기화 - func resetAll() { - resetCustomizingData() - resetInfoData() - } -} diff --git a/Keychy/Keychy/Presentation/KeyringMaker/Templates/NeonSign/Views/DrawingCanvasView.swift b/Keychy/Keychy/Presentation/KeyringMaker/Templates/NeonSign/Views/DrawingCanvasView.swift deleted file mode 100644 index 170f8dab4..000000000 --- a/Keychy/Keychy/Presentation/KeyringMaker/Templates/NeonSign/Views/DrawingCanvasView.swift +++ /dev/null @@ -1,112 +0,0 @@ -// -// DrawingCanvasView.swift -// Keychy -// -// 네온사인 템플릿 전용 그리기 캔버스 뷰 -// - -import SwiftUI - -struct DrawingCanvasView: View { - @Bindable var viewModel: NeonSignVM - let onSceneReady: () -> Void - - @State private var currentPath = Path() - @State private var imageFrame: CGRect = .zero - - var body: some View { - GeometryReader { geometry in - ZStack { - // 배경 - Color.white100 - - // 네온사인 바디 이미지 (그리기 가능 영역) - if let bodyImage = viewModel.bodyImage { - Image(uiImage: bodyImage) - .resizable() - .scaledToFit() - .frame(maxWidth: 200, maxHeight: 200) - .background( - GeometryReader { imageGeometry in - Color.clear - .preference(key: ImageFramePreferenceKey.self, - value: imageGeometry.frame(in: .named("canvasSpace"))) - } - ) - .onPreferenceChange(ImageFramePreferenceKey.self) { frame in - imageFrame = frame - viewModel.imageFrame = frame // ViewModel에도 저장 - } - } - - // 그리기 캔버스 (이미지 위에만 그려짐) - Canvas { context, size in - // 저장된 경로들 그리기 - for drawnPath in viewModel.drawingPaths { - var path = drawnPath.path - context.stroke( - path, - with: .color(drawnPath.color), - lineWidth: drawnPath.lineWidth - ) - } - - // 현재 그리는 경로 - context.stroke( - currentPath, - with: .color(viewModel.currentDrawingColor), - lineWidth: viewModel.currentLineWidth - ) - } - .gesture( - DragGesture(minimumDistance: 0) - .onChanged { value in - let point = value.location - - // bodyImage 영역 내에서만 그리기 허용 - guard imageFrame.contains(point) else { return } - - if currentPath.isEmpty { - currentPath.move(to: point) - } else { - currentPath.addLine(to: point) - } - } - .onEnded { _ in - // 경로가 비어있지 않으면 저장 - if !currentPath.isEmpty { - viewModel.drawingPaths.append(DrawnPath( - path: currentPath, - color: viewModel.currentDrawingColor, - lineWidth: viewModel.currentLineWidth - )) - currentPath = Path() - } - } - ) - } - .coordinateSpace(name: "canvasSpace") - } - .onAppear { - // 일반 SwiftUI View는 즉시 준비 완료 - onSceneReady() - } - } -} - -// MARK: - Drawing Data Model -struct DrawnPath: Identifiable { - let id = UUID() - let path: Path - let color: Color - let lineWidth: CGFloat -} - -// MARK: - Preference Key for Image Frame -struct ImageFramePreferenceKey: PreferenceKey { - static var defaultValue: CGRect = .zero - - static func reduce(value: inout CGRect, nextValue: () -> CGRect) { - value = nextValue() - } -} diff --git a/Keychy/Keychy/Presentation/KeyringMaker/Templates/NeonSign/Views/DrawingToolsView.swift b/Keychy/Keychy/Presentation/KeyringMaker/Templates/NeonSign/Views/DrawingToolsView.swift deleted file mode 100644 index ca529f94e..000000000 --- a/Keychy/Keychy/Presentation/KeyringMaker/Templates/NeonSign/Views/DrawingToolsView.swift +++ /dev/null @@ -1,120 +0,0 @@ -// -// DrawingToolsView.swift -// Keychy -// -// 네온사인 템플릿 전용 그리기 도구 뷰 -// - -import SwiftUI - -struct DrawingToolsView: View { - @Bindable var viewModel: NeonSignVM - - let colors: [Color] = [.white, .red, .orange, .yellow, .green, .blue, .purple, .pink] - - var body: some View { - VStack(alignment: .leading, spacing: 24) { - // 색상 선택 - colorSelector - - // 선 굵기 조절 - lineWidthSelector - - // 실행 취소 버튼 - undoButton - - Spacer() - } - .background( - UnevenRoundedRectangle( - topLeadingRadius: 24, - topTrailingRadius: 24 - ) - .fill(.white100) - .shadow(color: .black.opacity(0.15), radius: 9) - .ignoresSafeArea(edges: .bottom) - ) - } - - // MARK: - Color Selector - - private var colorSelector: some View { - VStack(alignment: .leading, spacing: 12) { - Text("색상") - .typography(.suit16B) - .foregroundStyle(.black100) - .padding(.leading, 20) - .padding(.top, 30) - - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 12) { - ForEach(colors, id: \.self) { color in - Button { - viewModel.currentDrawingColor = color - } label: { - Circle() - .fill(color) - .frame(width: 40, height: 40) - .overlay( - Circle() - .strokeBorder( - viewModel.currentDrawingColor == color ? .black : .gray300, - lineWidth: viewModel.currentDrawingColor == color ? 3 : 1 - ) - ) - } - } - } - .padding(.horizontal, 20) - } - } - } - - // MARK: - Line Width Selector - - private var lineWidthSelector: some View { - VStack(alignment: .leading, spacing: 12) { - HStack { - Text("선 굵기") - .typography(.suit16B) - .foregroundStyle(.black100) - - Spacer() - - Text("\(Int(viewModel.currentLineWidth))pt") - .typography(.suit14M) - .foregroundStyle(.gray500) - } - .padding(.horizontal, 20) - - Slider(value: $viewModel.currentLineWidth, in: 1...20, step: 1) - .tint(.main500) - .padding(.horizontal, 20) - } - } - - // MARK: - Undo Button - - private var undoButton: some View { - Button { - if !viewModel.drawingPaths.isEmpty { - viewModel.drawingPaths.removeLast() - } - } label: { - HStack { - Image(systemName: "arrow.uturn.backward") - Text("실행 취소") - } - .typography(.suit15SB25) - .foregroundStyle(viewModel.drawingPaths.isEmpty ? .gray400 : .black100) - .padding(.horizontal, 20) - .padding(.vertical, 12) - .background( - RoundedRectangle(cornerRadius: 12) - .fill(viewModel.drawingPaths.isEmpty ? .gray100 : .gray50) - ) - } - .disabled(viewModel.drawingPaths.isEmpty) - .padding(.horizontal, 20) - } -} diff --git a/Keychy/Keychy/Presentation/KeyringMaker/Templates/NeonSign/Views/NeonSignPreview.swift b/Keychy/Keychy/Presentation/KeyringMaker/Templates/NeonSign/Views/NeonSignPreview.swift deleted file mode 100644 index 55aab78a6..000000000 --- a/Keychy/Keychy/Presentation/KeyringMaker/Templates/NeonSign/Views/NeonSignPreview.swift +++ /dev/null @@ -1,33 +0,0 @@ -// -// NeonSignPreView.swift -// Keychy -// -// Created by rundo on 10/29/25. -// - -import SwiftUI - -struct NeonSignPreView: View { - @Bindable var router: NavigationRouter - @State var viewModel: NeonSignVM - - var body: some View { - TemplatePreviewBody( - template: viewModel.template, - fetchTemplate: { await viewModel.fetchTemplate() }, - onMake: { - router.push(.neonSignCustomizing) - }, - router: router - ) - .swipeBackGesture(enabled: true) - } -} - -#Preview { - NeonSignPreView( - router: NavigationRouter(), - viewModel: NeonSignVM() - ) - .environment(UserManager.shared) -} From f7259c47df53531aefd22a9c27b3a6609df2103b Mon Sep 17 00:00:00 2001 From: giljihun Date: Tue, 10 Feb 2026 21:50:29 +0900 Subject: [PATCH 02/17] =?UTF-8?q?feat:=20KeyringScale.swift=20=EC=99=84?= =?UTF-8?q?=EC=84=B1=20+=20NeonSign=20=EC=A0=9C=EA=B1=B0=20+=20=EC=A4=91?= =?UTF-8?q?=EB=B3=B5=20=ED=8C=8C=EC=9D=BC=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Keychy/Keychy.xcodeproj/project.pbxproj | 52 ++--------------- .../Components/KeyringBodyComponent.swift | 17 ++---- Keychy/Keychy/Core/Keyring/KeyringScale.swift | 15 ----- .../Scene/CellScene/KeyringScale.swift | 15 ----- .../Core/Keyring/Scene/KeyringScale.swift | 58 ++++++++++++++++++- .../Navigation/Routes/WorkshopRoute.swift | 8 --- .../Presentation/Tab/Views/WorkshopTab.swift | 38 ------------ 7 files changed, 65 insertions(+), 138 deletions(-) delete mode 100644 Keychy/Keychy/Core/Keyring/KeyringScale.swift delete mode 100644 Keychy/Keychy/Core/Keyring/Scene/CellScene/KeyringScale.swift diff --git a/Keychy/Keychy.xcodeproj/project.pbxproj b/Keychy/Keychy.xcodeproj/project.pbxproj index a7d14d6b5..7135d8960 100644 --- a/Keychy/Keychy.xcodeproj/project.pbxproj +++ b/Keychy/Keychy.xcodeproj/project.pbxproj @@ -129,6 +129,7 @@ 4C2526222F3B290A003CC5AD /* KeyringScene+Swipe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C2526182F3B290A003CC5AD /* KeyringScene+Swipe.swift */; }; 4C2526232F3B290A003CC5AD /* KeyringScene+Setup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C2526172F3B290A003CC5AD /* KeyringScene+Setup.swift */; }; 4C2526242F3B290A003CC5AD /* KeyringCellScene+Capture.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C25260F2F3B290A003CC5AD /* KeyringCellScene+Capture.swift */; }; + 4C2526262F3B2BBE003CC5AD /* KeyringScale.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C2526252F3B2BBE003CC5AD /* KeyringScale.swift */; }; 4C3687F72EBFA87800C64E75 /* Pretendard-Medium.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 4C3687F62EBFA87800C64E75 /* Pretendard-Medium.ttf */; }; 4C3687FA2EBFC0FB00C64E75 /* NotificationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C3687F82EBFC0FB00C64E75 /* NotificationManager.swift */; }; 4C3687FC2EC05E6800C64E75 /* AccountAlert.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C3687FB2EC05E6800C64E75 /* AccountAlert.swift */; }; @@ -156,8 +157,6 @@ 4C47339A2F1FA388005D2376 /* AcrylicPhotoEditedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C4733582F1FA388005D2376 /* AcrylicPhotoEditedView.swift */; }; 4C47339B2F1FA388005D2376 /* KeyringCustomizingView+Purchase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C4733442F1FA388005D2376 /* KeyringCustomizingView+Purchase.swift */; }; 4C47339C2F1FA388005D2376 /* PreviewGuiding.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C4733382F1FA388005D2376 /* PreviewGuiding.swift */; }; - 4C47339D2F1FA388005D2376 /* DrawingCanvasView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C47336E2F1FA388005D2376 /* DrawingCanvasView.swift */; }; - 4C47339E2F1FA388005D2376 /* NeonSignVM+Drawing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C47336B2F1FA388005D2376 /* NeonSignVM+Drawing.swift */; }; 4C47339F2F1FA388005D2376 /* SpeechBubblePreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C47338D2F1FA388005D2376 /* SpeechBubblePreview.swift */; }; 4C4733A02F1FA388005D2376 /* KeyringCustomizingView+PurchaseSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C4733452F1FA388005D2376 /* KeyringCustomizingView+PurchaseSheet.swift */; }; 4C4733A12F1FA388005D2376 /* AcrylicPhotoCropView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C4733572F1FA388005D2376 /* AcrylicPhotoCropView.swift */; }; @@ -168,7 +167,6 @@ 4C4733A62F1FA388005D2376 /* KeyringCompleteView+VideoGen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C4733412F1FA388005D2376 /* KeyringCompleteView+VideoGen.swift */; }; 4C4733A72F1FA388005D2376 /* KeyringCompleteView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C47333E2F1FA388005D2376 /* KeyringCompleteView.swift */; }; 4C4733A82F1FA388005D2376 /* AcrylicPhotoVM+ImageLoad.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C4733552F1FA388005D2376 /* AcrylicPhotoVM+ImageLoad.swift */; }; - 4C4733A92F1FA388005D2376 /* NeonSignPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C4733702F1FA388005D2376 /* NeonSignPreview.swift */; }; 4C4733AA2F1FA388005D2376 /* SpeechBubbleFrameSelectorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C47338C2F1FA388005D2376 /* SpeechBubbleFrameSelectorView.swift */; }; 4C4733AB2F1FA388005D2376 /* KeyringViewModelProtocol+Reset.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C47332F2F1FA388005D2376 /* KeyringViewModelProtocol+Reset.swift */; }; 4C4733AC2F1FA388005D2376 /* PixelPreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C4733782F1FA388005D2376 /* PixelPreviewView.swift */; }; @@ -198,13 +196,10 @@ 4C4733C42F1FA388005D2376 /* SpeechBubbleVM+Effect.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C4733872F1FA388005D2376 /* SpeechBubbleVM+Effect.swift */; }; 4C4733C52F1FA388005D2376 /* ClearSketchCropView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C4733632F1FA388005D2376 /* ClearSketchCropView.swift */; }; 4C4733C62F1FA388005D2376 /* AudioRecorderManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C4733332F1FA388005D2376 /* AudioRecorderManager.swift */; }; - 4C4733C72F1FA388005D2376 /* DrawingToolsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C47336F2F1FA388005D2376 /* DrawingToolsView.swift */; }; 4C4733C82F1FA388005D2376 /* KeyringInfoInputView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C4733462F1FA388005D2376 /* KeyringInfoInputView.swift */; }; 4C4733C92F1FA388005D2376 /* PolaroidVM+Frame.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C47337D2F1FA388005D2376 /* PolaroidVM+Frame.swift */; }; 4C4733CA2F1FA388005D2376 /* KeyringViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C47332E2F1FA388005D2376 /* KeyringViewModelProtocol.swift */; }; - 4C4733CB2F1FA388005D2376 /* NeonSignVM+Effect.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C47336C2F1FA388005D2376 /* NeonSignVM+Effect.swift */; }; 4C4733CC2F1FA388005D2376 /* SpeechBubbleVM+Frame.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C4733892F1FA388005D2376 /* SpeechBubbleVM+Frame.swift */; }; - 4C4733CD2F1FA388005D2376 /* NeonSignVM.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C47336A2F1FA388005D2376 /* NeonSignVM.swift */; }; 4C4733CE2F1FA388005D2376 /* KeyringCompleteView+SaveImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C4733402F1FA388005D2376 /* KeyringCompleteView+SaveImage.swift */; }; 4C4733CF2F1FA388005D2376 /* ClearSketchVM.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C47335D2F1FA388005D2376 /* ClearSketchVM.swift */; }; 4C4733D02F1FA388005D2376 /* RecordingSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C4733342F1FA388005D2376 /* RecordingSheet.swift */; }; @@ -594,6 +589,7 @@ 4C2526172F3B290A003CC5AD /* KeyringScene+Setup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "KeyringScene+Setup.swift"; sourceTree = ""; }; 4C2526182F3B290A003CC5AD /* KeyringScene+Swipe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "KeyringScene+Swipe.swift"; sourceTree = ""; }; 4C2526192F3B290A003CC5AD /* KeyringScene+Touch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "KeyringScene+Touch.swift"; sourceTree = ""; }; + 4C2526252F3B2BBE003CC5AD /* KeyringScale.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyringScale.swift; sourceTree = ""; }; 4C3687F62EBFA87800C64E75 /* Pretendard-Medium.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "Pretendard-Medium.ttf"; sourceTree = ""; }; 4C3687F82EBFC0FB00C64E75 /* NotificationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationManager.swift; sourceTree = ""; }; 4C3687FB2EC05E6800C64E75 /* AccountAlert.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountAlert.swift; sourceTree = ""; }; @@ -656,12 +652,6 @@ 4C4733652F1FA388005D2376 /* ClearSketchDrawingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClearSketchDrawingView.swift; sourceTree = ""; }; 4C4733662F1FA388005D2376 /* ClearSketchGuiding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClearSketchGuiding.swift; sourceTree = ""; }; 4C4733672F1FA388005D2376 /* ClearSketchPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClearSketchPreview.swift; sourceTree = ""; }; - 4C47336A2F1FA388005D2376 /* NeonSignVM.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NeonSignVM.swift; sourceTree = ""; }; - 4C47336B2F1FA388005D2376 /* NeonSignVM+Drawing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NeonSignVM+Drawing.swift"; sourceTree = ""; }; - 4C47336C2F1FA388005D2376 /* NeonSignVM+Effect.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NeonSignVM+Effect.swift"; sourceTree = ""; }; - 4C47336E2F1FA388005D2376 /* DrawingCanvasView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DrawingCanvasView.swift; sourceTree = ""; }; - 4C47336F2F1FA388005D2376 /* DrawingToolsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DrawingToolsView.swift; sourceTree = ""; }; - 4C4733702F1FA388005D2376 /* NeonSignPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NeonSignPreview.swift; sourceTree = ""; }; 4C4733732F1FA388005D2376 /* PixelVM.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PixelVM.swift; sourceTree = ""; }; 4C4733742F1FA388005D2376 /* PixelVM+Effect.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PixelVM+Effect.swift"; sourceTree = ""; }; 4C4733752F1FA388005D2376 /* PixelVM+ImageConversion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PixelVM+ImageConversion.swift"; sourceTree = ""; }; @@ -1128,6 +1118,7 @@ 4C2525EC2F3B27B3003CC5AD /* Scene */ = { isa = PBXGroup; children = ( + 4C2526252F3B2BBE003CC5AD /* KeyringScale.swift */, 4C25261A2F3B290A003CC5AD /* KeyringScene */, 4C2526142F3B290A003CC5AD /* DetailScene */, 4C2526112F3B290A003CC5AD /* CellScene */, @@ -1459,35 +1450,6 @@ path = ClearSketch; sourceTree = ""; }; - 4C47336D2F1FA388005D2376 /* ViewModels */ = { - isa = PBXGroup; - children = ( - 4C47336A2F1FA388005D2376 /* NeonSignVM.swift */, - 4C47336B2F1FA388005D2376 /* NeonSignVM+Drawing.swift */, - 4C47336C2F1FA388005D2376 /* NeonSignVM+Effect.swift */, - ); - path = ViewModels; - sourceTree = ""; - }; - 4C4733712F1FA388005D2376 /* Views */ = { - isa = PBXGroup; - children = ( - 4C47336E2F1FA388005D2376 /* DrawingCanvasView.swift */, - 4C47336F2F1FA388005D2376 /* DrawingToolsView.swift */, - 4C4733702F1FA388005D2376 /* NeonSignPreview.swift */, - ); - path = Views; - sourceTree = ""; - }; - 4C4733722F1FA388005D2376 /* NeonSign */ = { - isa = PBXGroup; - children = ( - 4C47336D2F1FA388005D2376 /* ViewModels */, - 4C4733712F1FA388005D2376 /* Views */, - ); - path = NeonSign; - sourceTree = ""; - }; 4C4733762F1FA388005D2376 /* ViewModels */ = { isa = PBXGroup; children = ( @@ -1582,7 +1544,6 @@ children = ( 4C47335C2F1FA388005D2376 /* AcrylicPhoto */, 4C4733692F1FA388005D2376 /* ClearSketch */, - 4C4733722F1FA388005D2376 /* NeonSign */, 4C47337A2F1FA388005D2376 /* Pixel */, 4C4733842F1FA388005D2376 /* Polaroid */, 4C47338F2F1FA388005D2376 /* SpeechBubble */, @@ -2700,8 +2661,6 @@ 4C47339A2F1FA388005D2376 /* AcrylicPhotoEditedView.swift in Sources */, 4C47339B2F1FA388005D2376 /* KeyringCustomizingView+Purchase.swift in Sources */, 4C47339C2F1FA388005D2376 /* PreviewGuiding.swift in Sources */, - 4C47339D2F1FA388005D2376 /* DrawingCanvasView.swift in Sources */, - 4C47339E2F1FA388005D2376 /* NeonSignVM+Drawing.swift in Sources */, 4C47339F2F1FA388005D2376 /* SpeechBubblePreview.swift in Sources */, 4C4733A02F1FA388005D2376 /* KeyringCustomizingView+PurchaseSheet.swift in Sources */, 4C4733A12F1FA388005D2376 /* AcrylicPhotoCropView.swift in Sources */, @@ -2713,7 +2672,6 @@ 4C4733A72F1FA388005D2376 /* KeyringCompleteView.swift in Sources */, 4C4733A82F1FA388005D2376 /* AcrylicPhotoVM+ImageLoad.swift in Sources */, 38489EBD2F290BD000E41FAE /* CopyTooltipView.swift in Sources */, - 4C4733A92F1FA388005D2376 /* NeonSignPreview.swift in Sources */, 4C4733AA2F1FA388005D2376 /* SpeechBubbleFrameSelectorView.swift in Sources */, 4C4733AB2F1FA388005D2376 /* KeyringViewModelProtocol+Reset.swift in Sources */, 4C4733AC2F1FA388005D2376 /* PixelPreviewView.swift in Sources */, @@ -2746,13 +2704,10 @@ 4C4733C42F1FA388005D2376 /* SpeechBubbleVM+Effect.swift in Sources */, 4C4733C52F1FA388005D2376 /* ClearSketchCropView.swift in Sources */, 4C4733C62F1FA388005D2376 /* AudioRecorderManager.swift in Sources */, - 4C4733C72F1FA388005D2376 /* DrawingToolsView.swift in Sources */, 4C4733C82F1FA388005D2376 /* KeyringInfoInputView.swift in Sources */, 4C4733C92F1FA388005D2376 /* PolaroidVM+Frame.swift in Sources */, 4C4733CA2F1FA388005D2376 /* KeyringViewModelProtocol.swift in Sources */, - 4C4733CB2F1FA388005D2376 /* NeonSignVM+Effect.swift in Sources */, 4C4733CC2F1FA388005D2376 /* SpeechBubbleVM+Frame.swift in Sources */, - 4C4733CD2F1FA388005D2376 /* NeonSignVM.swift in Sources */, 4C4733CE2F1FA388005D2376 /* KeyringCompleteView+SaveImage.swift in Sources */, 4C4733CF2F1FA388005D2376 /* ClearSketchVM.swift in Sources */, 4C4733D02F1FA388005D2376 /* RecordingSheet.swift in Sources */, @@ -2788,6 +2743,7 @@ 4CC3D36E2EC2801F0009D376 /* WelcomeKeyringViewModel.swift in Sources */, 4CC3D36F2EC2801F0009D376 /* IntroViewModel+WelcomeKeyring.swift in Sources */, 4C4733F52F225785005D2376 /* WorkshopGridBuilder.swift in Sources */, + 4C2526262F3B2BBE003CC5AD /* KeyringScale.swift in Sources */, 38173D112EB90CCE00E36F7E /* TagInputPopup.swift in Sources */, 4CEBB1722EFE66CF00CF53E2 /* ToastManager.swift in Sources */, C6B56F232EC0341B0049F969 /* KeyringImageCache.swift in Sources */, diff --git a/Keychy/Keychy/Core/Keyring/Components/KeyringBodyComponent.swift b/Keychy/Keychy/Core/Keyring/Components/KeyringBodyComponent.swift index 415e45785..583e2f18f 100644 --- a/Keychy/Keychy/Core/Keyring/Components/KeyringBodyComponent.swift +++ b/Keychy/Keychy/Core/Keyring/Components/KeyringBodyComponent.swift @@ -43,10 +43,9 @@ struct KeyringBodyComponent { } } - // MARK: - Multi용 String URL로 노드 생성 (비동기, 150x300 aspect fit) + // MARK: - Multi용 String URL로 노드 생성 (비동기, 160x400 aspect fit) static func createNodeForMulti( from bodyImageURL: String, - templateId: String? = nil, completion: @escaping (SKNode?) -> Void ) { Task { @@ -54,7 +53,7 @@ struct KeyringBodyComponent { let image = try await StorageManager.shared.getImage(path: bodyImageURL) await MainActor.run { - let node = createMultiImageBody(image: image, templateId: templateId) + let node = createMultiImageBody(image: image) completion(node) } } catch { @@ -163,15 +162,9 @@ struct KeyringBodyComponent { return spriteNode } - // MARK: - Multi용 (150x300 aspect fit) - private static func createMultiImageBody(image: UIImage, templateId: String? = nil) -> SKNode { - // 말풍선 템플릿만 더 큰 maxSize 사용 - let maxSize: CGSize - if templateId == "SpeechBubble" { - maxSize = CGSize(width: 240, height: 400) - } else { - maxSize = CGSize(width: 160, height: 400) - } + // MARK: - Multi용 (160x400 aspect fit) + private static func createMultiImageBody(image: UIImage) -> SKNode { + let maxSize = CGSize(width: 160, height: 400) let originalSize = image.size diff --git a/Keychy/Keychy/Core/Keyring/KeyringScale.swift b/Keychy/Keychy/Core/Keyring/KeyringScale.swift deleted file mode 100644 index 20008afc8..000000000 --- a/Keychy/Keychy/Core/Keyring/KeyringScale.swift +++ /dev/null @@ -1,15 +0,0 @@ -// -// KeyringScale.swift -// Keychy -// -// Created by 길지훈 on 2/10/26. -// - -import Foundation - -/// 키링 스케일 중앙 관리 -/// - 화면별 기본 스케일 -/// - 템플릿별 커스텀 스케일 -enum KeyringScale { - -} diff --git a/Keychy/Keychy/Core/Keyring/Scene/CellScene/KeyringScale.swift b/Keychy/Keychy/Core/Keyring/Scene/CellScene/KeyringScale.swift deleted file mode 100644 index 20008afc8..000000000 --- a/Keychy/Keychy/Core/Keyring/Scene/CellScene/KeyringScale.swift +++ /dev/null @@ -1,15 +0,0 @@ -// -// KeyringScale.swift -// Keychy -// -// Created by 길지훈 on 2/10/26. -// - -import Foundation - -/// 키링 스케일 중앙 관리 -/// - 화면별 기본 스케일 -/// - 템플릿별 커스텀 스케일 -enum KeyringScale { - -} diff --git a/Keychy/Keychy/Core/Keyring/Scene/KeyringScale.swift b/Keychy/Keychy/Core/Keyring/Scene/KeyringScale.swift index 20008afc8..4cba8b910 100644 --- a/Keychy/Keychy/Core/Keyring/Scene/KeyringScale.swift +++ b/Keychy/Keychy/Core/Keyring/Scene/KeyringScale.swift @@ -6,10 +6,64 @@ // import Foundation +import CoreGraphics /// 키링 스케일 중앙 관리 -/// - 화면별 기본 스케일 -/// - 템플릿별 커스텀 스케일 +/// - 템플릿별 maxSize +/// - 화면별 zoomScale +/// - 카라비너별 뭉치 키링 스케일 enum KeyringScale { + // MARK: - 화면 종류 + enum Screen { + case customizing // 커스터마이징뷰 + case infoInput // 정보입력뷰 + case complete // 완성뷰 + } + + // MARK: - 템플릿별 maxSize + private static let templateMaxSizes: [String: CGSize] = [ + "Polaroid": CGSize(width: 667, height: 402), + "AcrylicPhoto": CGSize(width: 210, height: 210), + "ClearSketch": CGSize(width: 210, height: 210), + "PixelKeyring": CGSize(width: 277, height: 257), + "SpeechBubble": CGSize(width: 249, height: 360) + ] + + // MARK: - 템플릿 × 화면별 zoomScale + private static let templateZoomScales: [String: [Screen: CGFloat]] = [ + "Polaroid": [.customizing: 1.0, .infoInput: 1.0, .complete: 1.0], + "AcrylicPhoto": [.customizing: 1.0, .infoInput: 1.0, .complete: 1.0], + "ClearSketch": [.customizing: 1.0, .infoInput: 1.0, .complete: 1.0], + "PixelKeyring": [.customizing: 1.0, .infoInput: 1.0, .complete: 1.0], + "SpeechBubble": [.customizing: 1.0, .infoInput: 1.0, .complete: 1.0] + ] + + // MARK: - 카라비너별 뭉치 키링 스케일 + private static let carabinerScales: [String: CGFloat] = [ + "basic": 1.0 + // TODO: 추가 카라비너 스케일 + ] + + // MARK: - 기본값 + private static let defaultMaxSize = CGSize(width: 210, height: 210) + private static let defaultZoomScale: CGFloat = 1.0 + private static let defaultCarabinerScale: CGFloat = 1.0 + + // MARK: - Public API + + /// 템플릿별 maxSize 반환 + static func maxSize(for template: String) -> CGSize { + return templateMaxSizes[template] ?? defaultMaxSize + } + + /// 화면 × 템플릿별 zoomScale 반환 + static func zoomScale(for screen: Screen, template: String) -> CGFloat { + return templateZoomScales[template]?[screen] ?? defaultZoomScale + } + + /// 카라비너별 뭉치 키링 스케일 반환 + static func bundleKeyringScale(for carabiner: String) -> CGFloat { + return carabinerScales[carabiner] ?? defaultCarabinerScale + } } diff --git a/Keychy/Keychy/Core/Navigation/Routes/WorkshopRoute.swift b/Keychy/Keychy/Core/Navigation/Routes/WorkshopRoute.swift index 39b187de4..250545d48 100644 --- a/Keychy/Keychy/Core/Navigation/Routes/WorkshopRoute.swift +++ b/Keychy/Keychy/Core/Navigation/Routes/WorkshopRoute.swift @@ -31,12 +31,6 @@ enum WorkshopRoute: Hashable, BundleRoute { case acrylicPhotoInfoInput case acrylicPhotoComplete - // MARK: - 네온 사인 템플릿 - case neonSignPreview - case neonSignCustomizing - case neonSignInfoInput - case neonSignComplete - // MARK: - 폴라로이드 템플릿 case polaroidPreview case polaroidCustomizing @@ -75,8 +69,6 @@ enum WorkshopRoute: Hashable, BundleRoute { switch string { case "AcrylicPhoto": return .acrylicPhotoPreview - case "NeonSign": - return .neonSignPreview case "Polaroid": return .polaroidPreview case "ClearSketch": diff --git a/Keychy/Keychy/Presentation/Tab/Views/WorkshopTab.swift b/Keychy/Keychy/Presentation/Tab/Views/WorkshopTab.swift index 6b9129800..a9a3d80b5 100644 --- a/Keychy/Keychy/Presentation/Tab/Views/WorkshopTab.swift +++ b/Keychy/Keychy/Presentation/Tab/Views/WorkshopTab.swift @@ -13,7 +13,6 @@ struct WorkshopTab: View { @Bindable var collectionViewModel: CollectionViewModel @State private var acrylicPhotoVM: AcrylicPhotoVM? - @State private var neonSignVM: NeonSignVM? @State private var polaroidVM: PolaroidVM? @State private var clearSketchVM: ClearSketchVM? @State private var pixelKeyringVM: PixelVM? @@ -93,28 +92,6 @@ struct WorkshopTab: View { navigationTitle: "키링이 완성되었어요!" ) - // MARK: - NeonSign - case .neonSignPreview: - NeonSignPreView(router: router, viewModel: getNeonSignVM()) - case .neonSignCustomizing: - KeyringCustomizingView( - router: router, - viewModel: getNeonSignVM(), - nextRoute: .neonSignInfoInput - ) - case .neonSignInfoInput: - KeyringInfoInputView( - router: router, - viewModel: getNeonSignVM(), - nextRoute: .neonSignComplete - ) - case .neonSignComplete: - KeyringCompleteView( - router: router, - viewModel: getNeonSignVM(), - navigationTitle: "키링이 완성되었어요!" - ) - // MARK: - Polaroid case .polaroidPreview: PolaroidPreview(router: router, viewModel: getPolaroidVM()) @@ -247,15 +224,6 @@ struct WorkshopTab: View { return viewModel } - private func getNeonSignVM() -> NeonSignVM { - guard let viewModel = neonSignVM else { - let newViewModel = NeonSignVM() - neonSignVM = newViewModel - return newViewModel - } - return viewModel - } - private func getPolaroidVM() -> PolaroidVM { guard let viewModel = polaroidVM else { let newViewModel = PolaroidVM() @@ -297,8 +265,6 @@ struct WorkshopTab: View { switch templateId { case "AcrylicPhoto": return getAcrylicPhotoVM() - case "NeonSign": - return getNeonSignVM() case "Polaroid": return getPolaroidVM() case "ClearSketch": @@ -317,10 +283,6 @@ struct WorkshopTab: View { acrylicPhotoVM = nil } - func resetNeonSignVM() { - neonSignVM = nil - } - func resetPolaroidVM() { polaroidVM = nil } From de43a4fd73d0c7f03fa4535d9cab90fe0bc271be Mon Sep 17 00:00:00 2001 From: giljihun Date: Tue, 10 Feb 2026 23:03:49 +0900 Subject: [PATCH 03/17] =?UTF-8?q?feat:=20KeyringScale=20=EC=A4=91=EC=95=99?= =?UTF-8?q?=20=EC=8A=A4=EC=BC=80=EC=9D=BC=20=EA=B4=80=EB=A6=AC=20=EC=8B=9C?= =?UTF-8?q?=EC=8A=A4=ED=85=9C=20=EA=B5=AC=EC=B6=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Keychy/Keychy/Core/Keyring/Scene/KeyringScale.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Keychy/Keychy/Core/Keyring/Scene/KeyringScale.swift b/Keychy/Keychy/Core/Keyring/Scene/KeyringScale.swift index 4cba8b910..c8bc78a26 100644 --- a/Keychy/Keychy/Core/Keyring/Scene/KeyringScale.swift +++ b/Keychy/Keychy/Core/Keyring/Scene/KeyringScale.swift @@ -23,7 +23,7 @@ enum KeyringScale { // MARK: - 템플릿별 maxSize private static let templateMaxSizes: [String: CGSize] = [ - "Polaroid": CGSize(width: 667, height: 402), + "Polaroid": CGSize(width: 263, height: 321), "AcrylicPhoto": CGSize(width: 210, height: 210), "ClearSketch": CGSize(width: 210, height: 210), "PixelKeyring": CGSize(width: 277, height: 257), From aef6f74470faa7a391f596b578a9da80715b3204 Mon Sep 17 00:00:00 2001 From: giljihun Date: Tue, 10 Feb 2026 23:04:14 +0900 Subject: [PATCH 04/17] =?UTF-8?q?=20refactor:=20KeyringBodyComponent?= =?UTF-8?q?=EC=97=90=20templateId=20=ED=8C=8C=EB=9D=BC=EB=AF=B8=ED=84=B0?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - KeyringScale.maxSize(for:) 사용하여 템플릿별 크기 적용 - 중복 함수 제거 (createMiniImageBody, createMultiImageBody) --- .../Components/KeyringBodyComponent.swift | 119 +++--------------- 1 file changed, 17 insertions(+), 102 deletions(-) diff --git a/Keychy/Keychy/Core/Keyring/Components/KeyringBodyComponent.swift b/Keychy/Keychy/Core/Keyring/Components/KeyringBodyComponent.swift index 583e2f18f..146845ada 100644 --- a/Keychy/Keychy/Core/Keyring/Components/KeyringBodyComponent.swift +++ b/Keychy/Keychy/Core/Keyring/Components/KeyringBodyComponent.swift @@ -11,17 +11,20 @@ import SpriteKit // MARK: - Keyring Body Component struct KeyringBodyComponent { + // MARK: - UIImage로 노드 생성 static func createNode( from bodyImage: UIImage, + templateId: String, completion: @escaping (SKNode?) -> Void ) { - let node = createImageBody(image: bodyImage) + let node = createImageBody(image: bodyImage, templateId: templateId) completion(node) } - + // MARK: - String URL로 노드 생성 (비동기) static func createNode( from bodyImageURL: String, + templateId: String, completion: @escaping (SKNode?) -> Void ) { Task { @@ -29,7 +32,7 @@ struct KeyringBodyComponent { let image = try await StorageManager.shared.getImage(path: bodyImageURL) await MainActor.run { - let node = createMiniImageBody(image: image) + let node = createImageBody(image: image, templateId: templateId) completion(node) } } catch { @@ -43,37 +46,13 @@ struct KeyringBodyComponent { } } - // MARK: - Multi용 String URL로 노드 생성 (비동기, 160x400 aspect fit) - static func createNodeForMulti( - from bodyImageURL: String, - completion: @escaping (SKNode?) -> Void - ) { - Task { - do { - let image = try await StorageManager.shared.getImage(path: bodyImageURL) - - await MainActor.run { - let node = createMultiImageBody(image: image) - completion(node) - } - } catch { - print("Body 이미지 로드 실패: \(error)") - - await MainActor.run { - let node = createBasicBody() - completion(node) - } - } - } - } - - // Body 타입 받아서 SKNode로 생성 - static func createNode(from bodyType: BodyType) -> SKNode { + // MARK: - BodyType으로 노드 생성 + static func createNode(from bodyType: BodyType, templateId: String) -> SKNode { switch bodyType { case .basic: return createBasicBody() case .customImage(let image): - return createImageBody(image: image) + return createImageBody(image: image, templateId: templateId) } } @@ -81,19 +60,17 @@ struct KeyringBodyComponent { private static func createBasicBody() -> SKShapeNode { let radius: CGFloat = 80 - // 원형 바디 let path = CGPath(ellipseIn: CGRect(x: -radius, y: -radius, width: radius * 2, height: radius * 2), transform: nil) let node = SKShapeNode(path: path) node.fillColor = .white node.strokeColor = UIColor(white: 0.8, alpha: 0.4) node.lineWidth = 1.0 - node.zPosition = -1 // Body는 체인 아래 + node.zPosition = -1 - // 물리 바디 설정 (원형 - 기본값으로 설정, 씬에서 조정됨) let physicsBody = SKPhysicsBody(circleOfRadius: radius - 2) - physicsBody.isDynamic = true // 기본값은 움직이게 설정, 나중에 씬에서 조정 - physicsBody.affectedByGravity = true // 기본값은 중력 적용, 나중에 씬에서 조정 + physicsBody.isDynamic = true + physicsBody.affectedByGravity = true physicsBody.mass = 3.0 physicsBody.friction = 0.5 physicsBody.restitution = 0.2 @@ -104,87 +81,25 @@ struct KeyringBodyComponent { return node } - // MARK: - Image Body - private static func createImageBody(image: UIImage) -> SKNode { - // 최대 크기 제한 (aspect fit) - let maxSize = CGSize(width: 210, height: 210) - let originalSize = image.size - - let widthRatio = maxSize.width / originalSize.width - let heightRatio = maxSize.height / originalSize.height - let scale = min(widthRatio, heightRatio, 1.0) // 1.0 이하로만 축소 - - let displaySize = CGSize( - width: originalSize.width * scale, - height: originalSize.height * scale - ) - - let texture = SKTexture(image: image) - texture.filteringMode = .linear - let spriteNode = SKSpriteNode(texture: texture, size: displaySize) - spriteNode.zPosition = -1 // Body는 체인 아래 - - // 물리 바디 설정 - let physicsBody = SKPhysicsBody(rectangleOf: displaySize) - physicsBody.isDynamic = true - physicsBody.affectedByGravity = true - physicsBody.mass = 6.0 - physicsBody.friction = 0.5 - physicsBody.restitution = 0.2 - physicsBody.linearDamping = 0.8 - physicsBody.angularDamping = 0.95 - spriteNode.physicsBody = physicsBody - - return spriteNode - } - - // 셀용 - private static func createMiniImageBody(image: UIImage) -> SKNode { - let displaySize = CGSize(width: 150, height: 150) - - // 텍스처 생성 - let texture = SKTexture(image: image) - texture.filteringMode = .linear // 부드럽게 렌더링 - let spriteNode = SKSpriteNode(texture: texture, size: displaySize) - spriteNode.zPosition = -1 // Body는 체인 아래 - - // 물리 바디 설정 (원본 크기에 맞게) - let physicsBody = SKPhysicsBody(rectangleOf: displaySize) - physicsBody.isDynamic = true // 기본값은 움직이게 설정, 나중에 씬에서 조정 - physicsBody.affectedByGravity = true // 기본값은 중력 적용, 나중에 씬에서 조정 - physicsBody.mass = 6.0 - physicsBody.friction = 0.5 - physicsBody.restitution = 0.2 - physicsBody.linearDamping = 0.8 - physicsBody.angularDamping = 0.95 - spriteNode.physicsBody = physicsBody - - return spriteNode - } - - // MARK: - Multi용 (160x400 aspect fit) - private static func createMultiImageBody(image: UIImage) -> SKNode { - let maxSize = CGSize(width: 160, height: 400) - + // MARK: - Image Body (KeyringScale 사용) + private static func createImageBody(image: UIImage, templateId: String) -> SKNode { + let maxSize = KeyringScale.maxSize(for: templateId) let originalSize = image.size - // Aspect fit 계산: 원본 비율 유지하며 maxSize 안에 들어가도록 let widthRatio = maxSize.width / originalSize.width let heightRatio = maxSize.height / originalSize.height - let scale = min(widthRatio, heightRatio) + let scale = min(widthRatio, heightRatio, 1.0) let displaySize = CGSize( width: originalSize.width * scale, height: originalSize.height * scale ) - // 텍스처 생성 let texture = SKTexture(image: image) texture.filteringMode = .linear let spriteNode = SKSpriteNode(texture: texture, size: displaySize) - spriteNode.zPosition = -1 // Body는 체인 아래 + spriteNode.zPosition = -1 - // 물리 바디 설정 let physicsBody = SKPhysicsBody(rectangleOf: displaySize) physicsBody.isDynamic = true physicsBody.affectedByGravity = true From d05bfe20298b5b30525f1c6e40b08e096d00687e Mon Sep 17 00:00:00 2001 From: giljihun Date: Tue, 10 Feb 2026 23:05:09 +0900 Subject: [PATCH 05/17] =?UTF-8?q?feat:=20KeyringScene=EC=97=90=20screen?= =?UTF-8?q?=EB=B3=84=20zoomScale=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SKCameraNode 사용하여 물리 시뮬레이션 영향 없이 스케일 조정 (이런거도 있더군요) - KeyringSceneView, VideoGenerator 호출부 수정 --- .../KeyringScene/KeyringScene+Setup.swift | 14 ++++++---- .../Scene/KeyringScene/KeyringScene.swift | 27 ++++++++++++++++--- .../Core/Keyring/View/KeyringSceneView.swift | 3 +++ .../Keyring/KeyringVideoGenerator+Setup.swift | 2 ++ 4 files changed, 37 insertions(+), 9 deletions(-) diff --git a/Keychy/Keychy/Core/Keyring/Scene/KeyringScene/KeyringScene+Setup.swift b/Keychy/Keychy/Core/Keyring/Scene/KeyringScene/KeyringScene+Setup.swift index 58bcb9fd4..78bc29dba 100644 --- a/Keychy/Keychy/Core/Keyring/Scene/KeyringScene/KeyringScene+Setup.swift +++ b/Keychy/Keychy/Core/Keyring/Scene/KeyringScene/KeyringScene+Setup.swift @@ -66,13 +66,14 @@ extension KeyringScene { if let bodyImage = bodyImage { // UIImage인 경우 KeyringBodyComponent.createNode( - from: bodyImage + from: bodyImage, + templateId: templateId ) { [weak self] body in guard let self = self, let body = body else { print("Body 생성 실패") return } - + self.positionAndConnectBody( body: body, ring: ring, @@ -84,12 +85,15 @@ extension KeyringScene { } } else if let bodyImageURL = bodyImageURL { // URL만 있는 경우 - KeyringBodyComponent.createNode(from: bodyImageURL) { [weak self] body in + KeyringBodyComponent.createNode( + from: bodyImageURL, + templateId: templateId + ) { [weak self] body in guard let self = self, let body = body else { print("Body 생성 실패") return } - + self.positionAndConnectBody( body: body, ring: ring, @@ -100,7 +104,7 @@ extension KeyringScene { ) } } else { - let body = KeyringBodyComponent.createNode(from: .basic) + let body = KeyringBodyComponent.createNode(from: .basic, templateId: templateId) positionAndConnectBody( body: body, ring: ring, diff --git a/Keychy/Keychy/Core/Keyring/Scene/KeyringScene/KeyringScene.swift b/Keychy/Keychy/Core/Keyring/Scene/KeyringScene/KeyringScene.swift index 734364132..5cfca66a4 100644 --- a/Keychy/Keychy/Core/Keyring/Scene/KeyringScene/KeyringScene.swift +++ b/Keychy/Keychy/Core/Keyring/Scene/KeyringScene/KeyringScene.swift @@ -18,6 +18,8 @@ class KeyringScene: SKScene { // MARK: - Properties var bodyImage: UIImage? // UIImage용 var bodyImageURL: String? // Firebase URL용 + var templateId: String // 템플릿 ID (KeyringScale용) + var screen: KeyringScale.Screen // 화면 종류 (zoomScale용) var customSoundURL: URL? // 커스텀 녹음 파일 URL var hookOffsetY: CGFloat? // 바디 연결 지점 Y 오프셋 (nil이면 0.0 사용) var chainLength: Int = 5 // 체인 링크 개수 (기본값 5) @@ -57,15 +59,18 @@ class KeyringScene: SKScene { init( ringType: RingType, chainType: ChainType, + templateId: String, + screen: KeyringScale.Screen = .customizing, bodyImage: UIImage? = nil, bodyImageURL: String? = nil, backgroundColor: UIColor = .gray50, hookOffsetY: CGFloat? = nil, chainLength: Int = 5 ) { - self.currentRingType = ringType self.currentChainType = chainType + self.templateId = templateId + self.screen = screen self.bodyImageURL = bodyImageURL self.customBackgroundColor = backgroundColor self.hookOffsetY = hookOffsetY @@ -79,9 +84,7 @@ class KeyringScene: SKScene { super.init(size: .zero) } - required init?(coder aDecoder: NSCoder) { - super.init(coder: aDecoder) - } + required init?(coder aDecoder: NSCoder) { fatalError() } deinit { cleanup() @@ -147,6 +150,22 @@ class KeyringScene: SKScene { backgroundColor = customBackgroundColor physicsWorld.gravity = CGVector(dx: 0, dy: -9.8) + // 카메라 설정 (zoomScale 적용) + setupCamera() + setupKeyring() } + + /// 카메라 설정 - zoomScale 적용 + private func setupCamera() { + let cameraNode = SKCameraNode() + cameraNode.position = CGPoint(x: size.width / 2, y: size.height / 2) + + // zoomScale 적용 (카메라 scale은 역수) + let zoom = KeyringScale.zoomScale(for: screen, template: templateId) + cameraNode.setScale(1.0 / zoom) + + addChild(cameraNode) + self.camera = cameraNode + } } diff --git a/Keychy/Keychy/Core/Keyring/View/KeyringSceneView.swift b/Keychy/Keychy/Core/Keyring/View/KeyringSceneView.swift index f3d1f8af1..66cb965ef 100644 --- a/Keychy/Keychy/Core/Keyring/View/KeyringSceneView.swift +++ b/Keychy/Keychy/Core/Keyring/View/KeyringSceneView.swift @@ -12,6 +12,7 @@ import Lottie /// 키링 SpriteKit Scene + 로티재생 ZStack뷰 (Generic) struct KeyringSceneView: View { @Bindable var viewModel: VM + var screen: KeyringScale.Screen = .customizing // 화면 종류 (zoomScale용) var backgroundColor: UIColor = .gray50 var applyWelcomeImpulse: Bool = false // 씬 준비 완료 시 자동 파티클 효과 var onSceneReady: (() -> Void)? = nil // 씬 준비 완료 콜백 @@ -65,6 +66,8 @@ struct KeyringSceneView: View { let newScene = KeyringScene( ringType: .basic, chainType: .basic, + templateId: viewModel.templateId, + screen: screen, bodyImage: viewModel.bodyImage, backgroundColor: backgroundColor, hookOffsetY: viewModel.hookOffsetY != 0 ? viewModel.hookOffsetY : nil, diff --git a/Keychy/Keychy/Core/Video/Keyring/KeyringVideoGenerator+Setup.swift b/Keychy/Keychy/Core/Video/Keyring/KeyringVideoGenerator+Setup.swift index 11d031cfa..cc17558c2 100644 --- a/Keychy/Keychy/Core/Video/Keyring/KeyringVideoGenerator+Setup.swift +++ b/Keychy/Keychy/Core/Video/Keyring/KeyringVideoGenerator+Setup.swift @@ -25,6 +25,8 @@ extension KeyringVideoGenerator { let scene = KeyringScene( ringType: .basic, chainType: .basic, + templateId: viewModel.templateId, + screen: .complete, bodyImage: viewModel.bodyImage, backgroundColor: .clear, hookOffsetY: viewModel.hookOffsetY != 0 ? viewModel.hookOffsetY : nil, From 728ddb513d0bd7705d8d96f249eabb1d83978c47 Mon Sep 17 00:00:00 2001 From: giljihun Date: Tue, 10 Feb 2026 23:05:46 +0900 Subject: [PATCH 06/17] =?UTF-8?q?refactor:=20CellScene/DetailScene?= =?UTF-8?q?=EC=97=90=20KeyringScale=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - createMiniImageBody에서 KeyringScale.maxSize 사용 - 셀 auto-fit 기능 추가 (autoFitKeyringToCell) --- .../CellScene/KeyringCellScene+Setup.swift | 81 ++++++++++++++----- .../KeyringDetailScene+Setup.swift | 29 +++---- 2 files changed, 76 insertions(+), 34 deletions(-) diff --git a/Keychy/Keychy/Core/Keyring/Scene/CellScene/KeyringCellScene+Setup.swift b/Keychy/Keychy/Core/Keyring/Scene/CellScene/KeyringCellScene+Setup.swift index 4ae135f7d..f52f08e3d 100644 --- a/Keychy/Keychy/Core/Keyring/Scene/CellScene/KeyringCellScene+Setup.swift +++ b/Keychy/Keychy/Core/Keyring/Scene/CellScene/KeyringCellScene+Setup.swift @@ -174,9 +174,12 @@ extension KeyringCellScene { body.zPosition = -1 // Body는 체인 아래 containerNode.addChild(body) - // 4. 조인트 연결 + // 4. 셀 내부에 키링 auto-fit (일관된 패딩) - 조인트 생성 전에 호출해야 함 + autoFitKeyringToCell() + + // 5. 조인트 연결 (auto-fit 후에 생성해야 anchor가 올바름) connectComponents(ring: ring, chains: chains, body: body) - + self.onLoadingComplete?() } @@ -282,28 +285,24 @@ extension KeyringCellScene { return node } - // MARK: - Mini Body 생성 + // MARK: - Mini Body 생성 (KeyringScale 사용) private func createMiniImageBody(image: UIImage) -> SKSpriteNode { - // 크기 제한 (비율 유지) + let maxSize = KeyringScale.maxSize(for: templateId ?? "") let originalSize = image.size - // 말풍선 템플릿만 더 큰 maxSize 사용 - let maxSize: CGFloat = (templateId == "SpeechBubble") ? 400 : 200 - var displaySize = originalSize + let widthRatio = maxSize.width / originalSize.width + let heightRatio = maxSize.height / originalSize.height + let scale = min(widthRatio, heightRatio, 1.0) + + let displaySize = CGSize( + width: originalSize.width * scale, + height: originalSize.height * scale + ) - let maxDimension = max(originalSize.width, originalSize.height) - if maxDimension > maxSize { - let scale = maxSize / maxDimension - displaySize = CGSize( - width: originalSize.width * scale, - height: originalSize.height * scale - ) - } - let texture = SKTexture(image: image) texture.filteringMode = .linear let spriteNode = SKSpriteNode(texture: texture, size: displaySize) - + let physicsBody = SKPhysicsBody(rectangleOf: displaySize) physicsBody.mass = 3.0 physicsBody.friction = 0.5 @@ -311,7 +310,7 @@ extension KeyringCellScene { physicsBody.linearDamping = 0.8 physicsBody.angularDamping = 0.95 spriteNode.physicsBody = physicsBody - + return spriteNode } @@ -464,6 +463,52 @@ extension KeyringCellScene { bodyPhysics.collisionBitMask = 0 // 아무것과도 충돌하지 않음 } } + + // MARK: - 셀 내부 Auto-Fit + /// 키링 전체를 셀 내부에 일관된 패딩으로 맞춤 + private func autoFitKeyringToCell() { + guard let containerNode = containerNode else { return } + + // 현재 키링 전체 bounds (scene 좌표) + let keyringFrame = containerNode.calculateAccumulatedFrame() + + // 유효하지 않은 frame 체크 + guard keyringFrame.width > 0, keyringFrame.height > 0 else { return } + + // 타겟 영역 (패딩 적용) + let padding: CGFloat = 12 + let targetRect = CGRect( + x: padding, + y: padding, + width: size.width - padding * 2, + height: size.height - padding * 2 + ) + + // 피팅 스케일 계산 + let scaleX = targetRect.width / keyringFrame.width + let scaleY = targetRect.height / keyringFrame.height + let fitScale = min(scaleX, scaleY) + + // 현재 스케일에 비례하여 조정 + let newScale = containerNode.xScale * fitScale + containerNode.setScale(newScale) + + // 스케일 변경 후 새로운 bounds 계산 + let newFrame = containerNode.calculateAccumulatedFrame() + + // 타겟 영역 중앙으로 이동 + let targetCenter = CGPoint(x: targetRect.midX, y: targetRect.midY) + let currentCenter = CGPoint(x: newFrame.midX, y: newFrame.midY) + let offset = CGPoint( + x: targetCenter.x - currentCenter.x, + y: targetCenter.y - currentCenter.y + ) + + containerNode.position = CGPoint( + x: containerNode.position.x + offset.x, + y: containerNode.position.y + offset.y + ) + } } // MARK: - Keyring 완전체 구조체 diff --git a/Keychy/Keychy/Core/Keyring/Scene/DetailScene/KeyringDetailScene+Setup.swift b/Keychy/Keychy/Core/Keyring/Scene/DetailScene/KeyringDetailScene+Setup.swift index c142a9ae7..1c619f321 100644 --- a/Keychy/Keychy/Core/Keyring/Scene/DetailScene/KeyringDetailScene+Setup.swift +++ b/Keychy/Keychy/Core/Keyring/Scene/DetailScene/KeyringDetailScene+Setup.swift @@ -263,27 +263,24 @@ extension KeyringDetailScene { return node } - // MARK: - Mini Body 생성 + // MARK: - Mini Body 생성 (KeyringScale 사용) private func createMiniImageBody(image: UIImage) -> SKSpriteNode { - // 크기 제한 (비율 유지) - // 말풍선 템플릿만 더 큰 maxSize 사용 - let maxSize: CGFloat = (templateId == "SpeechBubble") ? 450 : 200 + let maxSize = KeyringScale.maxSize(for: templateId ?? "") let originalSize = image.size - var displaySize = originalSize - let maxDimension = max(originalSize.width, originalSize.height) - if maxDimension > maxSize { - let scale = maxSize / maxDimension - displaySize = CGSize( - width: originalSize.width * scale, - height: originalSize.height * scale - ) - } - + let widthRatio = maxSize.width / originalSize.width + let heightRatio = maxSize.height / originalSize.height + let scale = min(widthRatio, heightRatio, 1.0) + + let displaySize = CGSize( + width: originalSize.width * scale, + height: originalSize.height * scale + ) + let texture = SKTexture(image: image) texture.filteringMode = .linear let spriteNode = SKSpriteNode(texture: texture, size: displaySize) - + let physicsBody = SKPhysicsBody(rectangleOf: displaySize) physicsBody.mass = 2.0 physicsBody.friction = 0.5 @@ -291,7 +288,7 @@ extension KeyringDetailScene { physicsBody.linearDamping = 0.8 physicsBody.angularDamping = 0.95 spriteNode.physicsBody = physicsBody - + return spriteNode } From 3d7c9c8c6c9559b5c87db4a245a4e36856dab4e0 Mon Sep 17 00:00:00 2001 From: giljihun Date: Tue, 10 Feb 2026 23:06:20 +0900 Subject: [PATCH 07/17] =?UTF-8?q?feat:=20MultiKeyringScene/CaptureScene?= =?UTF-8?q?=EC=97=90=20templateId=20=EC=A0=84=EB=8B=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - KeyringData 구조체에 templateId 추가 - createBodyNode에서 KeyringScale 사용 --- .../Scene/MultiKeyringCaptureScene.swift | 11 ++++- .../Scene/MultiKeyringScene.swift | 46 +++++++++++++------ 2 files changed, 41 insertions(+), 16 deletions(-) diff --git a/Keychy/Keychy/Core/KeyringBundle/Scene/MultiKeyringCaptureScene.swift b/Keychy/Keychy/Core/KeyringBundle/Scene/MultiKeyringCaptureScene.swift index 3eec3efff..d0a58d0a2 100644 --- a/Keychy/Keychy/Core/KeyringBundle/Scene/MultiKeyringCaptureScene.swift +++ b/Keychy/Keychy/Core/KeyringBundle/Scene/MultiKeyringCaptureScene.swift @@ -17,13 +17,15 @@ class MultiKeyringCaptureScene: SKScene { let index: Int let position: CGPoint // 절대 좌표 (SwiftUI 좌표계, 왼쪽 위 기준) let bodyImageURL: String + let templateId: String // 템플릿 ID (KeyringScale용) let hookOffsetY: CGFloat? // 바디 연결 지점 Y 오프셋 (nil이면 0.0 사용) let chainLength: Int // 체인 길이 (기본값 5) - init(index: Int, position: CGPoint, bodyImageURL: String, hookOffsetY: CGFloat? = nil, chainLength: Int = 5) { + init(index: Int, position: CGPoint, bodyImageURL: String, templateId: String, hookOffsetY: CGFloat? = nil, chainLength: Int = 5) { self.index = index self.position = position self.bodyImageURL = bodyImageURL + self.templateId = templateId self.hookOffsetY = hookOffsetY self.chainLength = chainLength } @@ -293,6 +295,7 @@ class MultiKeyringCaptureScene: SKScene { ring: ring, centerX: spriteKitPosition.x, bodyImageURL: data.bodyImageURL, + templateId: data.templateId, hookOffsetY: data.hookOffsetY, chainLength: data.chainLength, baseZPosition: baseZPosition @@ -337,6 +340,7 @@ class MultiKeyringCaptureScene: SKScene { ring: ring, centerX: spriteKitPosition.x, bodyImageURL: data.bodyImageURL, + templateId: data.templateId, hookOffsetY: data.hookOffsetY, chainLength: data.chainLength, baseZPosition: baseZPosition, @@ -350,6 +354,7 @@ class MultiKeyringCaptureScene: SKScene { ring: SKSpriteNode, centerX: CGFloat, bodyImageURL: String, + templateId: String, hookOffsetY: CGFloat?, chainLength: Int, baseZPosition: CGFloat, @@ -397,6 +402,7 @@ class MultiKeyringCaptureScene: SKScene { chainStartY: chainStartY, chainSpacing: chainSpacing, bodyImageURL: bodyImageURL, + templateId: templateId, hookOffsetY: hookOffsetY, baseZPosition: baseZPosition, carabinerType: carabinerType @@ -411,11 +417,12 @@ class MultiKeyringCaptureScene: SKScene { chainStartY: CGFloat, chainSpacing: CGFloat, bodyImageURL: String, + templateId: String, hookOffsetY: CGFloat?, baseZPosition: CGFloat, carabinerType: CarabinerType? = nil ) { - KeyringBodyComponent.createNodeForMulti(from: bodyImageURL) { [weak self] body in + KeyringBodyComponent.createNode(from: bodyImageURL, templateId: templateId) { [weak self] body in guard let self = self, let body = body else { self?.checkLoadingComplete() return diff --git a/Keychy/Keychy/Core/KeyringBundle/Scene/MultiKeyringScene.swift b/Keychy/Keychy/Core/KeyringBundle/Scene/MultiKeyringScene.swift index c09ec0b99..849e9699b 100644 --- a/Keychy/Keychy/Core/KeyringBundle/Scene/MultiKeyringScene.swift +++ b/Keychy/Keychy/Core/KeyringBundle/Scene/MultiKeyringScene.swift @@ -87,6 +87,7 @@ class MultiKeyringScene: SKScene { var carabinerFrontImageURL: String? // 카라비너 앞면 이미지 (hamburger 타입) // MARK: - 카라비너 크기 및 위치 정보 + var carabinerId: String = "" // 카라비너 ID (bundleKeyringScale용) var carabinerX: CGFloat = 0 // 카라비너 중심 X 좌표 var carabinerY: CGFloat = 0 // 카라비너 중심 Y 좌표 var carabinerWidth: CGFloat = 0 // 카라비너 너비 @@ -110,6 +111,7 @@ class MultiKeyringScene: SKScene { backgroundImageURL: String? = nil, carabinerBackImageURL: String? = nil, carabinerFrontImageURL: String? = nil, + carabinerId: String = "", carabinerX: CGFloat = 0, carabinerY: CGFloat = 0, carabinerWidth: CGFloat = 0 @@ -121,10 +123,11 @@ class MultiKeyringScene: SKScene { self.backgroundImageURL = backgroundImageURL self.carabinerBackImageURL = carabinerBackImageURL self.carabinerFrontImageURL = carabinerFrontImageURL + self.carabinerId = carabinerId self.carabinerX = carabinerX self.carabinerY = carabinerY self.carabinerWidth = carabinerWidth - + super.init(size: .zero) } @@ -170,6 +173,9 @@ class MultiKeyringScene: SKScene { // 물리 시뮬레이션을 처음에는 비활성화 physicsWorld.gravity = CGVector(dx: 0, dy: 0) // 중력 0으로 설정 + // 카메라 설정 (carabinerScale 적용) + setupCamera() + // 씬 사이즈가 아직 0일 수 있으므로 한 프레임 지연 if size.width == 0 || size.height == 0 { DispatchQueue.main.async { [weak self] in @@ -180,6 +186,19 @@ class MultiKeyringScene: SKScene { } } + /// 카메라 설정 - carabinerScale 적용 + private func setupCamera() { + let cameraNode = SKCameraNode() + cameraNode.position = CGPoint(x: size.width / 2, y: size.height / 2) + + // carabinerScale 적용 (카메라 scale은 역수) + let scale = KeyringScale.bundleKeyringScale(for: carabinerId) + cameraNode.setScale(1.0 / scale) + + addChild(cameraNode) + self.camera = cameraNode + } + private func beginLoading() { Task { [weak self] in guard let self else { return } @@ -590,7 +609,7 @@ class MultiKeyringScene: SKScene { // 3. Body 생성 let body: SKNode if let bodyImage = images.body { - body = self.createBodyNode(image: bodyImage) + body = self.createBodyNode(image: bodyImage, templateId: data.templateId) } else { body = self.createBasicBodyNode() } @@ -630,19 +649,18 @@ class MultiKeyringScene: SKScene { } } - private func createBodyNode(image: UIImage) -> SKSpriteNode { - let maxSize: CGFloat = 200 + private func createBodyNode(image: UIImage, templateId: String?) -> SKSpriteNode { + let maxSize = KeyringScale.maxSize(for: templateId ?? "") let originalSize = image.size - var displaySize = originalSize - - let maxDimension = max(originalSize.width, originalSize.height) - if maxDimension > maxSize { - let scale = maxSize / maxDimension - displaySize = CGSize( - width: originalSize.width * scale, - height: originalSize.height * scale - ) - } + + let widthRatio = maxSize.width / originalSize.width + let heightRatio = maxSize.height / originalSize.height + let scale = min(widthRatio, heightRatio, 1.0) + + let displaySize = CGSize( + width: originalSize.width * scale, + height: originalSize.height * scale + ) let texture = SKTexture(image: image) texture.filteringMode = .linear From 70e2687e871817374a2e0d59cb65888a60a88c56 Mon Sep 17 00:00:00 2001 From: giljihun Date: Tue, 10 Feb 2026 23:07:02 +0900 Subject: [PATCH 08/17] =?UTF-8?q?fix:=20Bundle=20=EA=B4=80=EB=A0=A8=20?= =?UTF-8?q?=EB=B7=B0=EC=97=90=EC=84=9C=20KeyringData=EC=97=90=20templateId?= =?UTF-8?q?=20=EC=A0=84=EB=8B=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - selectedTemplate 프로퍼티 사용 --- .../Bundle/Views/Complete/BundleCompleteView+SaveImage.swift | 1 + .../Bundle/Views/Create/BundleCreateView+Capture.swift | 1 + .../Bundle/Views/Detail/BundleDetailView+SaveImage.swift | 1 + .../Bundle/Views/Edit/BundleEditView+Capture.swift | 1 + .../Presentation/Bundle/Views/Shared/BundleGridItem.swift | 5 ++++- 5 files changed, 8 insertions(+), 1 deletion(-) diff --git a/Keychy/Keychy/Presentation/Bundle/Views/Complete/BundleCompleteView+SaveImage.swift b/Keychy/Keychy/Presentation/Bundle/Views/Complete/BundleCompleteView+SaveImage.swift index d9260bfc1..40f5310b9 100644 --- a/Keychy/Keychy/Presentation/Bundle/Views/Complete/BundleCompleteView+SaveImage.swift +++ b/Keychy/Keychy/Presentation/Bundle/Views/Complete/BundleCompleteView+SaveImage.swift @@ -91,6 +91,7 @@ extension BundleCompleteView { y: carabiner.keyringYPosition[index] ), bodyImageURL: keyring.bodyImage, + templateId: keyring.selectedTemplate, hookOffsetY: keyring.hookOffsetY, chainLength: keyring.chainLength ) diff --git a/Keychy/Keychy/Presentation/Bundle/Views/Create/BundleCreateView+Capture.swift b/Keychy/Keychy/Presentation/Bundle/Views/Create/BundleCreateView+Capture.swift index 076083089..a6bef44d7 100644 --- a/Keychy/Keychy/Presentation/Bundle/Views/Create/BundleCreateView+Capture.swift +++ b/Keychy/Keychy/Presentation/Bundle/Views/Create/BundleCreateView+Capture.swift @@ -84,6 +84,7 @@ extension BundleCreateView { y: carabiner.keyringYPosition[index] ), bodyImageURL: keyring.bodyImage, + templateId: keyring.selectedTemplate, hookOffsetY: keyring.hookOffsetY, chainLength: keyring.chainLength ) diff --git a/Keychy/Keychy/Presentation/Bundle/Views/Detail/BundleDetailView+SaveImage.swift b/Keychy/Keychy/Presentation/Bundle/Views/Detail/BundleDetailView+SaveImage.swift index 275a38bc0..5d8c45c91 100644 --- a/Keychy/Keychy/Presentation/Bundle/Views/Detail/BundleDetailView+SaveImage.swift +++ b/Keychy/Keychy/Presentation/Bundle/Views/Detail/BundleDetailView+SaveImage.swift @@ -106,6 +106,7 @@ extension BundleDetailView { y: cb.keyringYPosition[index] ), bodyImageURL: keyringInfo.bodyImage, + templateId: keyringInfo.selectedTemplate ?? "", hookOffsetY: keyringInfo.hookOffsetY, chainLength: keyringInfo.chainLength ) diff --git a/Keychy/Keychy/Presentation/Bundle/Views/Edit/BundleEditView+Capture.swift b/Keychy/Keychy/Presentation/Bundle/Views/Edit/BundleEditView+Capture.swift index ed88e1652..30a6d0e67 100644 --- a/Keychy/Keychy/Presentation/Bundle/Views/Edit/BundleEditView+Capture.swift +++ b/Keychy/Keychy/Presentation/Bundle/Views/Edit/BundleEditView+Capture.swift @@ -52,6 +52,7 @@ extension BundleEditView { index: item.index, position: item.position, bodyImageURL: item.bodyImageURL, + templateId: item.templateId ?? "", hookOffsetY: item.hookOffsetY, chainLength: item.chainLength ) diff --git a/Keychy/Keychy/Presentation/Bundle/Views/Shared/BundleGridItem.swift b/Keychy/Keychy/Presentation/Bundle/Views/Shared/BundleGridItem.swift index ec381a71f..f4b0e364b 100644 --- a/Keychy/Keychy/Presentation/Bundle/Views/Shared/BundleGridItem.swift +++ b/Keychy/Keychy/Presentation/Bundle/Views/Shared/BundleGridItem.swift @@ -192,6 +192,7 @@ extension BundleGridItem { y: carabiner.keyringYPosition[originalIndex] ), bodyImageURL: keyringInfo.bodyImage, + templateId: keyringInfo.templateId, hookOffsetY: keyringInfo.hookOffsetY, chainLength: keyringInfo.chainLength ) @@ -286,10 +287,11 @@ extension BundleGridItem { return nil } + let templateId = data["selectedTemplate"] as? String ?? "" let hookOffsetY = data["hookOffsetY"] as? CGFloat ?? 0.0 let chainLength = data["chainLength"] as? Int ?? 5 - return KeyringCaptureInfo(id: keyringId, bodyImage: bodyImage, hookOffsetY: hookOffsetY, chainLength: chainLength) + return KeyringCaptureInfo(id: keyringId, bodyImage: bodyImage, templateId: templateId, hookOffsetY: hookOffsetY, chainLength: chainLength) } catch { print("[BundleItem] 키링 정보 로드 실패: \(keyringId) - \(error.localizedDescription)") return nil @@ -301,6 +303,7 @@ extension BundleGridItem { struct KeyringCaptureInfo { let id: String let bodyImage: String + let templateId: String let hookOffsetY: CGFloat? let chainLength: Int } From e124a1b032b8d8022538b2f85990a2054e92e8a7 Mon Sep 17 00:00:00 2001 From: giljihun Date: Tue, 10 Feb 2026 23:07:28 +0900 Subject: [PATCH 09/17] =?UTF-8?q?feat:=20MultiKeyringSceneView=EC=97=90=20?= =?UTF-8?q?carabinerId=20=ED=8C=8C=EB=9D=BC=EB=AF=B8=ED=84=B0=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - bundleKeyringScale 적용을 위한 인프라 --- .../Core/KeyringBundle/View/MultiKeyringSceneView.swift | 4 ++++ .../Bundle/Views/Complete/BundleCompleteView.swift | 1 + .../Presentation/Bundle/Views/Create/BundleCreateView.swift | 1 + .../Presentation/Bundle/Views/Detail/BundleDetailView.swift | 1 + .../Presentation/Bundle/Views/Edit/BundleEditView.swift | 1 + Keychy/Keychy/Presentation/Home/Views/Main/HomeView.swift | 1 + 6 files changed, 9 insertions(+) diff --git a/Keychy/Keychy/Core/KeyringBundle/View/MultiKeyringSceneView.swift b/Keychy/Keychy/Core/KeyringBundle/View/MultiKeyringSceneView.swift index 1b0b686d3..0970d043f 100644 --- a/Keychy/Keychy/Core/KeyringBundle/View/MultiKeyringSceneView.swift +++ b/Keychy/Keychy/Core/KeyringBundle/View/MultiKeyringSceneView.swift @@ -26,6 +26,7 @@ struct MultiKeyringSceneView: View { let backgroundImageURL: String? let carabinerBackImageURL: String? let carabinerFrontImageURL: String? + let carabinerId: String let carabinerX: CGFloat let carabinerY: CGFloat let carabinerWidth: CGFloat @@ -49,6 +50,7 @@ struct MultiKeyringSceneView: View { backgroundImageURL: String? = nil, carabinerBackImageURL: String? = nil, carabinerFrontImageURL: String? = nil, + carabinerId: String = "", carabinerX: CGFloat = 0, carabinerY: CGFloat = 0, carabinerWidth: CGFloat = 0, @@ -63,6 +65,7 @@ struct MultiKeyringSceneView: View { self.backgroundImageURL = backgroundImageURL self.carabinerBackImageURL = carabinerBackImageURL self.carabinerFrontImageURL = carabinerFrontImageURL + self.carabinerId = carabinerId self.carabinerX = carabinerX self.carabinerY = carabinerY self.carabinerWidth = carabinerWidth @@ -178,6 +181,7 @@ extension MultiKeyringSceneView { backgroundImageURL: nil, carabinerBackImageURL: carabinerBackImageURL, carabinerFrontImageURL: carabinerFrontImageURL, + carabinerId: carabinerId, carabinerX: carabinerX, carabinerY: carabinerY, carabinerWidth: carabinerWidth diff --git a/Keychy/Keychy/Presentation/Bundle/Views/Complete/BundleCompleteView.swift b/Keychy/Keychy/Presentation/Bundle/Views/Complete/BundleCompleteView.swift index b3068daa8..203bd5547 100644 --- a/Keychy/Keychy/Presentation/Bundle/Views/Complete/BundleCompleteView.swift +++ b/Keychy/Keychy/Presentation/Bundle/Views/Complete/BundleCompleteView.swift @@ -95,6 +95,7 @@ extension BundleCompleteView { backgroundImageURL: background.backgroundImage, carabinerBackImageURL: carabiner.backImageURL, carabinerFrontImageURL: carabiner.frontImageURL, + carabinerId: carabiner.id ?? "", carabinerX: carabiner.carabinerX, carabinerY: carabiner.carabinerY, carabinerWidth: carabiner.carabinerWidth, diff --git a/Keychy/Keychy/Presentation/Bundle/Views/Create/BundleCreateView.swift b/Keychy/Keychy/Presentation/Bundle/Views/Create/BundleCreateView.swift index a8a1b809a..5f773922d 100644 --- a/Keychy/Keychy/Presentation/Bundle/Views/Create/BundleCreateView.swift +++ b/Keychy/Keychy/Presentation/Bundle/Views/Create/BundleCreateView.swift @@ -75,6 +75,7 @@ struct BundleCreateView: View { backgroundImageURL: bg.background.backgroundImage, carabinerBackImageURL: cb.carabiner.backImageURL, carabinerFrontImageURL: cb.carabiner.frontImageURL, + carabinerId: cb.carabiner.id ?? "", carabinerX: cb.carabiner.carabinerX, carabinerY: cb.carabiner.carabinerY, carabinerWidth: cb.carabiner.carabinerWidth, diff --git a/Keychy/Keychy/Presentation/Bundle/Views/Detail/BundleDetailView.swift b/Keychy/Keychy/Presentation/Bundle/Views/Detail/BundleDetailView.swift index 231ac50b3..9680370bc 100644 --- a/Keychy/Keychy/Presentation/Bundle/Views/Detail/BundleDetailView.swift +++ b/Keychy/Keychy/Presentation/Bundle/Views/Detail/BundleDetailView.swift @@ -75,6 +75,7 @@ struct BundleDetailView: View { backgroundImageURL: background.backgroundImage, carabinerBackImageURL: carabiner.backImageURL, carabinerFrontImageURL: carabiner.frontImageURL, + carabinerId: carabiner.id ?? "", carabinerX: carabiner.carabinerX, carabinerY: carabiner.carabinerY, carabinerWidth: carabiner.carabinerWidth, diff --git a/Keychy/Keychy/Presentation/Bundle/Views/Edit/BundleEditView.swift b/Keychy/Keychy/Presentation/Bundle/Views/Edit/BundleEditView.swift index 4836d09d4..c9e9e96e5 100644 --- a/Keychy/Keychy/Presentation/Bundle/Views/Edit/BundleEditView.swift +++ b/Keychy/Keychy/Presentation/Bundle/Views/Edit/BundleEditView.swift @@ -185,6 +185,7 @@ struct BundleEditView: View { backgroundImageURL: background.background.backgroundImage, carabinerBackImageURL: carabiner.carabiner.backImageURL, carabinerFrontImageURL: carabiner.carabiner.frontImageURL, + carabinerId: carabiner.carabiner.id ?? "", carabinerX: carabiner.carabiner.carabinerX, carabinerY: carabiner.carabiner.carabinerY, carabinerWidth: carabiner.carabiner.carabinerWidth, diff --git a/Keychy/Keychy/Presentation/Home/Views/Main/HomeView.swift b/Keychy/Keychy/Presentation/Home/Views/Main/HomeView.swift index 9117da4f6..da6446b69 100644 --- a/Keychy/Keychy/Presentation/Home/Views/Main/HomeView.swift +++ b/Keychy/Keychy/Presentation/Home/Views/Main/HomeView.swift @@ -54,6 +54,7 @@ struct HomeView: View { backgroundImageURL: background.backgroundImage, carabinerBackImageURL: carabiner.backImageURL, carabinerFrontImageURL: carabiner.frontImageURL, + carabinerId: carabiner.id ?? "", carabinerX: carabiner.carabinerX, carabinerY: carabiner.carabinerY, carabinerWidth: carabiner.carabinerWidth, From 8696e2e3e0c511b1a52a2db080ce16cd715483a3 Mon Sep 17 00:00:00 2001 From: giljihun Date: Tue, 10 Feb 2026 23:07:53 +0900 Subject: [PATCH 10/17] =?UTF-8?q?feat:=20=EC=BA=90=EC=8B=9C=20=EC=8A=A4?= =?UTF-8?q?=EC=BC=80=EC=9D=BC=20=EB=B2=84=EC=A0=84=20=EA=B4=80=EB=A6=AC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - KeyringScale 변경 시 자동 캐시 무효화 - 앱 시작 시 invalidateCacheIfScaleVersionChanged 호출 --- Keychy/Keychy/App/KeychyApp.swift | 3 +++ .../Keychy/Core/Cache/KeyringImageCache.swift | 24 +++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/Keychy/Keychy/App/KeychyApp.swift b/Keychy/Keychy/App/KeychyApp.swift index 630d0f138..520dfa541 100644 --- a/Keychy/Keychy/App/KeychyApp.swift +++ b/Keychy/Keychy/App/KeychyApp.swift @@ -19,6 +19,9 @@ struct KeychyApp: App { init() { // 네트워크 모니터링 시작 NetworkManager.shared.startMonitoring() + + // 키링 캐시 스케일 버전 체크 (KeyringScale 적용으로 인한 재캡처) + KeyringImageCache.shared.invalidateCacheIfScaleVersionChanged() } // MARK: - Body diff --git a/Keychy/Keychy/Core/Cache/KeyringImageCache.swift b/Keychy/Keychy/Core/Cache/KeyringImageCache.swift index 595976717..2a22d0fc0 100644 --- a/Keychy/Keychy/Core/Cache/KeyringImageCache.swift +++ b/Keychy/Keychy/Core/Cache/KeyringImageCache.swift @@ -402,4 +402,28 @@ class KeyringImageCache { UserDefaults.standard.set(currentMigrationVersion, forKey: migrationVersionKey) } + + // MARK: - 캐시 스케일 버전 관리 + + /// 캐시 스케일 버전 (KeyringScale 적용으로 인해 증가) + /// - v1: 기존 하드코딩 210x210 + /// - v2: KeyringScale 템플릿별 maxSize 적용 + private let currentScaleVersion = 2 + private let scaleVersionKey = "keyringCacheScaleVersion" + + /// 캐시 스케일 버전이 변경되었으면 전체 캐시 삭제 + /// - 앱 시작 시 호출 + /// - 새 KeyringScale 로직으로 재캡처 유도 + func invalidateCacheIfScaleVersionChanged() { + let savedVersion = UserDefaults.standard.integer(forKey: scaleVersionKey) + + guard savedVersion < currentScaleVersion else { return } + + print("[KeyringCache] 스케일 버전 변경 감지: v\(savedVersion) → v\(currentScaleVersion)") + print("[KeyringCache] 캐시 전체 삭제 후 재캡처 예정") + + clearAll() + + UserDefaults.standard.set(currentScaleVersion, forKey: scaleVersionKey) + } } From a5800b617012ee16a9afb3106dad87a761ed418f Mon Sep 17 00:00:00 2001 From: giljihun Date: Tue, 10 Feb 2026 23:41:32 +0900 Subject: [PATCH 11/17] =?UTF-8?q?fix:=20KeyringScale=20=ED=85=9C=ED=94=8C?= =?UTF-8?q?=EB=A6=BF=EB=B3=84=20maxSize=20=EB=B0=8F=20zoomScale=20?= =?UTF-8?q?=EC=A1=B0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Polaroid maxSize 미세 조정 (263x321 → 265x324) - SpeechBubble maxSize 가로/세로 수정 (249x360 → 360x249) - Polaroid 완성뷰 zoomScale 0.7로 설정 --- Keychy/Keychy/Core/Keyring/Scene/KeyringScale.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Keychy/Keychy/Core/Keyring/Scene/KeyringScale.swift b/Keychy/Keychy/Core/Keyring/Scene/KeyringScale.swift index c8bc78a26..9e4ead1f9 100644 --- a/Keychy/Keychy/Core/Keyring/Scene/KeyringScale.swift +++ b/Keychy/Keychy/Core/Keyring/Scene/KeyringScale.swift @@ -23,16 +23,16 @@ enum KeyringScale { // MARK: - 템플릿별 maxSize private static let templateMaxSizes: [String: CGSize] = [ - "Polaroid": CGSize(width: 263, height: 321), + "Polaroid": CGSize(width: 265, height: 324), "AcrylicPhoto": CGSize(width: 210, height: 210), "ClearSketch": CGSize(width: 210, height: 210), "PixelKeyring": CGSize(width: 277, height: 257), - "SpeechBubble": CGSize(width: 249, height: 360) + "SpeechBubble": CGSize(width: 360, height: 249) ] // MARK: - 템플릿 × 화면별 zoomScale private static let templateZoomScales: [String: [Screen: CGFloat]] = [ - "Polaroid": [.customizing: 1.0, .infoInput: 1.0, .complete: 1.0], + "Polaroid": [.customizing: 1.0, .infoInput: 1.0, .complete: 0.7], "AcrylicPhoto": [.customizing: 1.0, .infoInput: 1.0, .complete: 1.0], "ClearSketch": [.customizing: 1.0, .infoInput: 1.0, .complete: 1.0], "PixelKeyring": [.customizing: 1.0, .infoInput: 1.0, .complete: 1.0], From b8c21a623dfb26f9a88a52d5cd4810e288e7f177 Mon Sep 17 00:00:00 2001 From: giljihun Date: Tue, 10 Feb 2026 23:41:43 +0900 Subject: [PATCH 12/17] =?UTF-8?q?feat:=20KeyringCompleteView=EC=97=90=20sc?= =?UTF-8?q?reen=20=ED=8C=8C=EB=9D=BC=EB=AF=B8=ED=84=B0=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../KeyringMaker/Shared/Views/KeyringCompleteView.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Keychy/Keychy/Presentation/KeyringMaker/Shared/Views/KeyringCompleteView.swift b/Keychy/Keychy/Presentation/KeyringMaker/Shared/Views/KeyringCompleteView.swift index 2e74a733e..162824e2d 100644 --- a/Keychy/Keychy/Presentation/KeyringMaker/Shared/Views/KeyringCompleteView.swift +++ b/Keychy/Keychy/Presentation/KeyringMaker/Shared/Views/KeyringCompleteView.swift @@ -177,6 +177,7 @@ extension KeyringCompleteView { private var keyringScene: some View { KeyringSceneView( viewModel: viewModel, + screen: .complete, backgroundColor: .clear, applyWelcomeImpulse: true, onSceneReady: { From e7f436f059f1c215d922439370ace9513fb9b518 Mon Sep 17 00:00:00 2001 From: giljihun Date: Tue, 10 Feb 2026 23:42:10 +0900 Subject: [PATCH 13/17] =?UTF-8?q?style:=20=EC=A0=95=EB=B3=B4=EC=9E=85?= =?UTF-8?q?=EB=A0=A5=EB=B7=B0=20=ED=82=A4=EB=A7=81=20=EC=94=AC=20=EC=8A=A4?= =?UTF-8?q?=EC=BC=80=EC=9D=BC=20=EC=A1=B0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 최소화 -> 1.2에서 1.0 - 최대화 -> 0.7에서 0.6 (키링이 시트에 너무 가려지던거 수정) --- .../Shared/Views/KeyringInfoInputView+Helpers.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Keychy/Keychy/Presentation/KeyringMaker/Shared/Views/KeyringInfoInputView+Helpers.swift b/Keychy/Keychy/Presentation/KeyringMaker/Shared/Views/KeyringInfoInputView+Helpers.swift index c78020851..f1ffe2228 100644 --- a/Keychy/Keychy/Presentation/KeyringMaker/Shared/Views/KeyringInfoInputView+Helpers.swift +++ b/Keychy/Keychy/Presentation/KeyringMaker/Shared/Views/KeyringInfoInputView+Helpers.swift @@ -16,7 +16,7 @@ extension KeyringInfoInputView { /// 씬 스케일 (시트 최대화 시 작게, 최소화 시 크게) var sceneScale: CGFloat { - isSheetExpanded ? 0.7 : 1.2 + isSheetExpanded ? 0.6 : 1.0 } /// 씬 Y 오프셋 (시트 최대화 시 위로 이동) From d19d5a688a3e4f60b425c0fa1a0b3cd05a7564ae Mon Sep 17 00:00:00 2001 From: giljihun Date: Wed, 11 Feb 2026 00:26:23 +0900 Subject: [PATCH 14/17] =?UTF-8?q?fix:=20=ED=82=A4=EB=A7=81=EC=8A=A4?= =?UTF-8?q?=EC=BC=80=EC=9D=BC=20=ED=85=9C=ED=94=8C=EB=A6=BF=EB=B3=84=20max?= =?UTF-8?q?Size,=20zoomScale=20=EC=A1=B0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Keychy/Keychy/Core/Keyring/Scene/KeyringScale.swift | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Keychy/Keychy/Core/Keyring/Scene/KeyringScale.swift b/Keychy/Keychy/Core/Keyring/Scene/KeyringScale.swift index 9e4ead1f9..fa8dfda19 100644 --- a/Keychy/Keychy/Core/Keyring/Scene/KeyringScale.swift +++ b/Keychy/Keychy/Core/Keyring/Scene/KeyringScale.swift @@ -24,7 +24,7 @@ enum KeyringScale { // MARK: - 템플릿별 maxSize private static let templateMaxSizes: [String: CGSize] = [ "Polaroid": CGSize(width: 265, height: 324), - "AcrylicPhoto": CGSize(width: 210, height: 210), + "AcrylicPhoto": CGSize(width: 360, height: 360), "ClearSketch": CGSize(width: 210, height: 210), "PixelKeyring": CGSize(width: 277, height: 257), "SpeechBubble": CGSize(width: 360, height: 249) @@ -35,20 +35,20 @@ enum KeyringScale { "Polaroid": [.customizing: 1.0, .infoInput: 1.0, .complete: 0.7], "AcrylicPhoto": [.customizing: 1.0, .infoInput: 1.0, .complete: 1.0], "ClearSketch": [.customizing: 1.0, .infoInput: 1.0, .complete: 1.0], - "PixelKeyring": [.customizing: 1.0, .infoInput: 1.0, .complete: 1.0], - "SpeechBubble": [.customizing: 1.0, .infoInput: 1.0, .complete: 1.0] + "PixelKeyring": [.customizing: 1.0, .infoInput: 1.0, .complete: 0.9], + "SpeechBubble": [.customizing: 1.0, .infoInput: 1.0, .complete: 0.9] ] // MARK: - 카라비너별 뭉치 키링 스케일 private static let carabinerScales: [String: CGFloat] = [ - "basic": 1.0 + "basic": 0.65 // TODO: 추가 카라비너 스케일 ] // MARK: - 기본값 private static let defaultMaxSize = CGSize(width: 210, height: 210) private static let defaultZoomScale: CGFloat = 1.0 - private static let defaultCarabinerScale: CGFloat = 1.0 + private static let defaultCarabinerScale: CGFloat = 0.65 // MARK: - Public API From 2985ed34dfa08ac1584f9e8498b9c26d5b985fb1 Mon Sep 17 00:00:00 2001 From: giljihun Date: Wed, 11 Feb 2026 00:26:36 +0900 Subject: [PATCH 15/17] =?UTF-8?q?refactor:=20=ED=82=A4=EB=A7=81=20?= =?UTF-8?q?=EC=98=81=EC=83=81=20=EB=B0=B0=EA=B2=BD=20=EA=B8=B0=EB=B3=B8?= =?UTF-8?q?=EA=B0=92=20=ED=88=AC=EB=AA=85=EC=9C=BC=EB=A1=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Core/Video/Keyring/KeyringVideoGenerator+Keyring.swift | 2 +- Keychy/Keychy/Core/Video/Keyring/KeyringVideoGenerator.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Keychy/Keychy/Core/Video/Keyring/KeyringVideoGenerator+Keyring.swift b/Keychy/Keychy/Core/Video/Keyring/KeyringVideoGenerator+Keyring.swift index 7d2a8e22a..8fa2fa035 100644 --- a/Keychy/Keychy/Core/Video/Keyring/KeyringVideoGenerator+Keyring.swift +++ b/Keychy/Keychy/Core/Video/Keyring/KeyringVideoGenerator+Keyring.swift @@ -21,7 +21,7 @@ extension KeyringVideoGenerator { /// - Returns: 생성된 영상 파일 URL func generateVideo( keyring: Keyring, - backgroundImage: UIImage? = UIImage(named: "completeBG2"), + backgroundImage: UIImage? = nil, keyringScale: CGFloat = 3.5 ) async throws -> URL { // 이미지를 비동기로 먼저 다운로드 (메인스레드 블로킹 방지) diff --git a/Keychy/Keychy/Core/Video/Keyring/KeyringVideoGenerator.swift b/Keychy/Keychy/Core/Video/Keyring/KeyringVideoGenerator.swift index 1f1a68aa6..bebe7f138 100644 --- a/Keychy/Keychy/Core/Video/Keyring/KeyringVideoGenerator.swift +++ b/Keychy/Keychy/Core/Video/Keyring/KeyringVideoGenerator.swift @@ -115,7 +115,7 @@ class KeyringVideoGenerator { /// - Returns: 생성된 영상 파일 URL func generateVideo( viewModel: VM, - backgroundImage: UIImage? = UIImage(named: "completeBG2"), + backgroundImage: UIImage? = nil, keyringScale: CGFloat = 3.5 ) async throws -> URL { self.backgroundImage = backgroundImage From b357f32aaeac3053b427a9574418f0e1a2045374 Mon Sep 17 00:00:00 2001 From: giljihun Date: Wed, 11 Feb 2026 00:26:55 +0900 Subject: [PATCH 16/17] =?UTF-8?q?feat:=20=EB=AD=89=EC=B9=98=20=EC=94=AC?= =?UTF-8?q?=EC=97=90=20bundleKeyringScale=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Ring, Chain 노드에 bundleScale 적용 - Chain 간격도 bundleScale에 맞게 조정 - Body 크기에 bundleScale 적용 --- .../Scene/MultiKeyringScene.swift | 26 ++++++++++++------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/Keychy/Keychy/Core/KeyringBundle/Scene/MultiKeyringScene.swift b/Keychy/Keychy/Core/KeyringBundle/Scene/MultiKeyringScene.swift index 849e9699b..9615132d1 100644 --- a/Keychy/Keychy/Core/KeyringBundle/Scene/MultiKeyringScene.swift +++ b/Keychy/Keychy/Core/KeyringBundle/Scene/MultiKeyringScene.swift @@ -186,15 +186,10 @@ class MultiKeyringScene: SKScene { } } - /// 카메라 설정 - carabinerScale 적용 + /// 카메라 설정 (기본 카메라, 스케일 없음) private func setupCamera() { let cameraNode = SKCameraNode() cameraNode.position = CGPoint(x: size.width / 2, y: size.height / 2) - - // carabinerScale 적용 (카메라 scale은 역수) - let scale = KeyringScale.bundleKeyringScale(for: carabinerId) - cameraNode.setScale(1.0 / scale) - addChild(cameraNode) self.camera = cameraNode } @@ -541,7 +536,11 @@ class MultiKeyringScene: SKScene { ringType: currentRingType ) : BundleRingComponent.createHamburgerRingNode(image: images.ring, ringType: currentRingType) ) - + + // 뭉치용 키링 스케일 적용 (카라비너는 그대로, 키링만 축소) + let bundleScale = KeyringScale.bundleKeyringScale(for: carabinerId) + ring.setScale(bundleScale) + // 햄버거 타입일 때 Ring을 카라비너 뒷면과 앞면 사이에 배치 if carabinerType == .hamburger { ring.zPosition = -850 // 카라비너 뒷면(-900)과 앞면(-800) 사이 @@ -577,7 +576,8 @@ class MultiKeyringScene: SKScene { let ringHeight = ring.calculateAccumulatedFrame().height let ringBottomY = ring.position.y - ringHeight / 2 let chainStartY = ringBottomY + 2 - let chainSpacing: CGFloat = 22 + // 체인 간격도 bundleScale에 맞게 조정 + let chainSpacing: CGFloat = 22 * bundleScale let chainCount: Int = (currentCarabinerType == .plain ? max(data.chainLength - 1, 1) : data.chainLength) @@ -594,6 +594,9 @@ class MultiKeyringScene: SKScene { var chains: [SKSpriteNode] = [] for (_, chainNode) in createdChains.enumerated() { + // 뭉치용 키링 스케일 적용 (카라비너는 그대로, 키링만 축소) + chainNode.setScale(bundleScale) + // assembleKeyring 기존 초기 물리 설정 유지 chainNode.physicsBody?.isDynamic = false chainNode.physicsBody?.categoryBitMask = categoryBitMask @@ -657,9 +660,12 @@ class MultiKeyringScene: SKScene { let heightRatio = maxSize.height / originalSize.height let scale = min(widthRatio, heightRatio, 1.0) + // 뭉치용 키링 스케일 적용 (카라비너는 그대로, 키링 바디만 축소) + let bundleScale = KeyringScale.bundleKeyringScale(for: carabinerId) + let displaySize = CGSize( - width: originalSize.width * scale, - height: originalSize.height * scale + width: originalSize.width * scale * bundleScale, + height: originalSize.height * scale * bundleScale ) let texture = SKTexture(image: image) From fd15ba757d41b4937e80f8b4801290a4a1d115bb Mon Sep 17 00:00:00 2001 From: giljihun Date: Wed, 11 Feb 2026 00:27:38 +0900 Subject: [PATCH 17/17] =?UTF-8?q?feat:=20=EB=AD=89=EC=B9=98=20=EC=BA=A1?= =?UTF-8?q?=EC=B2=98=20=EC=94=AC=EC=97=90=20bundleKeyringScale=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MultiKeyringCaptureScene에 carabinerId 파라미터 추가 - captureBundleImage에 carabinerId 파라미터 추가 - Ring, Chain, Body에 bundleScale 적용 - 호출부 업데이트: BundleGridItem, BundleDetailView+SaveImage, BundleCreateView+Capture, BundleCompleteView+SaveImage, BundleEditView+Capture --- .../MultiKeyringCaptureScene+Capture.swift | 2 ++ .../Scene/MultiKeyringCaptureScene.swift | 20 ++++++++++++++++++- .../BundleCompleteView+SaveImage.swift | 1 + .../Create/BundleCreateView+Capture.swift | 1 + .../Detail/BundleDetailView+SaveImage.swift | 2 ++ .../Views/Edit/BundleEditView+Capture.swift | 2 ++ .../Bundle/Views/Shared/BundleGridItem.swift | 2 ++ 7 files changed, 29 insertions(+), 1 deletion(-) diff --git a/Keychy/Keychy/Core/KeyringBundle/Scene/MultiKeyringCaptureScene+Capture.swift b/Keychy/Keychy/Core/KeyringBundle/Scene/MultiKeyringCaptureScene+Capture.swift index 7d32504c5..ee7ded9ef 100644 --- a/Keychy/Keychy/Core/KeyringBundle/Scene/MultiKeyringCaptureScene+Capture.swift +++ b/Keychy/Keychy/Core/KeyringBundle/Scene/MultiKeyringCaptureScene+Capture.swift @@ -67,6 +67,7 @@ extension MultiKeyringCaptureScene { carabinerBackImageURL: String? = nil, carabinerFrontImageURL: String? = nil, carabinerType: CarabinerType? = nil, + carabinerId: String = "", carabinerX: CGFloat = 0, carabinerY: CGFloat = 0, carabinerWidth: CGFloat = 0, @@ -102,6 +103,7 @@ extension MultiKeyringCaptureScene { backgroundImageURL: backgroundImageURL, carabinerBackImageURL: carabinerBackImageURL, carabinerFrontImageURL: carabinerFrontImageURL, + carabinerId: carabinerId, carabinerX: carabinerX, carabinerY: carabinerY, carabinerWidth: carabinerWidth, diff --git a/Keychy/Keychy/Core/KeyringBundle/Scene/MultiKeyringCaptureScene.swift b/Keychy/Keychy/Core/KeyringBundle/Scene/MultiKeyringCaptureScene.swift index d0a58d0a2..f867bff64 100644 --- a/Keychy/Keychy/Core/KeyringBundle/Scene/MultiKeyringCaptureScene.swift +++ b/Keychy/Keychy/Core/KeyringBundle/Scene/MultiKeyringCaptureScene.swift @@ -42,6 +42,7 @@ class MultiKeyringCaptureScene: SKScene { var onLoadingComplete: (() -> Void)? // MARK: - 카라비너 크기 및 위치 정보 + var carabinerId: String = "" // 카라비너 ID (bundleKeyringScale용) var carabinerX: CGFloat = 0 // 카라비너 왼쪽 상단 X 좌표 var carabinerY: CGFloat = 0 // 카라비너 왼쪽 상단 Y 좌표 var carabinerWidth: CGFloat = 0 // 카라비너 너비 @@ -66,6 +67,7 @@ class MultiKeyringCaptureScene: SKScene { backgroundImageURL: String? = nil, // 배경 이미지 URL (옵션) carabinerBackImageURL: String? = nil, // 카라비너 뒷면 이미지 (hamburger 타입) carabinerFrontImageURL: String? = nil, // 카라비너 앞면 이미지 (hamburger 타입) + carabinerId: String = "", // 카라비너 ID (bundleKeyringScale용) carabinerX: CGFloat = 0, carabinerY: CGFloat = 0, carabinerWidth: CGFloat = 0, @@ -79,6 +81,7 @@ class MultiKeyringCaptureScene: SKScene { self.backgroundImageURL = backgroundImageURL self.carabinerBackImageURL = carabinerBackImageURL self.carabinerFrontImageURL = carabinerFrontImageURL + self.carabinerId = carabinerId self.carabinerX = carabinerX self.carabinerY = carabinerY self.carabinerWidth = carabinerWidth @@ -314,6 +317,10 @@ class MultiKeyringCaptureScene: SKScene { return } + // 뭉치용 키링 스케일 적용 (카라비너는 그대로, 키링만 축소) + let bundleScale = KeyringScale.bundleKeyringScale(for: self.carabinerId) + ring.setScale(bundleScale) + // 햄버거 타입일 때 Ring을 카라비너 뒷면과 앞면 사이에 배치 if carabinerType == .hamburger { ring.zPosition = -850 // 카라비너 뒷면(-900)과 앞면(-800) 사이 @@ -360,10 +367,14 @@ class MultiKeyringCaptureScene: SKScene { baseZPosition: CGFloat, carabinerType: CarabinerType? = nil ) { + // 뭉치용 키링 스케일 + let bundleScale = KeyringScale.bundleKeyringScale(for: carabinerId) + let ringHeight = ring.calculateAccumulatedFrame().height let ringBottomY = ring.position.y - ringHeight / 2 let chainStartY = ringBottomY - 2 - let chainSpacing: CGFloat = 22 + // 체인 간격도 bundleScale에 맞게 조정 + let chainSpacing: CGFloat = 22 * bundleScale // chainLength를 기본으로 사용하되, 카라비너 타입에 따라 조정 let chainCount: Int = { @@ -388,6 +399,9 @@ class MultiKeyringCaptureScene: SKScene { guard let self = self else { return } for chain in chains { + // 뭉치용 키링 스케일 적용 + chain.setScale(bundleScale) + chain.physicsBody = nil self.addChild(chain) @@ -428,6 +442,10 @@ class MultiKeyringCaptureScene: SKScene { return } + // 뭉치용 키링 스케일 적용 + let bundleScale = KeyringScale.bundleKeyringScale(for: self.carabinerId) + body.setScale(bundleScale) + let bodyFrame = body.calculateAccumulatedFrame() let bodyHalfHeight = bodyFrame.height / 2 diff --git a/Keychy/Keychy/Presentation/Bundle/Views/Complete/BundleCompleteView+SaveImage.swift b/Keychy/Keychy/Presentation/Bundle/Views/Complete/BundleCompleteView+SaveImage.swift index 40f5310b9..87799f7cf 100644 --- a/Keychy/Keychy/Presentation/Bundle/Views/Complete/BundleCompleteView+SaveImage.swift +++ b/Keychy/Keychy/Presentation/Bundle/Views/Complete/BundleCompleteView+SaveImage.swift @@ -118,6 +118,7 @@ extension BundleCompleteView { carabinerBackImageURL: carabinerBackURL, carabinerFrontImageURL: carabinerFrontURL, carabinerType: carabinerType, + carabinerId: carabiner.id ?? "", carabinerX: carabiner.carabinerX, carabinerY: carabiner.carabinerY, carabinerWidth: carabiner.carabinerWidth diff --git a/Keychy/Keychy/Presentation/Bundle/Views/Create/BundleCreateView+Capture.swift b/Keychy/Keychy/Presentation/Bundle/Views/Create/BundleCreateView+Capture.swift index a6bef44d7..96a97c3e6 100644 --- a/Keychy/Keychy/Presentation/Bundle/Views/Create/BundleCreateView+Capture.swift +++ b/Keychy/Keychy/Presentation/Bundle/Views/Create/BundleCreateView+Capture.swift @@ -111,6 +111,7 @@ extension BundleCreateView { carabinerBackImageURL: carabinerBackURL, carabinerFrontImageURL: carabinerFrontURL, carabinerType: carabinerType, + carabinerId: carabiner.id ?? "", carabinerX: carabiner.carabinerX, carabinerY: carabiner.carabinerY, carabinerWidth: carabiner.carabinerWidth diff --git a/Keychy/Keychy/Presentation/Bundle/Views/Detail/BundleDetailView+SaveImage.swift b/Keychy/Keychy/Presentation/Bundle/Views/Detail/BundleDetailView+SaveImage.swift index 5d8c45c91..23af003ab 100644 --- a/Keychy/Keychy/Presentation/Bundle/Views/Detail/BundleDetailView+SaveImage.swift +++ b/Keychy/Keychy/Presentation/Bundle/Views/Detail/BundleDetailView+SaveImage.swift @@ -133,6 +133,7 @@ extension BundleDetailView { carabinerBackImageURL: carabinerBackURL, carabinerFrontImageURL: carabinerFrontURL, carabinerType: carabinerType, + carabinerId: bundle.selectedCarabiner, carabinerX: cb.carabinerX, carabinerY: cb.carabinerY, carabinerWidth: cb.carabinerWidth @@ -158,6 +159,7 @@ extension BundleDetailView { carabinerBackImageURL: carabinerBackURL, carabinerFrontImageURL: carabinerFrontURL, carabinerType: carabinerType, + carabinerId: bundle.selectedCarabiner, carabinerX: cb.carabinerX, carabinerY: cb.carabinerY, carabinerWidth: cb.carabinerWidth, diff --git a/Keychy/Keychy/Presentation/Bundle/Views/Edit/BundleEditView+Capture.swift b/Keychy/Keychy/Presentation/Bundle/Views/Edit/BundleEditView+Capture.swift index 30a6d0e67..62de0e539 100644 --- a/Keychy/Keychy/Presentation/Bundle/Views/Edit/BundleEditView+Capture.swift +++ b/Keychy/Keychy/Presentation/Bundle/Views/Edit/BundleEditView+Capture.swift @@ -77,6 +77,7 @@ extension BundleEditView { carabinerBackImageURL: carabinerBackURL, carabinerFrontImageURL: carabinerFrontURL, carabinerType: carabinerType, + carabinerId: cb.id ?? "", carabinerX: cb.carabinerX, carabinerY: cb.carabinerY, carabinerWidth: cb.carabinerWidth @@ -94,6 +95,7 @@ extension BundleEditView { carabinerBackImageURL: carabinerBackURL, carabinerFrontImageURL: carabinerFrontURL, carabinerType: carabinerType, + carabinerId: cb.id ?? "", carabinerX: cb.carabinerX, carabinerY: cb.carabinerY, carabinerWidth: cb.carabinerWidth, diff --git a/Keychy/Keychy/Presentation/Bundle/Views/Shared/BundleGridItem.swift b/Keychy/Keychy/Presentation/Bundle/Views/Shared/BundleGridItem.swift index f4b0e364b..672e9606a 100644 --- a/Keychy/Keychy/Presentation/Bundle/Views/Shared/BundleGridItem.swift +++ b/Keychy/Keychy/Presentation/Bundle/Views/Shared/BundleGridItem.swift @@ -219,6 +219,7 @@ extension BundleGridItem { carabinerBackImageURL: carabinerBackURL, carabinerFrontImageURL: carabinerFrontURL, carabinerType: carabinerType, + carabinerId: bundle.selectedCarabiner, carabinerX: carabiner.carabinerX, carabinerY: carabiner.carabinerY, carabinerWidth: carabiner.carabinerWidth @@ -236,6 +237,7 @@ extension BundleGridItem { carabinerBackImageURL: carabinerBackURL, carabinerFrontImageURL: carabinerFrontURL, carabinerType: carabinerType, + carabinerId: bundle.selectedCarabiner, carabinerX: carabiner.carabinerX, carabinerY: carabiner.carabinerY, carabinerWidth: carabiner.carabinerWidth,