From 75ab2e1819b08a21015004c77c6291fa3ceeca52 Mon Sep 17 00:00:00 2001 From: Geoff Pado Date: Mon, 17 Jun 2024 13:45:44 -0700 Subject: [PATCH 01/16] Fix recognitions that overlap with detections --- .../Geometry/Sources/CGPathExtensions.swift | 78 ++++++++++++------- .../Capabilities/Geometry/Sources/Shape.swift | 41 ++++------ .../Observations/CharacterObservation.swift | 5 ++ .../RecognizedTextObservation.swift | 4 + .../PhotoEditingObservationDebugLayer.swift | 1 + .../PhotoEditingObservationDebugView.swift | 4 +- .../PhotoEditingObservationCalculator.swift | 31 ++++---- ...PhotoEditingSystemIntersectionFinder.swift | 4 +- 8 files changed, 98 insertions(+), 70 deletions(-) diff --git a/Modules/Capabilities/Geometry/Sources/CGPathExtensions.swift b/Modules/Capabilities/Geometry/Sources/CGPathExtensions.swift index dcf8ebfa..a9bbb41d 100644 --- a/Modules/Capabilities/Geometry/Sources/CGPathExtensions.swift +++ b/Modules/Capabilities/Geometry/Sources/CGPathExtensions.swift @@ -3,16 +3,39 @@ import CoreGraphics +struct PathElement { + let points: [CGPoint] + let type: CGPathElementType + + init(elementPointer: UnsafePointer) { + let element = elementPointer.pointee + type = element.type + points = Array(UnsafeBufferPointer(start: element.points, count: type.pointCount)) + } +} + +extension CGPathElementType { + var pointCount: Int { + switch self { + case .closeSubpath: 0 + case .moveToPoint, .addLineToPoint: 1 + case .addQuadCurveToPoint: 2 + case .addCurveToPoint: 3 + @unknown default: 0 + } + } +} + public extension CGPath { func isEqual(to otherPath: CGPath, accuracy: Double) -> Bool { - var ourPathElements = [CGPathElement]() + var ourPathElements = [PathElement]() applyWithBlock { elementPointer in - ourPathElements.append(elementPointer.pointee) + ourPathElements.append(PathElement(elementPointer: elementPointer)) } - var otherPathElements = [CGPathElement]() + var otherPathElements = [PathElement]() otherPath.applyWithBlock { elementPointer in - otherPathElements.append(elementPointer.pointee) + otherPathElements.append(PathElement(elementPointer: elementPointer)) } return ourPathElements.elementsEqual(otherPathElements) { ourElement, otherElement in @@ -49,35 +72,32 @@ public extension CGPath { } } - func svg(color: String) -> String { - var string = "" - applyWithBlock { elementPointer in - let element = elementPointer.pointee - let elementType = element.type - switch elementType { + func area() -> CGFloat { + var area: CGFloat = 0.0 + var firstPoint: CGPoint? + var previousPoint: CGPoint? + + self.applyWithBlock { element in + let points = element.pointee.points + switch element.pointee.type { case .moveToPoint: - string.append("M ") - let elementPoint = element.points.pointee - string.append("\(elementPoint.x),\(elementPoint.y)") - string.append("\n") + firstPoint = points[0] + previousPoint = points[0] case .addLineToPoint: - string.append("L ") - let elementPoint = element.points.pointee - string.append("\(elementPoint.x),\(elementPoint.y)") - string.append("\n") - case .addCurveToPoint: - string.append("C ") - string.append("\n") - #warning("#60: Finish implementing this") - case .addQuadCurveToPoint: - string.append("Q ") - string.append("\n") - #warning("#60: Finish implementing this") + if let previous = previousPoint { + area += (previous.x * points[0].y) - (points[0].x * previous.y) + } + previousPoint = points[0] case .closeSubpath: - string.append("Z\n") - @unknown default: break + if let first = firstPoint, let previous = previousPoint { + area += (previous.x * first.y) - (first.x * previous.y) + } + previousPoint = firstPoint + default: + break } } - return "" + + return abs(area) / 2.0 } } diff --git a/Modules/Capabilities/Geometry/Sources/Shape.swift b/Modules/Capabilities/Geometry/Sources/Shape.swift index e7879ddd..ec7fe1d1 100644 --- a/Modules/Capabilities/Geometry/Sources/Shape.swift +++ b/Modules/Capabilities/Geometry/Sources/Shape.swift @@ -10,6 +10,10 @@ public struct Shape: Hashable { public let topLeft: CGPoint public let topRight: CGPoint + public init() { + self = .zero + } + public init(bottomLeft: CGPoint, bottomRight: CGPoint, topLeft: CGPoint, topRight: CGPoint) { self.bottomLeft = bottomLeft self.bottomRight = bottomRight @@ -68,30 +72,19 @@ public struct Shape: Hashable { } public func union(_ other: Shape) -> Shape { - let transform = CGAffineTransformMakeRotation(angle) - - let ourRotatedCenterLeft = centerLeft.applying(transform) - let otherRotatedCenterLeft = other.centerLeft.applying(transform) - let ourRotatedCenterRight = centerRight.applying(transform) - let otherRotatedCenterRight = other.centerRight.applying(transform) - - // rightyTighty by @KaenAitch on 2/3/22 - // the left-most shape - let rightyTighty: Shape - if ourRotatedCenterLeft.x < otherRotatedCenterLeft.x { - rightyTighty = self - } else { - rightyTighty = other - } - - let rightMostShape: Shape - if ourRotatedCenterRight.x > otherRotatedCenterRight.x { - rightMostShape = self - } else { - rightMostShape = other - } - - return Shape(bottomLeft: rightyTighty.bottomLeft, bottomRight: rightMostShape.bottomRight, topLeft: rightyTighty.topLeft, topRight: rightMostShape.topRight) + let allPoints = [self.bottomLeft, self.bottomRight, self.topLeft, self.topRight, other.bottomLeft, other.bottomRight, other.topLeft, other.topRight] + + let minX = allPoints.map { $0.x }.min() ?? 0 + let minY = allPoints.map { $0.y }.min() ?? 0 + let maxX = allPoints.map { $0.x }.max() ?? 0 + let maxY = allPoints.map { $0.y }.max() ?? 0 + + return Shape( + bottomLeft: CGPoint(x: minX, y: maxY), + bottomRight: CGPoint(x: maxX, y: maxY), + topLeft: CGPoint(x: minX, y: minY), + topRight: CGPoint(x: maxX, y: minY) + ) } static let zero = Shape(bottomLeft: .zero, bottomRight: .zero, topLeft: .zero, topRight: .zero) diff --git a/Modules/Capabilities/Observations/Sources/Observations/CharacterObservation.swift b/Modules/Capabilities/Observations/Sources/Observations/CharacterObservation.swift index 28ea643a..4f044964 100644 --- a/Modules/Capabilities/Observations/Sources/Observations/CharacterObservation.swift +++ b/Modules/Capabilities/Observations/Sources/Observations/CharacterObservation.swift @@ -15,4 +15,9 @@ public struct CharacterObservation: Hashable, RedactableObservation { public let textObservationUUID: UUID public var characterObservations: [CharacterObservation] { [self] } + + public init(bounds: Shape, textObservationUUID: UUID) { + self.bounds = bounds + self.textObservationUUID = textObservationUUID + } } diff --git a/Modules/Capabilities/Observations/Sources/Observations/RecognizedTextObservation.swift b/Modules/Capabilities/Observations/Sources/Observations/RecognizedTextObservation.swift index 7e8ff70a..34ed6657 100644 --- a/Modules/Capabilities/Observations/Sources/Observations/RecognizedTextObservation.swift +++ b/Modules/Capabilities/Observations/Sources/Observations/RecognizedTextObservation.swift @@ -29,6 +29,10 @@ public struct RecognizedTextObservation: TextObservation, RedactableObservation guard let characterShapeThing = try? visionText.boundingBox(for: characterRange) else { return nil } let shape = Shape(characterShapeThing).scaled(to: imageSize) return CharacterObservation(bounds: shape, textObservationUUID: recognizedText.uuid) + }.reduce(into: [CharacterObservation]()) { uniqueObservations, observation in + if !uniqueObservations.contains(where: { $0.bounds == observation.bounds }) { + uniqueObservations.append(observation) + } } } diff --git a/Modules/Legacy/Editing/Sources/Editing View/Observation View/Debug/PhotoEditingObservationDebugLayer.swift b/Modules/Legacy/Editing/Sources/Editing View/Observation View/Debug/PhotoEditingObservationDebugLayer.swift index a298b112..cfffd354 100644 --- a/Modules/Legacy/Editing/Sources/Editing View/Observation View/Debug/PhotoEditingObservationDebugLayer.swift +++ b/Modules/Legacy/Editing/Sources/Editing View/Observation View/Debug/PhotoEditingObservationDebugLayer.swift @@ -4,6 +4,7 @@ class PhotoEditingObservationDebugLayer: CAShapeLayer { init(fillColor: UIColor, frame: CGRect, path: CGPath) { super.init() + self.strokeColor = fillColor.cgColor self.fillColor = fillColor.withAlphaComponent(0.3).cgColor self.frame = frame self.path = path diff --git a/Modules/Legacy/Editing/Sources/Editing View/Observation View/Debug/PhotoEditingObservationDebugView.swift b/Modules/Legacy/Editing/Sources/Editing View/Observation View/Debug/PhotoEditingObservationDebugView.swift index 4fe44e89..086b1b10 100644 --- a/Modules/Legacy/Editing/Sources/Editing View/Observation View/Debug/PhotoEditingObservationDebugView.swift +++ b/Modules/Legacy/Editing/Sources/Editing View/Observation View/Debug/PhotoEditingObservationDebugView.swift @@ -65,8 +65,8 @@ class PhotoEditingObservationDebugView: PhotoEditingRedactionView { // find words (new system) let wordLayers: [PhotoEditingObservationDebugLayer] if isRecognizedTextOverlayEnabled { - wordLayers = recognizedTextObservations.map { wordObservation in - PhotoEditingObservationDebugLayer(fillColor: .systemYellow, frame: bounds, path: wordObservation.path) + wordLayers = recognizedTextObservations.flatMap(\.characterObservations).map { observation in + PhotoEditingObservationDebugLayer(fillColor: .systemYellow, frame: bounds, path: observation.bounds.path) } } else { wordLayers = [] } diff --git a/Modules/Legacy/Editing/Sources/Editing View/Observation View/PhotoEditingObservationCalculator.swift b/Modules/Legacy/Editing/Sources/Editing View/Observation View/PhotoEditingObservationCalculator.swift index f226e304..82083f87 100644 --- a/Modules/Legacy/Editing/Sources/Editing View/Observation View/PhotoEditingObservationCalculator.swift +++ b/Modules/Legacy/Editing/Sources/Editing View/Observation View/PhotoEditingObservationCalculator.swift @@ -2,6 +2,7 @@ // Copyright © 2024 Cocoatype, LLC. All rights reserved. import Foundation +import Geometry import Observations import Redactions @@ -29,27 +30,29 @@ actor PhotoEditingObservationCalculator { let detectedCharacterObservations = detectedTextObservations.flatMap(\.characterObservations).filter(\.bounds.isNotZero) // do intersection detection to override detected with recognized text - let filteredDetectedObservations = detectedCharacterObservations.filter { detectedObservation in - let detectedCGPath = detectedObservation.bounds.path + let calculatedObservations = recognizedCharacterObservations.reduce(into: [CharacterObservation]()) { calculatedObservations, recognizedObservation in + let recognizedPath = recognizedObservation.bounds.path + var intersectingObservations = detectedCharacterObservations.filter { detectedObservation in + let detectedPath = detectedObservation.bounds.path - let hasIntersection = recognizedCharacterObservations.contains { recognizedObservation in - let recognizedCGPath = recognizedObservation.bounds.path + let isEqual = detectedPath.isEqual(to: recognizedPath, accuracy: 0.01) + guard isEqual == false else { + return true + } - let isEqual = detectedCGPath.isEqual(to: recognizedCGPath, accuracy: 0.01) - guard isEqual == false else { return true } + return finder.intersectionExists(between: detectedPath, and: recognizedPath) + } - let isContained = detectedCGPath.contains(recognizedCGPath.currentPoint) || recognizedCGPath.contains(detectedCGPath.currentPoint) - guard isContained == false else { return true } + guard intersectingObservations.count > 0 else { return } - return finder.intersectionExists(between: detectedCGPath, and: recognizedCGPath) + let firstShape = intersectingObservations.removeFirst().bounds + let intersectingObservationsShape = intersectingObservations.reduce(into: firstShape) { combinedShape, observation in + combinedShape = combinedShape.union(observation.bounds) } - return !hasIntersection + calculatedObservations.append(CharacterObservation(bounds: intersectingObservationsShape, textObservationUUID: recognizedObservation.textObservationUUID)) } - // unique by adding to set - let characterObservationSet = Set(filteredDetectedObservations + recognizedCharacterObservations) - - return Array(characterObservationSet) + return calculatedObservations } } diff --git a/Modules/Legacy/Editing/Sources/Editing View/Observation View/PhotoEditingSystemIntersectionFinder.swift b/Modules/Legacy/Editing/Sources/Editing View/Observation View/PhotoEditingSystemIntersectionFinder.swift index 585b5a40..346a26db 100644 --- a/Modules/Legacy/Editing/Sources/Editing View/Observation View/PhotoEditingSystemIntersectionFinder.swift +++ b/Modules/Legacy/Editing/Sources/Editing View/Observation View/PhotoEditingSystemIntersectionFinder.swift @@ -6,6 +6,8 @@ import Observations @available(iOS 16.0, *) struct PhotoEditingSystemIntersectionFinder: PhotoEditingIntersectionFinder { func intersectionExists(between lhs: CGPath, and rhs: CGPath) -> Bool { - lhs.intersects(rhs) || rhs.intersects(lhs) + guard lhs.intersects(rhs) || rhs.intersects(lhs) else { return false } + let intersection = lhs.intersection(rhs) + return intersection.area() > lhs.area() / 2 } } From edf3b5e3ab94d592029934c0200162edde3ca372 Mon Sep 17 00:00:00 2001 From: Geoff Pado Date: Mon, 17 Jun 2024 15:04:45 -0700 Subject: [PATCH 02/16] Improve handling of orphaned/childless observations --- .../PhotoEditingObservationCalculation.swift | 14 +++++++ .../PhotoEditingObservationCalculator.swift | 37 ++++++++++++++----- 2 files changed, 41 insertions(+), 10 deletions(-) create mode 100644 Modules/Legacy/Editing/Sources/Editing View/Observation View/PhotoEditingObservationCalculation.swift diff --git a/Modules/Legacy/Editing/Sources/Editing View/Observation View/PhotoEditingObservationCalculation.swift b/Modules/Legacy/Editing/Sources/Editing View/Observation View/PhotoEditingObservationCalculation.swift new file mode 100644 index 00000000..60798009 --- /dev/null +++ b/Modules/Legacy/Editing/Sources/Editing View/Observation View/PhotoEditingObservationCalculation.swift @@ -0,0 +1,14 @@ +// Created by Geoff Pado on 6/17/24. +// Copyright © 2024 Cocoatype, LLC. All rights reserved. + +import Foundation +import Observations + +struct PhotoEditingObservationCalculationPass { + // a dictionary with a key of a recognized observation, and a value of an + // array of matching detected observations + var recognizedObservations = [CharacterObservation: [CharacterObservation]]() + + // any detected observations who have no matching recognized observations + var orphanedObservations = [CharacterObservation]() +} diff --git a/Modules/Legacy/Editing/Sources/Editing View/Observation View/PhotoEditingObservationCalculator.swift b/Modules/Legacy/Editing/Sources/Editing View/Observation View/PhotoEditingObservationCalculator.swift index 82083f87..836db64c 100644 --- a/Modules/Legacy/Editing/Sources/Editing View/Observation View/PhotoEditingObservationCalculator.swift +++ b/Modules/Legacy/Editing/Sources/Editing View/Observation View/PhotoEditingObservationCalculator.swift @@ -29,11 +29,11 @@ actor PhotoEditingObservationCalculator { // get all character observations from detected text let detectedCharacterObservations = detectedTextObservations.flatMap(\.characterObservations).filter(\.bounds.isNotZero) - // do intersection detection to override detected with recognized text - let calculatedObservations = recognizedCharacterObservations.reduce(into: [CharacterObservation]()) { calculatedObservations, recognizedObservation in - let recognizedPath = recognizedObservation.bounds.path - var intersectingObservations = detectedCharacterObservations.filter { detectedObservation in - let detectedPath = detectedObservation.bounds.path + // find where all detected observations belong + let calculationPass = detectedCharacterObservations.reduce(into: PhotoEditingObservationCalculationPass()) { currentPass, detectedObservation in + let detectedPath = detectedObservation.bounds.path + let intersectingObservation = recognizedCharacterObservations.first(where: { recognizedObservation in + let recognizedPath = recognizedObservation.bounds.path let isEqual = detectedPath.isEqual(to: recognizedPath, accuracy: 0.01) guard isEqual == false else { @@ -41,18 +41,35 @@ actor PhotoEditingObservationCalculator { } return finder.intersectionExists(between: detectedPath, and: recognizedPath) + }) + + if let intersectingObservation { + var siblingObservations = currentPass.recognizedObservations[intersectingObservation] ?? [] + siblingObservations.append(detectedObservation) + currentPass.recognizedObservations[intersectingObservation] = siblingObservations + } else { + currentPass.orphanedObservations.append(detectedObservation) } + } - guard intersectingObservations.count > 0 else { return } + // find recognized observations with no related detected observations + let parentObservations = calculationPass.recognizedObservations.keys + let childlessObservations = recognizedCharacterObservations.filter { recognizedObservation in + let isParent = parentObservations.contains(recognizedObservation) + return isParent == false + } - let firstShape = intersectingObservations.removeFirst().bounds - let intersectingObservationsShape = intersectingObservations.reduce(into: firstShape) { combinedShape, observation in + // combine the parent observations and their children + let combinedObservations = calculationPass.recognizedObservations.map { parent, children in + var children = children + let firstShape = children.removeFirst().bounds + let combinedShape = children.reduce(into: firstShape) { combinedShape, observation in combinedShape = combinedShape.union(observation.bounds) } - calculatedObservations.append(CharacterObservation(bounds: intersectingObservationsShape, textObservationUUID: recognizedObservation.textObservationUUID)) + return CharacterObservation(bounds: combinedShape, textObservationUUID: parent.textObservationUUID) } - return calculatedObservations + return combinedObservations + childlessObservations + calculationPass.orphanedObservations } } From 18e387a17c23f9b48e9fae2dc06636a77f3a84f7 Mon Sep 17 00:00:00 2001 From: Geoff Pado Date: Mon, 17 Jun 2024 16:13:22 -0700 Subject: [PATCH 03/16] Handle rotated text better --- ...otoEditingObservationCalculationPass.swift} | 0 .../PhotoEditingObservationCalculator.swift | 18 +++++++++++++++--- 2 files changed, 15 insertions(+), 3 deletions(-) rename Modules/Legacy/Editing/Sources/Editing View/Observation View/{PhotoEditingObservationCalculation.swift => PhotoEditingObservationCalculationPass.swift} (100%) diff --git a/Modules/Legacy/Editing/Sources/Editing View/Observation View/PhotoEditingObservationCalculation.swift b/Modules/Legacy/Editing/Sources/Editing View/Observation View/PhotoEditingObservationCalculationPass.swift similarity index 100% rename from Modules/Legacy/Editing/Sources/Editing View/Observation View/PhotoEditingObservationCalculation.swift rename to Modules/Legacy/Editing/Sources/Editing View/Observation View/PhotoEditingObservationCalculationPass.swift diff --git a/Modules/Legacy/Editing/Sources/Editing View/Observation View/PhotoEditingObservationCalculator.swift b/Modules/Legacy/Editing/Sources/Editing View/Observation View/PhotoEditingObservationCalculator.swift index 836db64c..e663cde6 100644 --- a/Modules/Legacy/Editing/Sources/Editing View/Observation View/PhotoEditingObservationCalculator.swift +++ b/Modules/Legacy/Editing/Sources/Editing View/Observation View/PhotoEditingObservationCalculator.swift @@ -59,8 +59,20 @@ actor PhotoEditingObservationCalculator { return isParent == false } - // combine the parent observations and their children - let combinedObservations = calculationPass.recognizedObservations.map { parent, children in + // find recognized observations that are insufficiently filled to count + let unfulfilledObservations = calculationPass.recognizedObservations.filter { parent, children in + let childArea = children.reduce(Double.zero) { childArea, observation in + childArea + observation.bounds.path.area() + } + let parentArea = parent.bounds.path.area() + return childArea < (parentArea / 2) + }.keys + + // combine the remaining parent observations and their children + let remainingObservations = calculationPass.recognizedObservations.filter { parent, _ in + unfulfilledObservations.contains(parent) == false + } + let combinedObservations = remainingObservations.map { parent, children in var children = children let firstShape = children.removeFirst().bounds let combinedShape = children.reduce(into: firstShape) { combinedShape, observation in @@ -70,6 +82,6 @@ actor PhotoEditingObservationCalculator { return CharacterObservation(bounds: combinedShape, textObservationUUID: parent.textObservationUUID) } - return combinedObservations + childlessObservations + calculationPass.orphanedObservations + return combinedObservations + childlessObservations + unfulfilledObservations + calculationPass.orphanedObservations } } From 1bf83f5ddbe7a372fbe7730d39191037c4dda1dc Mon Sep 17 00:00:00 2001 From: Geoff Pado Date: Tue, 18 Jun 2024 13:14:43 -0700 Subject: [PATCH 04/16] Add method for finding minimum rect --- .../Sources/MinimumAreaRectFinder.swift | 120 ++++++++++++++++++ .../Capabilities/Geometry/Sources/Shape.swift | 14 +- .../Geometry/Tests/ShapeTests.swift | 26 ++++ .../CharacterObservationRedaction.swift | 4 +- .../PhotoEditingObservationDebugLayer.swift | 6 +- .../PhotoEditingObservationDebugView.swift | 8 +- .../PhotoEditingObservationCalculator.swift | 2 +- .../Workspace/RedactionPathLayer.swift | 28 ++-- 8 files changed, 173 insertions(+), 35 deletions(-) create mode 100644 Modules/Capabilities/Geometry/Sources/MinimumAreaRectFinder.swift diff --git a/Modules/Capabilities/Geometry/Sources/MinimumAreaRectFinder.swift b/Modules/Capabilities/Geometry/Sources/MinimumAreaRectFinder.swift new file mode 100644 index 00000000..100a01e7 --- /dev/null +++ b/Modules/Capabilities/Geometry/Sources/MinimumAreaRectFinder.swift @@ -0,0 +1,120 @@ +// Created by Geoff Pado on 6/17/24. +// Copyright © 2024 Cocoatype, LLC. All rights reserved. + +import CoreGraphics +import Foundation + +enum MinimumAreaRectFinder { + static func minimumAreaShape(for points: [CGPoint]) -> Shape { + let (rect, angle) = minimumAreaEnclosingRectangle(for: points)! + let rotationTransform = CGAffineTransform(rotationAngle: angle) + let translationTransform = CGAffineTransform(translationX: -rect.minX, y: -rect.maxY) + let inverseTranslate = CGAffineTransform(translationX: rect.minX, y: rect.maxY) + + let finalTransform = translationTransform.concatenating(rotationTransform).concatenating(inverseTranslate) + + let points = [CGPoint(x: rect.minX, y: rect.maxY), CGPoint(x: rect.maxX, y: rect.maxY), CGPoint(x: rect.minX, y: rect.minY), CGPoint(x: rect.maxX, y: rect.minY)] + let transformedPoints = points.map { $0.applying(finalTransform) } + + return Shape(bottomLeft: transformedPoints[0], bottomRight: transformedPoints[1], topLeft: transformedPoints[2], topRight: transformedPoints[3]) + } + + private static func minimumAreaEnclosingRectangle(for points: [CGPoint]) -> (CGRect, Double)? { + guard points.count > 2 else { return nil } + + var minArea = CGFloat.greatestFiniteMagnitude + var bestRect = CGRect.zero + var bestAngle = Double.zero + + for i in 0.. CGPoint { + let translatedX = point.x - origin.x + let translatedY = point.y - origin.y + let rotatedX = translatedX * cos(angle) - translatedY * sin(angle) + let rotatedY = translatedX * sin(angle) + translatedY * cos(angle) + return CGPoint(x: rotatedX + origin.x, y: rotatedY + origin.y) + } + + private static func boundingRect(for points: [CGPoint]) -> CGRect { + let minX = points.map { $0.x }.min() ?? 0 + let maxX = points.map { $0.x }.max() ?? 0 + let minY = points.map { $0.y }.min() ?? 0 + let maxY = points.map { $0.y }.max() ?? 0 + return CGRect(x: minX, y: minY, width: maxX - minX, height: maxY - minY) + } + + // Helper function to find the orientation of the triplet (p, q, r) + // 0 -> p, q and r are collinear + // 1 -> Clockwise + // 2 -> Counterclockwise + private static func orientation(_ p: CGPoint, _ q: CGPoint, _ r: CGPoint) -> Int { + let val = (q.y - p.y) * (r.x - q.x) - (q.x - p.x) * (r.y - q.y) + if val == 0 { return 0 } + return (val > 0) ? 1 : 2 + } + + // Helper function to find the square of the distance between two points + private static func distanceSquared(_ p1: CGPoint, _ p2: CGPoint) -> CGFloat { + return (p1.x - p2.x) * (p1.x - p2.x) + (p1.y - p2.y) * (p1.y - p2.y) + } + + // Function to find the convex hull using Graham Scan algorithm + static func convexHull(for points: [CGPoint]) -> [CGPoint] { + guard points.count >= 3 else { return points } + + // Find the point with the lowest y-coordinate, break ties by x-coordinate + var minYPoint = points[0] + var minYIndex = 0 + for (index, point) in points.enumerated() { + if (point.y < minYPoint.y) || (point.y == minYPoint.y && point.x < minYPoint.x) { + minYPoint = point + minYIndex = index + } + } + + // Place the bottom-most point at the first position + var sortedPoints = points + sortedPoints.swapAt(0, minYIndex) + + // Sort the remaining points based on the polar angle with the first point + let p0 = sortedPoints[0] + sortedPoints = sortedPoints.sorted { (p1, p2) -> Bool in + let o = orientation(p0, p1, p2) + if o == 0 { + return distanceSquared(p0, p1) < distanceSquared(p0, p2) + } + return o == 2 + } + + // Create an empty stack and push the first three points to it + var stack: [CGPoint] = [sortedPoints[0], sortedPoints[1], sortedPoints[2]] + + // Process the remaining points + for i in 3.. 1 && orientation(stack[stack.count - 2], stack.last!, sortedPoints[i]) != 2 { + stack.removeLast() + } + stack.append(sortedPoints[i]) + } + + return stack + } +} diff --git a/Modules/Capabilities/Geometry/Sources/Shape.swift b/Modules/Capabilities/Geometry/Sources/Shape.swift index ec7fe1d1..53c406bd 100644 --- a/Modules/Capabilities/Geometry/Sources/Shape.swift +++ b/Modules/Capabilities/Geometry/Sources/Shape.swift @@ -72,19 +72,7 @@ public struct Shape: Hashable { } public func union(_ other: Shape) -> Shape { - let allPoints = [self.bottomLeft, self.bottomRight, self.topLeft, self.topRight, other.bottomLeft, other.bottomRight, other.topLeft, other.topRight] - - let minX = allPoints.map { $0.x }.min() ?? 0 - let minY = allPoints.map { $0.y }.min() ?? 0 - let maxX = allPoints.map { $0.x }.max() ?? 0 - let maxY = allPoints.map { $0.y }.max() ?? 0 - - return Shape( - bottomLeft: CGPoint(x: minX, y: maxY), - bottomRight: CGPoint(x: maxX, y: maxY), - topLeft: CGPoint(x: minX, y: minY), - topRight: CGPoint(x: maxX, y: minY) - ) + MinimumAreaRectFinder.minimumAreaShape(for: [self.bottomLeft, self.bottomRight, self.topLeft, self.topRight, other.bottomLeft, other.bottomRight, other.topLeft, other.topRight]) } static let zero = Shape(bottomLeft: .zero, bottomRight: .zero, topLeft: .zero, topRight: .zero) diff --git a/Modules/Capabilities/Geometry/Tests/ShapeTests.swift b/Modules/Capabilities/Geometry/Tests/ShapeTests.swift index ba293d6b..97ca1944 100644 --- a/Modules/Capabilities/Geometry/Tests/ShapeTests.swift +++ b/Modules/Capabilities/Geometry/Tests/ShapeTests.swift @@ -27,4 +27,30 @@ final class ShapeTests: XCTestCase { XCTAssertTrue(shape.isNotEmpty) } + + func testUnionOfRotatedShapes() { + let firstShape = Shape( + bottomLeft: CGPoint(x: 126.74248082927248, y: 1112.9254289499572), + bottomRight: CGPoint(x: 342.6968497848278, y: 948.305784288113), + topLeft: CGPoint(x: 53.420223253429214, y: 1016.2733621454367), + topRight: CGPoint(x: 269.7560323061029, y: 852.1565330586908) + ) + + let secondShape = Shape( + bottomLeft: CGPoint(x: 354.71550618850557, y: 939.1881821032728), + bottomRight: CGPoint(x: 805.0458405740253, y: 598.3504663849724), + topLeft: CGPoint(x: 281.7746887097807, y: 843.0389308738502), + topRight: CGPoint(x: 731.723582998182, y: 501.6983995804519) + ) + + let expectedShape = Shape( + bottomLeft: CGPoint(x: 126.74248082927248, y: 1112.9254289499572), + bottomRight: CGPoint(x: 805.0458405740253, y: 598.3504663849724), + topLeft: CGPoint(x: 53.420223253429214, y: 1016.2733621454367), + topRight: CGPoint(x: 731.723582998182, y: 501.6983995804519) + ) + + let unionShape = firstShape.union(secondShape) + XCTAssert(unionShape.path.isEqual(to: expectedShape.path, accuracy: 0.01)) + } } diff --git a/Modules/Capabilities/Redactions/Sources/Redactions/CharacterObservationRedaction.swift b/Modules/Capabilities/Redactions/Sources/Redactions/CharacterObservationRedaction.swift index ae4c258d..0068ec40 100644 --- a/Modules/Capabilities/Redactions/Sources/Redactions/CharacterObservationRedaction.swift +++ b/Modules/Capabilities/Redactions/Sources/Redactions/CharacterObservationRedaction.swift @@ -38,7 +38,9 @@ extension Redaction { siblingObservations.append(characterObservation) result[textObservationUUID] = siblingObservations }.values.map { siblingObservations in - siblingObservations.reduce(siblingObservations[0].bounds, { currentRect, characterObservation in + var siblingObservations = siblingObservations + let firstObservation = siblingObservations.removeFirst() + return siblingObservations.reduce(firstObservation.bounds, { currentRect, characterObservation in currentRect.union(characterObservation.bounds) }) }.map(RedactionPart.shape) diff --git a/Modules/Legacy/Editing/Sources/Editing View/Observation View/Debug/PhotoEditingObservationDebugLayer.swift b/Modules/Legacy/Editing/Sources/Editing View/Observation View/Debug/PhotoEditingObservationDebugLayer.swift index cfffd354..021008a4 100644 --- a/Modules/Legacy/Editing/Sources/Editing View/Observation View/Debug/PhotoEditingObservationDebugLayer.swift +++ b/Modules/Legacy/Editing/Sources/Editing View/Observation View/Debug/PhotoEditingObservationDebugLayer.swift @@ -1,13 +1,15 @@ // Created by Geoff Pado on 6/17/24. // Copyright © 2024 Cocoatype, LLC. All rights reserved. +import Geometry + class PhotoEditingObservationDebugLayer: CAShapeLayer { - init(fillColor: UIColor, frame: CGRect, path: CGPath) { + init(fillColor: UIColor, frame: CGRect, shape: Shape) { super.init() self.strokeColor = fillColor.cgColor self.fillColor = fillColor.withAlphaComponent(0.3).cgColor self.frame = frame - self.path = path + self.path = shape.path } override init(layer: Any) { diff --git a/Modules/Legacy/Editing/Sources/Editing View/Observation View/Debug/PhotoEditingObservationDebugView.swift b/Modules/Legacy/Editing/Sources/Editing View/Observation View/Debug/PhotoEditingObservationDebugView.swift index 086b1b10..ad6343a6 100644 --- a/Modules/Legacy/Editing/Sources/Editing View/Observation View/Debug/PhotoEditingObservationDebugView.swift +++ b/Modules/Legacy/Editing/Sources/Editing View/Observation View/Debug/PhotoEditingObservationDebugView.swift @@ -66,7 +66,7 @@ class PhotoEditingObservationDebugView: PhotoEditingRedactionView { let wordLayers: [PhotoEditingObservationDebugLayer] if isRecognizedTextOverlayEnabled { wordLayers = recognizedTextObservations.flatMap(\.characterObservations).map { observation in - PhotoEditingObservationDebugLayer(fillColor: .systemYellow, frame: bounds, path: observation.bounds.path) + PhotoEditingObservationDebugLayer(fillColor: .systemYellow, frame: bounds, shape: observation.bounds) } } else { wordLayers = [] } @@ -76,12 +76,12 @@ class PhotoEditingObservationDebugView: PhotoEditingRedactionView { let characterLayers: [PhotoEditingObservationDebugLayer] if isDetectedCharactersOverlayEnabled { characterLayers = characterObservations.map { observation -> PhotoEditingObservationDebugLayer in - PhotoEditingObservationDebugLayer(fillColor: .systemBlue, frame: bounds, path: observation.bounds.path) + PhotoEditingObservationDebugLayer(fillColor: .systemBlue, frame: bounds, shape: observation.bounds) } } else { characterLayers = [] } if Defaults.Value(key: .showDetectedTextOverlay).wrappedValue { - let textLayer = PhotoEditingObservationDebugLayer(fillColor: .systemRed, frame: bounds, path: CGPath(rect: textObservation.bounds.boundingBox, transform: nil)) + let textLayer = PhotoEditingObservationDebugLayer(fillColor: .systemRed, frame: bounds, shape: textObservation.bounds) return characterLayers + [textLayer] } else { return characterLayers } @@ -92,7 +92,7 @@ class PhotoEditingObservationDebugView: PhotoEditingRedactionView { let wordCharacterLayers: [PhotoEditingObservationDebugLayer] if isCalculatedOverlayEnabled { wordCharacterLayers = calculatedObservations.map { (calculatedObservation: CharacterObservation) -> PhotoEditingObservationDebugLayer in - PhotoEditingObservationDebugLayer(fillColor: .systemGreen, frame: bounds, path: calculatedObservation.bounds.path) + PhotoEditingObservationDebugLayer(fillColor: .systemGreen, frame: bounds, shape: calculatedObservation.bounds) } } else { wordCharacterLayers = [] } diff --git a/Modules/Legacy/Editing/Sources/Editing View/Observation View/PhotoEditingObservationCalculator.swift b/Modules/Legacy/Editing/Sources/Editing View/Observation View/PhotoEditingObservationCalculator.swift index e663cde6..a68a83f6 100644 --- a/Modules/Legacy/Editing/Sources/Editing View/Observation View/PhotoEditingObservationCalculator.swift +++ b/Modules/Legacy/Editing/Sources/Editing View/Observation View/PhotoEditingObservationCalculator.swift @@ -65,7 +65,7 @@ actor PhotoEditingObservationCalculator { childArea + observation.bounds.path.area() } let parentArea = parent.bounds.path.area() - return childArea < (parentArea / 2) + return childArea < (parentArea * 0.35) }.keys // combine the remaining parent observations and their children diff --git a/Modules/Legacy/Editing/Sources/Editing View/Workspace/RedactionPathLayer.swift b/Modules/Legacy/Editing/Sources/Editing View/Workspace/RedactionPathLayer.swift index 85aabb1f..156c3a2a 100644 --- a/Modules/Legacy/Editing/Sources/Editing View/Workspace/RedactionPathLayer.swift +++ b/Modules/Legacy/Editing/Sources/Editing View/Workspace/RedactionPathLayer.swift @@ -74,20 +74,20 @@ class RedactionPathLayer: CALayer { let offsetPath = UIBezierPath(cgPath: shape.path) offsetPath.apply(offsetTransform) offsetPath.fill() - - context.saveGState() - context.translateBy(x: shape.topLeft.x - frame.origin.x, y: shape.topLeft.y - frame.origin.y) - context.rotate(by: shape.angle) - context.translateBy(x: -(startImage.size.width - 1), y: 0) - context.draw(startImage, in: CGRect(origin: .zero, size: startImage.size)) - context.restoreGState() - - context.saveGState() - context.translateBy(x: shape.topRight.x - frame.origin.x, y: shape.topRight.y - frame.origin.y) - context.rotate(by: shape.angle) - context.translateBy(x: -1, y: 0) - context.draw(endImage, in: CGRect(origin: .zero, size: endImage.size)) - context.restoreGState() +// +// context.saveGState() +// context.translateBy(x: shape.topLeft.x - frame.origin.x, y: shape.topLeft.y - frame.origin.y) +// context.rotate(by: shape.angle) +// context.translateBy(x: -(startImage.size.width - 1), y: 0) +// context.draw(startImage, in: CGRect(origin: .zero, size: startImage.size)) +// context.restoreGState() +// +// context.saveGState() +// context.translateBy(x: shape.topRight.x - frame.origin.x, y: shape.topRight.y - frame.origin.y) +// context.rotate(by: shape.angle) +// context.translateBy(x: -1, y: 0) +// context.draw(endImage, in: CGRect(origin: .zero, size: endImage.size)) +// context.restoreGState() case let .path(path, dikembeMutombo): let dashedPath = path.dashedPath From b1dfeeaabd6c2bb1de8c80dba70cc8a294000acc Mon Sep 17 00:00:00 2001 From: Geoff Pado Date: Tue, 18 Jun 2024 15:15:09 -0700 Subject: [PATCH 05/16] Fix placement of rotated redaction layers --- .../Sources/MinimumAreaRectFinder.swift | 36 ++++------ .../Capabilities/Geometry/Sources/Shape.swift | 67 ++++++++++++++++++- .../Workspace/RedactionPathLayer.swift | 38 ++++++++--- 3 files changed, 108 insertions(+), 33 deletions(-) diff --git a/Modules/Capabilities/Geometry/Sources/MinimumAreaRectFinder.swift b/Modules/Capabilities/Geometry/Sources/MinimumAreaRectFinder.swift index 100a01e7..9483371d 100644 --- a/Modules/Capabilities/Geometry/Sources/MinimumAreaRectFinder.swift +++ b/Modules/Capabilities/Geometry/Sources/MinimumAreaRectFinder.swift @@ -5,18 +5,8 @@ import CoreGraphics import Foundation enum MinimumAreaRectFinder { - static func minimumAreaShape(for points: [CGPoint]) -> Shape { - let (rect, angle) = minimumAreaEnclosingRectangle(for: points)! - let rotationTransform = CGAffineTransform(rotationAngle: angle) - let translationTransform = CGAffineTransform(translationX: -rect.minX, y: -rect.maxY) - let inverseTranslate = CGAffineTransform(translationX: rect.minX, y: rect.maxY) - - let finalTransform = translationTransform.concatenating(rotationTransform).concatenating(inverseTranslate) - - let points = [CGPoint(x: rect.minX, y: rect.maxY), CGPoint(x: rect.maxX, y: rect.maxY), CGPoint(x: rect.minX, y: rect.minY), CGPoint(x: rect.maxX, y: rect.minY)] - let transformedPoints = points.map { $0.applying(finalTransform) } - - return Shape(bottomLeft: transformedPoints[0], bottomRight: transformedPoints[1], topLeft: transformedPoints[2], topRight: transformedPoints[3]) + static func minimumAreaShape(for points: [CGPoint]) -> Shape? { + minimumAreaEnclosingRectangle(for: points).map(Shape.init) } private static func minimumAreaEnclosingRectangle(for points: [CGPoint]) -> (CGRect, Double)? { @@ -61,14 +51,16 @@ enum MinimumAreaRectFinder { return CGRect(x: minX, y: minY, width: maxX - minX, height: maxY - minY) } - // Helper function to find the orientation of the triplet (p, q, r) - // 0 -> p, q and r are collinear - // 1 -> Clockwise - // 2 -> Counterclockwise - private static func orientation(_ p: CGPoint, _ q: CGPoint, _ r: CGPoint) -> Int { + enum Orientation { + case collinear + case clockwise + case counterClockwise + } + + private static func orientation(_ p: CGPoint, _ q: CGPoint, _ r: CGPoint) -> Orientation { let val = (q.y - p.y) * (r.x - q.x) - (q.x - p.x) * (r.y - q.y) - if val == 0 { return 0 } - return (val > 0) ? 1 : 2 + if val == 0 { return .collinear } + return (val > 0) ? .clockwise : .counterClockwise } // Helper function to find the square of the distance between two points @@ -98,10 +90,10 @@ enum MinimumAreaRectFinder { let p0 = sortedPoints[0] sortedPoints = sortedPoints.sorted { (p1, p2) -> Bool in let o = orientation(p0, p1, p2) - if o == 0 { + if o == .collinear { return distanceSquared(p0, p1) < distanceSquared(p0, p2) } - return o == 2 + return o == .counterClockwise } // Create an empty stack and push the first three points to it @@ -109,7 +101,7 @@ enum MinimumAreaRectFinder { // Process the remaining points for i in 3.. 1 && orientation(stack[stack.count - 2], stack.last!, sortedPoints[i]) != 2 { + while stack.count > 1 && orientation(stack[stack.count - 2], stack.last!, sortedPoints[i]) != .counterClockwise { stack.removeLast() } stack.append(sortedPoints[i]) diff --git a/Modules/Capabilities/Geometry/Sources/Shape.swift b/Modules/Capabilities/Geometry/Sources/Shape.swift index 53c406bd..7b4ab673 100644 --- a/Modules/Capabilities/Geometry/Sources/Shape.swift +++ b/Modules/Capabilities/Geometry/Sources/Shape.swift @@ -21,6 +21,25 @@ public struct Shape: Hashable { self.topRight = topRight } + public init(rect: CGRect, angle: Double) { + print(rect) + let rotationTransform = CGAffineTransform(rotationAngle: angle) + let translationTransform = CGAffineTransform(translationX: -rect.minX, y: -rect.maxY) + let inverseTranslate = CGAffineTransform(translationX: rect.minX, y: rect.maxY) + + let finalTransform = translationTransform.concatenating(rotationTransform).concatenating(inverseTranslate) + + let points = [CGPoint(x: rect.minX, y: rect.maxY), CGPoint(x: rect.maxX, y: rect.maxY), CGPoint(x: rect.minX, y: rect.minY), CGPoint(x: rect.maxX, y: rect.minY)] + let transformedPoints = points.map { $0.applying(finalTransform) } + + self.init( + bottomLeft: transformedPoints[0], + bottomRight: transformedPoints[1], + topLeft: transformedPoints[2], + topRight: transformedPoints[3] + ) + } + public func scaled(to imageSize: CGSize) -> Shape { return Shape( bottomLeft: CGPoint.flippedPoint(from: bottomLeft, scaledTo: imageSize), @@ -71,8 +90,54 @@ public struct Shape: Hashable { ) } + // this section of code proudly sponsored by @KaenAitch + // between June 17th and June 18th, 2024 + func geometryStreamer(reversed: Bool) -> CGAffineTransform { + let reversedValue: Double = (reversed ? 1 : -1) + return CGAffineTransform(translationX: bottomLeft.x * reversedValue, y: bottomLeft.y * reversedValue) + } + + func thisGuyHeadBang(reversed: Bool) -> CGAffineTransform { + CGAffineTransform(rotationAngle: angle * (reversed ? -1 : 1)) + } + + func emotionalSupportVariable(reversed: Bool) -> CGAffineTransform { + CGAffineTransform(translationX: unionDotShapeDotShapeDotUnionCrash.width / (reversed ? -2 : 2), y: unionDotShapeDotShapeDotUnionCrash.height / (reversed ? 2 : -2)) + } + + public var inverseTranslateRotateTransform: CGAffineTransform { + return geometryStreamer(reversed: false) + .concatenating(thisGuyHeadBang(reversed: true)) + .concatenating(geometryStreamer(reversed: true)) + } + + public var forwardTranslateRotateTransform: CGAffineTransform { + return emotionalSupportVariable(reversed: false) + .concatenating(thisGuyHeadBang(reversed: false)) + .concatenating(emotionalSupportVariable(reversed: true)) + } + // thank you for your support for this channel @KaenAitch + + // unionDotShapeDotShapeDotUnionCrash by @AdamWulf on 2024-06-17 + // the unrotated rect for this shape + public var unionDotShapeDotShapeDotUnionCrash: CGRect { + let inverseTopLeft = topLeft.applying(inverseTranslateRotateTransform) + let inverseBottomRight = bottomRight.applying(inverseTranslateRotateTransform) + + return CGRect( + origin: CGPoint( + x: inverseTopLeft.x, + y: inverseTopLeft.y + ), + size: CGSize( + width: inverseBottomRight.x - inverseTopLeft.x, + height: inverseBottomRight.y - inverseTopLeft.y + ) + ) + } + public func union(_ other: Shape) -> Shape { - MinimumAreaRectFinder.minimumAreaShape(for: [self.bottomLeft, self.bottomRight, self.topLeft, self.topRight, other.bottomLeft, other.bottomRight, other.topLeft, other.topRight]) + MinimumAreaRectFinder.minimumAreaShape(for: [self.bottomLeft, self.bottomRight, self.topLeft, self.topRight, other.bottomLeft, other.bottomRight, other.topLeft, other.topRight]) ?? self } static let zero = Shape(bottomLeft: .zero, bottomRight: .zero, topLeft: .zero, topRight: .zero) diff --git a/Modules/Legacy/Editing/Sources/Editing View/Workspace/RedactionPathLayer.swift b/Modules/Legacy/Editing/Sources/Editing View/Workspace/RedactionPathLayer.swift index 156c3a2a..725fab4d 100644 --- a/Modules/Legacy/Editing/Sources/Editing View/Workspace/RedactionPathLayer.swift +++ b/Modules/Legacy/Editing/Sources/Editing View/Workspace/RedactionPathLayer.swift @@ -9,7 +9,13 @@ import UIKit class RedactionPathLayer: CALayer { init(part: RedactionPart, color: UIColor, scale: CGFloat) throws { - let pathBounds: CGRect + // gigiPath by @AdamWulf on 2024-06-17 + // the final bounds of the layer + let gigiPath: CGRect + + // youKnowWhatImAMoron by @nutterfi on 2024-06-17 + // an affine transform to apply to the layer + let youKnowWhatImAMoron: CGAffineTransform switch part { case .shape(let shape): @@ -26,31 +32,43 @@ class RedactionPathLayer: CALayer { ) // need to actually draw a larger extent on the corner - let outsetShape = Shape( - bottomLeft: shape.bottomLeft + startVector, - bottomRight: shape.bottomRight + endVector, - topLeft: shape.topLeft + startVector, - topRight: shape.topRight + endVector) - pathBounds = outsetShape.boundingBox +// let outsetShape = Shape( +// bottomLeft: shape.bottomLeft + startVector, +// bottomRight: shape.bottomRight + endVector, +// topLeft: shape.topLeft + startVector, +// topRight: shape.topRight + endVector) +// gigiPath = shape.unionDotShapeDotShapeDotUnionCrash +// print(rect) + let rect = shape.unionDotShapeDotShapeDotUnionCrash + gigiPath = rect //CGRect(origin: .zero, size: rect.size) +// youKnowWhatImAMoron = shape.angle + youKnowWhatImAMoron = shape.forwardTranslateRotateTransform + // set position and rotation instead self.part = Part.shape(shape: shape, startImage: startImage, endImage: endImage) case .path(let path): let dikembeMutombo = BrushStampFactory.brushStamp(scaledToHeight: path.lineWidth, color: color) let borderBounds = path.strokeBorderPath.bounds - pathBounds = borderBounds.inset(by: UIEdgeInsets(top: dikembeMutombo.size.height * -0.5, + gigiPath = borderBounds.inset(by: UIEdgeInsets(top: dikembeMutombo.size.height * -0.5, left: dikembeMutombo.size.width * -0.5, bottom: dikembeMutombo.size.height * -0.5, right: dikembeMutombo.size.width * -0.5)) + youKnowWhatImAMoron = .identity self.part = Part.path(path: path, dikembeMutombo: dikembeMutombo) } self.color = color super.init() - backgroundColor = UIColor.clear.cgColor + backgroundColor = UIColor.systemRed.cgColor drawsAsynchronously = true - frame = pathBounds masksToBounds = false + frame = gigiPath + transform = CATransform3DMakeAffineTransform(youKnowWhatImAMoron) +// setAffineTransform(youKnowWhatImAMoron) +// transform = CATransform3DMakeRotation(youKnowWhatImAMoron, 0, 0, 1) + + opacity = 0.3 setNeedsDisplay() } From a1f0d0e5993cfb693849853e0fa82fe7198cfe1b Mon Sep 17 00:00:00 2001 From: Geoff Pado Date: Tue, 18 Jun 2024 15:35:14 -0700 Subject: [PATCH 06/16] Re-enable drawing fringes on rects --- .../Capabilities/Geometry/Sources/Shape.swift | 1 - .../Workspace/RedactionPathLayer.swift | 61 ++++++------------- 2 files changed, 17 insertions(+), 45 deletions(-) diff --git a/Modules/Capabilities/Geometry/Sources/Shape.swift b/Modules/Capabilities/Geometry/Sources/Shape.swift index 7b4ab673..c37db910 100644 --- a/Modules/Capabilities/Geometry/Sources/Shape.swift +++ b/Modules/Capabilities/Geometry/Sources/Shape.swift @@ -22,7 +22,6 @@ public struct Shape: Hashable { } public init(rect: CGRect, angle: Double) { - print(rect) let rotationTransform = CGAffineTransform(rotationAngle: angle) let translationTransform = CGAffineTransform(translationX: -rect.minX, y: -rect.maxY) let inverseTranslate = CGAffineTransform(translationX: rect.minX, y: rect.maxY) diff --git a/Modules/Legacy/Editing/Sources/Editing View/Workspace/RedactionPathLayer.swift b/Modules/Legacy/Editing/Sources/Editing View/Workspace/RedactionPathLayer.swift index 725fab4d..5faebece 100644 --- a/Modules/Legacy/Editing/Sources/Editing View/Workspace/RedactionPathLayer.swift +++ b/Modules/Legacy/Editing/Sources/Editing View/Workspace/RedactionPathLayer.swift @@ -21,29 +21,16 @@ class RedactionPathLayer: CALayer { case .shape(let shape): let (startImage, endImage) = try BrushStampFactory.brushImages(for: shape, color: color, scale: scale) - let angle = shape.angle - let startVector = CGSize( - width: startImage.size.width * -1 * cos(angle), - height: startImage.size.width * -1 * sin(angle) - ) - let endVector = CGSize( - width: endImage.size.width * CoreGraphics.cos(angle), - height: endImage.size.width * sin(angle) - ) - - // need to actually draw a larger extent on the corner -// let outsetShape = Shape( -// bottomLeft: shape.bottomLeft + startVector, -// bottomRight: shape.bottomRight + endVector, -// topLeft: shape.topLeft + startVector, -// topRight: shape.topRight + endVector) -// gigiPath = shape.unionDotShapeDotShapeDotUnionCrash -// print(rect) let rect = shape.unionDotShapeDotShapeDotUnionCrash - gigiPath = rect //CGRect(origin: .zero, size: rect.size) -// youKnowWhatImAMoron = shape.angle + // need to actually draw a larger extent on the corner + gigiPath = CGRect( + origin: CGPoint(x: rect.origin.x - Double(startImage.width) , y: rect.origin.y), + size: CGSize( + width: rect.size.width + Double(startImage.width) + Double(endImage.width), + height: rect.size.height + ) + ) youKnowWhatImAMoron = shape.forwardTranslateRotateTransform - // set position and rotation instead self.part = Part.shape(shape: shape, startImage: startImage, endImage: endImage) case .path(let path): @@ -60,15 +47,11 @@ class RedactionPathLayer: CALayer { self.color = color super.init() - backgroundColor = UIColor.systemRed.cgColor + backgroundColor = UIColor.clear.cgColor drawsAsynchronously = true masksToBounds = false frame = gigiPath transform = CATransform3DMakeAffineTransform(youKnowWhatImAMoron) -// setAffineTransform(youKnowWhatImAMoron) -// transform = CATransform3DMakeRotation(youKnowWhatImAMoron, 0, 0, 1) - - opacity = 0.3 setNeedsDisplay() } @@ -87,25 +70,15 @@ class RedactionPathLayer: CALayer { switch part { case let .shape(shape, startImage, endImage): color.setFill() + let shapeRect = shape.unionDotShapeDotShapeDotUnionCrash + let insetRect = CGRect( + origin: CGPoint(x: startImage.width, y: 0), + size: shapeRect.size + ) + UIBezierPath(rect: insetRect).fill() - let offsetTransform = CGAffineTransformMakeTranslation(-frame.origin.x, -frame.origin.y) - let offsetPath = UIBezierPath(cgPath: shape.path) - offsetPath.apply(offsetTransform) - offsetPath.fill() -// -// context.saveGState() -// context.translateBy(x: shape.topLeft.x - frame.origin.x, y: shape.topLeft.y - frame.origin.y) -// context.rotate(by: shape.angle) -// context.translateBy(x: -(startImage.size.width - 1), y: 0) -// context.draw(startImage, in: CGRect(origin: .zero, size: startImage.size)) -// context.restoreGState() -// -// context.saveGState() -// context.translateBy(x: shape.topRight.x - frame.origin.x, y: shape.topRight.y - frame.origin.y) -// context.rotate(by: shape.angle) -// context.translateBy(x: -1, y: 0) -// context.draw(endImage, in: CGRect(origin: .zero, size: endImage.size)) -// context.restoreGState() + context.draw(startImage, in: CGRect(origin: .zero, size: startImage.size)) + context.draw(endImage, in: CGRect(origin: CGPoint(x: Double(startImage.width) + shapeRect.width, y: 0), size: endImage.size)) case let .path(path, dikembeMutombo): let dashedPath = path.dashedPath From 6f03173a96b0570cb5ff160ccaeddc2c46525e06 Mon Sep 17 00:00:00 2001 From: Geoff Pado Date: Wed, 19 Jun 2024 00:30:52 -0700 Subject: [PATCH 07/16] Return shape for minimum area instead of rect --- .../Sources/MinimumAreaRectFinder.swift | 130 +++++++++--------- .../Capabilities/Geometry/Sources/Shape.swift | 20 +-- .../Capabilities/Geometry/Tests/Asserts.swift | 16 +++ .../Tests/MinimumAreaRectFinderTests.swift | 65 +++++++++ .../Geometry/Tests/ShapeTests.swift | 51 +++++++ .../PhotoEditingObservationCalculator.swift | 4 +- 6 files changed, 202 insertions(+), 84 deletions(-) create mode 100644 Modules/Capabilities/Geometry/Tests/Asserts.swift create mode 100644 Modules/Capabilities/Geometry/Tests/MinimumAreaRectFinderTests.swift diff --git a/Modules/Capabilities/Geometry/Sources/MinimumAreaRectFinder.swift b/Modules/Capabilities/Geometry/Sources/MinimumAreaRectFinder.swift index 9483371d..22b512db 100644 --- a/Modules/Capabilities/Geometry/Sources/MinimumAreaRectFinder.swift +++ b/Modules/Capabilities/Geometry/Sources/MinimumAreaRectFinder.swift @@ -5,16 +5,16 @@ import CoreGraphics import Foundation enum MinimumAreaRectFinder { - static func minimumAreaShape(for points: [CGPoint]) -> Shape? { - minimumAreaEnclosingRectangle(for: points).map(Shape.init) + static func minimumAreaShape(for shapes: [Shape]) -> Shape { + let points = shapes.flatMap { shape in + [shape.bottomLeft, shape.bottomRight, shape.topLeft, shape.topRight] + } + return minimumAreaShape(for: points) } - private static func minimumAreaEnclosingRectangle(for points: [CGPoint]) -> (CGRect, Double)? { - guard points.count > 2 else { return nil } - + private static func minimumAreaShape(for points: [CGPoint]) -> Shape { var minArea = CGFloat.greatestFiniteMagnitude - var bestRect = CGRect.zero - var bestAngle = Double.zero + var bestShape = Shape.zero for i in 0.. CGPoint { @@ -51,62 +55,62 @@ enum MinimumAreaRectFinder { return CGRect(x: minX, y: minY, width: maxX - minX, height: maxY - minY) } - enum Orientation { - case collinear - case clockwise - case counterClockwise - } +// enum Orientation { +// case collinear +// case clockwise +// case counterClockwise +// } - private static func orientation(_ p: CGPoint, _ q: CGPoint, _ r: CGPoint) -> Orientation { - let val = (q.y - p.y) * (r.x - q.x) - (q.x - p.x) * (r.y - q.y) - if val == 0 { return .collinear } - return (val > 0) ? .clockwise : .counterClockwise - } +// private static func orientation(_ p: CGPoint, _ q: CGPoint, _ r: CGPoint) -> Orientation { +// let val = (q.y - p.y) * (r.x - q.x) - (q.x - p.x) * (r.y - q.y) +// if val == 0 { return .collinear } +// return (val > 0) ? .clockwise : .counterClockwise +// } // Helper function to find the square of the distance between two points - private static func distanceSquared(_ p1: CGPoint, _ p2: CGPoint) -> CGFloat { - return (p1.x - p2.x) * (p1.x - p2.x) + (p1.y - p2.y) * (p1.y - p2.y) - } +// private static func distanceSquared(_ p1: CGPoint, _ p2: CGPoint) -> CGFloat { +// return (p1.x - p2.x) * (p1.x - p2.x) + (p1.y - p2.y) * (p1.y - p2.y) +// } // Function to find the convex hull using Graham Scan algorithm - static func convexHull(for points: [CGPoint]) -> [CGPoint] { - guard points.count >= 3 else { return points } - - // Find the point with the lowest y-coordinate, break ties by x-coordinate - var minYPoint = points[0] - var minYIndex = 0 - for (index, point) in points.enumerated() { - if (point.y < minYPoint.y) || (point.y == minYPoint.y && point.x < minYPoint.x) { - minYPoint = point - minYIndex = index - } - } - - // Place the bottom-most point at the first position - var sortedPoints = points - sortedPoints.swapAt(0, minYIndex) - - // Sort the remaining points based on the polar angle with the first point - let p0 = sortedPoints[0] - sortedPoints = sortedPoints.sorted { (p1, p2) -> Bool in - let o = orientation(p0, p1, p2) - if o == .collinear { - return distanceSquared(p0, p1) < distanceSquared(p0, p2) - } - return o == .counterClockwise - } - - // Create an empty stack and push the first three points to it - var stack: [CGPoint] = [sortedPoints[0], sortedPoints[1], sortedPoints[2]] - - // Process the remaining points - for i in 3.. 1 && orientation(stack[stack.count - 2], stack.last!, sortedPoints[i]) != .counterClockwise { - stack.removeLast() - } - stack.append(sortedPoints[i]) - } - - return stack - } +// static func convexHull(for points: [CGPoint]) -> [CGPoint] { +// guard points.count >= 3 else { return points } +// +// // Find the point with the lowest y-coordinate, break ties by x-coordinate +// var minYPoint = points[0] +// var minYIndex = 0 +// for (index, point) in points.enumerated() { +// if (point.y < minYPoint.y) || (point.y == minYPoint.y && point.x < minYPoint.x) { +// minYPoint = point +// minYIndex = index +// } +// } +// +// // Place the bottom-most point at the first position +// var sortedPoints = points +// sortedPoints.swapAt(0, minYIndex) +// +// // Sort the remaining points based on the polar angle with the first point +// let p0 = sortedPoints[0] +// sortedPoints = sortedPoints.sorted { (p1, p2) -> Bool in +// let o = orientation(p0, p1, p2) +// if o == .collinear { +// return distanceSquared(p0, p1) < distanceSquared(p0, p2) +// } +// return o == .counterClockwise +// } +// +// // Create an empty stack and push the first three points to it +// var stack: [CGPoint] = [sortedPoints[0], sortedPoints[1], sortedPoints[2]] +// +// // Process the remaining points +// for i in 3.. 1 && orientation(stack[stack.count - 2], stack.last!, sortedPoints[i]) != .counterClockwise { +// stack.removeLast() +// } +// stack.append(sortedPoints[i]) +// } +// +// return stack +// } } diff --git a/Modules/Capabilities/Geometry/Sources/Shape.swift b/Modules/Capabilities/Geometry/Sources/Shape.swift index c37db910..ef17369e 100644 --- a/Modules/Capabilities/Geometry/Sources/Shape.swift +++ b/Modules/Capabilities/Geometry/Sources/Shape.swift @@ -21,24 +21,6 @@ public struct Shape: Hashable { self.topRight = topRight } - public init(rect: CGRect, angle: Double) { - let rotationTransform = CGAffineTransform(rotationAngle: angle) - let translationTransform = CGAffineTransform(translationX: -rect.minX, y: -rect.maxY) - let inverseTranslate = CGAffineTransform(translationX: rect.minX, y: rect.maxY) - - let finalTransform = translationTransform.concatenating(rotationTransform).concatenating(inverseTranslate) - - let points = [CGPoint(x: rect.minX, y: rect.maxY), CGPoint(x: rect.maxX, y: rect.maxY), CGPoint(x: rect.minX, y: rect.minY), CGPoint(x: rect.maxX, y: rect.minY)] - let transformedPoints = points.map { $0.applying(finalTransform) } - - self.init( - bottomLeft: transformedPoints[0], - bottomRight: transformedPoints[1], - topLeft: transformedPoints[2], - topRight: transformedPoints[3] - ) - } - public func scaled(to imageSize: CGSize) -> Shape { return Shape( bottomLeft: CGPoint.flippedPoint(from: bottomLeft, scaledTo: imageSize), @@ -136,7 +118,7 @@ public struct Shape: Hashable { } public func union(_ other: Shape) -> Shape { - MinimumAreaRectFinder.minimumAreaShape(for: [self.bottomLeft, self.bottomRight, self.topLeft, self.topRight, other.bottomLeft, other.bottomRight, other.topLeft, other.topRight]) ?? self + MinimumAreaRectFinder.minimumAreaShape(for: [self, other]) } static let zero = Shape(bottomLeft: .zero, bottomRight: .zero, topLeft: .zero, topRight: .zero) diff --git a/Modules/Capabilities/Geometry/Tests/Asserts.swift b/Modules/Capabilities/Geometry/Tests/Asserts.swift new file mode 100644 index 00000000..c903c30c --- /dev/null +++ b/Modules/Capabilities/Geometry/Tests/Asserts.swift @@ -0,0 +1,16 @@ +// Created by Geoff Pado on 6/19/24. +// Copyright © 2024 Cocoatype, LLC. All rights reserved. + +import Geometry +import XCTest + +func XCTAssertEqual(_ lhs: Shape, _ rhs: Shape, accuracy: Double) { + let lhsPoints = [lhs.bottomLeft, lhs.bottomRight, lhs.topLeft, lhs.topRight] + let rhsPoints = [rhs.bottomLeft, rhs.bottomRight, rhs.topLeft, rhs.topRight] + + for point in lhsPoints { + if !rhsPoints.contains(where: { abs($0.x - point.x) <= accuracy && abs($0.y - point.y) <= accuracy }) { + XCTFail("XCTAssertEqual failed: (\"\(String(describing: lhs))\") is not equal to (\"\(String(describing: rhs))\")") + } + } +} diff --git a/Modules/Capabilities/Geometry/Tests/MinimumAreaRectFinderTests.swift b/Modules/Capabilities/Geometry/Tests/MinimumAreaRectFinderTests.swift new file mode 100644 index 00000000..c67958fd --- /dev/null +++ b/Modules/Capabilities/Geometry/Tests/MinimumAreaRectFinderTests.swift @@ -0,0 +1,65 @@ +// Created by Geoff Pado on 6/18/24. +// Copyright © 2024 Cocoatype, LLC. All rights reserved. + +import XCTest + +@testable import Geometry + +class MinimumAreaRectFinderTests: XCTestCase { + func testMinimumAreaForRectWithNonIntegralOrigin() { + let shape = Shape( + bottomLeft: CGPoint(x: 50.0000000000001, y: 150.0000000000001), + bottomRight: CGPoint(x: 150.0000000000001, y: 150.0000000000001), + topLeft: CGPoint(x: 50.0000000000001, y: 50.0000000000001), + topRight: CGPoint(x: 150.0000000000001, y: 50.0000000000001) + ) + + let actualShape = MinimumAreaRectFinder.minimumAreaShape(for: [shape]) + + XCTAssertEqual(actualShape, shape, accuracy: 0.01) + } + + func testMinimumAreaForRectWithNonIntegralSize() { + let shape = Shape( + bottomLeft: CGPoint(x: 50.0, y: 150.0000000000001), + bottomRight: CGPoint(x: 150.0000000000001, y: 150.0000000000001), + topLeft: CGPoint(x: 50.0, y: 50.0), + topRight: CGPoint(x: 150.0000000000001, y: 50.0) + ) + + XCTAssertEqual(shape, MinimumAreaRectFinder.minimumAreaShape(for: [shape]), accuracy: 0.01) + } + + func testMinimumAreaForRectWithInverseIntegralSize() { + let shape = Shape( + bottomLeft: CGPoint(x: 50.0000000000001, y: 150.0), + bottomRight: CGPoint(x: 150.0, y: 150.0), + topLeft: CGPoint(x: 50.0000000000001, y: 50.0000000000001), + topRight: CGPoint(x: 150.0, y: 50.0000000000001) + ) + + XCTAssertEqual(shape, MinimumAreaRectFinder.minimumAreaShape(for: [shape]), accuracy: 0.01) + } + + func testMinimumAreaForIntegralShape() { + let shape = Shape( + bottomLeft: CGPoint(x: 405.0, y: 1009.0), + bottomRight: CGPoint(x: 432.0, y: 1009.0), + topLeft: CGPoint(x: 405.0, y: 968.0), + topRight: CGPoint(x: 432.0, y: 968.0) + ) + + XCTAssertEqual(shape, MinimumAreaRectFinder.minimumAreaShape(for: [shape]), accuracy: 0.01) + } + + func testMinimumAreaForNonIntegralShape() { + let shape = Shape( + bottomLeft: CGPoint(x: 405.0, y: 1009.0000000000001), + bottomRight: CGPoint(x: 432.0, y: 1009.0000000000001), + topLeft: CGPoint(x: 405.0, y: 968.0000000000002), + topRight: CGPoint(x: 432.0, y: 968.0000000000002) + ) + + XCTAssertEqual(shape, MinimumAreaRectFinder.minimumAreaShape(for: [shape]), accuracy: 0.01) + } +} diff --git a/Modules/Capabilities/Geometry/Tests/ShapeTests.swift b/Modules/Capabilities/Geometry/Tests/ShapeTests.swift index 97ca1944..31461cfe 100644 --- a/Modules/Capabilities/Geometry/Tests/ShapeTests.swift +++ b/Modules/Capabilities/Geometry/Tests/ShapeTests.swift @@ -53,4 +53,55 @@ final class ShapeTests: XCTestCase { let unionShape = firstShape.union(secondShape) XCTAssert(unionShape.path.isEqual(to: expectedShape.path, accuracy: 0.01)) } + + func testUnionOfFiveShapes() { + let shapes = [ + Shape( + bottomLeft: CGPoint(x: 405.0, y: 1009.0000000000001), + bottomRight: CGPoint(x: 432.0, y: 1009.0000000000001), + topLeft: CGPoint(x: 405.0, y: 968.0000000000002), + topRight: CGPoint(x: 432.0, y: 968.0000000000002) + ), + Shape( + bottomLeft: CGPoint(x: 435.0, y: 1008.0), + bottomRight: CGPoint(x: 500.0, y: 1008.0), + topLeft: CGPoint(x: 435.0, y: 968.0000000000002), + topRight: CGPoint(x: 500.0, y: 968.0000000000002) + ), + Shape( + bottomLeft: CGPoint(x: 503.0, y: 1008.0), + bottomRight: CGPoint(x: 522.0, y: 1008.0), + topLeft: CGPoint(x: 503.0, y: 951.0), + topRight: CGPoint(x: 522.0, y: 951.0) + ), + Shape( + bottomLeft: CGPoint(x: 525.0, y: 1021.0000000000001), + bottomRight: CGPoint(x: 566.0, y: 1021.0000000000001), + topLeft: CGPoint(x: 525.0, y: 968.0000000000002), + topRight: CGPoint(x: 566.0, y: 968.0000000000002) + ), + Shape( + bottomLeft: CGPoint(x: 568.0, y: 1009.0000000000001), + bottomRight: CGPoint(x: 603.0, y: 1009.0000000000001), + topLeft: CGPoint(x: 568.0, y: 968.0000000000002), + topRight: CGPoint(x: 603.0, y: 968.0000000000002) + ), + ] + + let expectedShape = Shape( + bottomLeft: CGPoint(x: 405.0, y: 1021.0), + bottomRight: CGPoint(x: 603.0, y: 1021.0), + topLeft: CGPoint(x: 405.0, y: 951.0), + topRight: CGPoint(x: 603.0, y: 951.0) + ) + + var remainingShapes = shapes + let firstShape = remainingShapes.removeFirst() + + let unionShape = remainingShapes.reduce(firstShape) { combinedShape, newShape in + combinedShape.union(newShape) + } + + XCTAssertEqual(unionShape, expectedShape, accuracy: 0.01) + } } diff --git a/Modules/Legacy/Editing/Sources/Editing View/Observation View/PhotoEditingObservationCalculator.swift b/Modules/Legacy/Editing/Sources/Editing View/Observation View/PhotoEditingObservationCalculator.swift index a68a83f6..14ae44cc 100644 --- a/Modules/Legacy/Editing/Sources/Editing View/Observation View/PhotoEditingObservationCalculator.swift +++ b/Modules/Legacy/Editing/Sources/Editing View/Observation View/PhotoEditingObservationCalculator.swift @@ -75,8 +75,8 @@ actor PhotoEditingObservationCalculator { let combinedObservations = remainingObservations.map { parent, children in var children = children let firstShape = children.removeFirst().bounds - let combinedShape = children.reduce(into: firstShape) { combinedShape, observation in - combinedShape = combinedShape.union(observation.bounds) + let combinedShape = children.reduce(firstShape) { combinedShape, observation in + combinedShape.union(observation.bounds) } return CharacterObservation(bounds: combinedShape, textObservationUUID: parent.textObservationUUID) From 29d19bd80156d4b54a75d00aa61275d70c00f48c Mon Sep 17 00:00:00 2001 From: Geoff Pado Date: Wed, 19 Jun 2024 02:03:24 -0700 Subject: [PATCH 08/16] Implement new method for determining unrotated rect --- .../Brushes/Sources/BrushStampFactory.swift | 13 +- .../Sources/OverlayPreferencesView.swift | 2 + .../Defaults/Sources/DefaultsKey.swift | 1 + .../Sources/EmotionalSupportVariable.swift | 13 ++ ...der.swift => MinimumAreaShapeFinder.swift} | 19 ++- .../Capabilities/Geometry/Sources/Shape.swift | 65 ++++----- .../Capabilities/Geometry/Tests/Asserts.swift | 7 +- .../Tests/MinimumAreaRectFinderTests.swift | 65 --------- .../Tests/MinimumAreaShapeFinderTests.swift | 136 ++++++++++++++++++ .../Geometry/Tests/ShapeTests.swift | 80 +---------- .../CharacterObservationRedaction.swift | 7 +- .../Redactions/WordObservationRedaction.swift | 5 +- .../PhotoEditingObservationDebugView.swift | 17 ++- .../PhotoEditingObservationCalculator.swift | 23 ++- ...oEditingObservationVisualizationView.swift | 18 +-- .../Workspace/RedactionPathLayer.swift | 13 +- 16 files changed, 243 insertions(+), 241 deletions(-) create mode 100644 Modules/Capabilities/Geometry/Sources/EmotionalSupportVariable.swift rename Modules/Capabilities/Geometry/Sources/{MinimumAreaRectFinder.swift => MinimumAreaShapeFinder.swift} (91%) delete mode 100644 Modules/Capabilities/Geometry/Tests/MinimumAreaRectFinderTests.swift create mode 100644 Modules/Capabilities/Geometry/Tests/MinimumAreaShapeFinderTests.swift diff --git a/Modules/Capabilities/Brushes/Sources/BrushStampFactory.swift b/Modules/Capabilities/Brushes/Sources/BrushStampFactory.swift index 89a0c56c..51288991 100644 --- a/Modules/Capabilities/Brushes/Sources/BrushStampFactory.swift +++ b/Modules/Capabilities/Brushes/Sources/BrushStampFactory.swift @@ -40,12 +40,11 @@ import ErrorHandling import Geometry import UIKit -public class BrushStampFactory: NSObject { +public enum BrushStampFactory { public static func brushImages(for shape: Shape, color: UIColor, scale: CGFloat) throws -> (CGImage, CGImage) { - let startHeight = shape.topLeft.distance(to: shape.bottomLeft) - let startImage = BrushStampFactory.brushStart(scaledToHeight: startHeight, color: color) - let endHeight = shape.topRight.distance(to: shape.bottomRight) - let endImage = BrushStampFactory.brushEnd(scaledToHeight: endHeight, color: color) + let height = shape.unionDotShapeDotShapeDotUnionCrash.geometryStreamer.height + let startImage = BrushStampFactory.brushStart(scaledToHeight: height, color: color) + let endImage = BrushStampFactory.brushEnd(scaledToHeight: height, color: color) guard let startCGImage = startImage.cgImage(scale: scale), let endCGImage = endImage.cgImage(scale: scale) @@ -61,7 +60,7 @@ public class BrushStampFactory: NSObject { } private static func brushStart(scaledToHeight height: CGFloat, color: UIColor) -> UIImage { - guard let startImage = UIImage(named: "Brush Start", in: Bundle(for: BrushStampFactory.self), compatibleWith: nil) + guard let startImage = UIImage(named: "Brush Start", in: .module, compatibleWith: nil) else { ErrorHandler().crash("Unable to load brush start image") } let brushScale = height / startImage.size.height @@ -79,7 +78,7 @@ public class BrushStampFactory: NSObject { } private static func brushEnd(scaledToHeight height: CGFloat, color: UIColor) -> UIImage { - guard let endImage = UIImage(named: "Brush End", in: Bundle(for: BrushStampFactory.self), compatibleWith: nil) + guard let endImage = UIImage(named: "Brush End", in: .module, compatibleWith: nil) else { ErrorHandler().crash("Unable to load brush end image") } let brushScale = height / endImage.size.height diff --git a/Modules/Capabilities/DebugOverlay/Sources/OverlayPreferencesView.swift b/Modules/Capabilities/DebugOverlay/Sources/OverlayPreferencesView.swift index f1f5eed2..278bd337 100644 --- a/Modules/Capabilities/DebugOverlay/Sources/OverlayPreferencesView.swift +++ b/Modules/Capabilities/DebugOverlay/Sources/OverlayPreferencesView.swift @@ -11,6 +11,7 @@ public struct OverlayPreferencesView: View { @Defaults.Binding(key: .showDetectedCharactersOverlay) private var isDetectedCharactersOverlayEnabled: Bool @Defaults.Binding(key: .showRecognizedTextOverlay) private var isRecognizedTextOverlayEnabled: Bool @Defaults.Binding(key: .showCalculatedOverlay) private var isCalculatedOverlayEnabled: Bool + @Defaults.Binding(key: .showCombinedOverlay) private var isCombinedOverlayEnabled: Bool public var body: some View { List { @@ -18,6 +19,7 @@ public struct OverlayPreferencesView: View { PreferencesCell(isOn: $isDetectedCharactersOverlayEnabled, title: "Detected Characters", color: .blue) PreferencesCell(isOn: $isRecognizedTextOverlayEnabled, title: "Recognized Text", color: .yellow) PreferencesCell(isOn: $isCalculatedOverlayEnabled, title: "Calculated Area", color: .green) + PreferencesCell(isOn: $isCombinedOverlayEnabled, title: "Combined Area", color: .purple) } } diff --git a/Modules/Capabilities/Defaults/Sources/DefaultsKey.swift b/Modules/Capabilities/Defaults/Sources/DefaultsKey.swift index 2b304ff2..9b84ef08 100644 --- a/Modules/Capabilities/Defaults/Sources/DefaultsKey.swift +++ b/Modules/Capabilities/Defaults/Sources/DefaultsKey.swift @@ -19,5 +19,6 @@ extension Defaults { case showDetectedCharactersOverlay = "Defaults.Keys.showDetectedCharactersOverlay" case showRecognizedTextOverlay = "Defaults.Keys.showRecognizedTextOverlay" case showCalculatedOverlay = "Defaults.Keys.showCalculatedOverlay" + case showCombinedOverlay = "Defaults.Keys.showCombinedOverlay" } } diff --git a/Modules/Capabilities/Geometry/Sources/EmotionalSupportVariable.swift b/Modules/Capabilities/Geometry/Sources/EmotionalSupportVariable.swift new file mode 100644 index 00000000..0f1c26bd --- /dev/null +++ b/Modules/Capabilities/Geometry/Sources/EmotionalSupportVariable.swift @@ -0,0 +1,13 @@ +// Created by Geoff Pado on 6/19/24. +// Copyright © 2024 Cocoatype, LLC. All rights reserved. + +import CoreGraphics + +// this type proudly sponsored by @KaenAitch +// between June 17th and June 18th, 2024 +public struct EmotionalSupportVariable { + public let geometryStreamer: CGRect + public let thisGuyHeadBang: Double +} +// thank you for your support for this channel @KaenAitch + diff --git a/Modules/Capabilities/Geometry/Sources/MinimumAreaRectFinder.swift b/Modules/Capabilities/Geometry/Sources/MinimumAreaShapeFinder.swift similarity index 91% rename from Modules/Capabilities/Geometry/Sources/MinimumAreaRectFinder.swift rename to Modules/Capabilities/Geometry/Sources/MinimumAreaShapeFinder.swift index 22b512db..70734db2 100644 --- a/Modules/Capabilities/Geometry/Sources/MinimumAreaRectFinder.swift +++ b/Modules/Capabilities/Geometry/Sources/MinimumAreaShapeFinder.swift @@ -4,12 +4,9 @@ import CoreGraphics import Foundation -enum MinimumAreaRectFinder { - static func minimumAreaShape(for shapes: [Shape]) -> Shape { - let points = shapes.flatMap { shape in - [shape.bottomLeft, shape.bottomRight, shape.topLeft, shape.topRight] - } - return minimumAreaShape(for: points) +public enum MinimumAreaShapeFinder { + public static func minimumAreaShape(for shapes: [Shape]) -> Shape { + minimumAreaShape(for: shapes.flatMap(\.inverseTranslateRotateTransform)) } private static func minimumAreaShape(for points: [CGPoint]) -> Shape { @@ -60,19 +57,19 @@ enum MinimumAreaRectFinder { // case clockwise // case counterClockwise // } - +// // private static func orientation(_ p: CGPoint, _ q: CGPoint, _ r: CGPoint) -> Orientation { // let val = (q.y - p.y) * (r.x - q.x) - (q.x - p.x) * (r.y - q.y) // if val == 0 { return .collinear } // return (val > 0) ? .clockwise : .counterClockwise // } - - // Helper function to find the square of the distance between two points +// +// // Helper function to find the square of the distance between two points // private static func distanceSquared(_ p1: CGPoint, _ p2: CGPoint) -> CGFloat { // return (p1.x - p2.x) * (p1.x - p2.x) + (p1.y - p2.y) * (p1.y - p2.y) // } - - // Function to find the convex hull using Graham Scan algorithm +// +// // Function to find the convex hull using Graham Scan algorithm // static func convexHull(for points: [CGPoint]) -> [CGPoint] { // guard points.count >= 3 else { return points } // diff --git a/Modules/Capabilities/Geometry/Sources/Shape.swift b/Modules/Capabilities/Geometry/Sources/Shape.swift index ef17369e..e89707e3 100644 --- a/Modules/Capabilities/Geometry/Sources/Shape.swift +++ b/Modules/Capabilities/Geometry/Sources/Shape.swift @@ -71,54 +71,37 @@ public struct Shape: Hashable { ) } - // this section of code proudly sponsored by @KaenAitch - // between June 17th and June 18th, 2024 - func geometryStreamer(reversed: Bool) -> CGAffineTransform { - let reversedValue: Double = (reversed ? 1 : -1) - return CGAffineTransform(translationX: bottomLeft.x * reversedValue, y: bottomLeft.y * reversedValue) + // inverseTranslateRotateTransform by @KaenAitch on 2024-06-17 + // the set of points in this Shape + public var inverseTranslateRotateTransform: [CGPoint] { + [bottomLeft, bottomRight, topLeft, topRight] } - func thisGuyHeadBang(reversed: Bool) -> CGAffineTransform { - CGAffineTransform(rotationAngle: angle * (reversed ? -1 : 1)) - } + // unionDotShapeDotShapeDotUnionCrash by @AdamWulf on 2024-06-17 + // the unrotated form of this shape + public var unionDotShapeDotShapeDotUnionCrash: EmotionalSupportVariable { + // Sort points by x-coordinate + let sortedPoints = inverseTranslateRotateTransform.sorted { $0.x < $1.x } - func emotionalSupportVariable(reversed: Bool) -> CGAffineTransform { - CGAffineTransform(translationX: unionDotShapeDotShapeDotUnionCrash.width / (reversed ? -2 : 2), y: unionDotShapeDotShapeDotUnionCrash.height / (reversed ? 2 : -2)) - } + // Determine the two leftmost and two rightmost points + let leftPoints = [sortedPoints[0], sortedPoints[1]].sorted { $0.y < $1.y } + let rightPoints = [sortedPoints[2], sortedPoints[3]].sorted { $0.y < $1.y } - public var inverseTranslateRotateTransform: CGAffineTransform { - return geometryStreamer(reversed: false) - .concatenating(thisGuyHeadBang(reversed: true)) - .concatenating(geometryStreamer(reversed: true)) - } + // Calculate the center points of the left and right sides + let leftCenter = CGPoint(x: (leftPoints[0].x + leftPoints[1].x) / 2, y: (leftPoints[0].y + leftPoints[1].y) / 2) + let rightCenter = CGPoint(x: (rightPoints[0].x + rightPoints[1].x) / 2, y: (rightPoints[0].y + rightPoints[1].y) / 2) - public var forwardTranslateRotateTransform: CGAffineTransform { - return emotionalSupportVariable(reversed: false) - .concatenating(thisGuyHeadBang(reversed: false)) - .concatenating(emotionalSupportVariable(reversed: true)) - } - // thank you for your support for this channel @KaenAitch + // Calculate the angle of rotation + let angle = atan2(rightCenter.y - leftCenter.y, rightCenter.x - leftCenter.x) - // unionDotShapeDotShapeDotUnionCrash by @AdamWulf on 2024-06-17 - // the unrotated rect for this shape - public var unionDotShapeDotShapeDotUnionCrash: CGRect { - let inverseTopLeft = topLeft.applying(inverseTranslateRotateTransform) - let inverseBottomRight = bottomRight.applying(inverseTranslateRotateTransform) - - return CGRect( - origin: CGPoint( - x: inverseTopLeft.x, - y: inverseTopLeft.y - ), - size: CGSize( - width: inverseBottomRight.x - inverseTopLeft.x, - height: inverseBottomRight.y - inverseTopLeft.y - ) - ) - } + // Calculate the width and height of the unrotated rectangle + let width = hypot(rightCenter.x - leftCenter.x, rightCenter.y - leftCenter.y) + let height = hypot(leftPoints[0].x - leftPoints[1].x, leftPoints[0].y - leftPoints[1].y) + + // Create the unrotated CGRect + let rect = CGRect(origin: leftPoints[0], size: CGSize(width: width, height: height)) - public func union(_ other: Shape) -> Shape { - MinimumAreaRectFinder.minimumAreaShape(for: [self, other]) + return EmotionalSupportVariable(geometryStreamer: rect, thisGuyHeadBang: angle) } static let zero = Shape(bottomLeft: .zero, bottomRight: .zero, topLeft: .zero, topRight: .zero) diff --git a/Modules/Capabilities/Geometry/Tests/Asserts.swift b/Modules/Capabilities/Geometry/Tests/Asserts.swift index c903c30c..b83ea9c7 100644 --- a/Modules/Capabilities/Geometry/Tests/Asserts.swift +++ b/Modules/Capabilities/Geometry/Tests/Asserts.swift @@ -5,11 +5,8 @@ import Geometry import XCTest func XCTAssertEqual(_ lhs: Shape, _ rhs: Shape, accuracy: Double) { - let lhsPoints = [lhs.bottomLeft, lhs.bottomRight, lhs.topLeft, lhs.topRight] - let rhsPoints = [rhs.bottomLeft, rhs.bottomRight, rhs.topLeft, rhs.topRight] - - for point in lhsPoints { - if !rhsPoints.contains(where: { abs($0.x - point.x) <= accuracy && abs($0.y - point.y) <= accuracy }) { + for point in lhs.inverseTranslateRotateTransform { + if !rhs.inverseTranslateRotateTransform.contains(where: { abs($0.x - point.x) <= accuracy && abs($0.y - point.y) <= accuracy }) { XCTFail("XCTAssertEqual failed: (\"\(String(describing: lhs))\") is not equal to (\"\(String(describing: rhs))\")") } } diff --git a/Modules/Capabilities/Geometry/Tests/MinimumAreaRectFinderTests.swift b/Modules/Capabilities/Geometry/Tests/MinimumAreaRectFinderTests.swift deleted file mode 100644 index c67958fd..00000000 --- a/Modules/Capabilities/Geometry/Tests/MinimumAreaRectFinderTests.swift +++ /dev/null @@ -1,65 +0,0 @@ -// Created by Geoff Pado on 6/18/24. -// Copyright © 2024 Cocoatype, LLC. All rights reserved. - -import XCTest - -@testable import Geometry - -class MinimumAreaRectFinderTests: XCTestCase { - func testMinimumAreaForRectWithNonIntegralOrigin() { - let shape = Shape( - bottomLeft: CGPoint(x: 50.0000000000001, y: 150.0000000000001), - bottomRight: CGPoint(x: 150.0000000000001, y: 150.0000000000001), - topLeft: CGPoint(x: 50.0000000000001, y: 50.0000000000001), - topRight: CGPoint(x: 150.0000000000001, y: 50.0000000000001) - ) - - let actualShape = MinimumAreaRectFinder.minimumAreaShape(for: [shape]) - - XCTAssertEqual(actualShape, shape, accuracy: 0.01) - } - - func testMinimumAreaForRectWithNonIntegralSize() { - let shape = Shape( - bottomLeft: CGPoint(x: 50.0, y: 150.0000000000001), - bottomRight: CGPoint(x: 150.0000000000001, y: 150.0000000000001), - topLeft: CGPoint(x: 50.0, y: 50.0), - topRight: CGPoint(x: 150.0000000000001, y: 50.0) - ) - - XCTAssertEqual(shape, MinimumAreaRectFinder.minimumAreaShape(for: [shape]), accuracy: 0.01) - } - - func testMinimumAreaForRectWithInverseIntegralSize() { - let shape = Shape( - bottomLeft: CGPoint(x: 50.0000000000001, y: 150.0), - bottomRight: CGPoint(x: 150.0, y: 150.0), - topLeft: CGPoint(x: 50.0000000000001, y: 50.0000000000001), - topRight: CGPoint(x: 150.0, y: 50.0000000000001) - ) - - XCTAssertEqual(shape, MinimumAreaRectFinder.minimumAreaShape(for: [shape]), accuracy: 0.01) - } - - func testMinimumAreaForIntegralShape() { - let shape = Shape( - bottomLeft: CGPoint(x: 405.0, y: 1009.0), - bottomRight: CGPoint(x: 432.0, y: 1009.0), - topLeft: CGPoint(x: 405.0, y: 968.0), - topRight: CGPoint(x: 432.0, y: 968.0) - ) - - XCTAssertEqual(shape, MinimumAreaRectFinder.minimumAreaShape(for: [shape]), accuracy: 0.01) - } - - func testMinimumAreaForNonIntegralShape() { - let shape = Shape( - bottomLeft: CGPoint(x: 405.0, y: 1009.0000000000001), - bottomRight: CGPoint(x: 432.0, y: 1009.0000000000001), - topLeft: CGPoint(x: 405.0, y: 968.0000000000002), - topRight: CGPoint(x: 432.0, y: 968.0000000000002) - ) - - XCTAssertEqual(shape, MinimumAreaRectFinder.minimumAreaShape(for: [shape]), accuracy: 0.01) - } -} diff --git a/Modules/Capabilities/Geometry/Tests/MinimumAreaShapeFinderTests.swift b/Modules/Capabilities/Geometry/Tests/MinimumAreaShapeFinderTests.swift new file mode 100644 index 00000000..1f28a361 --- /dev/null +++ b/Modules/Capabilities/Geometry/Tests/MinimumAreaShapeFinderTests.swift @@ -0,0 +1,136 @@ +// Created by Geoff Pado on 6/18/24. +// Copyright © 2024 Cocoatype, LLC. All rights reserved. + +import XCTest + +@testable import Geometry + +class MinimumAreaShapeFinderTests: XCTestCase { + func testMinimumAreaForRectWithNonIntegralOrigin() { + let shape = Shape( + bottomLeft: CGPoint(x: 50.0000000000001, y: 150.0000000000001), + bottomRight: CGPoint(x: 150.0000000000001, y: 150.0000000000001), + topLeft: CGPoint(x: 50.0000000000001, y: 50.0000000000001), + topRight: CGPoint(x: 150.0000000000001, y: 50.0000000000001) + ) + + let actualShape = MinimumAreaShapeFinder.minimumAreaShape(for: [shape]) + + XCTAssertEqual(actualShape, shape, accuracy: 0.01) + } + + func testMinimumAreaForRectWithNonIntegralSize() { + let shape = Shape( + bottomLeft: CGPoint(x: 50.0, y: 150.0000000000001), + bottomRight: CGPoint(x: 150.0000000000001, y: 150.0000000000001), + topLeft: CGPoint(x: 50.0, y: 50.0), + topRight: CGPoint(x: 150.0000000000001, y: 50.0) + ) + + XCTAssertEqual(shape, MinimumAreaShapeFinder.minimumAreaShape(for: [shape]), accuracy: 0.01) + } + + func testMinimumAreaForRectWithInverseIntegralSize() { + let shape = Shape( + bottomLeft: CGPoint(x: 50.0000000000001, y: 150.0), + bottomRight: CGPoint(x: 150.0, y: 150.0), + topLeft: CGPoint(x: 50.0000000000001, y: 50.0000000000001), + topRight: CGPoint(x: 150.0, y: 50.0000000000001) + ) + + XCTAssertEqual(shape, MinimumAreaShapeFinder.minimumAreaShape(for: [shape]), accuracy: 0.01) + } + + func testMinimumAreaForIntegralShape() { + let shape = Shape( + bottomLeft: CGPoint(x: 405.0, y: 1009.0), + bottomRight: CGPoint(x: 432.0, y: 1009.0), + topLeft: CGPoint(x: 405.0, y: 968.0), + topRight: CGPoint(x: 432.0, y: 968.0) + ) + + XCTAssertEqual(shape, MinimumAreaShapeFinder.minimumAreaShape(for: [shape]), accuracy: 0.01) + } + + func testMinimumAreaForNonIntegralShape() { + let shape = Shape( + bottomLeft: CGPoint(x: 405.0, y: 1009.0000000000001), + bottomRight: CGPoint(x: 432.0, y: 1009.0000000000001), + topLeft: CGPoint(x: 405.0, y: 968.0000000000002), + topRight: CGPoint(x: 432.0, y: 968.0000000000002) + ) + + XCTAssertEqual(shape, MinimumAreaShapeFinder.minimumAreaShape(for: [shape]), accuracy: 0.01) + } + + func testMinimumAreaForRotatedShapes() { + let firstShape = Shape( + bottomLeft: CGPoint(x: 126.74248082927248, y: 1112.9254289499572), + bottomRight: CGPoint(x: 342.6968497848278, y: 948.305784288113), + topLeft: CGPoint(x: 53.420223253429214, y: 1016.2733621454367), + topRight: CGPoint(x: 269.7560323061029, y: 852.1565330586908) + ) + + let secondShape = Shape( + bottomLeft: CGPoint(x: 354.71550618850557, y: 939.1881821032728), + bottomRight: CGPoint(x: 805.0458405740253, y: 598.3504663849724), + topLeft: CGPoint(x: 281.7746887097807, y: 843.0389308738502), + topRight: CGPoint(x: 731.723582998182, y: 501.6983995804519) + ) + + let expectedShape = Shape( + bottomLeft: CGPoint(x: 126.74248082927248, y: 1112.9254289499572), + bottomRight: CGPoint(x: 805.0458405740253, y: 598.3504663849724), + topLeft: CGPoint(x: 53.420223253429214, y: 1016.2733621454367), + topRight: CGPoint(x: 731.723582998182, y: 501.6983995804519) + ) + + let unionShape = MinimumAreaShapeFinder.minimumAreaShape(for: [firstShape, secondShape]) + XCTAssertEqual(unionShape, expectedShape, accuracy: 0.01) + } + + func testMinimumAreaForFiveShapes() { + let shapes = [ + Shape( + bottomLeft: CGPoint(x: 405.0, y: 1009.0000000000001), + bottomRight: CGPoint(x: 432.0, y: 1009.0000000000001), + topLeft: CGPoint(x: 405.0, y: 968.0000000000002), + topRight: CGPoint(x: 432.0, y: 968.0000000000002) + ), + Shape( + bottomLeft: CGPoint(x: 435.0, y: 1008.0), + bottomRight: CGPoint(x: 500.0, y: 1008.0), + topLeft: CGPoint(x: 435.0, y: 968.0000000000002), + topRight: CGPoint(x: 500.0, y: 968.0000000000002) + ), + Shape( + bottomLeft: CGPoint(x: 503.0, y: 1008.0), + bottomRight: CGPoint(x: 522.0, y: 1008.0), + topLeft: CGPoint(x: 503.0, y: 951.0), + topRight: CGPoint(x: 522.0, y: 951.0) + ), + Shape( + bottomLeft: CGPoint(x: 525.0, y: 1021.0000000000001), + bottomRight: CGPoint(x: 566.0, y: 1021.0000000000001), + topLeft: CGPoint(x: 525.0, y: 968.0000000000002), + topRight: CGPoint(x: 566.0, y: 968.0000000000002) + ), + Shape( + bottomLeft: CGPoint(x: 568.0, y: 1009.0000000000001), + bottomRight: CGPoint(x: 603.0, y: 1009.0000000000001), + topLeft: CGPoint(x: 568.0, y: 968.0000000000002), + topRight: CGPoint(x: 603.0, y: 968.0000000000002) + ), + ] + + let expectedShape = Shape( + bottomLeft: CGPoint(x: 405.0, y: 1021.0), + bottomRight: CGPoint(x: 603.0, y: 1021.0), + topLeft: CGPoint(x: 405.0, y: 951.0), + topRight: CGPoint(x: 603.0, y: 951.0) + ) + + let unionShape = MinimumAreaShapeFinder.minimumAreaShape(for: shapes) + XCTAssertEqual(unionShape, expectedShape, accuracy: 0.01) + } +} diff --git a/Modules/Capabilities/Geometry/Tests/ShapeTests.swift b/Modules/Capabilities/Geometry/Tests/ShapeTests.swift index 31461cfe..63e90b73 100644 --- a/Modules/Capabilities/Geometry/Tests/ShapeTests.swift +++ b/Modules/Capabilities/Geometry/Tests/ShapeTests.swift @@ -28,80 +28,14 @@ final class ShapeTests: XCTestCase { XCTAssertTrue(shape.isNotEmpty) } - func testUnionOfRotatedShapes() { - let firstShape = Shape( - bottomLeft: CGPoint(x: 126.74248082927248, y: 1112.9254289499572), - bottomRight: CGPoint(x: 342.6968497848278, y: 948.305784288113), - topLeft: CGPoint(x: 53.420223253429214, y: 1016.2733621454367), - topRight: CGPoint(x: 269.7560323061029, y: 852.1565330586908) - ) - - let secondShape = Shape( - bottomLeft: CGPoint(x: 354.71550618850557, y: 939.1881821032728), - bottomRight: CGPoint(x: 805.0458405740253, y: 598.3504663849724), - topLeft: CGPoint(x: 281.7746887097807, y: 843.0389308738502), - topRight: CGPoint(x: 731.723582998182, y: 501.6983995804519) - ) - - let expectedShape = Shape( - bottomLeft: CGPoint(x: 126.74248082927248, y: 1112.9254289499572), - bottomRight: CGPoint(x: 805.0458405740253, y: 598.3504663849724), - topLeft: CGPoint(x: 53.420223253429214, y: 1016.2733621454367), - topRight: CGPoint(x: 731.723582998182, y: 501.6983995804519) - ) - - let unionShape = firstShape.union(secondShape) - XCTAssert(unionShape.path.isEqual(to: expectedShape.path, accuracy: 0.01)) - } - - func testUnionOfFiveShapes() { - let shapes = [ - Shape( - bottomLeft: CGPoint(x: 405.0, y: 1009.0000000000001), - bottomRight: CGPoint(x: 432.0, y: 1009.0000000000001), - topLeft: CGPoint(x: 405.0, y: 968.0000000000002), - topRight: CGPoint(x: 432.0, y: 968.0000000000002) - ), - Shape( - bottomLeft: CGPoint(x: 435.0, y: 1008.0), - bottomRight: CGPoint(x: 500.0, y: 1008.0), - topLeft: CGPoint(x: 435.0, y: 968.0000000000002), - topRight: CGPoint(x: 500.0, y: 968.0000000000002) - ), - Shape( - bottomLeft: CGPoint(x: 503.0, y: 1008.0), - bottomRight: CGPoint(x: 522.0, y: 1008.0), - topLeft: CGPoint(x: 503.0, y: 951.0), - topRight: CGPoint(x: 522.0, y: 951.0) - ), - Shape( - bottomLeft: CGPoint(x: 525.0, y: 1021.0000000000001), - bottomRight: CGPoint(x: 566.0, y: 1021.0000000000001), - topLeft: CGPoint(x: 525.0, y: 968.0000000000002), - topRight: CGPoint(x: 566.0, y: 968.0000000000002) - ), - Shape( - bottomLeft: CGPoint(x: 568.0, y: 1009.0000000000001), - bottomRight: CGPoint(x: 603.0, y: 1009.0000000000001), - topLeft: CGPoint(x: 568.0, y: 968.0000000000002), - topRight: CGPoint(x: 603.0, y: 968.0000000000002) - ), - ] - - let expectedShape = Shape( - bottomLeft: CGPoint(x: 405.0, y: 1021.0), - bottomRight: CGPoint(x: 603.0, y: 1021.0), - topLeft: CGPoint(x: 405.0, y: 951.0), - topRight: CGPoint(x: 603.0, y: 951.0) + func testRectForIllDefinedShape() { + let shape = Shape( + bottomLeft: CGPoint(x: 980.34822556083, y: 1495.0016611479539), + bottomRight: CGPoint(x: 265.68262147954465, y: 1491.5924762993577), + topLeft: CGPoint(x: 979.9312930237021, y: 1582.403006848055), + topRight: CGPoint(x: 265.26568894241666, y: 1578.9938219994588) ) - var remainingShapes = shapes - let firstShape = remainingShapes.removeFirst() - - let unionShape = remainingShapes.reduce(firstShape) { combinedShape, newShape in - combinedShape.union(newShape) - } - - XCTAssertEqual(unionShape, expectedShape, accuracy: 0.01) + dump(shape.unionDotShapeDotShapeDotUnionCrash) } } diff --git a/Modules/Capabilities/Redactions/Sources/Redactions/CharacterObservationRedaction.swift b/Modules/Capabilities/Redactions/Sources/Redactions/CharacterObservationRedaction.swift index 0068ec40..1c0ce042 100644 --- a/Modules/Capabilities/Redactions/Sources/Redactions/CharacterObservationRedaction.swift +++ b/Modules/Capabilities/Redactions/Sources/Redactions/CharacterObservationRedaction.swift @@ -25,6 +25,7 @@ extension Redaction { } #elseif canImport(UIKit) +import Geometry import Observations import UIKit @@ -38,11 +39,7 @@ extension Redaction { siblingObservations.append(characterObservation) result[textObservationUUID] = siblingObservations }.values.map { siblingObservations in - var siblingObservations = siblingObservations - let firstObservation = siblingObservations.removeFirst() - return siblingObservations.reduce(firstObservation.bounds, { currentRect, characterObservation in - currentRect.union(characterObservation.bounds) - }) + MinimumAreaShapeFinder.minimumAreaShape(for: siblingObservations.map(\.bounds)) }.map(RedactionPart.shape) self.init(color: color, parts: parts) diff --git a/Modules/Capabilities/Redactions/Sources/Redactions/WordObservationRedaction.swift b/Modules/Capabilities/Redactions/Sources/Redactions/WordObservationRedaction.swift index e2a0bf06..10d8fae5 100644 --- a/Modules/Capabilities/Redactions/Sources/Redactions/WordObservationRedaction.swift +++ b/Modules/Capabilities/Redactions/Sources/Redactions/WordObservationRedaction.swift @@ -2,6 +2,7 @@ // Copyright © 2020 Cocoatype, LLC. All rights reserved. #if canImport(UIKit) +import Geometry import Observations import UIKit @@ -13,9 +14,7 @@ extension Redaction { siblingObservations.append(wordObservation) result[textObservationUUID] = siblingObservations }.values.map { siblingObservations in - siblingObservations.reduce(siblingObservations[0].bounds, { currentRect, wordObservation in - currentRect.union(wordObservation.bounds) - }) + MinimumAreaShapeFinder.minimumAreaShape(for: siblingObservations.map(\.bounds)) }.map(RedactionPart.shape) self.init(color: color, parts: parts) diff --git a/Modules/Legacy/Editing/Sources/Editing View/Observation View/Debug/PhotoEditingObservationDebugView.swift b/Modules/Legacy/Editing/Sources/Editing View/Observation View/Debug/PhotoEditingObservationDebugView.swift index ad6343a6..55391752 100644 --- a/Modules/Legacy/Editing/Sources/Editing View/Observation View/Debug/PhotoEditingObservationDebugView.swift +++ b/Modules/Legacy/Editing/Sources/Editing View/Observation View/Debug/PhotoEditingObservationDebugView.swift @@ -4,6 +4,7 @@ @_implementationOnly import ClippingBezier import Combine import Defaults +import Geometry import Observations import UIKit @@ -40,6 +41,7 @@ class PhotoEditingObservationDebugView: PhotoEditingRedactionView { @Defaults.Value(key: .showDetectedCharactersOverlay) private var isDetectedCharactersOverlayEnabled: Bool @Defaults.Value(key: .showRecognizedTextOverlay) private var isRecognizedTextOverlayEnabled: Bool @Defaults.Value(key: .showCalculatedOverlay) private var isCalculatedOverlayEnabled: Bool + @Defaults.Value(key: .showCombinedOverlay) private var isCombinedOverlayEnabled: Bool private var cancellables = [any NSObjectProtocol]() private func subscribeToUpdates() { @@ -48,6 +50,7 @@ class PhotoEditingObservationDebugView: PhotoEditingRedactionView { cancellables.append(NotificationCenter.default.addObserver(for: _isDetectedCharactersOverlayEnabled, block: update)) cancellables.append(NotificationCenter.default.addObserver(for: _isRecognizedTextOverlayEnabled, block: update)) cancellables.append(NotificationCenter.default.addObserver(for: _isCalculatedOverlayEnabled, block: update)) + cancellables.append(NotificationCenter.default.addObserver(for: _isCombinedOverlayEnabled, block: update)) } private func updateDebugLayers() { @@ -88,15 +91,23 @@ class PhotoEditingObservationDebugView: PhotoEditingRedactionView { } let calculator = PhotoEditingObservationCalculator(detectedTextObservations: textObservations, recognizedTextObservations: recognizedTextObservations) - let calculatedObservations = await calculator.calculatedObservations + let calculatedObservations = await calculator.calculatedObservationsByUUID + let wordCharacterLayers: [PhotoEditingObservationDebugLayer] if isCalculatedOverlayEnabled { - wordCharacterLayers = calculatedObservations.map { (calculatedObservation: CharacterObservation) -> PhotoEditingObservationDebugLayer in + wordCharacterLayers = calculatedObservations.flatMap(\.value).map { (calculatedObservation: CharacterObservation) -> PhotoEditingObservationDebugLayer in PhotoEditingObservationDebugLayer(fillColor: .systemGreen, frame: bounds, shape: calculatedObservation.bounds) } } else { wordCharacterLayers = [] } - return textLayers + wordLayers + wordCharacterLayers + let combinedLayers: [PhotoEditingObservationDebugLayer] + if isCombinedOverlayEnabled { + combinedLayers = calculatedObservations.map { (_, observations) in + PhotoEditingObservationDebugLayer(fillColor: .systemPurple, frame: bounds, shape: MinimumAreaShapeFinder.minimumAreaShape(for: observations.map(\.bounds))) + } + } else { combinedLayers = [] } + + return textLayers + wordLayers + wordCharacterLayers + combinedLayers } } } diff --git a/Modules/Legacy/Editing/Sources/Editing View/Observation View/PhotoEditingObservationCalculator.swift b/Modules/Legacy/Editing/Sources/Editing View/Observation View/PhotoEditingObservationCalculator.swift index 14ae44cc..60938bb7 100644 --- a/Modules/Legacy/Editing/Sources/Editing View/Observation View/PhotoEditingObservationCalculator.swift +++ b/Modules/Legacy/Editing/Sources/Editing View/Observation View/PhotoEditingObservationCalculator.swift @@ -73,15 +73,28 @@ actor PhotoEditingObservationCalculator { unfulfilledObservations.contains(parent) == false } let combinedObservations = remainingObservations.map { parent, children in - var children = children - let firstShape = children.removeFirst().bounds - let combinedShape = children.reduce(firstShape) { combinedShape, observation in - combinedShape.union(observation.bounds) - } + let combinedShape = MinimumAreaShapeFinder.minimumAreaShape(for: children.map(\.bounds)) return CharacterObservation(bounds: combinedShape, textObservationUUID: parent.textObservationUUID) } return combinedObservations + childlessObservations + unfulfilledObservations + calculationPass.orphanedObservations } + + var calculatedObservationsByUUID: [UUID: [CharacterObservation]] { + return calculatedObservations.reduce([UUID: [CharacterObservation]]()) { dictionary, observation in + var observationsByUUID: [CharacterObservation] + if let existing = dictionary[observation.textObservationUUID] { + observationsByUUID = existing + } else { + observationsByUUID = [] + } + + observationsByUUID.append(observation) + + var newDictionary = dictionary + newDictionary[observation.textObservationUUID] = observationsByUUID + return newDictionary + } + } } diff --git a/Modules/Legacy/Editing/Sources/Editing View/Observation View/PhotoEditingObservationVisualizationView.swift b/Modules/Legacy/Editing/Sources/Editing View/Observation View/PhotoEditingObservationVisualizationView.swift index 7fe6db1c..9034b85b 100644 --- a/Modules/Legacy/Editing/Sources/Editing View/Observation View/PhotoEditingObservationVisualizationView.swift +++ b/Modules/Legacy/Editing/Sources/Editing View/Observation View/PhotoEditingObservationVisualizationView.swift @@ -146,23 +146,7 @@ class PhotoEditingObservationVisualizationView: PhotoEditingRedactionView { guard let textObservations, let recognizedTextObservations else { return [] } let calculator = PhotoEditingObservationCalculator(detectedTextObservations: textObservations, recognizedTextObservations: recognizedTextObservations) - let calculatedObservations = await calculator.calculatedObservations - - // reduce into dictionary by textObservationUUID - let observationsByUUID = calculatedObservations.reduce([UUID: [CharacterObservation]]()) { dictionary, observation in - var observationsByUUID: [CharacterObservation] - if let existing = dictionary[observation.textObservationUUID] { - observationsByUUID = existing - } else { - observationsByUUID = [] - } - - observationsByUUID.append(observation) - - var newDictionary = dictionary - newDictionary[observation.textObservationUUID] = observationsByUUID - return newDictionary - } + let observationsByUUID = await calculator.calculatedObservationsByUUID // map dictionary keys into redactions let redactions = observationsByUUID.compactMap { (_, value: [CharacterObservation]) in diff --git a/Modules/Legacy/Editing/Sources/Editing View/Workspace/RedactionPathLayer.swift b/Modules/Legacy/Editing/Sources/Editing View/Workspace/RedactionPathLayer.swift index 5faebece..4ea2b5e5 100644 --- a/Modules/Legacy/Editing/Sources/Editing View/Workspace/RedactionPathLayer.swift +++ b/Modules/Legacy/Editing/Sources/Editing View/Workspace/RedactionPathLayer.swift @@ -15,13 +15,14 @@ class RedactionPathLayer: CALayer { // youKnowWhatImAMoron by @nutterfi on 2024-06-17 // an affine transform to apply to the layer - let youKnowWhatImAMoron: CGAffineTransform + let youKnowWhatImAMoron: Double switch part { case .shape(let shape): let (startImage, endImage) = try BrushStampFactory.brushImages(for: shape, color: color, scale: scale) - let rect = shape.unionDotShapeDotShapeDotUnionCrash + let unrotated = shape.unionDotShapeDotShapeDotUnionCrash + let rect = unrotated.geometryStreamer // need to actually draw a larger extent on the corner gigiPath = CGRect( origin: CGPoint(x: rect.origin.x - Double(startImage.width) , y: rect.origin.y), @@ -30,7 +31,7 @@ class RedactionPathLayer: CALayer { height: rect.size.height ) ) - youKnowWhatImAMoron = shape.forwardTranslateRotateTransform + youKnowWhatImAMoron = unrotated.thisGuyHeadBang self.part = Part.shape(shape: shape, startImage: startImage, endImage: endImage) case .path(let path): @@ -40,7 +41,7 @@ class RedactionPathLayer: CALayer { left: dikembeMutombo.size.width * -0.5, bottom: dikembeMutombo.size.height * -0.5, right: dikembeMutombo.size.width * -0.5)) - youKnowWhatImAMoron = .identity + youKnowWhatImAMoron = 0 self.part = Part.path(path: path, dikembeMutombo: dikembeMutombo) } @@ -51,7 +52,7 @@ class RedactionPathLayer: CALayer { drawsAsynchronously = true masksToBounds = false frame = gigiPath - transform = CATransform3DMakeAffineTransform(youKnowWhatImAMoron) + setAffineTransform(.init(rotationAngle: youKnowWhatImAMoron)) setNeedsDisplay() } @@ -70,7 +71,7 @@ class RedactionPathLayer: CALayer { switch part { case let .shape(shape, startImage, endImage): color.setFill() - let shapeRect = shape.unionDotShapeDotShapeDotUnionCrash + let shapeRect = shape.unionDotShapeDotShapeDotUnionCrash.geometryStreamer let insetRect = CGRect( origin: CGPoint(x: startImage.width, y: 0), size: shapeRect.size From 4398e3dfb686c9f86b68855da9632c53efbab5d5 Mon Sep 17 00:00:00 2001 From: Geoff Pado Date: Wed, 19 Jun 2024 02:13:37 -0700 Subject: [PATCH 09/16] Fix macOS build --- .../Detections/Sources/TextDetector.swift | 2 +- .../Sources/MinimumAreaShapeFinder.swift | 59 ------------------- .../Capabilities/Geometry/Sources/Shape.swift | 4 -- .../CharacterObservationRedaction.swift | 5 +- 4 files changed, 3 insertions(+), 67 deletions(-) diff --git a/Modules/Capabilities/Detections/Sources/TextDetector.swift b/Modules/Capabilities/Detections/Sources/TextDetector.swift index 29ba54b7..a1aa323b 100644 --- a/Modules/Capabilities/Detections/Sources/TextDetector.swift +++ b/Modules/Capabilities/Detections/Sources/TextDetector.swift @@ -89,7 +89,7 @@ open class TextDetector: NSObject { } } - public func detectText(in image: NSImage, completionHandler: @escaping (([RecognizedTextObservation]?) -> Void)) { + public func detectText(in image: NSImage, completionHandler: @escaping (([ObservationsMac.RecognizedTextObservation]?) -> Void)) { guard let recognitionOperation = try? TextRecognitionOperation(image: image) else { return completionHandler(nil) } Task { await completionHandler(detectText(with: recognitionOperation)) diff --git a/Modules/Capabilities/Geometry/Sources/MinimumAreaShapeFinder.swift b/Modules/Capabilities/Geometry/Sources/MinimumAreaShapeFinder.swift index 70734db2..b959d21d 100644 --- a/Modules/Capabilities/Geometry/Sources/MinimumAreaShapeFinder.swift +++ b/Modules/Capabilities/Geometry/Sources/MinimumAreaShapeFinder.swift @@ -51,63 +51,4 @@ public enum MinimumAreaShapeFinder { let maxY = points.map { $0.y }.max() ?? 0 return CGRect(x: minX, y: minY, width: maxX - minX, height: maxY - minY) } - -// enum Orientation { -// case collinear -// case clockwise -// case counterClockwise -// } -// -// private static func orientation(_ p: CGPoint, _ q: CGPoint, _ r: CGPoint) -> Orientation { -// let val = (q.y - p.y) * (r.x - q.x) - (q.x - p.x) * (r.y - q.y) -// if val == 0 { return .collinear } -// return (val > 0) ? .clockwise : .counterClockwise -// } -// -// // Helper function to find the square of the distance between two points -// private static func distanceSquared(_ p1: CGPoint, _ p2: CGPoint) -> CGFloat { -// return (p1.x - p2.x) * (p1.x - p2.x) + (p1.y - p2.y) * (p1.y - p2.y) -// } -// -// // Function to find the convex hull using Graham Scan algorithm -// static func convexHull(for points: [CGPoint]) -> [CGPoint] { -// guard points.count >= 3 else { return points } -// -// // Find the point with the lowest y-coordinate, break ties by x-coordinate -// var minYPoint = points[0] -// var minYIndex = 0 -// for (index, point) in points.enumerated() { -// if (point.y < minYPoint.y) || (point.y == minYPoint.y && point.x < minYPoint.x) { -// minYPoint = point -// minYIndex = index -// } -// } -// -// // Place the bottom-most point at the first position -// var sortedPoints = points -// sortedPoints.swapAt(0, minYIndex) -// -// // Sort the remaining points based on the polar angle with the first point -// let p0 = sortedPoints[0] -// sortedPoints = sortedPoints.sorted { (p1, p2) -> Bool in -// let o = orientation(p0, p1, p2) -// if o == .collinear { -// return distanceSquared(p0, p1) < distanceSquared(p0, p2) -// } -// return o == .counterClockwise -// } -// -// // Create an empty stack and push the first three points to it -// var stack: [CGPoint] = [sortedPoints[0], sortedPoints[1], sortedPoints[2]] -// -// // Process the remaining points -// for i in 3.. 1 && orientation(stack[stack.count - 2], stack.last!, sortedPoints[i]) != .counterClockwise { -// stack.removeLast() -// } -// stack.append(sortedPoints[i]) -// } -// -// return stack -// } } diff --git a/Modules/Capabilities/Geometry/Sources/Shape.swift b/Modules/Capabilities/Geometry/Sources/Shape.swift index e89707e3..c2c3e1fa 100644 --- a/Modules/Capabilities/Geometry/Sources/Shape.swift +++ b/Modules/Capabilities/Geometry/Sources/Shape.swift @@ -10,10 +10,6 @@ public struct Shape: Hashable { public let topLeft: CGPoint public let topRight: CGPoint - public init() { - self = .zero - } - public init(bottomLeft: CGPoint, bottomRight: CGPoint, topLeft: CGPoint, topRight: CGPoint) { self.bottomLeft = bottomLeft self.bottomRight = bottomRight diff --git a/Modules/Capabilities/Redactions/Sources/Redactions/CharacterObservationRedaction.swift b/Modules/Capabilities/Redactions/Sources/Redactions/CharacterObservationRedaction.swift index 1c0ce042..12e764fa 100644 --- a/Modules/Capabilities/Redactions/Sources/Redactions/CharacterObservationRedaction.swift +++ b/Modules/Capabilities/Redactions/Sources/Redactions/CharacterObservationRedaction.swift @@ -3,6 +3,7 @@ #if canImport(AppKit) && !targetEnvironment(macCatalyst) import AppKit +import GeometryMac import ObservationsMac extension Redaction { @@ -15,9 +16,7 @@ extension Redaction { siblingObservations.append(characterObservation) result[textObservationUUID] = siblingObservations }.values.map { siblingObservations in - siblingObservations.reduce(siblingObservations[0].bounds, { currentRect, characterObservation in - currentRect.union(characterObservation.bounds) - }) + MinimumAreaShapeFinder.minimumAreaShape(for: siblingObservations.map(\.bounds)) }.map(RedactionPart.shape) self.init(color: color, parts: parts) From 28ea6f5efc2411fce6dec4402b48528d7730701b Mon Sep 17 00:00:00 2001 From: Geoff Pado Date: Wed, 19 Jun 2024 02:19:50 -0700 Subject: [PATCH 10/16] Fix linting issues --- .../Geometry/Sources/EmotionalSupportVariable.swift | 1 - Modules/Capabilities/Geometry/Tests/Asserts.swift | 13 +++++++------ .../Editing View/Workspace/RedactionPathLayer.swift | 2 +- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/Modules/Capabilities/Geometry/Sources/EmotionalSupportVariable.swift b/Modules/Capabilities/Geometry/Sources/EmotionalSupportVariable.swift index 0f1c26bd..f609078e 100644 --- a/Modules/Capabilities/Geometry/Sources/EmotionalSupportVariable.swift +++ b/Modules/Capabilities/Geometry/Sources/EmotionalSupportVariable.swift @@ -10,4 +10,3 @@ public struct EmotionalSupportVariable { public let thisGuyHeadBang: Double } // thank you for your support for this channel @KaenAitch - diff --git a/Modules/Capabilities/Geometry/Tests/Asserts.swift b/Modules/Capabilities/Geometry/Tests/Asserts.swift index b83ea9c7..fa06128f 100644 --- a/Modules/Capabilities/Geometry/Tests/Asserts.swift +++ b/Modules/Capabilities/Geometry/Tests/Asserts.swift @@ -4,10 +4,11 @@ import Geometry import XCTest -func XCTAssertEqual(_ lhs: Shape, _ rhs: Shape, accuracy: Double) { - for point in lhs.inverseTranslateRotateTransform { - if !rhs.inverseTranslateRotateTransform.contains(where: { abs($0.x - point.x) <= accuracy && abs($0.y - point.y) <= accuracy }) { - XCTFail("XCTAssertEqual failed: (\"\(String(describing: lhs))\") is not equal to (\"\(String(describing: rhs))\")") - } - } +func XCTAssertEqual(_ lhs: Shape, _ rhs: Shape, accuracy: Double, file: StaticString = #filePath, line: UInt = #line) { + guard lhs.inverseTranslateRotateTransform.contains(where: { point in + let hasMatchingPoint = rhs.inverseTranslateRotateTransform.contains(where: { abs($0.x - point.x) <= accuracy && abs($0.y - point.y) <= accuracy }) + return hasMatchingPoint == false + }) else { return } + + XCTFail("XCTAssertEqual failed: (\"\(String(describing: lhs))\") is not equal to (\"\(String(describing: rhs))\")", file: file, line: line) } diff --git a/Modules/Legacy/Editing/Sources/Editing View/Workspace/RedactionPathLayer.swift b/Modules/Legacy/Editing/Sources/Editing View/Workspace/RedactionPathLayer.swift index 4ea2b5e5..fd19d4f2 100644 --- a/Modules/Legacy/Editing/Sources/Editing View/Workspace/RedactionPathLayer.swift +++ b/Modules/Legacy/Editing/Sources/Editing View/Workspace/RedactionPathLayer.swift @@ -25,7 +25,7 @@ class RedactionPathLayer: CALayer { let rect = unrotated.geometryStreamer // need to actually draw a larger extent on the corner gigiPath = CGRect( - origin: CGPoint(x: rect.origin.x - Double(startImage.width) , y: rect.origin.y), + origin: CGPoint(x: rect.origin.x - Double(startImage.width), y: rect.origin.y), size: CGSize( width: rect.size.width + Double(startImage.width) + Double(endImage.width), height: rect.size.height From 37787c411853f503366af2e2d5728e564791f7bd Mon Sep 17 00:00:00 2001 From: Geoff Pado Date: Fri, 21 Jun 2024 14:09:34 -0700 Subject: [PATCH 11/16] Fix anchor point for redaction layers --- .../Workspace/RedactionPathLayer.swift | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/Modules/Legacy/Editing/Sources/Editing View/Workspace/RedactionPathLayer.swift b/Modules/Legacy/Editing/Sources/Editing View/Workspace/RedactionPathLayer.swift index fd19d4f2..0d10d7bb 100644 --- a/Modules/Legacy/Editing/Sources/Editing View/Workspace/RedactionPathLayer.swift +++ b/Modules/Legacy/Editing/Sources/Editing View/Workspace/RedactionPathLayer.swift @@ -17,6 +17,10 @@ class RedactionPathLayer: CALayer { // an affine transform to apply to the layer let youKnowWhatImAMoron: Double + // iCanBelieveThisIsNotButter by @eaglenaut on 2024-06-18 + // the anchor point for the rect + let iCanBelieveThisIsNotButter: CGPoint + switch part { case .shape(let shape): let (startImage, endImage) = try BrushStampFactory.brushImages(for: shape, color: color, scale: scale) @@ -25,13 +29,14 @@ class RedactionPathLayer: CALayer { let rect = unrotated.geometryStreamer // need to actually draw a larger extent on the corner gigiPath = CGRect( - origin: CGPoint(x: rect.origin.x - Double(startImage.width), y: rect.origin.y), + origin: rect.origin, size: CGSize( - width: rect.size.width + Double(startImage.width) + Double(endImage.width), - height: rect.size.height + width: rect.width + Double(startImage.width) + Double(endImage.width), + height: rect.height ) ) youKnowWhatImAMoron = unrotated.thisGuyHeadBang + iCanBelieveThisIsNotButter = CGPoint(x: Double(startImage.width) / rect.width, y: 0) self.part = Part.shape(shape: shape, startImage: startImage, endImage: endImage) case .path(let path): @@ -42,6 +47,7 @@ class RedactionPathLayer: CALayer { bottom: dikembeMutombo.size.height * -0.5, right: dikembeMutombo.size.width * -0.5)) youKnowWhatImAMoron = 0 + iCanBelieveThisIsNotButter = .zero self.part = Part.path(path: path, dikembeMutombo: dikembeMutombo) } @@ -51,8 +57,12 @@ class RedactionPathLayer: CALayer { backgroundColor = UIColor.clear.cgColor drawsAsynchronously = true masksToBounds = false - frame = gigiPath - setAffineTransform(.init(rotationAngle: youKnowWhatImAMoron)) + + bounds = CGRect(origin: .zero, size: gigiPath.size) + anchorPoint = iCanBelieveThisIsNotButter + position = gigiPath.origin + + setAffineTransform(CGAffineTransform(rotationAngle: youKnowWhatImAMoron)) setNeedsDisplay() } From 419abe24b09d465c8b4dfbdd8acd823557a12a4a Mon Sep 17 00:00:00 2001 From: Geoff Pado Date: Fri, 21 Jun 2024 17:15:25 -0700 Subject: [PATCH 12/16] De-dupe redaction exporting code --- Automator/Sources/BrushStampFactory.swift | 37 ---- .../Sources/RedactActionExportOperation.swift | 174 +++++++----------- Automator/Sources/RedactOperation.swift | 13 +- Automator/Sources/RedactedImageExporter.swift | 34 ++-- .../Brushes/Sources/BrushStampFactory.swift | 45 ++++- .../Brushes/Sources/NSImageExtensions.swift | 12 ++ ...CGImagePropertyOrientationExtensions.swift | 20 ++ .../Extensions/NSBezierPathExtensions.swift | 102 ++++++++++ .../Extensions/UIBezierPathExtensions.swift | 2 + .../Extensions/UIImageExtensions.swift | 2 + .../Sources/PhotoExportRenderError.swift | 8 + .../Sources/PhotoExportRenderer.swift | 88 ++++++--- .../Exporting/Sources/PhotoExporter.swift | 5 + .../PhotoExportErrorAlertFactory.swift | 0 .../Workspace/RedactionPathLayer.swift | 1 + Project.swift | 3 +- .../Targets/Capabilities/Exporting.swift | 18 +- .../Targets/Capabilities/Shortcuts.swift | 2 +- .../Targets/Legacy/Editing.swift | 2 +- .../Targets/Products/AutomatorActions.swift | 1 + 20 files changed, 359 insertions(+), 210 deletions(-) delete mode 100644 Automator/Sources/BrushStampFactory.swift create mode 100644 Modules/Capabilities/Brushes/Sources/NSImageExtensions.swift create mode 100644 Modules/Capabilities/Exporting/Sources/Extensions/CGImagePropertyOrientationExtensions.swift create mode 100644 Modules/Capabilities/Exporting/Sources/Extensions/NSBezierPathExtensions.swift create mode 100644 Modules/Capabilities/Exporting/Sources/PhotoExportRenderError.swift rename Modules/{Capabilities/Exporting/Sources => Legacy/Editing/Sources/Editing View}/PhotoExportErrorAlertFactory.swift (100%) diff --git a/Automator/Sources/BrushStampFactory.swift b/Automator/Sources/BrushStampFactory.swift deleted file mode 100644 index 65eda8d9..00000000 --- a/Automator/Sources/BrushStampFactory.swift +++ /dev/null @@ -1,37 +0,0 @@ -// Created by Geoff Pado on 10/28/20. -// Copyright © 2020 Cocoatype, LLC. All rights reserved. - -import AppKit -import GeometryMac -import OSLog -import Redacting - -class BrushStampFactory: NSObject { - static func brushStart(scaledToHeight height: CGFloat, color: NSColor) -> NSImage { - guard let standardImage = Bundle(for: Self.self).image(forResource: "Brush Start") else { fatalError("Unable to load brush start image") } - return scaledImage(from: standardImage, toHeight: height, color: color) - } - - static func brushEnd(scaledToHeight height: CGFloat, color: NSColor) -> NSImage { - guard let standardImage = Bundle(for: Self.self).image(forResource: "Brush End") else { fatalError("Unable to load brush end image") } - return scaledImage(from: standardImage, toHeight: height, color: color) - } - - private static func scaledImage(from image: NSImage, toHeight height: CGFloat, color: NSColor) -> NSImage { - let brushScale = height / image.size.height - let scaledBrushSize = image.size * brushScale - - return NSImage(size: scaledBrushSize, flipped: false) { _ -> Bool in - color.setFill() - - CGRect(origin: .zero, size: scaledBrushSize).fill() - - guard let context = NSGraphicsContext.current?.cgContext else { return false } - context.scaleBy(x: brushScale, y: brushScale) - - image.draw(at: .zero, from: CGRect(origin: .zero, size: image.size), operation: .destinationIn, fraction: 1) - - return true - } - } -} diff --git a/Automator/Sources/RedactActionExportOperation.swift b/Automator/Sources/RedactActionExportOperation.swift index 7f648aaf..3bd16b6e 100644 --- a/Automator/Sources/RedactActionExportOperation.swift +++ b/Automator/Sources/RedactActionExportOperation.swift @@ -2,116 +2,70 @@ // Copyright © 2020 Cocoatype, LLC. All rights reserved. import AppKit +import BrushesMac import Redacting import RedactionsMac -class RedactActionExportOperation: Operation { - var result: Result? - - init(input: RedactActionInput, redactions: [Redaction]) { - self.redactions = redactions - self.input = input - } - - private lazy var sourceImageRep: NSBitmapImageRep? = { - guard let sourceImage = input.image, let imageRep = NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: Int(sourceImage.size.width), pixelsHigh: Int(sourceImage.size.height), bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false, colorSpaceName: .deviceRGB, bytesPerRow: Int(sourceImage.size.width) * 4, bitsPerPixel: 32) else { return nil } - - let context = NSGraphicsContext(bitmapImageRep: imageRep) - NSGraphicsContext.saveGraphicsState() - NSGraphicsContext.current = context - sourceImage.draw(at: .zero, from: CGRect(origin: .zero, size: sourceImage.size), operation: .copy, fraction: 1) - NSGraphicsContext.restoreGraphicsState() - return imageRep - }() - - #warning("#62: Simplify & de-dupe") - // swiftlint:disable:next function_body_length - override func main() { - do { - guard let sourceImage = input.image else { throw RedactActionExportError.noImageForInput } - let imageSize = sourceImage.size - - guard let imageRep = NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: Int(imageSize.width), pixelsHigh: Int(imageSize.height), bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false, colorSpaceName: .deviceRGB, bytesPerRow: Int(imageSize.width) * 4, bitsPerPixel: 32), let graphicsContext = NSGraphicsContext(bitmapImageRep: imageRep) else { throw RedactActionExportError.failedToGenerateGraphicsContext } - let context = graphicsContext.cgContext - - var tileRect = CGRect.zero - tileRect.size.width = imageSize.width - tileRect.size.height = floor(CGFloat(Self.tileTotalPixels) / CGFloat(imageSize.width)) - - let remainder = imageSize.height.truncatingRemainder(dividingBy: tileRect.height) - let baseIterationCount = Int(imageSize.height / tileRect.height) - let iterationCount = (remainder > 1) ? baseIterationCount + 1 : baseIterationCount - - let overlappingTileRect = CGRect(x: tileRect.minX, y: tileRect.minY, width: tileRect.width, height: tileRect.height + Self.seamOverlap) - - // draw tiles of source image - context.saveGState() - - for y in 0.. 0 { - let diffY = currentTileRect.maxY - imageSize.height - currentTileRect.size.height -= diffY - } - - if let imageRef = sourceImageRep?.cgImage?.cropping(to: currentTileRect) { - context.draw(imageRef, in: currentTileRect) - } - } - } - - context.restoreGState() - - // draw redactions - let drawings = redactions.flatMap { redaction -> [(path: NSBezierPath, color: NSColor)] in - return redaction.paths - .map { (path: $0, color: redaction.color) } - } - - drawings.forEach { drawing in - let (path, color) = drawing - let borderBounds = path.strokeBorderPath.bounds - let startImage = BrushStampFactory.brushStart(scaledToHeight: borderBounds.height, color: color) - let endImage = BrushStampFactory.brushEnd(scaledToHeight: borderBounds.height, color: color) - - color.setFill() - NSBezierPath(rect: borderBounds).fill() - - let drawContext = NSGraphicsContext(cgContext: context, flipped: false) - NSGraphicsContext.saveGraphicsState() - NSGraphicsContext.current = drawContext - let startRect = CGRect(origin: borderBounds.origin, size: startImage.size).offsetBy(dx: -startImage.size.width, dy: 0) - startImage.draw(in: startRect, from: CGRect(origin: .zero, size: startImage.size), operation: .sourceOver, fraction: 1) - - let endRect = CGRect(origin: borderBounds.origin, size: endImage.size).offsetBy(dx: borderBounds.width, dy: 0) - endImage.draw(in: endRect, from: CGRect(origin: .zero, size: endImage.size), operation: .sourceOver, fraction: 1) - NSGraphicsContext.restoreGraphicsState() - } - - // export image - let writeURL = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString).appendingPathExtension(input.fileType?.preferredFilenameExtension ?? "png") - - guard let data = imageRep.representation(using: input.imageType, properties: [:]) else { throw RedactActionExportError.writeError } - try data.write(to: writeURL) - - self.result = .success(writeURL.path) - } catch { - self.result = .failure(error) - } - } - - // MARK: Boilerplate - - private static let bytesPerMB = 1024 * 1024 - private static let bytesPerPixel = 4 - private static let pixelsPerMB = bytesPerMB / bytesPerPixel - private static let seamOverlap = CGFloat(2) - private static let sourceImageTileSizeMB = 120 - private static let tileTotalPixels = sourceImageTileSizeMB * pixelsPerMB - - private let redactions: [Redaction] - private let input: RedactActionInput -} +//class RedactActionExportOperation: Operation { +// var result: Result? +// +// init(input: RedactActionInput, redactions: [Redaction]) { +// self.redactions = redactions +// self.input = input +// } +// +// #warning("#62: Simplify & de-dupe") +// // swiftlint:disable:next function_body_length +// override func main() { +// do { +// +// // draw redactions +// let drawings = redactions.flatMap { redaction -> [(path: NSBezierPath, color: NSColor)] in +// return redaction.paths +// .map { (path: $0, color: redaction.color) } +// } +// +// drawings.forEach { drawing in +// let (path, color) = drawing +// let borderBounds = path.strokeBorderPath.bounds +// let startImage = BrushStampFactory.brushStart(scaledToHeight: borderBounds.height, color: color) +// let endImage = BrushStampFactory.brushEnd(scaledToHeight: borderBounds.height, color: color) +// +// color.setFill() +// NSBezierPath(rect: borderBounds).fill() +// +// let drawContext = NSGraphicsContext(cgContext: context, flipped: false) +// NSGraphicsContext.saveGraphicsState() +// NSGraphicsContext.current = drawContext +// let startRect = CGRect(origin: borderBounds.origin, size: startImage.size).offsetBy(dx: -startImage.size.width, dy: 0) +// startImage.draw(in: startRect, from: CGRect(origin: .zero, size: startImage.size), operation: .sourceOver, fraction: 1) +// +// let endRect = CGRect(origin: borderBounds.origin, size: endImage.size).offsetBy(dx: borderBounds.width, dy: 0) +// endImage.draw(in: endRect, from: CGRect(origin: .zero, size: endImage.size), operation: .sourceOver, fraction: 1) +// NSGraphicsContext.restoreGraphicsState() +// } +// +// // export image +// let writeURL = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString).appendingPathExtension(input.fileType?.preferredFilenameExtension ?? "png") +// +// guard let data = imageRep.representation(using: input.imageType, properties: [:]) else { throw RedactActionExportError.writeError } +// try data.write(to: writeURL) +// +// self.result = .success(writeURL.path) +// } catch { +// self.result = .failure(error) +// } +// } +// +// // MARK: Boilerplate +// +// private static let bytesPerMB = 1024 * 1024 +// private static let bytesPerPixel = 4 +// private static let pixelsPerMB = bytesPerMB / bytesPerPixel +// private static let seamOverlap = CGFloat(2) +// private static let sourceImageTileSizeMB = 120 +// private static let tileTotalPixels = sourceImageTileSizeMB * pixelsPerMB +// +// private let redactions: [Redaction] +// private let input: RedactActionInput +//} diff --git a/Automator/Sources/RedactOperation.swift b/Automator/Sources/RedactOperation.swift index 39a8d212..10fd1e2d 100644 --- a/Automator/Sources/RedactOperation.swift +++ b/Automator/Sources/RedactOperation.swift @@ -2,11 +2,12 @@ // Copyright © 2020 Cocoatype, LLC. All rights reserved. import DetectionsMac +import ExportingMac import Foundation import Redacting import RedactionsMac -class RedactOperation: Operation { +class RedactOperation: Operation, @unchecked Sendable { var result: Result? init(input: RedactActionInput, wordList: [String]) { self.input = input @@ -27,8 +28,14 @@ class RedactOperation: Operation { let redactions = matchingObservations.map { Redaction($0, color: .black) } - RedactActionExporter.export(input, redactions: redactions) { [weak self] result in - self?.finish(with: result) + Task { [weak self] in + do { + guard let inputImage = input.image else { throw RedactActionExportError.noImageForInput } + let redactedImage = try await PhotoExportRenderer(image: inputImage, redactions: redactions).render() + self?.finish(with: .success("")) + } catch { + self?.finish(with: .failure(error)) + } } } } diff --git a/Automator/Sources/RedactedImageExporter.swift b/Automator/Sources/RedactedImageExporter.swift index ca8092fd..c253c73d 100644 --- a/Automator/Sources/RedactedImageExporter.swift +++ b/Automator/Sources/RedactedImageExporter.swift @@ -5,20 +5,20 @@ import AppKit import Redacting import RedactionsMac -class RedactActionExporter: NSObject { - static func export(_ input: RedactActionInput, redactions: [Redaction], completionHandler: @escaping((Result) -> Void)) { - let exportOperation = RedactActionExportOperation(input: input, redactions: redactions) - let callbackOperation = BlockOperation { - guard let result = exportOperation.result else { - return completionHandler(.failure(RedactActionExportError.operationReturnedNoResult)) - } - - completionHandler(result) - } - - callbackOperation.addDependency(exportOperation) - operationQueue.addOperations([exportOperation, callbackOperation], waitUntilFinished: false) - } - - private static let operationQueue = OperationQueue() -} +//class RedactActionExporter: NSObject { +// static func export(_ input: RedactActionInput, redactions: [Redaction], completionHandler: @escaping((Result) -> Void)) { +// let exportOperation = RedactActionExportOperation(input: input, redactions: redactions) +// let callbackOperation = BlockOperation { +// guard let result = exportOperation.result else { +// return completionHandler(.failure(RedactActionExportError.operationReturnedNoResult)) +// } +// +// completionHandler(result) +// } +// +// callbackOperation.addDependency(exportOperation) +// operationQueue.addOperations([exportOperation, callbackOperation], waitUntilFinished: false) +// } +// +// private static let operationQueue = OperationQueue() +//} diff --git a/Modules/Capabilities/Brushes/Sources/BrushStampFactory.swift b/Modules/Capabilities/Brushes/Sources/BrushStampFactory.swift index 51288991..928495bc 100644 --- a/Modules/Capabilities/Brushes/Sources/BrushStampFactory.swift +++ b/Modules/Capabilities/Brushes/Sources/BrushStampFactory.swift @@ -6,14 +6,32 @@ import AppKit import ErrorHandlingMac import GeometryMac -class BrushStampFactory: NSObject { - static func brushStart(scaledToHeight height: CGFloat, color: NSColor) -> NSImage { - guard let standardImage = Bundle(for: Self.self).image(forResource: "Brush Start") else { fatalError("Unable to load brush start image") } +public enum BrushStampFactory { + public static func brushImages(for shape: Shape, color: NSColor, scale: CGFloat) throws -> (CGImage, CGImage) { + let height = shape.unionDotShapeDotShapeDotUnionCrash.geometryStreamer.height + let startImage = BrushStampFactory.brushStart(scaledToHeight: height, color: color) + let endImage = BrushStampFactory.brushEnd(scaledToHeight: height, color: color) + + guard let startCGImage = startImage.cgImage, + let endCGImage = endImage.cgImage + else { + throw BrushStampFactoryError.cannotGenerateCGImage( + shape: shape, + color: color, + scale: scale + ) + } + + return (startCGImage, endCGImage) + } + + public static func brushStart(scaledToHeight height: CGFloat, color: NSColor) -> NSImage { + guard let standardImage = Bundle.module.image(forResource: "Brush Start") else { ErrorHandler().crash("Unable to load brush start image") } return scaledImage(from: standardImage, toHeight: height, color: color) } - static func brushEnd(scaledToHeight height: CGFloat, color: NSColor) -> NSImage { - guard let standardImage = Bundle(for: Self.self).image(forResource: "Brush End") else { fatalError("Unable to load brush end image") } + public static func brushEnd(scaledToHeight height: CGFloat, color: NSColor) -> NSImage { + guard let standardImage = Bundle.module.image(forResource: "Brush End") else { ErrorHandler().crash("Unable to load brush end image") } return scaledImage(from: standardImage, toHeight: height, color: color) } @@ -34,6 +52,23 @@ class BrushStampFactory: NSObject { return true } } + + public static func brushStamp(scaledToHeight height: CGFloat, color: NSColor) throws -> CGImage { + guard let stampImage = Bundle.module.image(forResource: "Brush") else { ErrorHandler().crash("Unable to load brush stamp image") } + + let scaledImage = scaledImage(from: stampImage, toHeight: height, color: color) + + guard let scaledCGImage = scaledImage.cgImage else { + throw BrushStampFactoryError.cannotGenerateCGImage(color: color, scale: 1) + } + + return scaledCGImage + } +} + +enum BrushStampFactoryError: Error { + case cannotGenerateCGImage(shape: Shape, color: NSColor, scale: CGFloat) + case cannotGenerateCGImage(color: NSColor, scale: CGFloat) } #elseif canImport(UIKit) import ErrorHandling diff --git a/Modules/Capabilities/Brushes/Sources/NSImageExtensions.swift b/Modules/Capabilities/Brushes/Sources/NSImageExtensions.swift new file mode 100644 index 00000000..94212d30 --- /dev/null +++ b/Modules/Capabilities/Brushes/Sources/NSImageExtensions.swift @@ -0,0 +1,12 @@ +// Created by Geoff Pado on 6/21/24. +// Copyright © 2024 Cocoatype, LLC. All rights reserved. + +#if canImport(AppKit) && !targetEnvironment(macCatalyst) +import AppKit + +extension NSImage { + var cgImage: CGImage? { + cgImage(forProposedRect: nil, context: nil, hints: nil) + } +} +#endif diff --git a/Modules/Capabilities/Exporting/Sources/Extensions/CGImagePropertyOrientationExtensions.swift b/Modules/Capabilities/Exporting/Sources/Extensions/CGImagePropertyOrientationExtensions.swift new file mode 100644 index 00000000..8afff190 --- /dev/null +++ b/Modules/Capabilities/Exporting/Sources/Extensions/CGImagePropertyOrientationExtensions.swift @@ -0,0 +1,20 @@ +// Created by Geoff Pado on 6/21/24. +// Copyright © 2024 Cocoatype, LLC. All rights reserved. + +import ImageIO + +extension CGImagePropertyOrientation { + var rotationAngle: Double { + switch self { + case .up: return 0 + case .down: return .pi + case .left: return -1 * .pi / 2 + case .right: return .pi / 2 + case .upMirrored: return 0 + case .downMirrored: return .pi + case .leftMirrored: return .pi / 2 + case .rightMirrored: return -1 * .pi / 2 + @unknown default: return 0 + } + } +} diff --git a/Modules/Capabilities/Exporting/Sources/Extensions/NSBezierPathExtensions.swift b/Modules/Capabilities/Exporting/Sources/Extensions/NSBezierPathExtensions.swift new file mode 100644 index 00000000..4c6a1c02 --- /dev/null +++ b/Modules/Capabilities/Exporting/Sources/Extensions/NSBezierPathExtensions.swift @@ -0,0 +1,102 @@ +// Created by Geoff Pado on 6/21/24. +// Copyright © 2024 Cocoatype, LLC. All rights reserved. + +#if canImport(AppKit) && !targetEnvironment(macCatalyst) +import AppKit +import ObservationsMac + +extension NSBezierPath { + convenience init(cgPath: CGPath) { + self.init() + + cgPath.applyWithBlock { elementPointer in + let element = elementPointer.pointee + + switch element.type { + case .moveToPoint: + let point = element.points.pointee + self.move(to: point) + case .addLineToPoint: + let point = element.points.pointee + self.line(to: point) + case .addQuadCurveToPoint: + break // NSBezierPath does not support + case .addCurveToPoint: + let bufferPointer = UnsafeBufferPointer(start: element.points, count: 3) + let points = Array(bufferPointer) + self.curve(to: points[0], controlPoint1: points[1], controlPoint2: points[2]) + case .closeSubpath: + self.close() + @unknown default: + break + } + } + } + + public var cgPath: CGPath { + let path = CGMutablePath() + var points = [CGPoint](repeating: .zero, count: 3) + + for i in 0 ..< self.elementCount { + let type = self.element(at: i, associatedPoints: &points) + switch type { + case .moveTo: + path.move(to: points[0]) + case .lineTo: + path.addLine(to: points[0]) + case .curveTo: + path.addCurve(to: points[2], control1: points[0], control2: points[1]) + case .closePath: + path.closeSubpath() + @unknown default: + break + } + } + + return path + } + + public var strokeBorderPath: NSBezierPath { + let cgPath = self.cgPath + let strokedCGPath = cgPath.copy(strokingWithWidth: lineWidth, + lineCap: lineCapStyle.cgLineCap, + lineJoin: lineJoinStyle.cgLineJoin, + miterLimit: miterLimit) + return NSBezierPath(cgPath: strokedCGPath) + } + + public var dashedPath: NSBezierPath { + let cgPath = self.cgPath + let dashedCGPath = cgPath.copy(dashingWithPhase: 0, lengths: [4, 4]) + let dashedPath = NSBezierPath(cgPath: dashedCGPath) + dashedPath.lineWidth = lineWidth + return dashedPath + } + + public func forEachPoint(_ function: @escaping ((CGPoint) -> Void)) { + cgPath.forEachPoint(function) + } +} + +extension NSBezierPath.LineCapStyle { + var cgLineCap: CGLineCap { + switch self { + case .butt: return .butt + case .round: return .round + case .square: return .square + @unknown default: return .butt + } + } +} + +extension NSBezierPath.LineJoinStyle { + var cgLineJoin: CGLineJoin { + switch self { + case .round: return .round + case .bevel: return .bevel + case .miter: return .miter + @unknown default: return .round + } + } +} +#endif diff --git a/Modules/Capabilities/Exporting/Sources/Extensions/UIBezierPathExtensions.swift b/Modules/Capabilities/Exporting/Sources/Extensions/UIBezierPathExtensions.swift index 94de8dd4..1d8eff81 100644 --- a/Modules/Capabilities/Exporting/Sources/Extensions/UIBezierPathExtensions.swift +++ b/Modules/Capabilities/Exporting/Sources/Extensions/UIBezierPathExtensions.swift @@ -1,6 +1,7 @@ // Created by Geoff Pado on 5/20/24. // Copyright © 2024 Cocoatype, LLC. All rights reserved. +#if canImport(UIKit) import UIKit extension UIBezierPath { @@ -16,3 +17,4 @@ extension UIBezierPath { cgPath.forEachPoint(function) } } +#endif diff --git a/Modules/Capabilities/Exporting/Sources/Extensions/UIImageExtensions.swift b/Modules/Capabilities/Exporting/Sources/Extensions/UIImageExtensions.swift index 8e684ac7..468a2de6 100644 --- a/Modules/Capabilities/Exporting/Sources/Extensions/UIImageExtensions.swift +++ b/Modules/Capabilities/Exporting/Sources/Extensions/UIImageExtensions.swift @@ -1,6 +1,7 @@ // Created by Geoff Pado on 5/20/24. // Copyright © 2024 Cocoatype, LLC. All rights reserved. +#if canImport(UIKit) import UIKit extension UIImage { @@ -31,3 +32,4 @@ extension UIImage.Orientation { } } } +#endif diff --git a/Modules/Capabilities/Exporting/Sources/PhotoExportRenderError.swift b/Modules/Capabilities/Exporting/Sources/PhotoExportRenderError.swift new file mode 100644 index 00000000..8d077edf --- /dev/null +++ b/Modules/Capabilities/Exporting/Sources/PhotoExportRenderError.swift @@ -0,0 +1,8 @@ +// Created by Geoff Pado on 6/21/24. +// Copyright © 2024 Cocoatype, LLC. All rights reserved. + +public enum PhotoExportRenderError: Error { + case noCurrentGraphicsContext + case noCGImage + case noResultImage +} diff --git a/Modules/Capabilities/Exporting/Sources/PhotoExportRenderer.swift b/Modules/Capabilities/Exporting/Sources/PhotoExportRenderer.swift index d51369ad..3152120b 100644 --- a/Modules/Capabilities/Exporting/Sources/PhotoExportRenderer.swift +++ b/Modules/Capabilities/Exporting/Sources/PhotoExportRenderer.swift @@ -1,48 +1,91 @@ // Created by Geoff Pado on 7/18/22. // Copyright © 2022 Cocoatype, LLC. All rights reserved. +#if canImport(AppKit) && !targetEnvironment(macCatalyst) +import AppKit +import BrushesMac +import GeometryMac +import RedactionsMac +#elseif canImport(UIKit) import Brushes import Geometry import Redactions import UIKit +#endif public actor PhotoExportRenderer { - public init(image: UIImage, redactions: [Redaction]) { + private let redactions: [Redaction] + private let sourceImage: CGImage + #if canImport(AppKit) && !targetEnvironment(macCatalyst) + public init(image: NSImage, redactions: [Redaction]) throws { + guard let sourceImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) + else { throw PhotoExportRenderError.noCGImage } + + self.sourceImage = sourceImage + self.redactions = redactions + } + + public func render() throws -> NSImage { + let imageSize = sourceImage.size + guard let imageRep = NSBitmapImageRep( + bitmapDataPlanes: nil, + pixelsWide: Int(imageSize.width), + pixelsHigh: Int(imageSize.height), + bitsPerSample: 8, + samplesPerPixel: 4, + hasAlpha: true, + isPlanar: false, + colorSpaceName: .deviceRGB, + bytesPerRow: Int(imageSize.width) * 4, + bitsPerPixel: 32 + ), + let graphicsContext = NSGraphicsContext(bitmapImageRep: imageRep) + else { throw PhotoExportRenderError.noCurrentGraphicsContext } + let context = graphicsContext.cgContext + + let cgImage = try render(context: context, orientation: .up, imageSize: imageSize) + return NSImage(cgImage: cgImage, size: imageSize) + } + #elseif canImport(UIKit) + public init(image: UIImage, redactions: [Redaction]) throws { self.redactions = redactions self.sourceImage = image } - #warning("#62: Simplify & de-dupe") - // swiftlint:disable:next function_body_length public func render() throws -> UIImage { let imageSize = sourceImage.realSize * sourceImage.scale + UIGraphicsBeginImageContextWithOptions(sourceImage.size, false, sourceImage.scale) + defer { UIGraphicsEndImageContext() } + guard let context = UIGraphicsGetCurrentContext() else { throw PhotoExportRenderError.noCurrentGraphicsContext } + + return try render(context: context, imageSize: imageSize) + } + #endif + + #warning("#62: Simplify & de-dupe") + // swiftlint:disable:next function_body_length + private func render(context: CGContext, orientation: CGImagePropertyOrientation, imageSize: CGSize) throws -> CGImage { var tileRect = CGRect.zero tileRect.size.width = imageSize.width tileRect.size.height = floor(CGFloat(Self.tileTotalPixels) / CGFloat(imageSize.width)) - NSLog("source tile size: %f x %f", tileRect.width, tileRect.height) - let remainder = imageSize.height.truncatingRemainder(dividingBy: tileRect.height) let baseIterationCount = Int(imageSize.height / tileRect.height) let iterationCount = (remainder > 1) ? baseIterationCount + 1 : baseIterationCount let overlappingTileRect = CGRect(x: tileRect.minX, y: tileRect.minY, width: tileRect.width, height: tileRect.height + Self.seamOverlap) - UIGraphicsBeginImageContextWithOptions(sourceImage.size, false, sourceImage.scale) - defer { UIGraphicsEndImageContext() } - guard let context = UIGraphicsGetCurrentContext() else { throw PhotoExportRenderError.noCurrentGraphicsContext } - // draw tiles of source image context.saveGState() let translateTransform = CGAffineTransform(translationX: sourceImage.size.width / 2, y: sourceImage.size.height / 2) context.concatenate(translateTransform) - let rotateTransform = CGAffineTransform(rotationAngle: sourceImage.imageOrientation.rotationAngle) + let rotateTransform = CGAffineTransform(rotationAngle: orientation.rotationAngle) context.concatenate(rotateTransform) - let untranslateTransform = CGAffineTransform(translationX: sourceImage.realSize.width / -2, y: sourceImage.realSize.height / -2) + let untranslateTransform = CGAffineTransform(translationX: imageSize.width / -2, y: imageSize.height / -2) context.concatenate(untranslateTransform) let transform = CGAffineTransform(scaleX: 1, y: -1).translatedBy(x: 0, y: imageSize.height * -1) @@ -50,7 +93,6 @@ public actor PhotoExportRenderer { for y in 0.. [(part: RedactionPart, color: UIColor)] in + let drawings = redactions.flatMap { redaction -> [(part: RedactionPart, color: RedactionColor)] in return redaction.parts .map { (part: $0, color: redaction.color) } } @@ -82,7 +124,8 @@ public actor PhotoExportRenderer { let (startImage, endImage) = try BrushStampFactory.brushImages(for: shape, color: color, scale: 1) color.setFill() - UIBezierPath(cgPath: shape.path).fill() + context.addPath(shape.path) + context.fillPath() context.saveGState() context.translateBy(x: shape.topLeft.x, y: shape.topLeft.y) @@ -98,19 +141,19 @@ public actor PhotoExportRenderer { context.draw(endImage, in: CGRect(origin: .zero, size: endImage.size)) context.restoreGState() case .path(let path): - let stampImage = BrushStampFactory.brushStamp(scaledToHeight: path.lineWidth, color: color) + let stampImage = try BrushStampFactory.brushStamp(scaledToHeight: path.lineWidth, color: color) let dashedPath = path.dashedPath dashedPath.forEachPoint { point in context.saveGState() defer { context.restoreGState() } context.translateBy(x: stampImage.size.width * -0.5, y: stampImage.size.height * -0.5) - stampImage.draw(at: point) + context.draw(stampImage, in: CGRect(origin: point, size: stampImage.size)) } } } - guard let image = UIGraphicsGetImageFromCurrentImageContext() else { throw PhotoExportRenderError.noResultImage } + guard let image = context.makeImage() else { throw PhotoExportRenderError.noResultImage } return image } @@ -122,13 +165,4 @@ public actor PhotoExportRenderer { private static let seamOverlap = CGFloat(2) private static let sourceImageTileSizeMB = 120 private static let tileTotalPixels = sourceImageTileSizeMB * pixelsPerMB - - private let redactions: [Redaction] - private let sourceImage: UIImage -} - -public enum PhotoExportRenderError: Error { - case noCurrentGraphicsContext - case noCGImage - case noResultImage } diff --git a/Modules/Capabilities/Exporting/Sources/PhotoExporter.swift b/Modules/Capabilities/Exporting/Sources/PhotoExporter.swift index 3de3e3ae..209e406b 100644 --- a/Modules/Capabilities/Exporting/Sources/PhotoExporter.swift +++ b/Modules/Capabilities/Exporting/Sources/PhotoExporter.swift @@ -1,6 +1,10 @@ // Created by Geoff Pado on 5/13/19. // Copyright © 2019 Cocoatype, LLC. All rights reserved. +#if canImport(AppKit) && !targetEnvironment(macCatalyst) +import AppKit +import RedactionsMac +#elseif canImport(UIKit) import Redactions import UIKit @@ -9,3 +13,4 @@ public class PhotoExporter: NSObject { return try await PhotoExportRenderer(image: image, redactions: redactions).render() } } +#endif diff --git a/Modules/Capabilities/Exporting/Sources/PhotoExportErrorAlertFactory.swift b/Modules/Legacy/Editing/Sources/Editing View/PhotoExportErrorAlertFactory.swift similarity index 100% rename from Modules/Capabilities/Exporting/Sources/PhotoExportErrorAlertFactory.swift rename to Modules/Legacy/Editing/Sources/Editing View/PhotoExportErrorAlertFactory.swift diff --git a/Modules/Legacy/Editing/Sources/Editing View/Workspace/RedactionPathLayer.swift b/Modules/Legacy/Editing/Sources/Editing View/Workspace/RedactionPathLayer.swift index 0d10d7bb..6458cb8b 100644 --- a/Modules/Legacy/Editing/Sources/Editing View/Workspace/RedactionPathLayer.swift +++ b/Modules/Legacy/Editing/Sources/Editing View/Workspace/RedactionPathLayer.swift @@ -54,6 +54,7 @@ class RedactionPathLayer: CALayer { self.color = color super.init() + allowsEdgeAntialiasing = true backgroundColor = UIColor.clear.cgColor drawsAsynchronously = true masksToBounds = false diff --git a/Project.swift b/Project.swift index 27715667..4e97cbb9 100644 --- a/Project.swift +++ b/Project.swift @@ -25,7 +25,8 @@ let project = Project( Editing.target, ErrorHandling.target(sdk: .catalyst), ErrorHandling.target(sdk: .native), - Exporting.target, + Exporting.target(sdk: .catalyst), + Exporting.target(sdk: .native), Geometry.target(sdk: .catalyst), Geometry.target(sdk: .native), Logging.target(sdk: .catalyst), diff --git a/Tuist/ProjectDescriptionHelpers/Targets/Capabilities/Exporting.swift b/Tuist/ProjectDescriptionHelpers/Targets/Capabilities/Exporting.swift index 430a8780..da815841 100644 --- a/Tuist/ProjectDescriptionHelpers/Targets/Capabilities/Exporting.swift +++ b/Tuist/ProjectDescriptionHelpers/Targets/Capabilities/Exporting.swift @@ -1,14 +1,16 @@ import ProjectDescription public enum Exporting { - public static var target = Target.capabilitiesTarget( - name: "Exporting", - dependencies: [ - .target(Brushes.target(sdk: .catalyst)), - .target(DesignSystem.target), - .target(Geometry.target(sdk: .catalyst)), - ] - ) + public static func target(sdk: SDK) -> Target { + Target.capabilitiesTarget( + name: "Exporting", + sdk: sdk, + dependencies: [ + .target(Brushes.target(sdk: sdk)), + .target(Geometry.target(sdk: sdk)), + ] + ) + } public static let testTarget = Target.capabilitiesTestTarget( name: "Exporting", diff --git a/Tuist/ProjectDescriptionHelpers/Targets/Capabilities/Shortcuts.swift b/Tuist/ProjectDescriptionHelpers/Targets/Capabilities/Shortcuts.swift index 64f345d0..b3954b13 100644 --- a/Tuist/ProjectDescriptionHelpers/Targets/Capabilities/Shortcuts.swift +++ b/Tuist/ProjectDescriptionHelpers/Targets/Capabilities/Shortcuts.swift @@ -6,7 +6,7 @@ public enum Shortcuts { hasResources: true, dependencies: [ .target(Detections.target(sdk: .catalyst)), - .target(Exporting.target), + .target(Exporting.target(sdk: .catalyst)), .target(Navigation.target), .target(Observations.target(sdk: .catalyst)), .target(Purchasing.target), diff --git a/Tuist/ProjectDescriptionHelpers/Targets/Legacy/Editing.swift b/Tuist/ProjectDescriptionHelpers/Targets/Legacy/Editing.swift index 8f49fb34..0ef2dbe0 100644 --- a/Tuist/ProjectDescriptionHelpers/Targets/Legacy/Editing.swift +++ b/Tuist/ProjectDescriptionHelpers/Targets/Legacy/Editing.swift @@ -14,7 +14,7 @@ public enum Editing { .target(DebugOverlay.target), .target(Detections.target(sdk: .catalyst)), .target(ErrorHandling.target(sdk: .catalyst)), - .target(Exporting.target), + .target(Exporting.target(sdk: .catalyst)), .target(Observations.target(sdk: .catalyst)), .target(PurchaseMarketing.target), .target(Purchasing.doublesTarget), diff --git a/Tuist/ProjectDescriptionHelpers/Targets/Products/AutomatorActions.swift b/Tuist/ProjectDescriptionHelpers/Targets/Products/AutomatorActions.swift index 29777928..3989fb4d 100644 --- a/Tuist/ProjectDescriptionHelpers/Targets/Products/AutomatorActions.swift +++ b/Tuist/ProjectDescriptionHelpers/Targets/Products/AutomatorActions.swift @@ -11,6 +11,7 @@ public enum AutomatorActions { resources: ["Automator/Resources/**"], dependencies: [ .target(Detections.target(sdk: .native)), + .target(Exporting.target(sdk: .native)), .target(Redacting.target), .target(Redactions.target(sdk: .native)), ], From ecc3185cc0f60fc1a98a8dfa5bafb5c8c39eb037 Mon Sep 17 00:00:00 2001 From: Geoff Pado Date: Fri, 21 Jun 2024 17:40:13 -0700 Subject: [PATCH 13/16] Fix running Automator actions --- .../Targets/Products/AutomatorActions.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Tuist/ProjectDescriptionHelpers/Targets/Products/AutomatorActions.swift b/Tuist/ProjectDescriptionHelpers/Targets/Products/AutomatorActions.swift index 3989fb4d..14e0d153 100644 --- a/Tuist/ProjectDescriptionHelpers/Targets/Products/AutomatorActions.swift +++ b/Tuist/ProjectDescriptionHelpers/Targets/Products/AutomatorActions.swift @@ -16,6 +16,11 @@ public enum AutomatorActions { .target(Redactions.target(sdk: .native)), ], settings: .settings(base: [ + "LD_RUNPATH_SEARCH_PATHS": [ + "$(inherited)", + "@executable_path/../Frameworks", + "@loader_path/../Frameworks", + ], "WRAPPER_EXTENSION": "action", "OTHER_OSAFLAGS": "-x -t 0 -c 0", ]) From d8d94dd7b7401aa9054df728f53b2663cad7c72d Mon Sep 17 00:00:00 2001 From: Geoff Pado Date: Sat, 22 Jun 2024 03:12:23 -0700 Subject: [PATCH 14/16] Fix exporting in Automator actions --- .../Sources/RedactActionExportError.swift | 3 +- Automator/Sources/RedactOperation.swift | 19 +++- .../Brushes/Sources/BrushStampFactory.swift | 90 ++++++++++--------- .../Brushes/Sources/NSImageExtensions.swift | 6 +- .../Extensions/UIImageExtensions.swift | 15 ++++ .../Sources/PhotoExportRenderer.swift | 55 +++++++++--- .../Workspace/RedactionPathLayer.swift | 17 ++-- 7 files changed, 138 insertions(+), 67 deletions(-) diff --git a/Automator/Sources/RedactActionExportError.swift b/Automator/Sources/RedactActionExportError.swift index c62f3bf5..15bd6fd0 100644 --- a/Automator/Sources/RedactActionExportError.swift +++ b/Automator/Sources/RedactActionExportError.swift @@ -4,6 +4,7 @@ enum RedactActionExportError: Error { case failedToGenerateGraphicsContext case noImageForInput - case operationReturnedNoResult case writeError + case failedToGetBitmapRepresentation + case failedToGetData } diff --git a/Automator/Sources/RedactOperation.swift b/Automator/Sources/RedactOperation.swift index 10fd1e2d..1b948df3 100644 --- a/Automator/Sources/RedactOperation.swift +++ b/Automator/Sources/RedactOperation.swift @@ -4,8 +4,10 @@ import DetectionsMac import ExportingMac import Foundation +import OSLog import Redacting import RedactionsMac +import AppKit class RedactOperation: Operation, @unchecked Sendable { var result: Result? @@ -32,8 +34,23 @@ class RedactOperation: Operation, @unchecked Sendable { do { guard let inputImage = input.image else { throw RedactActionExportError.noImageForInput } let redactedImage = try await PhotoExportRenderer(image: inputImage, redactions: redactions).render() - self?.finish(with: .success("")) + let writeURL = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString, conformingTo: input.fileType ?? .png) + + os_log("export representations: %{public}@", String(describing: redactedImage.representations)) + + guard let cgImage = redactedImage.cgImage(forProposedRect: nil, context: nil, hints: nil) + else { throw RedactActionExportError.failedToGetBitmapRepresentation } + + let imageRep = NSBitmapImageRep(cgImage: cgImage) + + guard let data = imageRep.representation(using: input.imageType, properties: [:]) + else { throw RedactActionExportError.failedToGetData } + + try data.write(to: writeURL) + + self?.finish(with: .success(writeURL.path)) } catch { + os_log("export error occured: %{public}@", String(describing: error)) self?.finish(with: .failure(error)) } } diff --git a/Modules/Capabilities/Brushes/Sources/BrushStampFactory.swift b/Modules/Capabilities/Brushes/Sources/BrushStampFactory.swift index 928495bc..31678906 100644 --- a/Modules/Capabilities/Brushes/Sources/BrushStampFactory.swift +++ b/Modules/Capabilities/Brushes/Sources/BrushStampFactory.swift @@ -5,70 +5,69 @@ import AppKit import ErrorHandlingMac import GeometryMac +import OSLog public enum BrushStampFactory { public static func brushImages(for shape: Shape, color: NSColor, scale: CGFloat) throws -> (CGImage, CGImage) { let height = shape.unionDotShapeDotShapeDotUnionCrash.geometryStreamer.height - let startImage = BrushStampFactory.brushStart(scaledToHeight: height, color: color) - let endImage = BrushStampFactory.brushEnd(scaledToHeight: height, color: color) + let startImage = try BrushStampFactory.brushStart(scaledToHeight: height, color: color) + let endImage = try BrushStampFactory.brushEnd(scaledToHeight: height, color: color) - guard let startCGImage = startImage.cgImage, - let endCGImage = endImage.cgImage - else { - throw BrushStampFactoryError.cannotGenerateCGImage( - shape: shape, - color: color, - scale: scale - ) - } - - return (startCGImage, endCGImage) + return (startImage, endImage) } - public static func brushStart(scaledToHeight height: CGFloat, color: NSColor) -> NSImage { + public static func brushStart(scaledToHeight height: CGFloat, color: NSColor) throws -> CGImage { guard let standardImage = Bundle.module.image(forResource: "Brush Start") else { ErrorHandler().crash("Unable to load brush start image") } - return scaledImage(from: standardImage, toHeight: height, color: color) + return try scaledImage(from: standardImage, toHeight: height, color: color) } - public static func brushEnd(scaledToHeight height: CGFloat, color: NSColor) -> NSImage { + public static func brushEnd(scaledToHeight height: CGFloat, color: NSColor) throws -> CGImage { guard let standardImage = Bundle.module.image(forResource: "Brush End") else { ErrorHandler().crash("Unable to load brush end image") } - return scaledImage(from: standardImage, toHeight: height, color: color) + return try scaledImage(from: standardImage, toHeight: height, color: color) } - private static func scaledImage(from image: NSImage, toHeight height: CGFloat, color: NSColor) -> NSImage { + private static func scaledImage(from image: NSImage, toHeight height: CGFloat, color: NSColor) throws -> CGImage { let brushScale = height / image.size.height let scaledBrushSize = image.size * brushScale - - return NSImage(size: scaledBrushSize, flipped: false) { _ -> Bool in - color.setFill() - - CGRect(origin: .zero, size: scaledBrushSize).fill() - - guard let context = NSGraphicsContext.current?.cgContext else { return false } - context.scaleBy(x: brushScale, y: brushScale) - - image.draw(at: .zero, from: CGRect(origin: .zero, size: image.size), operation: .destinationIn, fraction: 1) - - return true - } + os_log("scaling brush images from %{public}@ to %{public}@, scale: %{public}f", String(describing: image.size), String(describing: scaledBrushSize), brushScale) + + guard let imageRep = NSBitmapImageRep( + bitmapDataPlanes: nil, + pixelsWide: Int(scaledBrushSize.width), + pixelsHigh: Int(scaledBrushSize.height), + bitsPerSample: 8, + samplesPerPixel: 4, + hasAlpha: true, + isPlanar: false, + colorSpaceName: .deviceRGB, + bytesPerRow: Int(scaledBrushSize.width) * 4, + bitsPerPixel: 32 + ), + let graphicsContext = NSGraphicsContext(bitmapImageRep: imageRep) + else { throw BrushStampFactoryError.cannotCreateImageContext } + NSGraphicsContext.current = graphicsContext + let context = graphicsContext.cgContext + context.setFillColor(color.cgColor) + context.beginPath() + context.addRect(CGRect(origin: .zero, size: scaledBrushSize)) + context.fillPath() + context.scaleBy(x: brushScale, y: brushScale) + + image.draw(at: .zero, from: CGRect(origin: .zero, size: image.size), operation: .destinationIn, fraction: 1) + guard let cgImage = context.makeImage() else { throw BrushStampFactoryError.cannotGenerateCGImage(color: color, height: height) } + return cgImage } public static func brushStamp(scaledToHeight height: CGFloat, color: NSColor) throws -> CGImage { guard let stampImage = Bundle.module.image(forResource: "Brush") else { ErrorHandler().crash("Unable to load brush stamp image") } - let scaledImage = scaledImage(from: stampImage, toHeight: height, color: color) - - guard let scaledCGImage = scaledImage.cgImage else { - throw BrushStampFactoryError.cannotGenerateCGImage(color: color, scale: 1) - } - - return scaledCGImage + return try scaledImage(from: stampImage, toHeight: height, color: color) } } enum BrushStampFactoryError: Error { - case cannotGenerateCGImage(shape: Shape, color: NSColor, scale: CGFloat) - case cannotGenerateCGImage(color: NSColor, scale: CGFloat) + case cannotCreateImageContext + case cannotGenerateCGImage(color: NSColor, height: CGFloat) } #elseif canImport(UIKit) import ErrorHandling @@ -130,13 +129,13 @@ public enum BrushStampFactory { } } - public static func brushStamp(scaledToHeight height: CGFloat, color: UIColor) -> UIImage { + public static func brushStamp(scaledToHeight height: CGFloat, color: UIColor) throws -> CGImage { guard let stampImage = UIImage(named: "Brush") else { ErrorHandler().crash("Unable to load brush stamp image") } let brushScale = height / stampImage.size.height let scaledBrushSize = stampImage.size * brushScale - return UIGraphicsImageRenderer(size: scaledBrushSize).image { context in + let scaledBrushImage = UIGraphicsImageRenderer(size: scaledBrushSize).image { context in color.setFill() context.fill(CGRect(origin: .zero, size: scaledBrushSize)) @@ -145,10 +144,17 @@ public enum BrushStampFactory { stampImage.draw(at: .zero, blendMode: .destinationIn, alpha: 1) } + + guard let scaledCGImage = scaledBrushImage.cgImage else { + throw BrushStampFactoryError.cannotGenerateCGImage(color: color, scale: 1) + } + + return scaledCGImage } } enum BrushStampFactoryError: Error { case cannotGenerateCGImage(shape: Shape, color: UIColor, scale: CGFloat) + case cannotGenerateCGImage(color: UIColor, scale: CGFloat) } #endif diff --git a/Modules/Capabilities/Brushes/Sources/NSImageExtensions.swift b/Modules/Capabilities/Brushes/Sources/NSImageExtensions.swift index 94212d30..21976aec 100644 --- a/Modules/Capabilities/Brushes/Sources/NSImageExtensions.swift +++ b/Modules/Capabilities/Brushes/Sources/NSImageExtensions.swift @@ -4,9 +4,5 @@ #if canImport(AppKit) && !targetEnvironment(macCatalyst) import AppKit -extension NSImage { - var cgImage: CGImage? { - cgImage(forProposedRect: nil, context: nil, hints: nil) - } -} +extension NSImage {} #endif diff --git a/Modules/Capabilities/Exporting/Sources/Extensions/UIImageExtensions.swift b/Modules/Capabilities/Exporting/Sources/Extensions/UIImageExtensions.swift index 468a2de6..103e96f7 100644 --- a/Modules/Capabilities/Exporting/Sources/Extensions/UIImageExtensions.swift +++ b/Modules/Capabilities/Exporting/Sources/Extensions/UIImageExtensions.swift @@ -2,6 +2,7 @@ // Copyright © 2024 Cocoatype, LLC. All rights reserved. #if canImport(UIKit) +import ImageIO import UIKit extension UIImage { @@ -31,5 +32,19 @@ extension UIImage.Orientation { @unknown default: return 0 } } + + public var cgImageOrientation: CGImagePropertyOrientation { + switch self { + case .up: .up + case .down: .down + case .left: .left + case .right: .right + case .upMirrored: .upMirrored + case .downMirrored: .downMirrored + case .leftMirrored: .leftMirrored + case .rightMirrored: .rightMirrored + @unknown default: .up + } + } } #endif diff --git a/Modules/Capabilities/Exporting/Sources/PhotoExportRenderer.swift b/Modules/Capabilities/Exporting/Sources/PhotoExportRenderer.swift index 3152120b..c4e893c4 100644 --- a/Modules/Capabilities/Exporting/Sources/PhotoExportRenderer.swift +++ b/Modules/Capabilities/Exporting/Sources/PhotoExportRenderer.swift @@ -9,6 +9,7 @@ import RedactionsMac #elseif canImport(UIKit) import Brushes import Geometry +import ImageIO import Redactions import UIKit #endif @@ -43,29 +44,49 @@ public actor PhotoExportRenderer { else { throw PhotoExportRenderError.noCurrentGraphicsContext } let context = graphicsContext.cgContext - let cgImage = try render(context: context, orientation: .up, imageSize: imageSize) + let cgImage = try render( + context: context, + orientation: .up, + imageSize: imageSize, + flipped: false + ) return NSImage(cgImage: cgImage, size: imageSize) } #elseif canImport(UIKit) + private let orientation: CGImagePropertyOrientation + private let imageSize: CGSize + private let imageScale: Double + public init(image: UIImage, redactions: [Redaction]) throws { + guard let sourceImage = image.cgImage + else { throw PhotoExportRenderError.noCGImage } + self.redactions = redactions - self.sourceImage = image + self.sourceImage = sourceImage + + self.imageSize = image.realSize + self.imageScale = image.scale + self.orientation = image.imageOrientation.cgImageOrientation } public func render() throws -> UIImage { - let imageSize = sourceImage.realSize * sourceImage.scale - - UIGraphicsBeginImageContextWithOptions(sourceImage.size, false, sourceImage.scale) + UIGraphicsBeginImageContextWithOptions(sourceImage.size, false, imageScale) defer { UIGraphicsEndImageContext() } guard let context = UIGraphicsGetCurrentContext() else { throw PhotoExportRenderError.noCurrentGraphicsContext } - return try render(context: context, imageSize: imageSize) + let cgImage = try render( + context: context, + orientation: orientation, + imageSize: imageSize, + flipped: true + ) + return UIImage(cgImage: cgImage) } #endif #warning("#62: Simplify & de-dupe") // swiftlint:disable:next function_body_length - private func render(context: CGContext, orientation: CGImagePropertyOrientation, imageSize: CGSize) throws -> CGImage { + private func render(context: CGContext, orientation: CGImagePropertyOrientation, imageSize: CGSize, flipped: Bool) throws -> CGImage { var tileRect = CGRect.zero tileRect.size.width = imageSize.width tileRect.size.height = floor(CGFloat(Self.tileTotalPixels) / CGFloat(imageSize.width)) @@ -88,8 +109,10 @@ public actor PhotoExportRenderer { let untranslateTransform = CGAffineTransform(translationX: imageSize.width / -2, y: imageSize.height / -2) context.concatenate(untranslateTransform) - let transform = CGAffineTransform(scaleX: 1, y: -1).translatedBy(x: 0, y: imageSize.height * -1) - context.concatenate(transform) + if flipped { + let flipTransform = CGAffineTransform(scaleX: 1, y: -1).translatedBy(x: 0, y: imageSize.height * -1) + context.concatenate(flipTransform) + } for y in 0.. Date: Sat, 22 Jun 2024 04:01:54 -0700 Subject: [PATCH 15/16] Fix exporting on iOS --- .../Sources/PhotoExportRenderer.swift | 23 +++++++------------ .../Capabilities/Geometry/Sources/Shape.swift | 13 +++++++++++ .../Geometry/Tests/ShapeTests.swift | 18 +++++++++++++++ 3 files changed, 39 insertions(+), 15 deletions(-) diff --git a/Modules/Capabilities/Exporting/Sources/PhotoExportRenderer.swift b/Modules/Capabilities/Exporting/Sources/PhotoExportRenderer.swift index c4e893c4..d4d5496c 100644 --- a/Modules/Capabilities/Exporting/Sources/PhotoExportRenderer.swift +++ b/Modules/Capabilities/Exporting/Sources/PhotoExportRenderer.swift @@ -84,7 +84,7 @@ public actor PhotoExportRenderer { } #endif - #warning("#62: Simplify & de-dupe") + #warning("#62: Simplify this method") // swiftlint:disable:next function_body_length private func render(context: CGContext, orientation: CGImagePropertyOrientation, imageSize: CGSize, flipped: Bool) throws -> CGImage { var tileRect = CGRect.zero @@ -144,30 +144,23 @@ public actor PhotoExportRenderer { let (part, color) = drawing switch part { case .shape(let shape): - let (startImage, endImage) = try BrushStampFactory.brushImages(for: shape, color: color, scale: context.ctm.a) + let normalizedShape = shape.ggImage + let (startImage, endImage) = try BrushStampFactory.brushImages(for: normalizedShape, color: color, scale: context.ctm.a) color.setFill() - context.addPath(shape.path) + context.addPath(normalizedShape.path) context.fillPath() context.saveGState() - if flipped { - context.translateBy(x: shape.topLeft.x, y: shape.topLeft.y) - } else { - context.translateBy(x: shape.bottomLeft.x, y: shape.bottomLeft.y) - } - context.rotate(by: shape.angle) + context.translateBy(x: normalizedShape.topLeft.x, y: normalizedShape.topLeft.y) + context.rotate(by: normalizedShape.angle) context.translateBy(x: -(startImage.size.width - 1), y: 0) context.draw(startImage, in: CGRect(origin: .zero, size: startImage.size)) context.restoreGState() context.saveGState() - if flipped { - context.translateBy(x: shape.topRight.x, y: shape.topRight.y) - } else { - context.translateBy(x: shape.bottomRight.x, y: shape.bottomRight.y) - } - context.rotate(by: shape.angle) + context.translateBy(x: normalizedShape.topRight.x, y: normalizedShape.topRight.y) + context.rotate(by: normalizedShape.angle) context.translateBy(x: -1, y: 0) context.draw(endImage, in: CGRect(origin: .zero, size: endImage.size)) context.restoreGState() diff --git a/Modules/Capabilities/Geometry/Sources/Shape.swift b/Modules/Capabilities/Geometry/Sources/Shape.swift index c2c3e1fa..973e6b73 100644 --- a/Modules/Capabilities/Geometry/Sources/Shape.swift +++ b/Modules/Capabilities/Geometry/Sources/Shape.swift @@ -73,6 +73,19 @@ public struct Shape: Hashable { [bottomLeft, bottomRight, topLeft, topRight] } + // ggImage by @AdamWulf on 2024-06-21 + // a normalized version of the shape, always right-side up + public var ggImage: Shape { + // Sort points by x-coordinate + let sortedPoints = inverseTranslateRotateTransform.sorted { $0.x < $1.x } + + // Determine the two leftmost and two rightmost points + let leftPoints = [sortedPoints[0], sortedPoints[1]].sorted { $0.y < $1.y } + let rightPoints = [sortedPoints[2], sortedPoints[3]].sorted { $0.y < $1.y } + + return Shape(bottomLeft: leftPoints[1], bottomRight: rightPoints[1], topLeft: leftPoints[0], topRight: rightPoints[0]) + } + // unionDotShapeDotShapeDotUnionCrash by @AdamWulf on 2024-06-17 // the unrotated form of this shape public var unionDotShapeDotShapeDotUnionCrash: EmotionalSupportVariable { diff --git a/Modules/Capabilities/Geometry/Tests/ShapeTests.swift b/Modules/Capabilities/Geometry/Tests/ShapeTests.swift index 63e90b73..41476375 100644 --- a/Modules/Capabilities/Geometry/Tests/ShapeTests.swift +++ b/Modules/Capabilities/Geometry/Tests/ShapeTests.swift @@ -38,4 +38,22 @@ final class ShapeTests: XCTestCase { dump(shape.unionDotShapeDotShapeDotUnionCrash) } + + func testNormalizedShape() { + let originalShape = Shape( + bottomLeft: CGPoint(x: 946.7962608595813, y: 1331.9914845573883), + bottomRight: CGPoint(x: 269.4029737073748, y: 1349.2245818822003), + topLeft: CGPoint(x: 948.5000076543305, y: 1398.961839335604), + topRight: CGPoint(x: 271.10672050212395, y: 1416.194936660416) + ) + + let expectedShape = Shape( + bottomLeft: originalShape.topRight, + bottomRight: originalShape.topLeft, + topLeft: originalShape.bottomRight, + topRight: originalShape.bottomLeft + ) + + XCTAssertEqual(originalShape.ggImage, expectedShape, accuracy: 0.01) + } } From 89b1bd4a8150eb38b424a6d2863b0162d89c6b4b Mon Sep 17 00:00:00 2001 From: Geoff Pado Date: Sat, 22 Jun 2024 04:08:58 -0700 Subject: [PATCH 16/16] Clean up after export changes --- .../Sources/RedactActionExportOperation.swift | 71 ------------------- Automator/Sources/RedactedImageExporter.swift | 24 ------- .../Brushes/Sources/BrushStampFactory.swift | 6 +- .../Brushes/Sources/NSImageExtensions.swift | 8 --- 4 files changed, 2 insertions(+), 107 deletions(-) delete mode 100644 Automator/Sources/RedactActionExportOperation.swift delete mode 100644 Automator/Sources/RedactedImageExporter.swift delete mode 100644 Modules/Capabilities/Brushes/Sources/NSImageExtensions.swift diff --git a/Automator/Sources/RedactActionExportOperation.swift b/Automator/Sources/RedactActionExportOperation.swift deleted file mode 100644 index 3bd16b6e..00000000 --- a/Automator/Sources/RedactActionExportOperation.swift +++ /dev/null @@ -1,71 +0,0 @@ -// Created by Geoff Pado on 10/28/20. -// Copyright © 2020 Cocoatype, LLC. All rights reserved. - -import AppKit -import BrushesMac -import Redacting -import RedactionsMac - -//class RedactActionExportOperation: Operation { -// var result: Result? -// -// init(input: RedactActionInput, redactions: [Redaction]) { -// self.redactions = redactions -// self.input = input -// } -// -// #warning("#62: Simplify & de-dupe") -// // swiftlint:disable:next function_body_length -// override func main() { -// do { -// -// // draw redactions -// let drawings = redactions.flatMap { redaction -> [(path: NSBezierPath, color: NSColor)] in -// return redaction.paths -// .map { (path: $0, color: redaction.color) } -// } -// -// drawings.forEach { drawing in -// let (path, color) = drawing -// let borderBounds = path.strokeBorderPath.bounds -// let startImage = BrushStampFactory.brushStart(scaledToHeight: borderBounds.height, color: color) -// let endImage = BrushStampFactory.brushEnd(scaledToHeight: borderBounds.height, color: color) -// -// color.setFill() -// NSBezierPath(rect: borderBounds).fill() -// -// let drawContext = NSGraphicsContext(cgContext: context, flipped: false) -// NSGraphicsContext.saveGraphicsState() -// NSGraphicsContext.current = drawContext -// let startRect = CGRect(origin: borderBounds.origin, size: startImage.size).offsetBy(dx: -startImage.size.width, dy: 0) -// startImage.draw(in: startRect, from: CGRect(origin: .zero, size: startImage.size), operation: .sourceOver, fraction: 1) -// -// let endRect = CGRect(origin: borderBounds.origin, size: endImage.size).offsetBy(dx: borderBounds.width, dy: 0) -// endImage.draw(in: endRect, from: CGRect(origin: .zero, size: endImage.size), operation: .sourceOver, fraction: 1) -// NSGraphicsContext.restoreGraphicsState() -// } -// -// // export image -// let writeURL = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString).appendingPathExtension(input.fileType?.preferredFilenameExtension ?? "png") -// -// guard let data = imageRep.representation(using: input.imageType, properties: [:]) else { throw RedactActionExportError.writeError } -// try data.write(to: writeURL) -// -// self.result = .success(writeURL.path) -// } catch { -// self.result = .failure(error) -// } -// } -// -// // MARK: Boilerplate -// -// private static let bytesPerMB = 1024 * 1024 -// private static let bytesPerPixel = 4 -// private static let pixelsPerMB = bytesPerMB / bytesPerPixel -// private static let seamOverlap = CGFloat(2) -// private static let sourceImageTileSizeMB = 120 -// private static let tileTotalPixels = sourceImageTileSizeMB * pixelsPerMB -// -// private let redactions: [Redaction] -// private let input: RedactActionInput -//} diff --git a/Automator/Sources/RedactedImageExporter.swift b/Automator/Sources/RedactedImageExporter.swift deleted file mode 100644 index c253c73d..00000000 --- a/Automator/Sources/RedactedImageExporter.swift +++ /dev/null @@ -1,24 +0,0 @@ -// Created by Geoff Pado on 10/28/20. -// Copyright © 2020 Cocoatype, LLC. All rights reserved. - -import AppKit -import Redacting -import RedactionsMac - -//class RedactActionExporter: NSObject { -// static func export(_ input: RedactActionInput, redactions: [Redaction], completionHandler: @escaping((Result) -> Void)) { -// let exportOperation = RedactActionExportOperation(input: input, redactions: redactions) -// let callbackOperation = BlockOperation { -// guard let result = exportOperation.result else { -// return completionHandler(.failure(RedactActionExportError.operationReturnedNoResult)) -// } -// -// completionHandler(result) -// } -// -// callbackOperation.addDependency(exportOperation) -// operationQueue.addOperations([exportOperation, callbackOperation], waitUntilFinished: false) -// } -// -// private static let operationQueue = OperationQueue() -//} diff --git a/Modules/Capabilities/Brushes/Sources/BrushStampFactory.swift b/Modules/Capabilities/Brushes/Sources/BrushStampFactory.swift index 31678906..3e5c0637 100644 --- a/Modules/Capabilities/Brushes/Sources/BrushStampFactory.swift +++ b/Modules/Capabilities/Brushes/Sources/BrushStampFactory.swift @@ -5,7 +5,6 @@ import AppKit import ErrorHandlingMac import GeometryMac -import OSLog public enum BrushStampFactory { public static func brushImages(for shape: Shape, color: NSColor, scale: CGFloat) throws -> (CGImage, CGImage) { @@ -29,7 +28,6 @@ public enum BrushStampFactory { private static func scaledImage(from image: NSImage, toHeight height: CGFloat, color: NSColor) throws -> CGImage { let brushScale = height / image.size.height let scaledBrushSize = image.size * brushScale - os_log("scaling brush images from %{public}@ to %{public}@, scale: %{public}f", String(describing: image.size), String(describing: scaledBrushSize), brushScale) guard let imageRep = NSBitmapImageRep( bitmapDataPlanes: nil, @@ -146,7 +144,7 @@ public enum BrushStampFactory { } guard let scaledCGImage = scaledBrushImage.cgImage else { - throw BrushStampFactoryError.cannotGenerateCGImage(color: color, scale: 1) + throw BrushStampFactoryError.cannotGenerateStampCGImage(color: color, scale: 1) } return scaledCGImage @@ -155,6 +153,6 @@ public enum BrushStampFactory { enum BrushStampFactoryError: Error { case cannotGenerateCGImage(shape: Shape, color: UIColor, scale: CGFloat) - case cannotGenerateCGImage(color: UIColor, scale: CGFloat) + case cannotGenerateStampCGImage(color: UIColor, scale: CGFloat) } #endif diff --git a/Modules/Capabilities/Brushes/Sources/NSImageExtensions.swift b/Modules/Capabilities/Brushes/Sources/NSImageExtensions.swift deleted file mode 100644 index 21976aec..00000000 --- a/Modules/Capabilities/Brushes/Sources/NSImageExtensions.swift +++ /dev/null @@ -1,8 +0,0 @@ -// Created by Geoff Pado on 6/21/24. -// Copyright © 2024 Cocoatype, LLC. All rights reserved. - -#if canImport(AppKit) && !targetEnvironment(macCatalyst) -import AppKit - -extension NSImage {} -#endif