mirror of https://github.com/oxen-io/session-ios
				
				
				
			
			You cannot select more than 25 topics
			Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
		
		
		
		
		
			
		
			
	
	
		
			241 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			Swift
		
	
		
		
			
		
	
	
			241 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			Swift
		
	
| 
											7 years ago
										 | // | ||
|  | //  Copyright (c) 2019 Open Whisper Systems. All rights reserved. | ||
|  | // | ||
|  | 
 | ||
|  | import UIKit | ||
|  | 
 | ||
|  | // The image editor uses multiple coordinate systems. | ||
|  | // | ||
|  | // * Image unit coordinates.  Brush stroke and text content should be pegged to | ||
|  | //   image content, so they are specified relative to the bounds of the image. | ||
|  | // * Canvas coordinates.  We render the image, strokes and text into the "canvas", | ||
|  | //   a viewport that has the aspect ratio of the view.  Rendering is transformed, so | ||
|  | //   this is pre-tranform. | ||
|  | // * View coordinates.  The coordinates of the actual view (or rendered output). | ||
|  | //   Bounded by the view's bounds / viewport. | ||
|  | // | ||
|  | // Sometimes we use unit coordinates.  This facilitates a number of operations such | ||
|  | // as clamping to 0-1, etc.  So in practice almost all values will be in one of six | ||
|  | // coordinate systems: | ||
|  | // | ||
|  | // * unit image coordinates | ||
|  | // * image coordinates | ||
|  | // * unit canvas coordinates | ||
|  | // * canvas coordinates | ||
|  | // * unit view coordinates | ||
|  | // * view coordinates | ||
|  | // | ||
|  | // For simplicity, the canvas bounds are always identical to view bounds. | ||
|  | // If we wanted to manipulate output quality, we would use the layer's "scale". | ||
|  | // But canvas values are pre-transform and view values are post-transform so they | ||
|  | // are only identical if the transform has no scaling, rotation or translation. | ||
|  | // | ||
|  | // The "ImageEditorTransform" can be used to generate an CGAffineTransform | ||
|  | // for the layers used to render the content.  In practice, the affine transform | ||
|  | // is applied to a superlayer of the sublayers used to render content. | ||
|  | // | ||
|  | // CALayers apply their transform relative to the layer's anchorPoint, which | ||
|  | // by default is the center of the layer's bounds.  E.g. rotation occurs | ||
|  | // around the center of the layer.  Therefore when projecting absolute | ||
|  | // (but not relative) coordinates between the "view" and "canvas" coordinate | ||
|  | // systems, it's necessary to project them relative to the center of the | ||
|  | // view/canvas. | ||
|  | // | ||
|  | // To simplify our representation & operations, the default size of the image | ||
|  | // content is "exactly large enough to fill the canvas if rotation | ||
|  | // but not scaling or translation were applied".  This might seem unusual, | ||
|  | // but we have a key invariant: we always want the image to fill the canvas. | ||
|  | // It's far easier to ensure this if the transform is always (just barely) | ||
|  | // valid when scaling = 1 and translation = .zero.  The image size that | ||
|  | // fulfills this criteria is calculated using | ||
|  | // ImageEditorCanvasView.imageFrame(forViewSize:...).  Transforming between | ||
|  | // the "image" and "canvas" coordinate systems is done with that image frame. | ||
|  | @objc | ||
|  | public class ImageEditorTransform: NSObject { | ||
|  |     // The outputSizePixels is used to specify the aspect ratio and size of the | ||
|  |     // output. | ||
|  |     public let outputSizePixels: CGSize | ||
|  |     // The unit translation of the content, relative to the | ||
|  |     // canvas viewport. | ||
|  |     public let unitTranslation: CGPoint | ||
|  |     // Rotation about the center of the content. | ||
|  |     public let rotationRadians: CGFloat | ||
|  |     // x >= 1.0. | ||
|  |     public let scaling: CGFloat | ||
|  |     // Flipping is horizontal. | ||
|  |     public let isFlipped: Bool | ||
|  | 
 | ||
|  |     public init(outputSizePixels: CGSize, | ||
|  |                 unitTranslation: CGPoint, | ||
|  |                 rotationRadians: CGFloat, | ||
|  |                 scaling: CGFloat, | ||
|  |                 isFlipped: Bool) { | ||
|  |         self.outputSizePixels = outputSizePixels | ||
|  |         self.unitTranslation = unitTranslation | ||
|  |         self.rotationRadians = rotationRadians | ||
|  |         self.scaling = scaling | ||
|  |         self.isFlipped = isFlipped | ||
|  |     } | ||
|  | 
 | ||
|  |     public class func defaultTransform(srcImageSizePixels: CGSize) -> ImageEditorTransform { | ||
|  |         // It shouldn't be necessary normalize the default transform, but we do so to be safe. | ||
|  |         return ImageEditorTransform(outputSizePixels: srcImageSizePixels, | ||
|  |                                     unitTranslation: .zero, | ||
|  |                                     rotationRadians: 0.0, | ||
|  |                                     scaling: 1.0, | ||
|  |                                     isFlipped: false).normalize(srcImageSizePixels: srcImageSizePixels) | ||
|  |     } | ||
|  | 
 | ||
|  |     public var isNonDefault: Bool { | ||
|  |         return !isEqual(ImageEditorTransform.defaultTransform(srcImageSizePixels: outputSizePixels)) | ||
|  |     } | ||
|  | 
 | ||
|  |     public func affineTransform(viewSize: CGSize) -> CGAffineTransform { | ||
|  |         let translation = unitTranslation.fromUnitCoordinates(viewSize: viewSize) | ||
|  |         // Order matters.  We need want SRT (scale-rotate-translate) ordering so that the translation | ||
|  |         // is not affected affected by the scaling or rotation, which shoud both be about the "origin" | ||
|  |         // (in this case the center of the content). | ||
|  |         // | ||
|  |         // NOTE: CGAffineTransform transforms are composed in reverse order. | ||
|  |         let transform = CGAffineTransform.identity.translate(translation).rotated(by: rotationRadians).scaledBy(x: scaling, y: scaling) | ||
|  |         return transform | ||
|  |     } | ||
|  | 
 | ||
|  |     // This method normalizes a "proposed" transform (self) into | ||
|  |     // one that is guaranteed to be valid. | ||
|  |     public func normalize(srcImageSizePixels: CGSize) -> ImageEditorTransform { | ||
|  |         // Normalize scaling. | ||
|  |         // The "src/background" image is rendered at a size that will fill | ||
|  |         // the canvas bounds if scaling = 1.0 and translation = .zero. | ||
|  |         // Therefore, any scaling >= 1.0 is valid. | ||
|  |         let minScaling: CGFloat = 1.0 | ||
|  |         let scaling = max(minScaling, self.scaling) | ||
|  | 
 | ||
|  |         // We don't need to normalize rotation. | ||
|  | 
 | ||
|  |         // Normalize translation. | ||
|  |         // | ||
|  |         // This is decidedly non-trivial because of the way that | ||
|  |         // scaling, rotation and translation combine.  We need to | ||
|  |         // guarantee that the image _always_ fills the canvas | ||
|  |         // bounds.  So want to clamp the translation such that the | ||
|  |         // image can be moved _exactly_ to the edge of the canvas | ||
|  |         // and no further in a way that reflects the current | ||
|  |         // crop, scaling and rotation. | ||
|  |         // | ||
|  |         // We need to clamp the translation to the valid "translation | ||
|  |         // region" which is a rectangle centered on the origin. | ||
|  |         // However, this rectangle is axis-aligned in canvas | ||
|  |         // coordinates, not view coordinates.  e.g. if you have | ||
|  |         // a long image and a square output size, you could "slide" | ||
|  |         // the crop region along the image's contents.  That | ||
|  |         // movement would appear diagonal to the user in the view | ||
|  |         // but would be vertical on the canvas. | ||
|  | 
 | ||
|  |         // Normalize translation, Step 1: | ||
|  |         // | ||
|  |         // We project the viewport onto the canvas to determine | ||
|  |         // its bounding box. | ||
|  |         let viewBounds = CGRect(origin: .zero, size: self.outputSizePixels) | ||
|  |         // This "naive" transform represents the proposed transform | ||
|  |         // with no translation. | ||
|  |         let naiveTransform = ImageEditorTransform(outputSizePixels: outputSizePixels, | ||
|  |                                                   unitTranslation: .zero, | ||
|  |                                                   rotationRadians: rotationRadians, | ||
|  |                                                   scaling: scaling, | ||
|  |                                                   isFlipped: self.isFlipped) | ||
|  |         let naiveAffineTransform = naiveTransform.affineTransform(viewSize: viewBounds.size) | ||
|  |         var naiveViewportMinCanvas = CGPoint.zero | ||
|  |         var naiveViewportMaxCanvas = CGPoint.zero | ||
|  |         var isFirstCorner = true | ||
|  |         // Find the "naive" bounding box of the viewport on the canvas | ||
|  |         // by projecting its corners from view coordinates to canvas | ||
|  |         // coordinates. | ||
|  |         // | ||
|  |         // Due to symmetry, it should be sufficient to project 2 corners | ||
|  |         // but we do all four corners for safety. | ||
|  |         for viewCorner in [ | ||
|  |             viewBounds.topLeft, | ||
|  |             viewBounds.topRight, | ||
|  |             viewBounds.bottomLeft, | ||
|  |             viewBounds.bottomRight | ||
|  |             ] { | ||
|  |                 let naiveViewCornerInCanvas = viewCorner.minus(viewBounds.center).applyingInverse(naiveAffineTransform).plus(viewBounds.center) | ||
|  |                 if isFirstCorner { | ||
|  |                     naiveViewportMinCanvas = naiveViewCornerInCanvas | ||
|  |                     naiveViewportMaxCanvas = naiveViewCornerInCanvas | ||
|  |                     isFirstCorner = false | ||
|  |                 } else { | ||
|  |                     naiveViewportMinCanvas = naiveViewportMinCanvas.min(naiveViewCornerInCanvas) | ||
|  |                     naiveViewportMaxCanvas = naiveViewportMaxCanvas.max(naiveViewCornerInCanvas) | ||
|  |                 } | ||
|  |         } | ||
|  |         let naiveViewportSizeCanvas: CGPoint = naiveViewportMaxCanvas.minus(naiveViewportMinCanvas) | ||
|  | 
 | ||
|  |         // Normalize translation, Step 2: | ||
|  |         // | ||
|  |         // Now determine the "naive" image frame on the canvas. | ||
|  |         let naiveImageFrameCanvas = ImageEditorCanvasView.imageFrame(forViewSize: viewBounds.size, imageSize: srcImageSizePixels, transform: naiveTransform) | ||
|  |         let naiveImageSizeCanvas = CGPoint(x: naiveImageFrameCanvas.width, y: naiveImageFrameCanvas.height) | ||
|  | 
 | ||
|  |         // Normalize translation, Step 3: | ||
|  |         // | ||
|  |         // The min/max translation can now by computed by diffing | ||
|  |         // the size of the bounding box of the naive viewport and | ||
|  |         // the size of the image on canvas. | ||
|  |         let maxTranslationCanvas = naiveImageSizeCanvas.minus(naiveViewportSizeCanvas).times(0.5).max(.zero) | ||
|  | 
 | ||
|  |         // Normalize translation, Step 4: | ||
|  |         // | ||
|  |         // Clamp the proposed translation to the "max translation" | ||
|  |         // from the last step. | ||
|  |         // | ||
|  |         // This is subtle.  We want to clamp in canvas coordinates | ||
|  |         // since the min/max translation is specified by a bounding | ||
|  |         // box in "unit canvas" coordinates.  However, because the | ||
|  |         // translation is applied in SRT order (scale-rotate-transform), | ||
|  |         // it effectively operates in view coordinates since it is | ||
|  |         // applied last.  So we project it from view coordinates | ||
|  |         // to canvas coordinates, clamp it, then project it back | ||
|  |         // into unit view coordinates using the "naive" (no translation) | ||
|  |         // transform. | ||
|  |         let translationInView = self.unitTranslation.fromUnitCoordinates(viewBounds: viewBounds) | ||
|  |         let translationInCanvas = translationInView.applyingInverse(naiveAffineTransform) | ||
|  |         // Clamp the translation to +/- maxTranslationCanvasUnit. | ||
|  |         let clampedTranslationInCanvas = translationInCanvas.min(maxTranslationCanvas).max(maxTranslationCanvas.inverse()) | ||
|  |         let clampedTranslationInView = clampedTranslationInCanvas.applying(naiveAffineTransform) | ||
|  |         let unitTranslation = clampedTranslationInView.toUnitCoordinates(viewBounds: viewBounds, shouldClamp: false) | ||
|  | 
 | ||
|  |         return ImageEditorTransform(outputSizePixels: outputSizePixels, | ||
|  |                                     unitTranslation: unitTranslation, | ||
|  |                                     rotationRadians: rotationRadians, | ||
|  |                                     scaling: scaling, | ||
|  |                                     isFlipped: self.isFlipped) | ||
|  |     } | ||
|  | 
 | ||
|  |     public override func isEqual(_ object: Any?) -> Bool { | ||
|  |         guard let other = object as? ImageEditorTransform  else { | ||
|  |             return false | ||
|  |         } | ||
|  |         return (outputSizePixels == other.outputSizePixels && | ||
|  |             unitTranslation == other.unitTranslation && | ||
|  |             rotationRadians == other.rotationRadians && | ||
|  |             scaling == other.scaling && | ||
|  |             isFlipped == other.isFlipped) | ||
|  |     } | ||
|  | 
 | ||
|  |     public override var hash: Int { | ||
|  |         return (outputSizePixels.width.hashValue ^ | ||
|  |             outputSizePixels.height.hashValue ^ | ||
|  |             unitTranslation.x.hashValue ^ | ||
|  |             unitTranslation.y.hashValue ^ | ||
|  |             rotationRadians.hashValue ^ | ||
|  |             scaling.hashValue ^ | ||
|  |             isFlipped.hashValue) | ||
|  |     } | ||
|  | 
 | ||
|  |     open override var description: String { | ||
|  |         return "[outputSizePixels: \(outputSizePixels), unitTranslation: \(unitTranslation), rotationRadians: \(rotationRadians), scaling: \(scaling), isFlipped: \(isFlipped)]" | ||
|  |     } | ||
|  | } |