@@ -67,7 +67,9 @@ extension LenticularVM {
6767 }
6868
6969 /// 핀치 제스처 종료 시 스케일 적용 + 오프셋 재클램핑
70+ /// - cardSize는 composeAtlas의 좌표계 변환을 위해 VM에도 저장됨
7071 func applyScale( _ gestureScale: CGFloat , target: ImageTarget , cardSize: CGSize ) {
72+ sourceCardSize = cardSize
7173 switch target {
7274 case . a:
7375 photoScaleA = min ( max ( photoScaleA * gestureScale, 1.0 ) , 3.0 )
@@ -81,7 +83,9 @@ extension LenticularVM {
8183 }
8284
8385 /// 드래그 제스처 종료 시 오프셋 적용 (클램핑 포함)
86+ /// - cardSize는 composeAtlas의 좌표계 변환을 위해 VM에도 저장됨
8487 func applyOffset( _ translation: CGSize , target: ImageTarget , cardSize: CGSize ) {
88+ sourceCardSize = cardSize
8589 switch target {
8690 case . a:
8791 let maxOff = maxOffset ( for: . a, scale: photoScaleA, cardSize: cardSize)
@@ -103,13 +107,22 @@ extension LenticularVM {
103107 // MARK: - 아틀라스 합성
104108
105109 /// 사용자 크롭(스케일/오프셋) 적용 후 A+B 아틀라스 합성 → bodyImage에 저장
110+ ///
111+ /// offset이 `sourceCardSize` 좌표계 기준이므로 `targetSize` 좌표계로 변환이 필요하다.
112+ /// `sourceCardSize`는 `applyScale`/`applyOffset` 호출 시 VM이 자동으로 기록한다.
113+ /// - Precondition: 호출 전에 사용자가 최소 한 번 이상 카드를 편집했어야 한다 (sourceCardSize 기록).
106114 func composeAtlas( ) {
107115 guard let a = imageA, let b = imageB else { return }
108- let targetSize = KeyringScale . maxSize ( for: " Lenticular " ) // 300×390
116+ assert ( sourceCardSize != . zero, " composeAtlas 호출 전 sourceCardSize가 기록되지 않음 — applyScale/applyOffset이 먼저 호출되어야 함 " )
117+ let targetSize = KeyringScale . maxSize ( for: templateId)
109118
110119 let croppedA = cropImage ( a, scale: photoScaleA, offset: photoOffsetA, targetSize: targetSize)
111120 let croppedB = cropImage ( b, scale: photoScaleB, offset: photoOffsetB, targetSize: targetSize)
112121
122+ // 썸네일 등 편집 결과를 보여주는 뷰에서 재사용
123+ croppedImageA = croppedA
124+ croppedImageB = croppedB
125+
113126 bodyImage = TextureComposer . compose (
114127 imageA: croppedA,
115128 imageB: croppedB,
@@ -118,6 +131,10 @@ extension LenticularVM {
118131 }
119132
120133 /// 이미지에 사용자 크롭(스케일/오프셋)을 적용하여 targetSize로 렌더링
134+ ///
135+ /// 핵심: 사용자의 offset은 `sourceCardSize` 좌표계에서 캡처된 값이므로,
136+ /// `targetSize` 좌표계로 그릴 때는 두 fillScale의 비율만큼 변환해야 한다.
137+ /// 변환 공식: `convertedOffset = offset × (fillScale_target / fillScale_source)`
121138 private func cropImage(
122139 _ image: UIImage ,
123140 scale: CGFloat ,
@@ -126,15 +143,29 @@ extension LenticularVM {
126143 ) -> UIImage {
127144 let renderer = UIGraphicsImageRenderer ( size: targetSize)
128145 return renderer. image { _ in
146+ // target 좌표계의 fillScale (실제 그리기에 사용)
129147 let fillScale = max ( targetSize. width / image. size. width,
130148 targetSize. height / image. size. height)
131149 let drawSize = CGSize (
132150 width: image. size. width * fillScale * scale,
133151 height: image. size. height * fillScale * scale
134152 )
153+
154+ // source 좌표계의 fillScale (사용자 offset이 기반으로 하는 좌표계)
155+ let sourceFillScale = max ( sourceCardSize. width / image. size. width,
156+ sourceCardSize. height / image. size. height)
157+ // 두 좌표계 비율 — source → target 변환 계수
158+ // sourceCardSize가 아직 기록되지 않았으면 변환 없이 원점에 그림 (이 경우 offset은 .zero일 것)
159+ let ratio = sourceFillScale > 0 ? ( fillScale / sourceFillScale) : 1.0
160+
161+ let convertedOffset = CGSize (
162+ width: offset. width * ratio,
163+ height: offset. height * ratio
164+ )
165+
135166 let drawOrigin = CGPoint (
136- x: ( targetSize. width - drawSize. width) / 2 + offset . width,
137- y: ( targetSize. height - drawSize. height) / 2 + offset . height
167+ x: ( targetSize. width - drawSize. width) / 2 + convertedOffset . width,
168+ y: ( targetSize. height - drawSize. height) / 2 + convertedOffset . height
138169 )
139170 image. draw ( in: CGRect ( origin: drawOrigin, size: drawSize) )
140171 }
0 commit comments