diff --git a/.maestro/photos-crop.yaml b/.maestro/photos-crop.yaml index 42919ece..e00b92b9 100644 --- a/.maestro/photos-crop.yaml +++ b/.maestro/photos-crop.yaml @@ -15,9 +15,20 @@ tags: - assertVisible: "Cancel" - assertVisible: "Done" - assertVisible: "Rotate" +- assertVisible: "Flip Horizontal" +- assertVisible: "Flip Vertical" - assertVisible: "Aspect Ratio" +- assertVisible: "Straighten" +- assertVisible: "Vertical" +- assertVisible: "Horizontal" - assertVisible: "Rotation" - assertNotVisible: "Reset" +- tapOn: "Flip Horizontal" +- assertVisible: "Reset" +- tapOn: "Flip Horizontal" +- tapOn: "Vertical" +- assertVisible: "Vertical Perspective" +- tapOn: "Straighten" - tapOn: "Aspect Ratio" - tapOn: "SQUARE" - assertVisible: "Reset" @@ -26,11 +37,12 @@ tags: - assertVisible: id: "photos.crop" - assertVisible: "Reset" -- tapOn: "Reset" +- tapOn: + point: "50%,10%" - assertNotVisible: "Reset" - swipe: start: 50%, 45% - end: 45%, 45% + end: 50%, 42% duration: 300 - assertNotVisible: "Reset" - tapOn: "Aspect Ratio" @@ -65,7 +77,10 @@ tags: - assertVisible: "Reset" - tapOn: text: "Rotate" -- tapOn: "Reset" +- tapOn: + point: "50%,10%" +- tapOn: + point: "50%,10%" - assertNotVisible: "Reset" - tapOn: "9:16" - tapOn: "Done" diff --git a/Dev/Tests/BrightroomEngineTests/RendererTests.swift b/Dev/Tests/BrightroomEngineTests/RendererTests.swift index 3f50468f..82eda6db 100644 --- a/Dev/Tests/BrightroomEngineTests/RendererTests.swift +++ b/Dev/Tests/BrightroomEngineTests/RendererTests.swift @@ -353,12 +353,16 @@ final class RenderCropTests: XCTestCase { cropRect: .init(x: 0.2, y: 0.2, width: 99.6, height: 99.6) ) editingCrop.rotation = .angle_90 + editingCrop.flip = [.horizontal] editingCrop.adjustmentAngle = .degrees(0.25) + editingCrop.perspectiveCorrection = .init(horizontal: 0.2, vertical: -0.35) let crop = RenderCrop(editingCrop) XCTAssertEqual(crop.rotation, .angle_90) + XCTAssertEqual(crop.flip, [.horizontal]) XCTAssertEqual(crop.adjustmentAngle, .degrees(0.25)) + XCTAssertEqual(crop.perspectiveCorrection, .init(horizontal: 0.2, vertical: -0.35)) } func testEditingCropRenderingEquivalenceUsesPixelCropContract() { @@ -472,6 +476,102 @@ final class RenderCropRendererTests: XCTestCase { try Self.assertEdgesAreDark(renderedImage) } + func testHorizontalFlipMirrorsRenderCrop() throws { + let sourceImage = try Self.makeHorizontalColorImage() + let imageSource = ImageSource(cgImage: sourceImage) + let renderer = BrightRoomImageRenderer(source: imageSource, orientation: .up) + + var crop = EditingCrop(imageSize: sourceImage.size) + crop.flip = [.horizontal] + + renderer.edit = .init( + croppingRect: crop, + modifiers: [], + drawer: [] + ) + + let renderedImage = try renderer.render().cgImage + let leftPixel = try Self.rgbaPixel(at: .init(x: 0, y: 0), in: renderedImage) + let rightPixel = try Self.rgbaPixel(at: .init(x: 1, y: 0), in: renderedImage) + + XCTAssertGreaterThan(leftPixel.blue, leftPixel.red) + XCTAssertGreaterThan(rightPixel.red, rightPixel.blue) + } + + func testPerspectiveCorrectionPreservesRenderCropSize() throws { + let sourceImage = try Self.makeImageWithBrightBorder(size: 16) + let imageSource = ImageSource(cgImage: sourceImage) + let renderer = BrightRoomImageRenderer(source: imageSource, orientation: .up) + + var crop = Self.fractionalCrop(for: sourceImage) + crop.perspectiveCorrection = .init(horizontal: 0.3, vertical: -0.2) + + renderer.edit = .init( + croppingRect: crop, + modifiers: [], + drawer: [] + ) + + let renderedImage = try renderer.render().cgImage + + XCTAssertEqual(renderedImage.width, 14) + XCTAssertEqual(renderedImage.height, 14) + } + + func testPerspectiveTransformCreatesTrapezoidCanvas() throws { + let sourceImage = try Self.makeSolidColorImage( + size: .init(width: 20, height: 20), + red: 1, + green: 0, + blue: 0 + ) + + let transformedImage = try sourceImage.perspectiveTransformed(.init(vertical: 1)) + + XCTAssertEqual(transformedImage.width, 20) + XCTAssertEqual(transformedImage.height, 20) + + let centerPixel = try Self.rgbaPixel(at: .init(x: 10, y: 10), in: transformedImage) + let corners = try [ + CGPoint(x: 0, y: 0), + CGPoint(x: 19, y: 0), + CGPoint(x: 0, y: 19), + CGPoint(x: 19, y: 19), + ].map { try Self.rgbaPixel(at: $0, in: transformedImage) } + + XCTAssertGreaterThan(centerPixel.red, 200) + XCTAssertTrue(corners.contains { $0.alpha < 16 }) + } + + func testPerspectiveCoverageRectUsesOnlyAlwaysCoveredArea() { + let rect = CGRect(x: 0, y: 0, width: 100, height: 80) + + Self.assertRectEqual( + EditingCrop.PerspectiveCorrection(vertical: 1).axisAlignedCoverageRect(in: rect), + CGRect(x: 36, y: 0, width: 28, height: 80) + ) + Self.assertRectEqual( + EditingCrop.PerspectiveCorrection(horizontal: 1).axisAlignedCoverageRect(in: rect), + CGRect(x: 0, y: 28.8, width: 100, height: 22.4) + ) + Self.assertRectEqual( + EditingCrop.PerspectiveCorrection(horizontal: 1, vertical: 1).axisAlignedCoverageRect(in: rect), + CGRect(x: 36, y: 28.8, width: 28, height: 22.4) + ) + } + + private static func assertRectEqual( + _ actual: CGRect, + _ expected: CGRect, + file: StaticString = #filePath, + line: UInt = #line + ) { + XCTAssertEqual(actual.origin.x, expected.origin.x, accuracy: 1e-6, file: file, line: line) + XCTAssertEqual(actual.origin.y, expected.origin.y, accuracy: 1e-6, file: file, line: line) + XCTAssertEqual(actual.size.width, expected.size.width, accuracy: 1e-6, file: file, line: line) + XCTAssertEqual(actual.size.height, expected.size.height, accuracy: 1e-6, file: file, line: line) + } + private static func fractionalCrop(for image: CGImage) -> EditingCrop { EditingCrop( imageSize: image.size, @@ -512,6 +612,55 @@ final class RenderCropRendererTests: XCTestCase { return try XCTUnwrap(context.makeImage()) } + private static func makeHorizontalColorImage() throws -> CGImage { + let bitmapInfo = CGBitmapInfo.byteOrder32Big.rawValue + | CGImageAlphaInfo.premultipliedLast.rawValue + let context = try XCTUnwrap( + CGContext( + data: nil, + width: 2, + height: 1, + bitsPerComponent: 8, + bytesPerRow: 2 * 4, + space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: bitmapInfo + ) + ) + + context.setFillColor(red: 1, green: 0, blue: 0, alpha: 1) + context.fill(.init(x: 0, y: 0, width: 1, height: 1)) + context.setFillColor(red: 0, green: 0, blue: 1, alpha: 1) + context.fill(.init(x: 1, y: 0, width: 1, height: 1)) + + return try XCTUnwrap(context.makeImage()) + } + + private static func makeSolidColorImage( + size: PixelDimensions, + red: CGFloat, + green: CGFloat, + blue: CGFloat + ) throws -> CGImage { + let bitmapInfo = CGBitmapInfo.byteOrder32Big.rawValue + | CGImageAlphaInfo.premultipliedLast.rawValue + let context = try XCTUnwrap( + CGContext( + data: nil, + width: size.width, + height: size.height, + bitsPerComponent: 8, + bytesPerRow: size.width * 4, + space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: bitmapInfo + ) + ) + + context.setFillColor(red: red, green: green, blue: blue, alpha: 1) + context.fill(.init(origin: .zero, size: size.cgSize)) + + return try XCTUnwrap(context.makeImage()) + } + private static func assertEdgesAreDark( _ image: CGImage, file: StaticString = #filePath, diff --git a/Sources/BrightroomEngine/Core/EditingCrop.swift b/Sources/BrightroomEngine/Core/EditingCrop.swift index 2c532522..3d484ef5 100644 --- a/Sources/BrightroomEngine/Core/EditingCrop.swift +++ b/Sources/BrightroomEngine/Core/EditingCrop.swift @@ -67,6 +67,166 @@ public struct EditingCrop: Equatable, Sendable { public typealias AdjustmentAngle = SwiftUI.Angle + public struct Flip: OptionSet, Equatable, Sendable, Hashable { + + public let rawValue: Int + + public init(rawValue: Int) { + self.rawValue = rawValue + } + + public static let horizontal = Flip(rawValue: 1 << 0) + public static let vertical = Flip(rawValue: 1 << 1) + } + + public struct PerspectiveCorrection: Equatable, Sendable, Hashable { + + public struct Quadrilateral: Equatable, Sendable, Hashable { + public var topLeft: CGPoint + public var topRight: CGPoint + public var bottomRight: CGPoint + public var bottomLeft: CGPoint + } + + public static let identity = PerspectiveCorrection() + + public static let maximumInsetRatio: CGFloat = 0.36 + + public private(set) var horizontal: CGFloat + public private(set) var vertical: CGFloat + + public var isIdentity: Bool { + horizontal == 0 && vertical == 0 + } + + public init( + horizontal: CGFloat = 0, + vertical: CGFloat = 0 + ) { + self.horizontal = Self.clamped(horizontal) + self.vertical = Self.clamped(vertical) + } + + public func settingHorizontal(_ horizontal: CGFloat) -> Self { + .init(horizontal: horizontal, vertical: vertical) + } + + public func settingVertical(_ vertical: CGFloat) -> Self { + .init(horizontal: horizontal, vertical: vertical) + } + + public func coreImageTargetQuadrilateral(in rect: CGRect) -> Quadrilateral { + guard rect.isEmpty == false else { + let origin = rect.origin + return .init( + topLeft: origin, + topRight: origin, + bottomRight: origin, + bottomLeft: origin + ) + } + + var topLeft = CGPoint(x: rect.minX, y: rect.maxY) + var topRight = CGPoint(x: rect.maxX, y: rect.maxY) + var bottomRight = CGPoint(x: rect.maxX, y: rect.minY) + var bottomLeft = CGPoint(x: rect.minX, y: rect.minY) + + let verticalInset = rect.width * min(abs(vertical), 1) * Self.maximumInsetRatio + if vertical > 0 { + topLeft.x += verticalInset + topRight.x -= verticalInset + } else if vertical < 0 { + bottomLeft.x += verticalInset + bottomRight.x -= verticalInset + } + + let horizontalInset = rect.height * min(abs(horizontal), 1) * Self.maximumInsetRatio + if horizontal > 0 { + topLeft.y -= horizontalInset + bottomLeft.y += horizontalInset + } else if horizontal < 0 { + topRight.y -= horizontalInset + bottomRight.y += horizontalInset + } + + return .init( + topLeft: topLeft, + topRight: topRight, + bottomRight: bottomRight, + bottomLeft: bottomLeft + ) + } + + public func displayTargetQuadrilateral(in rect: CGRect) -> Quadrilateral { + guard rect.isEmpty == false else { + let origin = rect.origin + return .init( + topLeft: origin, + topRight: origin, + bottomRight: origin, + bottomLeft: origin + ) + } + + var topLeft = CGPoint(x: rect.minX, y: rect.minY) + var topRight = CGPoint(x: rect.maxX, y: rect.minY) + var bottomRight = CGPoint(x: rect.maxX, y: rect.maxY) + var bottomLeft = CGPoint(x: rect.minX, y: rect.maxY) + + let verticalInset = rect.width * min(abs(vertical), 1) * Self.maximumInsetRatio + if vertical > 0 { + topLeft.x += verticalInset + topRight.x -= verticalInset + } else if vertical < 0 { + bottomLeft.x += verticalInset + bottomRight.x -= verticalInset + } + + let horizontalInset = rect.height * min(abs(horizontal), 1) * Self.maximumInsetRatio + if horizontal > 0 { + topLeft.y += horizontalInset + bottomLeft.y -= horizontalInset + } else if horizontal < 0 { + topRight.y += horizontalInset + bottomRight.y -= horizontalInset + } + + return .init( + topLeft: topLeft, + topRight: topRight, + bottomRight: bottomRight, + bottomLeft: bottomLeft + ) + } + + public func axisAlignedCoverageRect(in rect: CGRect) -> CGRect { + guard rect.isEmpty == false else { + return rect + } + + let quadrilateral = displayTargetQuadrilateral(in: rect) + let minX = max(quadrilateral.topLeft.x, quadrilateral.bottomLeft.x) + let maxX = min(quadrilateral.topRight.x, quadrilateral.bottomRight.x) + let minY = max(quadrilateral.topLeft.y, quadrilateral.topRight.y) + let maxY = min(quadrilateral.bottomLeft.y, quadrilateral.bottomRight.y) + + guard minX < maxX, minY < maxY else { + return rect + } + + return .init( + x: minX, + y: minY, + width: maxX - minX, + height: maxY - minY + ) + } + + static func clamped(_ value: CGFloat) -> CGFloat { + min(max(value, -1), 1) + } + } + /// The dimensions in pixel for the image. /// Applied image-orientation. public var imageSize: CGSize @@ -77,11 +237,17 @@ public struct EditingCrop: Equatable, Sendable { /// The angle that specifies rotation for the image. public var rotation: Rotation = .angle_0 + /// Mirroring applied to the visible crop result. + public var flip: Flip = [] + public private(set) var _usedAspectRatio: PixelAspectRatio? /// An angle to rotate in addition to the specified rotation. public var adjustmentAngle: AdjustmentAngle = .zero + /// Perspective correction applied to the image before producing the crop. + public var perspectiveCorrection: PerspectiveCorrection = .identity + public var aggregatedRotation: AdjustmentAngle { rotation.angle + adjustmentAngle } @@ -107,11 +273,15 @@ public struct EditingCrop: Equatable, Sendable { imageSize: CGSize, cropRect: CGRect, rotation: Rotation = .angle_0, + flip: Flip = [], + perspectiveCorrection: PerspectiveCorrection = .identity, scaleToRestore: CGFloat = 1 ) { self.imageSize = imageSize self.cropExtent = Self.fittingRect(rect: cropRect, in: imageSize, respectingAspectRatio: nil) self.rotation = rotation + self.flip = flip + self.perspectiveCorrection = perspectiveCorrection self.scaleToRestore = scaleToRestore } diff --git a/Sources/BrightroomEngine/Engine/CoreGraphics+.swift b/Sources/BrightroomEngine/Engine/CoreGraphics+.swift index af33a19d..8ceb62d4 100644 --- a/Sources/BrightroomEngine/Engine/CoreGraphics+.swift +++ b/Sources/BrightroomEngine/Engine/CoreGraphics+.swift @@ -20,6 +20,7 @@ // THE SOFTWARE. import CoreGraphics +import CoreImage import ImageIO extension CGContext { @@ -118,6 +119,21 @@ extension CGContext { translateBy(x: -anchor.x, y: -anchor.y) } + + func transformForCrop( + radians: CGFloat, + flip: EditingCrop.Flip, + anchor: CGPoint + ) { + translateBy(x: anchor.x, y: anchor.y) + + let scaleX: CGFloat = flip.contains(.horizontal) ? -1 : 1 + let scaleY: CGFloat = flip.contains(.vertical) ? -1 : 1 + scaleBy(x: scaleX, y: scaleY) + + rotate(by: radians) + translateBy(x: -anchor.x, y: -anchor.y) + } } extension CGImage { @@ -129,15 +145,19 @@ extension CGImage { func croppedWithColorspace( to crop: RenderCrop ) throws -> CGImage { - try croppedWithColorspace( + let sourceImage = try perspectiveTransformed(crop.perspectiveCorrection) + + return try sourceImage.croppedWithColorspace( to: crop.cropRect, - adjustmentAngleRadians: crop.aggregatedRotation.radians + adjustmentAngleRadians: crop.aggregatedRotation.radians, + flip: crop.flip ) } func croppedWithColorspace( to cropRect: PixelCropRect, - adjustmentAngleRadians: CGFloat + adjustmentAngleRadians: CGFloat, + flip: EditingCrop.Flip = [] ) throws -> CGImage { let cropExtent = cropRect.cgRect @@ -147,8 +167,9 @@ extension CGImage { let context = try CGContext.makeContext(for: self, pixelDimensions: cropRect.size) .perform { context in - context.rotate( + context.transformForCrop( radians: -adjustmentAngleRadians, + flip: flip, anchor: .init(x: context.boundingBoxOfClipPath.midX, y: context.boundingBoxOfClipPath.midY) ) @@ -171,6 +192,45 @@ extension CGImage { } + public func perspectiveTransformed( + _ correction: EditingCrop.PerspectiveCorrection + ) throws -> CGImage { + guard correction.isIdentity == false else { + return self + } + + let targetExtent = CGRect(origin: .zero, size: size) + let quadrilateral = correction.coreImageTargetQuadrilateral(in: targetExtent) + let colorSpace = colorSpace ?? CGColorSpaceCreateDeviceRGB() + let sourceImage = CIImage(cgImage: self) + + let corrected = sourceImage.applyingFilter( + "CIPerspectiveTransformWithExtent", + parameters: [ + "inputExtent": CIVector(cgRect: targetExtent), + "inputTopLeft": CIVector(cgPoint: quadrilateral.topLeft), + "inputTopRight": CIVector(cgPoint: quadrilateral.topRight), + "inputBottomRight": CIVector(cgPoint: quadrilateral.bottomRight), + "inputBottomLeft": CIVector(cgPoint: quadrilateral.bottomLeft), + ] + ) + + let context = CIContext(options: [ + .workingColorSpace: colorSpace, + .outputColorSpace: colorSpace, + .cacheIntermediates: false, + ]) + + return try context.createCGImage( + corrected, + from: targetExtent, + format: .RGBA8, + colorSpace: colorSpace, + deferred: false + ) + .unwrap() + } + func resized(maxPixelSize: CGFloat) throws -> CGImage { let cgImage = try autoreleasepool { () -> CGImage? in @@ -366,6 +426,14 @@ private enum MTLImageCreationError: Error { } extension MTLDevice { + fileprivate var brightroomMaximum2DTextureSideSize: Int { + #if targetEnvironment(simulator) + 8192 + #else + supportsFamily(.apple3) ? 16384 : 8192 + #endif + } + fileprivate func supportsImage(size: CGSize) -> Bool { #if DEBUG switch MTLGPUFamily.apple1 { @@ -391,7 +459,7 @@ extension MTLDevice { break } #endif - let maxSideSize: CGFloat = self.supportsFamily(.apple3) ? 16384 : 8192 + let maxSideSize = CGFloat(brightroomMaximum2DTextureSideSize) return size.width <= maxSideSize && size.height <= maxSideSize } } @@ -400,7 +468,11 @@ extension MTLDevice { /// 16bits image can't be MTLTexture with MTKTextureLoader. /// https://stackoverflow.com/questions/54710592/cant-load-large-jpeg-into-a-mtltexture-with-mtktextureloader private func makeMTLTexture(from cgImage: CGImage, device: MTLDevice) throws -> MTLTexture { - guard device.supportsImage(size: cgImage.size) else { + guard + device.supportsImage(size: cgImage.size), + cgImage.width <= device.brightroomMaximum2DTextureSideSize, + cgImage.height <= device.brightroomMaximum2DTextureSideSize + else { throw MTLImageCreationError.imageTooBig } diff --git a/Sources/BrightroomEngine/Engine/RenderCrop.swift b/Sources/BrightroomEngine/Engine/RenderCrop.swift index 2fa2a784..6b410bff 100644 --- a/Sources/BrightroomEngine/Engine/RenderCrop.swift +++ b/Sources/BrightroomEngine/Engine/RenderCrop.swift @@ -183,7 +183,9 @@ internal struct RenderCrop: Equatable, Sendable { internal var imageSize: PixelDimensions internal var cropRect: PixelCropRect internal var rotation: EditingCrop.Rotation + internal var flip: EditingCrop.Flip internal var adjustmentAngle: EditingCrop.AdjustmentAngle + internal var perspectiveCorrection: EditingCrop.PerspectiveCorrection internal var cropExtent: CGRect { cropRect.cgRect @@ -202,7 +204,9 @@ internal struct RenderCrop: Equatable, Sendable { imageSize: imageSize ?? crop.imageSize, cropExtent: crop.cropExtent, rotation: crop.rotation, + flip: crop.flip, adjustmentAngle: crop.adjustmentAngle, + perspectiveCorrection: crop.perspectiveCorrection, epsilon: epsilon ) } @@ -211,7 +215,9 @@ internal struct RenderCrop: Equatable, Sendable { imageSize: CGSize, cropExtent: CGRect, rotation: EditingCrop.Rotation = .angle_0, + flip: EditingCrop.Flip = [], adjustmentAngle: EditingCrop.AdjustmentAngle = .zero, + perspectiveCorrection: EditingCrop.PerspectiveCorrection = .identity, epsilon: CGFloat = Self.pixelEpsilon ) { let pixelImageSize = PixelDimensions(imageSize, epsilon: epsilon) @@ -223,19 +229,25 @@ internal struct RenderCrop: Equatable, Sendable { epsilon: epsilon ) self.rotation = rotation + self.flip = flip self.adjustmentAngle = adjustmentAngle + self.perspectiveCorrection = perspectiveCorrection } internal init( imageSize: PixelDimensions, cropRect: PixelCropRect, rotation: EditingCrop.Rotation = .angle_0, - adjustmentAngle: EditingCrop.AdjustmentAngle = .zero + flip: EditingCrop.Flip = [], + adjustmentAngle: EditingCrop.AdjustmentAngle = .zero, + perspectiveCorrection: EditingCrop.PerspectiveCorrection = .identity ) { self.imageSize = imageSize self.cropRect = cropRect self.rotation = rotation + self.flip = flip self.adjustmentAngle = adjustmentAngle + self.perspectiveCorrection = perspectiveCorrection } } diff --git a/Sources/BrightroomUI/Shared/Components/Crop/CropView._CropScrollView.swift b/Sources/BrightroomUI/Shared/Components/Crop/CropView._CropScrollView.swift index a2289f8a..f31bc295 100644 --- a/Sources/BrightroomUI/Shared/Components/Crop/CropView._CropScrollView.swift +++ b/Sources/BrightroomUI/Shared/Components/Crop/CropView._CropScrollView.swift @@ -21,6 +21,8 @@ import UIKit +import BrightroomEngine + extension CropView { /** @@ -78,11 +80,21 @@ extension CropView { } let imageView: UIImageView + var perspectiveCorrection: EditingCrop.PerspectiveCorrection = .identity { + didSet { + guard perspectiveCorrection != oldValue else { + return + } + + setNeedsLayout() + } + } var overlay: UIView? { didSet { oldValue?.removeFromSuperview() if let overlay { + overlay.layer.anchorPoint = .zero addSubview(overlay) } } @@ -92,6 +104,7 @@ extension CropView { self.imageView = _ImageView() super.init(frame: frame) + imageView.layer.anchorPoint = .zero addSubview(imageView) } @@ -101,14 +114,23 @@ extension CropView { override func layoutSubviews() { super.layoutSubviews() - imageView.frame = bounds - overlay?.frame = bounds + layoutPerspectiveContent(imageView) + if let overlay { + layoutPerspectiveContent(overlay) + } #if DEBUG layer.addSublayer(debugShapeLayer) debugShapeLayer.frame = bounds #endif } + private func layoutPerspectiveContent(_ view: UIView) { + view.layer.transform = CATransform3DIdentity + view.bounds = .init(origin: .zero, size: bounds.size) + view.layer.position = bounds.origin + view.layer.transform = perspectiveCorrection.displayLayerTransform(in: view.bounds) + } + func _debug_setPath(path: UIBezierPath) { #if DEBUG debugShapeLayer.path = path.cgPath @@ -118,3 +140,148 @@ extension CropView { } } + +private extension EditingCrop.PerspectiveCorrection { + + func displayLayerTransform(in bounds: CGRect) -> CATransform3D { + guard isIdentity == false, bounds.isEmpty == false else { + return CATransform3DIdentity + } + + let source = [ + CGPoint(x: bounds.minX, y: bounds.minY), + CGPoint(x: bounds.maxX, y: bounds.minY), + CGPoint(x: bounds.maxX, y: bounds.maxY), + CGPoint(x: bounds.minX, y: bounds.maxY), + ] + let targetQuadrilateral = displayTargetQuadrilateral(in: bounds) + let target = [ + targetQuadrilateral.topLeft, + targetQuadrilateral.topRight, + targetQuadrilateral.bottomRight, + targetQuadrilateral.bottomLeft, + ] + + guard let coefficients = ProjectiveTransformCoefficients(source: source, target: target) else { + return CATransform3DIdentity + } + + return coefficients.caTransform3D + } +} + +private struct ProjectiveTransformCoefficients { + + var a: CGFloat + var b: CGFloat + var c: CGFloat + var d: CGFloat + var e: CGFloat + var f: CGFloat + var g: CGFloat + var h: CGFloat + + var caTransform3D: CATransform3D { + .init( + m11: a, m12: d, m13: 0, m14: g, + m21: b, m22: e, m23: 0, m24: h, + m31: 0, m32: 0, m33: 1, m34: 0, + m41: c, m42: f, m43: 0, m44: 1 + ) + } + + init?( + source: [CGPoint], + target: [CGPoint] + ) { + guard source.count == 4, target.count == 4 else { + return nil + } + + var rows = Array( + repeating: Array(repeating: CGFloat.zero, count: 9), + count: 8 + ) + + for index in 0..<4 { + let sourcePoint = source[index] + let targetPoint = target[index] + let x = sourcePoint.x + let y = sourcePoint.y + let u = targetPoint.x + let v = targetPoint.y + let row = index * 2 + + rows[row] = [ + x, y, 1, + 0, 0, 0, + -u * x, -u * y, + u, + ] + rows[row + 1] = [ + 0, 0, 0, + x, y, 1, + -v * x, -v * y, + v, + ] + } + + guard let solution = Self.solve(rows) else { + return nil + } + + self.a = solution[0] + self.b = solution[1] + self.c = solution[2] + self.d = solution[3] + self.e = solution[4] + self.f = solution[5] + self.g = solution[6] + self.h = solution[7] + } + + private static func solve(_ augmentedRows: [[CGFloat]]) -> [CGFloat]? { + var rows = augmentedRows + let count = 8 + let epsilon: CGFloat = 1e-10 + + for column in 0.. pivotMagnitude { + pivotMagnitude = magnitude + pivotRow = candidateRow + } + } + + guard pivotMagnitude > epsilon else { + return nil + } + + if pivotRow != column { + rows.swapAt(pivotRow, column) + } + + let pivot = rows[column][column] + for index in column...count { + rows[column][index] /= pivot + } + + for row in 0.. epsilon else { + continue + } + + for index in column...count { + rows[row][index] -= factor * rows[column][index] + } + } + } + + return rows.map { $0[count] } + } +} diff --git a/Sources/BrightroomUI/Shared/Components/Crop/CropView.swift b/Sources/BrightroomUI/Shared/Components/Crop/CropView.swift index 19533a98..040aa721 100644 --- a/Sources/BrightroomUI/Shared/Components/Crop/CropView.swift +++ b/Sources/BrightroomUI/Shared/Components/Crop/CropView.swift @@ -386,6 +386,33 @@ final class CropView: UIView, UIScrollViewDelegate { setProposedCrop(crop) } + func setFlip(_ flip: EditingCrop.Flip) { + _pixeleditor_ensureMainThread() + + guard var crop = state.proposedCrop, crop.flip != flip else { + return + } + + crop.flip = flip + setProposedCrop(crop, forcesLayout: true) + } + + func toggleFlip(_ axis: EditingCrop.Flip) { + _pixeleditor_ensureMainThread() + + guard var crop = state.proposedCrop else { + return + } + + if crop.flip.contains(axis) { + crop.flip.remove(axis) + } else { + crop.flip.insert(axis) + } + + setProposedCrop(crop, forcesLayout: true) + } + func rotateClockwise() { _pixeleditor_ensureMainThread() @@ -419,6 +446,18 @@ final class CropView: UIView, UIScrollViewDelegate { record() } + func setPerspectiveCorrection(_ correction: EditingCrop.PerspectiveCorrection) { + _pixeleditor_ensureMainThread() + + guard var crop = state.proposedCrop, crop.perspectiveCorrection != correction else { + return + } + + crop.perspectiveCorrection = correction + setProposedCrop(crop, forcesLayout: true) + record() + } + func setCrop(_ crop: EditingCrop) { _pixeleditor_ensureMainThread() @@ -693,6 +732,7 @@ extension CropView { preferredAspectRatio: state.preferredAspectRatio, animated: areAnimationsEnabled && animationSourceCrop != nil /* whether first time load */, animatesRotation: animationSourceCrop?.rotation != crop.rotation + || animationSourceCrop?.flip != crop.flip ) lastLaidOutCrop = crop @@ -804,7 +844,7 @@ extension CropView { guideView.frame = contentRect - scrollView.transform = CGAffineTransform(rotationAngle: crop.aggregatedRotation.radians) + scrollView.transform = crop.cropDisplayTransform() updateScrollViewInset(crop: crop) @@ -812,22 +852,32 @@ extension CropView { do { let (min, max) = crop.calculateZoomScale( - visibleSize: guideView.bounds - .applying(CGAffineTransform(rotationAngle: crop.aggregatedRotation.radians)) - .size + visibleSize: guideView.bounds.size ) scrollView.minimumZoomScale = min scrollView.maximumZoomScale = max imagePlatterView.frame.origin = .zero + imagePlatterView.perspectiveCorrection = crop.perspectiveCorrection func _zoom() { scrollView.customZoom( to: crop.zoomExtent(), guideSize: guideView.bounds.size, - adjustmentRotation: crop.aggregatedRotation.radians, + adjustmentTransform: crop.cropDisplayTransform(), + contentInsetForZoomScale: { [weak self] zoomScale in + guard let self else { + return .zero + } + + return self.makeScrollViewInset( + displayTransform: crop.cropDisplayTransform(), + coveredContentRect: crop.perspectiveCoveredContentRect(), + zoomScale: zoomScale + ) + }, animated: false ) @@ -894,7 +944,11 @@ extension CropView { debounce.on { /* for debounce */ } } - private func makeScrollViewInset(aggregatedRotaion: CGFloat) -> UIEdgeInsets { + private func makeScrollViewInset( + displayTransform: CGAffineTransform, + coveredContentRect: CGRect, + zoomScale: CGFloat + ) -> UIEdgeInsets { let o: CGPoint = { @@ -929,27 +983,37 @@ extension CropView { .convert( guideView.bounds.applying( CGAffineTransform(translationX: -anchorOffset.x, y: -anchorOffset.y) - .concatenating(.init(rotationAngle: -aggregatedRotaion)) + .concatenating(displayTransform.inverted()) .concatenating(.init(translationX: anchorOffset.x, y: anchorOffset.y)) ), to: scrollBackdropView ) let bounds = scrollBackdropView.bounds + let contentSize = CGSize( + width: imagePlatterView.bounds.width * zoomScale, + height: imagePlatterView.bounds.height * zoomScale + ) + let coveredContentRect = coveredContentRect.applying(.init(scaleX: zoomScale, y: zoomScale)) let insetsForActual = UIEdgeInsets.init( - top: actualRect.minY, - left: actualRect.minX, - bottom: bounds.maxY - actualRect.maxY, - right: bounds.maxX - actualRect.maxX + top: actualRect.minY - coveredContentRect.minY, + left: actualRect.minX - coveredContentRect.minX, + bottom: bounds.maxY + coveredContentRect.maxY - actualRect.maxY - contentSize.height, + right: bounds.maxX + coveredContentRect.maxX - actualRect.maxX - contentSize.width ) return insetsForActual } - private func updateScrollViewInset(crop: EditingCrop) { + private func updateScrollViewInset( + crop: EditingCrop, + zoomScale: CGFloat? = nil + ) { scrollView.contentInset = makeScrollViewInset( - aggregatedRotaion: crop.aggregatedRotation.radians + displayTransform: crop.cropDisplayTransform(), + coveredContentRect: crop.perspectiveCoveredContentRect(), + zoomScale: zoomScale ?? scrollView.zoomScale ) } @@ -991,9 +1055,10 @@ extension CropView { let offsetY = croppingRect.midY - guideBackdropView.bounds.midY // move focusing area to center - scrollView.transform = CGAffineTransform(rotationAngle: crop.aggregatedRotation.radians) + let displayTransform = crop.cropDisplayTransform() + scrollView.transform = displayTransform .concatenating(.init(translationX: -offsetX, y: -offsetY)) - .concatenating(.init(rotationAngle: -crop.aggregatedRotation.radians)) + .concatenating(displayTransform.inverted()) // TODO: Find calculation way withoug using convert rect // To work correctly, ignoring transform temporarily. @@ -1044,17 +1109,17 @@ extension CropView { _ cropExtent: CGRect, currentCrop: EditingCrop ) -> CGRect { - guard let preferredAspectRatio = state.preferredAspectRatio else { - return cropExtent - } - - let imageBounds = CGRect(origin: .zero, size: currentCrop.imageSize) - let boundedCropExtent = imageBounds.intersection(cropExtent) + let coverageBounds = currentCrop.perspectiveCoveredImageRect() + let boundedCropExtent = coverageBounds.intersection(cropExtent) guard boundedCropExtent.isNull == false, boundedCropExtent.isEmpty == false else { return cropExtent } + guard let preferredAspectRatio = state.preferredAspectRatio else { + return boundedCropExtent + } + return preferredAspectRatio.rectThatFits(in: boundedCropExtent) } @@ -1138,6 +1203,7 @@ extension CropView { if let baselineCrop = scrollViewAdjustmentSession?.baselineCrop, let recordedCrop, + baselineCrop.perspectiveCorrection.isIdentity, baselineCrop.isRenderingEquivalent(to: recordedCrop) { setProposedCrop(baselineCrop) @@ -1176,6 +1242,10 @@ extension CropView { debugLogScrollViewAdjustment("did-zoom") + if let crop = state.proposedCrop { + updateScrollViewInset(crop: crop) + } + // TODO: consider if we need this. // adjustFrameToCenterOnZooming // do { @@ -1263,120 +1333,29 @@ extension CropView { return .zero } - let sourceInsets: UIEdgeInsets = { - - let guideViewRectInPlatter = guideView.convert(guideView.bounds, to: imagePlatterView) - - let scale = Geometry.diagonalRatio(to: guideView.bounds.size, from: guideViewRectInPlatter.size) - - let outbound = imagePlatterView.bounds - - let value = UIEdgeInsets( - top: guideViewRectInPlatter.minY - outbound.minY, - left: guideViewRectInPlatter.minX - outbound.minX, - bottom: outbound.maxY - guideViewRectInPlatter.maxY, - right: outbound.maxX - guideViewRectInPlatter.maxX - ) - -#if false - - let maxRectInPlatter = imagePlatterView.convert( - guideViewRectInPlatter.inset(by: value.inversed()), - to: imagePlatterView - ) - - let path = UIBezierPath() - path.append(.init(rect: guideViewRectInPlatter)) - path.append(.init(rect: maxRectInPlatter)) - - imagePlatterView._debug_setPath(path: path) - -#endif - - return value.multiplied(scale) - - }() - - var patternAngleDegree = crop.aggregatedRotation.degrees.truncatingRemainder(dividingBy: 360) - if patternAngleDegree > 0 { - patternAngleDegree -= 360 - } - - var resolvedInsets: UIEdgeInsets { - switch patternAngleDegree { - - case 0: - return sourceInsets - case -90: - - return .init( - top: sourceInsets.right, - left: sourceInsets.top, - bottom: sourceInsets.left, - right: sourceInsets.bottom - ) - - case -180: - - return .init( - top: sourceInsets.bottom, - left: sourceInsets.right, - bottom: sourceInsets.top, - right: sourceInsets.left - ) - - case -270: - - return .init( - top: sourceInsets.left, - left: sourceInsets.bottom, - bottom: sourceInsets.right, - right: sourceInsets.top - ) - - case -90..<0: - - return .init( - top: min(sourceInsets.top, sourceInsets.right), - left: min(sourceInsets.top, sourceInsets.left), - bottom: min(sourceInsets.bottom, sourceInsets.left), - right: min(sourceInsets.bottom, sourceInsets.right) - ) - - case -180..<(-90): - - return .init( - top: min(sourceInsets.bottom, sourceInsets.right), - left: min(sourceInsets.top, sourceInsets.right), - bottom: min(sourceInsets.top, sourceInsets.left), - right: min(sourceInsets.bottom, sourceInsets.left) - ) - - case -270..<(-180): - - return .init( - top: min(sourceInsets.bottom, sourceInsets.left), - left: min(sourceInsets.bottom, sourceInsets.right), - bottom: min(sourceInsets.top, sourceInsets.right), - right: min(sourceInsets.top, sourceInsets.left) - ) - - case -360..<(-270): - - return .init( - top: min(sourceInsets.top, sourceInsets.left), - left: min(sourceInsets.bottom, sourceInsets.left), - bottom: min(sourceInsets.bottom, sourceInsets.right), - right: min(sourceInsets.top, sourceInsets.right) - ) - - default: - return sourceInsets - } + let targetQuadrilateral = crop.perspectiveCorrection.displayTargetQuadrilateral( + in: imagePlatterView.bounds + ) + let projectedQuadrilateral = ProjectedQuadrilateral( + topLeft: imagePlatterView.convert(targetQuadrilateral.topLeft, to: self), + topRight: imagePlatterView.convert(targetQuadrilateral.topRight, to: self), + bottomRight: imagePlatterView.convert(targetQuadrilateral.bottomRight, to: self), + bottomLeft: imagePlatterView.convert(targetQuadrilateral.bottomLeft, to: self) + ) + let coveredRect = projectedQuadrilateral.axisAlignedInnerRect() + let guideRect = guideView.convert(guideView.bounds, to: self) + guard coveredRect.isEmpty == false else { + return .zero } - return resolvedInsets + return UIEdgeInsets( + top: guideRect.minY - coveredRect.minY, + left: guideRect.minX - coveredRect.minX, + bottom: coveredRect.maxY - guideRect.maxY, + right: coveredRect.maxX - guideRect.maxX + ) + .minZero() } } @@ -1425,6 +1404,14 @@ extension CGRect { ) } + fileprivate func applyingAroundCenter(_ transform: CGAffineTransform) -> CGRect { + let centeredTransform = CGAffineTransform(translationX: midX, y: midY) + .concatenating(transform) + .concatenating(.init(translationX: -midX, y: -midY)) + + return applying(centeredTransform) + } + } extension UIScrollView { @@ -1463,7 +1450,8 @@ extension UIScrollView { fileprivate func customZoom( to rect: CGRect, guideSize: CGSize, - adjustmentRotation: CGFloat, + adjustmentTransform: CGAffineTransform, + contentInsetForZoomScale: @escaping (CGFloat) -> UIEdgeInsets, animated: Bool ) { @@ -1474,12 +1462,16 @@ extension UIScrollView { let minXScale = boundSize.width / targetContentSize.width let minYScale = boundSize.height / targetContentSize.height - let targetScale = min(minXScale, minYScale) + let targetScale = min( + max(min(minXScale, minYScale), minimumZoomScale), + maximumZoomScale + ) setZoomScale(targetScale, animated: false) + contentInset = contentInsetForZoomScale(targetScale) var targetContentOffset = rect - .rotated(adjustmentRotation) + .applyingAroundCenter(adjustmentTransform) .applying(.init(scaleX: targetScale, y: targetScale)) .origin diff --git a/Sources/BrightroomUI/Shared/Components/Crop/SwiftUICropView.swift b/Sources/BrightroomUI/Shared/Components/Crop/SwiftUICropView.swift index 6ef52884..040d9c6f 100644 --- a/Sources/BrightroomUI/Shared/Components/Crop/SwiftUICropView.swift +++ b/Sources/BrightroomUI/Shared/Components/Crop/SwiftUICropView.swift @@ -124,7 +124,9 @@ public struct SwiftUICropView: View { private let editingStack: EditingStack private var rotationInput: Binding = .constant(nil) + private var flipInput: Binding = .constant(nil) private var adjustmentAngleInput: Binding = .constant(nil) + private var perspectiveCorrectionInput: Binding = .constant(nil) private var croppingAspectRatioInput: Binding = .constant(nil) private var _resetAction: ResetAction? private var _rotateAction: RotateAction? @@ -182,7 +184,9 @@ public struct SwiftUICropView: View { cropInsideOverlay: cropInsideOverlay, cropOutsideOverlay: cropOutsideOverlay, rotationInput: rotationInput, + flipInput: flipInput, adjustmentAngleInput: adjustmentAngleInput, + perspectiveCorrectionInput: perspectiveCorrectionInput, croppingAspectRatioInput: croppingAspectRatioInput, resetAction: _resetAction, rotateAction: _rotateAction, @@ -216,6 +220,16 @@ public struct SwiftUICropView: View { return self } + public consuming func flip(_ flip: EditingCrop.Flip?) -> Self { + self.flipInput = .constant(flip) + return self + } + + public consuming func flip(_ flip: Binding) -> Self { + self.flipInput = flip + return self + } + public consuming func adjustmentAngle(_ angle: EditingCrop.AdjustmentAngle?) -> Self { self.adjustmentAngleInput = .constant(angle) @@ -228,6 +242,16 @@ public struct SwiftUICropView: View { return self } + public consuming func perspectiveCorrection(_ correction: EditingCrop.PerspectiveCorrection?) -> Self { + self.perspectiveCorrectionInput = .constant(correction) + return self + } + + public consuming func perspectiveCorrection(_ correction: Binding) -> Self { + self.perspectiveCorrectionInput = correction + return self + } + public consuming func croppingAspectRatio(_ rect: PixelAspectRatio?) -> Self { self.croppingAspectRatioInput = .constant(rect) @@ -274,7 +298,9 @@ private struct LoadedCropViewRepresentable: UIViewControllerRepresentable { let cropInsideOverlay: ((SwiftUICropView.AdjustmentKind?) -> AnyView)? let cropOutsideOverlay: ((SwiftUICropView.AdjustmentKind?) -> AnyView)? let rotationInput: Binding + let flipInput: Binding let adjustmentAngleInput: Binding + let perspectiveCorrectionInput: Binding let croppingAspectRatioInput: Binding let resetAction: SwiftUICropView.ResetAction? let rotateAction: SwiftUICropView.RotateAction? @@ -339,10 +365,18 @@ private struct LoadedCropViewRepresentable: UIViewControllerRepresentable { cropView.setRotation(rotation) } + if let flip = flipInput.wrappedValue { + cropView.setFlip(flip) + } + if let adjustmentAngle = adjustmentAngleInput.wrappedValue { cropView.setAdjustmentAngle(adjustmentAngle) } + if let perspectiveCorrection = perspectiveCorrectionInput.wrappedValue { + cropView.setPerspectiveCorrection(perspectiveCorrection) + } + cropView.setCroppingAspectRatio(croppingAspectRatioInput.wrappedValue) } @@ -383,7 +417,9 @@ private struct LoadedCropViewRepresentable: UIViewControllerRepresentable { private func syncInputs(with snapshot: SwiftUICropView.StateSnapshot) { if let crop = snapshot.proposedCrop { rotationInput.setIfChanged(crop.rotation) + flipInput.setIfChanged(crop.flip) adjustmentAngleInput.setIfChanged(crop.adjustmentAngle) + perspectiveCorrectionInput.setIfChanged(crop.perspectiveCorrection) } croppingAspectRatioInput.setIfChanged(snapshot.preferredAspectRatio) } diff --git a/Sources/BrightroomUI/Shared/Utils/EditingCrop+.swift b/Sources/BrightroomUI/Shared/Utils/EditingCrop+.swift index fd81b5d2..7c831734 100644 --- a/Sources/BrightroomUI/Shared/Utils/EditingCrop+.swift +++ b/Sources/BrightroomUI/Shared/Utils/EditingCrop+.swift @@ -12,6 +12,24 @@ import CoreGraphics import BrightroomEngine extension EditingCrop { + func cropDisplayTransform() -> CGAffineTransform { + let scaleX: CGFloat = flip.contains(.horizontal) ? -1 : 1 + let scaleY: CGFloat = flip.contains(.vertical) ? -1 : 1 + + return CGAffineTransform(rotationAngle: aggregatedRotation.radians) + .concatenating(.init(scaleX: scaleX, y: scaleY)) + } + + func transformedVisibleSize(_ visibleSize: CGSize) -> CGSize { + let rect = CGRect(origin: .zero, size: visibleSize) + .applying(cropDisplayTransform()) + + return .init( + width: abs(rect.width), + height: abs(rect.height) + ) + } + func scrollViewContentSize() -> CGSize { // Use imageSize for masking view // imageSize @@ -26,9 +44,13 @@ extension EditingCrop { func calculateZoomScale(visibleSize: CGSize) -> (min: CGFloat, max: CGFloat) { - let contentSize = scrollViewContentSize() - let minXScale = visibleSize.width / contentSize.width - let minYScale = visibleSize.height / contentSize.height + let coveredSize = projectedContentCoverageRect().size + guard coveredSize.width > 0, coveredSize.height > 0 else { + return (min: 1, max: .greatestFiniteMagnitude) + } + + let minXScale = visibleSize.width / coveredSize.width + let minYScale = visibleSize.height / coveredSize.height /** max meaning scale aspect fill @@ -47,7 +69,7 @@ extension EditingCrop { let _cropExtent = cropExtent.applying(.init(scaleX: scaleFromOriginal, y: scaleFromOriginal)) - return _cropExtent + return _cropExtent.fitting(in: perspectiveCoveredContentRect()) } func makeCropExtent(rect: CGRect) -> CGRect { @@ -60,4 +82,219 @@ extension EditingCrop { return cropExtent.applying(.init(scaleX: scaleFromOriginal, y: scaleFromOriginal)) } + func perspectiveCoveredContentRect() -> CGRect { + let bounds = CGRect(origin: .zero, size: scrollViewContentSize()) + return perspectiveCorrection.axisAlignedCoverageRect(in: bounds) + } + + func perspectiveCoveredImageRect() -> CGRect { + let bounds = CGRect(origin: .zero, size: imageSize) + return perspectiveCorrection.axisAlignedCoverageRect(in: bounds) + } + + func projectedContentCoverageRect() -> CGRect { + let bounds = CGRect(origin: .zero, size: scrollViewContentSize()) + let quadrilateral = ProjectedQuadrilateral( + perspectiveCorrection.displayTargetQuadrilateral(in: bounds) + ) + + return quadrilateral + .applyingAroundCenter( + cropDisplayTransform(), + center: .init(x: bounds.midX, y: bounds.midY) + ) + .axisAlignedInnerRect() + } + +} + +struct ProjectedQuadrilateral { + + var topLeft: CGPoint + var topRight: CGPoint + var bottomRight: CGPoint + var bottomLeft: CGPoint + + init( + topLeft: CGPoint, + topRight: CGPoint, + bottomRight: CGPoint, + bottomLeft: CGPoint + ) { + self.topLeft = topLeft + self.topRight = topRight + self.bottomRight = bottomRight + self.bottomLeft = bottomLeft + } + + init(_ quadrilateral: EditingCrop.PerspectiveCorrection.Quadrilateral) { + self.init( + topLeft: quadrilateral.topLeft, + topRight: quadrilateral.topRight, + bottomRight: quadrilateral.bottomRight, + bottomLeft: quadrilateral.bottomLeft + ) + } + + var points: [CGPoint] { + [ + topLeft, + topRight, + bottomRight, + bottomLeft, + ] + } + + func applyingAroundCenter( + _ transform: CGAffineTransform, + center: CGPoint + ) -> Self { + let centeredTransform = CGAffineTransform(translationX: -center.x, y: -center.y) + .concatenating(transform) + .concatenating(.init(translationX: center.x, y: center.y)) + + return .init( + topLeft: topLeft.applying(centeredTransform), + topRight: topRight.applying(centeredTransform), + bottomRight: bottomRight.applying(centeredTransform), + bottomLeft: bottomLeft.applying(centeredTransform) + ) + } + + func axisAlignedInnerRect() -> CGRect { + let center = CGPoint( + x: points.reduce(0) { $0 + $1.x } / CGFloat(points.count), + y: points.reduce(0) { $0 + $1.y } / CGFloat(points.count) + ) + + let constraints = edgeConstraints(containing: center) + guard constraints.count >= 3 else { + return .zero + } + + let epsilon: CGFloat = 1e-6 + var bestHalfSize: CGSize = .zero + + func consider(halfWidth: CGFloat, halfHeight: CGFloat) { + guard + halfWidth > epsilon, + halfHeight > epsilon, + constraints.allSatisfy({ $0.contains(halfWidth: halfWidth, halfHeight: halfHeight) }) + else { + return + } + + if halfWidth * halfHeight > bestHalfSize.width * bestHalfSize.height { + bestHalfSize = .init(width: halfWidth, height: halfHeight) + } + } + + for firstIndex in constraints.indices { + for secondIndex in constraints.indices where firstIndex < secondIndex { + let first = constraints[firstIndex] + let second = constraints[secondIndex] + let determinant = first.xCoefficient * second.yCoefficient + - second.xCoefficient * first.yCoefficient + + guard abs(determinant) > epsilon else { + continue + } + + let halfWidth = ( + first.margin * second.yCoefficient + - second.margin * first.yCoefficient + ) / determinant + let halfHeight = ( + first.xCoefficient * second.margin + - second.xCoefficient * first.margin + ) / determinant + + consider(halfWidth: halfWidth, halfHeight: halfHeight) + } + } + + for constraint in constraints + where constraint.xCoefficient > epsilon && constraint.yCoefficient > epsilon + { + consider( + halfWidth: constraint.margin / (constraint.xCoefficient * 2), + halfHeight: constraint.margin / (constraint.yCoefficient * 2) + ) + } + + guard bestHalfSize.width > 0, bestHalfSize.height > 0 else { + return .zero + } + + return .init( + x: center.x - bestHalfSize.width, + y: center.y - bestHalfSize.height, + width: bestHalfSize.width * 2, + height: bestHalfSize.height * 2 + ) + } + + private func edgeConstraints(containing point: CGPoint) -> [EdgeConstraint] { + let epsilon: CGFloat = 1e-6 + let points = points + + return points.indices.compactMap { index in + let start = points[index] + let end = points[(index + 1) % points.count] + let edge = CGPoint(x: end.x - start.x, y: end.y - start.y) + + var xCoefficient = -edge.y + var yCoefficient = edge.x + var constant = edge.y * start.x - edge.x * start.y + + if xCoefficient * point.x + yCoefficient * point.y + constant < 0 { + xCoefficient = -xCoefficient + yCoefficient = -yCoefficient + constant = -constant + } + + let margin = xCoefficient * point.x + yCoefficient * point.y + constant + guard margin > epsilon else { + return nil + } + + return .init( + xCoefficient: abs(xCoefficient), + yCoefficient: abs(yCoefficient), + margin: margin + ) + } + } +} + +private struct EdgeConstraint { + var xCoefficient: CGFloat + var yCoefficient: CGFloat + var margin: CGFloat + + func contains(halfWidth: CGFloat, halfHeight: CGFloat) -> Bool { + let tolerance: CGFloat = 1e-4 + return xCoefficient * halfWidth + yCoefficient * halfHeight <= margin + tolerance + } +} + +private extension CGRect { + + func fitting(in bounds: CGRect) -> CGRect { + guard bounds.isNull == false, bounds.isEmpty == false else { + return self + } + + let width = min(size.width, bounds.width) + let height = min(size.height, bounds.height) + let minX = min(max(origin.x, bounds.minX), bounds.maxX - width) + let minY = min(max(origin.y, bounds.minY), bounds.maxY - height) + + return .init( + x: minX, + y: minY, + width: width, + height: height + ) + } } diff --git a/Sources/BrightroomUI/builtin/PhotosCrop/PhotosCropContentView.swift b/Sources/BrightroomUI/builtin/PhotosCrop/PhotosCropContentView.swift index 78c7edf2..6b5d848b 100644 --- a/Sources/BrightroomUI/builtin/PhotosCrop/PhotosCropContentView.swift +++ b/Sources/BrightroomUI/builtin/PhotosCrop/PhotosCropContentView.swift @@ -33,7 +33,10 @@ struct PhotosCropContentView: View { let onCancel: @MainActor () -> Void @State private var rotation: EditingCrop.Rotation? + @State private var flip: EditingCrop.Flip? @State private var adjustmentAngle: EditingCrop.AdjustmentAngle? + @State private var perspectiveCorrection: EditingCrop.PerspectiveCorrection? + @State private var adjustmentMode: PhotosCropAdjustmentMode = .straighten @State private var aspectRatioSelection: PhotosCropAspectRatioSelection @State private var isSelectingAspectRatio = false @State private var resetAction = SwiftUICropView.ResetAction() @@ -65,7 +68,7 @@ struct PhotosCropContentView: View { let loadedState = editingStack.loadedState let originalAspectRatio = loadedState.map { PixelAspectRatio($0.imageSize) } let isLoaded = loadedState != nil - let bottomControlHeight: CGFloat = 112 + let bottomControlHeight: CGFloat = 124 let bottomControlMaxWidth: CGFloat = 560 NavigationStack { @@ -79,7 +82,9 @@ struct PhotosCropContentView: View { isAutoApplyEditingStackEnabled: true ) .rotation($rotation) + .flip($flip) .adjustmentAngle($adjustmentAngle) + .perspectiveCorrection($perspectiveCorrection) .croppingAspectRatio(croppingAspectRatioBinding(originalAspectRatio: originalAspectRatio)) .registerResetAction(resetAction) .registerRotateAction(rotateAction) @@ -91,10 +96,13 @@ struct PhotosCropContentView: View { aspectRatioSelection: aspectRatioSelection, localizedStrings: localizedStrings, adjustmentAngle: adjustmentAngle, + perspectiveCorrection: perspectiveCorrection, + adjustmentMode: $adjustmentMode, isSelectingAspectRatio: isSelectingAspectRatio, isLoaded: isLoaded, onSelectAspectRatio: selectAspectRatio, - onSetAdjustmentAngle: setAdjustmentAngle + onSetAdjustmentAngle: setAdjustmentAngle, + onSetPerspectiveCorrection: setPerspectiveCorrection ) .frame(maxWidth: bottomControlMaxWidth) .frame(maxWidth: .infinity) @@ -114,7 +122,10 @@ struct PhotosCropContentView: View { isDoneEnabled: isLoaded, isAspectRatioControlAvailable: isAspectRatioControlAvailable, isSelectingAspectRatio: isSelectingAspectRatio, + flip: flip ?? [], onRotate: rotate, + onToggleHorizontalFlip: toggleHorizontalFlip, + onToggleVerticalFlip: toggleVerticalFlip, onReset: reset, onToggleAspectRatio: toggleAspectRatioControl, onCancel: onCancel, @@ -147,7 +158,29 @@ struct PhotosCropContentView: View { rotateAction() } + private func toggleHorizontalFlip() { + toggleFlip(.horizontal) + } + + private func toggleVerticalFlip() { + toggleFlip(.vertical) + } + + private func toggleFlip(_ axis: EditingCrop.Flip) { + var nextFlip = flip ?? [] + if nextFlip.contains(axis) { + nextFlip.remove(axis) + } else { + nextFlip.insert(axis) + } + flip = nextFlip + } + private func reset() { + rotation = .angle_0 + flip = [] + adjustmentAngle = .zero + perspectiveCorrection = .identity resetAction() } @@ -173,6 +206,21 @@ struct PhotosCropContentView: View { adjustmentAngle = angle } + private func setPerspectiveCorrection( + _ value: Double, + axis: PhotosCropPerspectiveAxis + ) { + let normalizedValue = CGFloat(value / 100) + let current = perspectiveCorrection ?? .identity + + switch axis { + case .vertical: + perspectiveCorrection = current.settingVertical(normalizedValue) + case .horizontal: + perspectiveCorrection = current.settingHorizontal(normalizedValue) + } + } + private func finish() { applyAction() onDone() @@ -200,14 +248,17 @@ private struct PhotosCropToolbar: ToolbarContent { let isDoneEnabled: Bool let isAspectRatioControlAvailable: Bool let isSelectingAspectRatio: Bool + let flip: EditingCrop.Flip let onRotate: () -> Void + let onToggleHorizontalFlip: () -> Void + let onToggleVerticalFlip: () -> Void let onReset: () -> Void let onToggleAspectRatio: () -> Void let onCancel: () -> Void let onDone: () -> Void var body: some ToolbarContent { - ToolbarItem(placement: .topBarLeading) { + ToolbarItemGroup(placement: .topBarLeading) { PhotosCropToolbarIconButton( systemName: "rotate.left", accessibilityLabel: "Rotate", @@ -216,6 +267,24 @@ private struct PhotosCropToolbar: ToolbarContent { isHighlighted: false, action: onRotate ) + + PhotosCropToolbarIconButton( + systemName: "arrow.left.and.right", + accessibilityLabel: "Flip Horizontal", + accessibilityIdentifier: "photos.crop.flip.horizontal", + isEnabled: isLoaded, + isHighlighted: flip.contains(.horizontal), + action: onToggleHorizontalFlip + ) + + PhotosCropToolbarIconButton( + systemName: "arrow.up.and.down", + accessibilityLabel: "Flip Vertical", + accessibilityIdentifier: "photos.crop.flip.vertical", + isEnabled: isLoaded, + isHighlighted: flip.contains(.vertical), + action: onToggleVerticalFlip + ) } ToolbarItem(placement: .principal) { @@ -346,16 +415,56 @@ private struct PhotosCropToolbarTextButton: View { } } +private enum PhotosCropAdjustmentMode: String, CaseIterable, Identifiable { + case straighten + case vertical + case horizontal + + var id: Self { + self + } + + var title: String { + switch self { + case .straighten: + return "Straighten" + case .vertical: + return "Vertical" + case .horizontal: + return "Horizontal" + } + } + + var accessibilityLabel: String { + switch self { + case .straighten: + return "Rotation" + case .vertical: + return "Vertical Perspective" + case .horizontal: + return "Horizontal Perspective" + } + } +} + +private enum PhotosCropPerspectiveAxis { + case vertical + case horizontal +} + private struct PhotosCropAdjustmentControl: View { let originalAspectRatio: PixelAspectRatio? let aspectRatioSelection: PhotosCropAspectRatioSelection let localizedStrings: SwiftUIPhotosCropView.LocalizedStrings let adjustmentAngle: EditingCrop.AdjustmentAngle? + let perspectiveCorrection: EditingCrop.PerspectiveCorrection? + @Binding var adjustmentMode: PhotosCropAdjustmentMode let isSelectingAspectRatio: Bool let isLoaded: Bool let onSelectAspectRatio: (PhotosCropAspectRatioSelection) -> Void let onSetAdjustmentAngle: (Double) -> Void + let onSetPerspectiveCorrection: (Double, PhotosCropPerspectiveAxis) -> Void var body: some View { Group { @@ -368,37 +477,101 @@ private struct PhotosCropAdjustmentControl: View { ) .transition(.opacity) } else { - PhotosCropRotationSlider( - value: adjustmentAngle?.degrees ?? 0, - isEnabled: isLoaded, - onChange: onSetAdjustmentAngle - ) + VStack(spacing: 8) { + PhotosCropAdjustmentModePicker(selection: $adjustmentMode) + + PhotosCropAdjustmentSlider( + value: sliderValue, + range: sliderRange, + stepCount: sliderStepCount, + accessibilityLabel: adjustmentMode.accessibilityLabel, + isEnabled: isLoaded, + onChange: setSliderValue + ) + } + .padding(.top, 8) .transition(.opacity) } } .animation(.spring(response: 0.35, dampingFraction: 1), value: isSelectingAspectRatio) } + + private var sliderValue: Double { + switch adjustmentMode { + case .straighten: + return adjustmentAngle?.degrees ?? 0 + case .vertical: + return Double((perspectiveCorrection ?? .identity).vertical * 100) + case .horizontal: + return Double((perspectiveCorrection ?? .identity).horizontal * 100) + } + } + + private var sliderRange: ClosedRange { + switch adjustmentMode { + case .straighten: + return -45...45 + case .vertical, .horizontal: + return -100...100 + } + } + + private var sliderStepCount: Int { + switch adjustmentMode { + case .straighten: + return 90 + case .vertical, .horizontal: + return 200 + } + } + + private func setSliderValue(_ value: Double) { + switch adjustmentMode { + case .straighten: + onSetAdjustmentAngle(value) + case .vertical: + onSetPerspectiveCorrection(value, .vertical) + case .horizontal: + onSetPerspectiveCorrection(value, .horizontal) + } + } +} + +private struct PhotosCropAdjustmentModePicker: View { + + @Binding var selection: PhotosCropAdjustmentMode + + var body: some View { + Picker("Adjustment", selection: $selection) { + ForEach(PhotosCropAdjustmentMode.allCases) { mode in + Text(mode.title) + .tag(mode) + } + } + .pickerStyle(.segmented) + .frame(maxWidth: 360) + .padding(.horizontal, 24) + .accessibilityIdentifier("photos.crop.adjustment.mode") + } } -private struct PhotosCropRotationSlider: View { +private struct PhotosCropAdjustmentSlider: View { let value: Double + let range: ClosedRange + let stepCount: Int + let accessibilityLabel: String let isEnabled: Bool let onChange: (Double) -> Void var body: some View { BrightroomSteppedSlider( value: valueBinding, - range: -45...45, - stepCount: 90, - style: .photosCropRotationSlider, - resetValue: 0, + range: range, + stepCount: stepCount, + style: .photosCropAdjustmentSlider, transform: { source in - if (-PhotosCropRotationSliderMetrics.neutralDeadZoneDegrees...PhotosCropRotationSliderMetrics.neutralDeadZoneDegrees).contains(source) { - return 0 - } - - return source.rounded(.toNearestOrEven) + source.rounded(.toNearestOrEven) }, hapticIdentity: { value in let degree = Int(value.rounded(.toNearestOrEven)) @@ -419,13 +592,13 @@ private struct PhotosCropRotationSlider: View { } ) .tint(.white) + .accentColor(.white) .frame(height: 50) .padding(.horizontal, 24) .frame(maxWidth: .infinity, maxHeight: .infinity) .opacity(isEnabled ? 1 : 0.5) .disabled(!isEnabled) - .accessibilityLabel("Rotation") - .environment(\.colorScheme, .dark) + .accessibilityLabel(accessibilityLabel) } private var valueBinding: Binding { @@ -442,16 +615,12 @@ private struct PhotosCropRotationSlider: View { } } -private enum PhotosCropRotationSliderMetrics { - static let neutralDeadZoneDegrees: Double = 2.5 -} - private extension BrightroomSteppedSliderStyle { - static let photosCropRotationSlider = BrightroomSteppedSliderStyle( - tickWidth: 2, + static let photosCropAdjustmentSlider = BrightroomSteppedSliderStyle( + tickWidth: 1, tickSpacing: 4, tickHeight: 10, - activeTickWidth: 3, + activeTickWidth: nil, activeTickHeight: 18, majorTickInterval: 5 ) diff --git a/docs/MIGRATION_VERGE_TO_STATEGRAPH.md b/docs/MIGRATION_VERGE_TO_STATEGRAPH.md new file mode 100644 index 00000000..c8703048 --- /dev/null +++ b/docs/MIGRATION_VERGE_TO_STATEGRAPH.md @@ -0,0 +1,286 @@ +# Migration Plan: Verge → swift-state-graph + +## Overview + +Migrate Brightroom's state management from Verge to [swift-state-graph](https://github.com/VergeGroup/swift-state-graph). + +- **Files to migrate**: 29 files +- **Modules**: BrightroomEngine, BrightroomUI, BrightroomUIPhotosCrop +- **swift-state-graph version**: `0.17.0` (exact) + +--- + +## API Mapping + +| Verge | swift-state-graph | Notes | +|-------|-------------------|-------| +| `Store` | `@GraphStored` properties | No separate container needed | +| `UIStateStore` | `@MainActor` class + `@GraphStored` | For UI-bound state | +| `StoreDriverType` | Remove | Direct property access | +| `.commit { $0.prop = val }` | `object.prop = val` | Direct assignment | +| `.sinkState { }` | `withGraphTracking { }` | Basic observation | +| `.ifChanged(\.keyPath).do { }` | `withGraphTrackingMap { } onChange: { }` | Granular observation | +| `Changes` | N/A | Use `withGraphTrackingMap` | +| `@Edge` | `@GraphStored` | Change-detection property | +| `VergeAnyCancellable` | `AnyCancellable` / Task | Cancellation management | + +--- + +## Migration Pattern Examples + +### Before (Verge) +```swift +open class EditingStack: StoreDriverType { + public let store: Store + + public struct State: Equatable { + public fileprivate(set) var hasStartedEditing = false + public fileprivate(set) var loadedState: Loaded? + } + + func doSomething() { + store.commit { $0.hasStartedEditing = true } + } +} + +// Observation +editingStack.sinkState { state in + state.ifChanged(\.loadedState).do { loaded in + updateUI(loaded) + } +}.store(in: &subscriptions) +``` + +### After (swift-state-graph) +```swift +open class EditingStack: Hashable { + @GraphStored public var hasStartedEditing = false + @GraphStored public var loadedState: Loaded? + + func doSomething() { + hasStartedEditing = true // Direct assignment + } +} + +// Observation +subscription = withGraphTracking { + withGraphTrackingMap { + editingStack.loadedState + } onChange: { [weak self] loaded in + self?.updateUI(loaded) + } +} +``` + +--- + +## EditingStack Isolation Design + +```swift +// Maintain current behavior: nonisolated nonsendable +open class EditingStack: Hashable { + // Keep DispatchQueue-based concurrency + private let backgroundQueue = DispatchQueue(...) + + @GraphStored public var hasStartedEditing = false + @GraphStored public var loadedState: Loaded? +} +``` + +**Design decisions**: +- Keep `nonisolated nonsendable` (no change from current) +- No Actor isolation +- Continue using `DispatchQueue`-based concurrency patterns +- Remove `StoreDriverType` protocol conformance + +--- + +## Migration Order + +### Phase 1: BrightroomEngine (Foundation) + +| Order | File | Complexity | +|-------|------|------------| +| 1.1 | `Sources/BrightroomEngine/DataSource/ImageSource.swift` | Low | +| 1.2 | `Sources/BrightroomEngine/DataSource/ImageProvider.swift` | High | +| 1.3 | `Sources/BrightroomEngine/Core/EditingStack.swift` | Very High | + +**EditingStack is critical** - All UI components depend on it + +### Phase 2: BrightroomUI Shared Components + +| Order | File | Complexity | +|-------|------|------------| +| 2.1 | `Sources/BrightroomUI/Shared/Components/Crop/CropView.swift` | Very High | +| 2.2 | `Sources/BrightroomUI/Shared/Components/Crop/SwiftUICropView.swift` | Medium | +| 2.3 | `Sources/BrightroomUI/Shared/Components/ImageViews/ImagePreviewView.swift` | Medium | +| 2.4 | `Sources/BrightroomUI/Shared/Components/Drawing/CanvasView.swift` | Medium | +| 2.5 | `Sources/BrightroomUI/Shared/Components/Drawing/BlurryMaskingView.swift` | High | + +### Phase 3: ClassicImageEdit + +| Order | File | Complexity | +|-------|------|------------| +| 3.1 | `ClassicImageEditViewModel.swift` | High | +| 3.2 | `ClassicImageEditControlViewBase.swift` | Medium | +| 3.3 | `ClassicImageEditViewController.swift` | High | +| 3.4 | `ClassicImageEditPresetListControl.swift` | Medium | +| 3.5 | `ClassicImageEditEditMenuControlView.swift` | Medium | +| 3.6-3.16 | FilterControl files (11 files) | Low | + +FilterControls: Exposure, Contrast, Saturation, Highlights, Shadows, Temperature, Fade, Clarity, Sharpen, Vignette, GaussianBlur + +### Phase 4: PhotosCrop + +| Order | File | Complexity | +|-------|------|------------| +| 4.1 | `PhotosCropAspectRatioControl.swift` | Medium | +| 4.2 | `PhotosCropViewController.swift` | High | + +### Phase 5: BrightroomUIPhotosCrop + +| Order | File | Complexity | +|-------|------|------------| +| 5.1 | `PhotosCropRotating.swift` | Medium | + +### Phase 6: Tests + +| Order | File | +|-------|------| +| 6.1 | `Dev/Tests/BrightroomEngineTests/LoadingTests.swift` | +| 6.2 | `Dev/Tests/BrightroomEngineTests/RendererTests.swift` | + +--- + +## Package.swift Changes + +```swift +// Before +.package(url: "https://github.com/VergeGroup/Verge", from: "14.0.0-beta.7"), + +// After +.package(url: "https://github.com/VergeGroup/swift-state-graph", exact: "0.17.0"), + +// Target dependencies +.target(name: "BrightroomEngine", dependencies: ["StateGraph"]), +.target(name: "BrightroomUI", dependencies: ["BrightroomEngine", "StateGraph", "TransitionPatch"]), +``` + +--- + +## Technical Challenges and Solutions + +### 1. Multi-threaded State Management +**Challenge**: EditingStack uses backgroundQueue for image processing while UI observes on main thread + +**Solution**: +- Keep DispatchQueue-based concurrency +- Use `withGraphTracking` callbacks dispatch appropriately +- Separate UI state updates from heavy processing if needed + +### 2. Changes Pattern +**Challenge**: `.ifChanged(\.keyPath).do { }` is heavily used throughout the codebase + +**Solution**: Use `withGraphTrackingMap` for equivalent functionality +```swift +withGraphTrackingMap { editingStack.loadedState?.currentEdit.crop } onChange: { crop in + // Called only when crop changes +} +``` + +### 3. Optional State Unwrapping +**Challenge**: `state.mapIfPresent(\.loadedState)` pattern is common + +**Solution**: Use conditional observation or computed properties +```swift +withGraphTracking { + guard let loaded = editingStack.loadedState else { return } + // Work with loaded state +} +``` + +### 4. Nested State Structs +**Challenge**: Deep nesting like `EditingStack.State.Loaded` + +**Solution**: +- Option 1: Flatten into `@GraphStored` properties +- Option 2: Keep `Loaded` as a struct wrapped in `@GraphStored` + +--- + +## Verification + +### Unit Tests +```bash +cd Dev && xcodebuild test -scheme BrightroomEngineTests -destination 'platform=iOS Simulator,name=iPhone 16' +``` + +### Demo App Verification +```bash +open Dev/Brightroom.xcodeproj +# Run both UIKit Demo and SwiftUI Demo +``` + +### Verification Checklist +- [ ] Image loading (URL, Data, PHAsset, UIImage) +- [ ] Filter application (all 11 filters) +- [ ] Crop operations (rotate, aspect ratio, freeform) +- [ ] Undo/Redo +- [ ] Blur masking drawing +- [ ] Final image rendering +- [ ] No memory leaks + +--- + +## Critical Files + +1. **EditingStack.swift** - Core state container, all UI depends on it +2. **ImageProvider.swift** - Image loading state, uses `@Edge` +3. **CropView.swift** - Most complex UI component, uses `UIStateStore` +4. **ClassicImageEditViewModel.swift** - Uses `assign(to: assignee)` pattern +5. **ClassicImageEditControlViewBase.swift** - Base class for all FilterControls + +--- + +## Migration Progress + +### Completed +- [x] Package.swift - Updated dependency +- [x] ImageProvider.swift - Converted to @GraphStored +- [x] EditingStack.swift - Converted to @GraphStored, kept nonisolated nonsendable +- [x] ImageSource.swift - Removed Verge import +- [x] ImageTool.swift - Updated type references +- [x] CropView.swift - Converted UIStateStore to @GraphStored +- [x] SwiftUICropView.swift - Updated observation patterns +- [x] ImagePreviewView.swift - Converted to @GraphStored +- [x] CanvasView.swift - Converted to @GraphStored +- [x] BlurryMaskingView.swift - Converted to @GraphStored +- [x] ClassicImageEditViewModel.swift - Removed State struct, flattened to @GraphStored properties +- [x] ClassicImageEditControlViewBase.swift - Updated base class +- [x] ClassicImageEditViewController.swift - Updated observation +- [x] ClassicImageEditPresetListControl.swift - Converted +- [x] ClassicImageEditEditMenuControlView.swift - Converted +- [x] ClassicImageEditFilterControlBase.swift - Updated base class +- [x] ClassicImageEditExposureControl.swift - Converted +- [x] ClassicImageEditContrastControl.swift - Converted +- [x] ClassicImageEditSaturationControl.swift - Converted +- [x] ClassicImageEditHighlightsControl.swift - Converted +- [x] ClassicImageEditShadowsControl.swift - Converted +- [x] ClassicImageEditTemperatureControl.swift - Converted +- [x] ClassicImageEditFadeControl.swift - Converted +- [x] ClassicImageEditClarityControl.swift - Converted +- [x] ClassicImageEditSharpenControl.swift - Converted +- [x] ClassicImageEditVignetteControl.swift - Converted +- [x] ClassicImageEditGaussianBlurControl.swift - Converted +- [x] PhotosCropViewController.swift - Converted +- [x] PhotosCropAspectRatioControl.swift - Converted +- [x] PhotosCropRotating.swift - Converted + +### Test Files +- [x] LoadingTests.swift - Updated observation pattern +- [x] RendererTests.swift - Updated import + +### Pending +- [ ] Build verification (requires Xcode macro approval) +- [ ] Run unit tests +- [ ] Demo app testing