This commit is contained in:
filip-sakel 2022-08-09 13:13:02 +07:00 committed by GitHub
commit 3f55c423f2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 719 additions and 106 deletions

View File

@ -22,6 +22,7 @@ extension CGPoint {
func rotate(_ angle: Angle, around origin: Self) -> Self {
let cosAngle = CGFloat(cos(angle.radians))
let sinAngle = CGFloat(sin(angle.radians))
return .init(
x: cosAngle * (x - origin.x) - sinAngle * (y - origin.y) + origin.x,
y: sinAngle * (x - origin.x) + cosAngle * (y - origin.y) + origin.y
@ -36,16 +37,6 @@ extension CGPoint {
}
}
public extension CGAffineTransform {
/// Transform the point into the transform's coordinate system.
func transform(point: CGPoint) -> CGPoint {
CGPoint(
x: (a * point.x) + (c * point.y) + tx,
y: (b * point.x) + (d * point.y) + ty
)
}
}
#if !canImport(CoreGraphics)
public enum CGLineCap {
/// A line with a squared-off end. Extends to the endpoint of the Path.
@ -63,149 +54,248 @@ public enum CGLineJoin {
/// A join with a squared-off end. Extends past the endpoint of the Path.
case bevel
}
#endif
#if !canImport(CoreGraphics)
// For cross-platform testing.
@_spi(Tests)
public struct _CGAffineTransform {
// Internal for testing purposes.
internal let _transform: AffineTransform
}
#else
public struct _CGAffineTransform {
// Internal for testing purposes.
internal private(set) var _transform: AffineTransform
}
/// An affine transformation matrix for use in drawing 2D graphics.
///
/// a b 0
/// c d 0
/// tx ty 1
public struct CGAffineTransform: Equatable {
public var a: CGFloat
public var b: CGFloat
public var c: CGFloat
public var d: CGFloat
public var tx: CGFloat
public var ty: CGFloat
public typealias CGAffineTransform = _CGAffineTransform
#endif
/// The identity matrix
public static let identity: Self = .init(
a: 1,
b: 0, // 0
c: 0,
d: 1, // 0
tx: 0,
ty: 0
) // 1
extension _CGAffineTransform: Equatable {}
public init(
extension _CGAffineTransform: Codable {
public init(from decoder: Decoder) throws {
try self.init(
_transform: AffineTransform(from: decoder)
)
}
public func encode(to encoder: Encoder) throws {
try _transform.encode(to: encoder)
}
}
public extension _CGAffineTransform {
/// The value at position [1,1] in the matrix.
var a: CGFloat {
get { _transform.m11 }
set { _transform.m11 = newValue }
}
/// The value at position [1,2] in the matrix.
var b: CGFloat {
get { _transform.m12 }
set { _transform.m12 = newValue }
}
/// The value at position [2,1] in the matrix.
var c: CGFloat {
get { _transform.m21 }
set { _transform.m21 = newValue }
}
/// The value at position [2,2] in the matrix.
var d: CGFloat {
get { _transform.m22 }
set { _transform.m22 = newValue }
}
/// The value at position [3,1] in the matrix.
var tx: CGFloat {
get { _transform.tX }
set { _transform.tX = newValue }
}
/// The value at position [3,2] in the matrix.
var ty: CGFloat {
get { _transform.tY }
set { _transform.tY = newValue }
}
/// Creates an affine transform with the given matrix values.
///
/// - Parameters:
/// - a: The value at position [1,1] in the matrix.
/// - b: The value at position [1,2] in the matrix.
/// - c: The value at position [2,1] in the matrix.
/// - d: The value at position [2,2] in the matrix.
/// - tx: The value at position [3,1] in the matrix.
/// - ty: The value at position [3,2] in the matrix.
init(
a: CGFloat, b: CGFloat,
c: CGFloat, d: CGFloat,
tx: CGFloat, ty: CGFloat
) {
self.a = a
self.b = b
self.c = c
self.d = d
self.tx = tx
self.ty = ty
self.init(_transform: AffineTransform(
m11: a, m12: b,
m21: c, m22: d,
tX: tx, tY: ty
))
}
}
public extension _CGAffineTransform {
/// The identity transformation matrix.
static let identity = Self(_transform: .identity)
/// Creates the identity transformation matrix.
init() {
self.init(_transform: AffineTransform())
}
/// Returns an affine transformation matrix constructed from a rotation value you provide.
var isIdentity: Bool {
self == .identity
}
}
public extension _CGAffineTransform {
/// Creates an affine transformation matrix constructed from a rotation value you
/// provide.
///
/// - Parameters:
/// - angle: The angle, in radians, by which this matrix rotates the coordinate system axes.
/// A positive value specifies clockwise rotation and anegative value specifies
/// counterclockwise rotation.
public init(rotationAngle angle: CGFloat) {
self.init(a: cos(angle), b: sin(angle), c: -sin(angle), d: cos(angle), tx: 0, ty: 0)
/// - angle: The angle, in radians, by which this matrix rotates the coordinate
/// system axes. A positive value specifies clockwise rotation and a negative value
/// specifies counterclockwise rotation.
init(rotationAngle angle: CGFloat) {
self.init(_transform: AffineTransform(rotationByRadians: angle))
}
/// Returns an affine transformation matrix constructed from scaling values you provide.
/// Creates an affine transformation matrix constructed from scaling values you provide.
///
/// - Parameters:
/// - sx: The factor by which to scale the x-axis of the coordinate system.
/// - sy: The factor by which to scale the y-axis of the coordinate system.
public init(scaleX sx: CGFloat, y sy: CGFloat) {
self.init(
a: sx,
b: 0,
c: 0,
d: sy,
tx: 0,
ty: 0
)
init(scaleX x: CGFloat, y: CGFloat) {
self.init(_transform: AffineTransform(scaleByX: x, byY: y))
}
/// Returns an affine transformation matrix constructed from translation values you provide.
/// Creates an affine transformation matrix constructed from translation values you
/// provide.
///
/// - Parameters:
/// - tx: The value by which to move the x-axis of the coordinate system.
/// - ty: The value by which to move the y-axis of the coordinate system.
public init(translationX tx: CGFloat, y ty: CGFloat) {
self.init(
a: 1,
b: 0,
c: 0,
d: 1,
tx: tx,
ty: ty
)
init(translationX x: CGFloat, y: CGFloat) {
self.init(_transform: AffineTransform(translationByX: x, byY: y))
}
}
public extension _CGAffineTransform {
private func withBackingTransform(
_ modify: (inout AffineTransform) -> ()
) -> Self {
var newTransform = _transform
modify(&newTransform)
return _CGAffineTransform(_transform: newTransform)
}
}
public extension _CGAffineTransform {
/// Returns an affine transformation matrix constructed by combining two existing affine
/// transforms.
///
/// Note that concatenation is not commutative, meaning that order is important. For
/// instance, `t1.concatenating(t2)` != `t2.concatenating(t1)` where
/// `t1` and `t2` are`CGAffineTransform` instances.
///
/// - Postcondition: The returned transformation is invertible if both `self` and
/// the given transformation (`t2`) are invertible.
///
/// - Parameters:
/// - t2: The affine transform to concatenate to this affine transform.
/// - Returns: A new affine transformation matrix. That is, `t = t1*t2`.
public func concatenating(_ t2: Self) -> Self {
let t1m = [[a, b, 0],
[c, d, 0],
[tx, ty, 1]]
let t2m = [[t2.a, t2.b, 0],
[t2.c, t2.d, 0],
[t2.tx, t2.ty, 1]]
var res: [[CGFloat]] = [[0, 0, 0],
[0, 0, 0],
[0, 0, 0]]
for i in 0..<3 {
for j in 0..<3 {
res[i][j] = 0
for k in 0..<3 {
res[i][j] += t1m[i][k] * t2m[k][j]
}
func concatenating(_ t2: Self) -> Self {
withBackingTransform { $0.append(t2._transform) }
}
}
public extension _CGAffineTransform {
/// Returns an affine transformation matrix constructed by inverting an existing affine
/// transform.
///
/// - Postcondition: Invertibility is preserved, meaning that if `self` is
/// invertible, so the returned transformation will also be invertible.
///
/// - Returns: A new affine transformation matrix. If `self` is not invertible, it's
/// returned unchanged.
func inverted() -> Self {
withBackingTransform { _transform in
guard let inverted = _transform.inverted() else {
fatalError("This affine transform is non invertible.")
}
_transform = inverted
}
return .init(
a: res[0][0],
b: res[0][1],
c: res[1][0],
d: res[1][1],
tx: res[2][0],
ty: res[2][1]
)
}
}
/// Returns an affine transformation matrix constructed by inverting an existing affine transform.
public func inverted() -> Self {
.init(a: -a, b: -b, c: -c, d: -d, tx: -tx, ty: -ty)
}
/// Returns an affine transformation matrix constructed by rotating an existing affine transform.
public extension _CGAffineTransform {
/// Returns an affine transformation matrix constructed by rotating an existing affine
/// transform.
///
/// - Parameters:
/// - angle: The angle, in radians, by which to rotate the affine transform.
/// A positive value specifies clockwise rotation and a negative value specifies
/// counterclockwise rotation.
public func rotated(by angle: CGFloat) -> Self {
Self(a: cos(angle), b: sin(angle), c: -sin(angle), d: cos(angle), tx: 0, ty: 0)
func rotated(by angle: CGFloat) -> Self {
withBackingTransform { $0.rotate(byRadians: angle) }
}
/// Returns an affine transformation matrix constructed by scaling an existing affine
/// transform.
///
/// - Postcondition: Invertibility is preserved if both `sx` and `sy` aren't `0`.
///
/// - Parameters:
/// - sx: The value by which to scale x values of the affine transform.
/// - sy: The value by which to scale y values of the affine transform.
func scaledBy(x: CGFloat, y: CGFloat) -> Self {
withBackingTransform { $0.scale(x: x, y: y) }
}
/// Returns an affine transformation matrix constructed by translating an existing
/// affine transform.
///
/// - Parameters:
/// - tx: The value by which to move x values with the affine transform.
/// - ty: The value by which to move y values with the affine transform.
public func translatedBy(x tx: CGFloat, y ty: CGFloat) -> Self {
.init(a: a, b: b, c: c, d: d, tx: self.tx + tx, ty: self.ty + ty)
}
/// Returns an affine transformation matrix constructed by scaling an existing affine transform.
/// - Parameters:
/// - sx: The value by which to scale x values of the affine transform.
/// - sy: The value by which to scale y values of the affine transform.
public func scaledBy(x sx: CGFloat, y sy: CGFloat) -> Self {
.init(a: a + sx, b: b, c: c, d: d + sy, tx: tx, ty: ty)
}
public var isIdentity: Bool {
self == Self.identity
func translatedBy(x: CGFloat, y: CGFloat) -> Self {
withBackingTransform { $0.translate(x: x, y: y) }
}
}
#endif
internal extension _CGAffineTransform {
func _transform(point: CGPoint) -> CGPoint {
_transform.transform(point)
}
}
public extension CGAffineTransform {
/// Transform the point into the transform's coordinate system.
func transform(point: CGPoint) -> CGPoint {
let transform = _CGAffineTransform(
a: a, b: b,
c: c, d: d,
tx: tx, ty: ty
)
return transform._transform(point: point)
}
}

View File

@ -0,0 +1,523 @@
// Copyright 2020 Tokamak contributors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
@_spi(Tests)
@testable import TokamakCore
import XCTest
typealias CGAffineTransform = TokamakCore._CGAffineTransform
// MARK: - Tests
final class CGAffineTransformTest: XCTestCase {
private let accuracyThreshold = 0.001
static var allTests: [(String, (CGAffineTransformTest) -> () throws -> ())] {
[
("testConstruction", testConstruction),
("testVectorTransformations", testVectorTransformations),
("testIdentityConstruction", testIdentityConstruction),
("testIdentity", testIdentity),
("testTranslationConstruction", testTranslationConstruction),
("testTranslation", testTranslation),
("testScalingConstruction", testScalingConstruction),
("testScaling", testScaling),
("testRotationConstruction", testRotationConstruction),
("testRotation", testRotation),
("testTranslationScaling", testTranslationScaling),
("testTranslationRotation", testTranslationRotation),
("testScalingRotation", testScalingRotation),
("testInversion", testInversion),
("testConcatenation", testConcatenation),
("testCoding", testCoding),
]
}
}
// MARK: - Helper
extension CGAffineTransformTest {
func check(
point: CGPoint,
withTransform transform: CGAffineTransform,
mapsTo expectedPoint: CGPoint,
_ message: String = "",
file: StaticString = #file, line: UInt = #line
) {
let newPoint = transform.transform(point: point)
XCTAssertEqual(
newPoint.x, expectedPoint.x,
accuracy: accuracyThreshold,
"Invalid x: \(message)",
file: file, line: line
)
XCTAssertEqual(
newPoint.y, expectedPoint.y,
accuracy: accuracyThreshold,
"Invalid y: \(message)",
file: file, line: line
)
}
}
// MARK: - Construction
extension CGAffineTransformTest {
func testConstruction() {
let transform = CGAffineTransform(
a: 1, b: 2,
c: 3, d: 4,
tx: 5, ty: 6
)
XCTAssertEqual(transform.a, 1)
XCTAssertEqual(transform.b, 2)
XCTAssertEqual(transform.c, 3)
XCTAssertEqual(transform.d, 4)
XCTAssertEqual(transform.tx, 5)
XCTAssertEqual(transform.ty, 6)
let mutatedTransform: CGAffineTransform = {
var copy = transform
copy.a = -1
copy.b = -2
copy.c = -3
copy.d = -4
copy.tx = -5
copy.ty = -6
return copy
}()
XCTAssertEqual(mutatedTransform.a, -1)
XCTAssertEqual(mutatedTransform.b, -2)
XCTAssertEqual(mutatedTransform.c, -3)
XCTAssertEqual(mutatedTransform.d, -4)
XCTAssertEqual(mutatedTransform.tx, -5)
XCTAssertEqual(mutatedTransform.ty, -6)
}
}
// MARK: - Vector Transformations
extension CGAffineTransformTest {
func testVectorTransformations() {
// To transform a given point with coordinates x and y,
// we do:
//
// [ m11 m12 0 ]
// [ w' h' 1 ] = [ x y 1 ] * [ m21 m22 0 ]
// [ tx ty 1 ]
//
// [ w' h' 1 ] = [ x*m11+y*m21+1*tX x*m12+y*m22+1*tY ]
check(
point: CGPoint(x: 10, y: 20),
withTransform: CGAffineTransform(
a: 1, b: 2,
c: 3, d: 4,
tx: 5, ty: 6
),
// [ px*m11+py*m21+tX px*m12+py*m22+tY ]
// [ 10*1+20*3+5 10*2+20*4+6 ]
// [ 75 106 ]
mapsTo: CGPoint(x: 75, y: 106)
)
check(
point: CGPoint(x: 5, y: 25),
withTransform: CGAffineTransform(
a: 5, b: 4,
c: 3, d: 2,
tx: 1, ty: 0
),
// [ px*m11+py*m21+tX px*m12+py*m22+tY ]
// [ 5*5+25*3+1 5*4+25*2+0 ]
// [ 101 70 ]
mapsTo: CGPoint(x: 101, y: 70)
)
}
}
// MARK: - Identity
extension CGAffineTransformTest {
func testIdentityConstruction() {
// Check that the transform matrix is the identity:
// [ 1 0 0 ]
// [ 0 1 0 ]
// [ 0 0 1 ]
let identity = CGAffineTransform(
a: 1, b: 0,
c: 0, d: 1,
tx: 0, ty: 0
)
XCTAssertEqual(CGAffineTransform(), identity)
XCTAssertEqual(CGAffineTransform.identity, identity)
}
func testIdentity() {
check(
point: CGPoint(x: 25, y: 10),
withTransform: .identity,
mapsTo: CGPoint(x: 25, y: 10)
)
}
}
// MARK: - Translation
extension CGAffineTransformTest {
func testTranslationConstruction() {
let translatedIdentity = CGAffineTransform.identity
.translatedBy(x: 15, y: 20)
let translation = CGAffineTransform(
translationX: 15, y: 20
)
XCTAssertEqual(translatedIdentity, translation)
}
func testTranslation() {
check(
point: CGPoint(x: 10, y: 10),
withTransform: CGAffineTransform(
translationX: 0, y: 0
),
mapsTo: CGPoint(x: 10, y: 10)
)
check(
point: CGPoint(x: 10, y: 10),
withTransform: CGAffineTransform(
translationX: 0, y: 5
),
mapsTo: CGPoint(x: 10, y: 15)
)
check(
point: CGPoint(x: 10, y: 10),
withTransform: CGAffineTransform(
translationX: 5, y: 5
),
mapsTo: CGPoint(x: 15, y: 15)
)
check(
point: CGPoint(x: -2, y: -3),
// Translate by 5
withTransform: CGAffineTransform.identity
.translatedBy(x: 2, y: 3)
.translatedBy(x: 3, y: 2),
mapsTo: CGPoint(x: 3, y: 2)
)
}
}
// MARK: - Scaling
extension CGAffineTransformTest {
func testScalingConstruction() {
let scaledIdentity = CGAffineTransform.identity
.scaledBy(x: 15, y: 20)
let scaling = CGAffineTransform(
scaleX: 15, y: 20
)
XCTAssertEqual(scaledIdentity, scaling)
}
func testScaling() {
check(
point: CGPoint(x: 10, y: 10),
withTransform: CGAffineTransform(
scaleX: 1, y: 0
),
mapsTo: CGPoint(x: 10, y: 0)
)
check(
point: CGPoint(x: 10, y: 10),
withTransform: CGAffineTransform(
scaleX: 0.5, y: 1
),
mapsTo: CGPoint(x: 5, y: 10)
)
check(
point: CGPoint(x: 10, y: 10),
withTransform: CGAffineTransform(
scaleX: 0, y: 2
),
mapsTo: CGPoint(x: 0, y: 20)
)
check(
point: CGPoint(x: 10, y: 10),
// Scale by (2, 0)
withTransform: CGAffineTransform.identity
.scaledBy(x: 4, y: 0)
.scaledBy(x: 0.5, y: 1),
mapsTo: CGPoint(x: 20, y: 0)
)
}
}
// MARK: - Rotation
extension CGAffineTransformTest {
func testRotationConstruction() {
let baseRotation = CGAffineTransform(rotationAngle: .pi)
let point = CGPoint(x: 10, y: 15)
let expectedPoint = baseRotation.transform(point: point)
check(
point: point,
withTransform: .identity.rotated(by: .pi),
mapsTo: expectedPoint,
"Rotation operation on identity doesn't work as identity init."
)
}
func testRotation() {
check(
point: CGPoint(x: 10, y: 15),
withTransform: CGAffineTransform(rotationAngle: 0),
mapsTo: CGPoint(x: 10, y: 15)
)
check(
point: CGPoint(x: 10, y: 15),
// Rotate by 3*360º
withTransform: CGAffineTransform(rotationAngle: .pi * 6),
mapsTo: CGPoint(x: 10, y: 15)
)
// Counter-clockwise rotation
check(
point: CGPoint(x: 15, y: 10),
// Rotate by 90º
withTransform: CGAffineTransform(rotationAngle: .pi / 2),
mapsTo: CGPoint(x: -10, y: 15)
)
// Clockwise rotation
check(
point: CGPoint(x: 15, y: 10),
// Rotate by -90º
withTransform: CGAffineTransform(rotationAngle: .pi / -2),
mapsTo: CGPoint(x: 10, y: -15)
)
// Reflect about origin
check(
point: CGPoint(x: 10, y: 15),
// Rotate by 180º
withTransform: CGAffineTransform(rotationAngle: .pi),
mapsTo: CGPoint(x: -10, y: -15)
)
// Composed reflection about origin
check(
point: CGPoint(x: 10, y: 15),
// Rotate by 180º
withTransform: CGAffineTransform.identity
.rotated(by: .pi / 2)
.rotated(by: .pi / 2),
mapsTo: CGPoint(x: -10, y: -15)
)
}
}
// MARK: - Permutations
extension CGAffineTransformTest {
func testTranslationScaling() {
check(
point: CGPoint(x: 1, y: 3),
// Translate by (2, 0) then scale by (5, -5)
withTransform: CGAffineTransform.identity
.translatedBy(x: 2, y: 0)
.scaledBy(x: 5, y: -5),
mapsTo: CGPoint(x: 15, y: -15)
)
check(
point: CGPoint(x: 3, y: 1),
// Scale by (-5, 5) then scale by (0, 10)
withTransform: CGAffineTransform.identity
.scaledBy(x: -5, y: 5)
.translatedBy(x: 0, y: 10),
mapsTo: CGPoint(x: -15, y: 15)
)
}
func testTranslationRotation() {
check(
point: CGPoint(x: 10, y: 10),
// Translate by (20, -5) then rotate by 90º
withTransform: CGAffineTransform.identity
.translatedBy(x: 20, y: -5)
.rotated(by: .pi / 2),
mapsTo: CGPoint(x: -5, y: 30)
)
check(
point: CGPoint(x: 10, y: 10),
// Rotate by 180º and then translate by (20, 15)
withTransform: CGAffineTransform.identity
.rotated(by: .pi)
.translatedBy(x: 20, y: 15),
mapsTo: CGPoint(x: 10, y: 5)
)
}
func testScalingRotation() {
check(
point: CGPoint(x: 20, y: 5),
// Scale by (0.5, 3) then rotate by -90º
withTransform: CGAffineTransform.identity
.scaledBy(x: 0.5, y: 3)
.rotated(by: .pi / -2),
mapsTo: CGPoint(x: 15, y: -10)
)
check(
point: CGPoint(x: 20, y: 5),
// Rotate by -90º the scale by (0.5, 3)
withTransform: CGAffineTransform.identity
.rotated(by: .pi / -2)
.scaledBy(x: 3, y: -0.5),
mapsTo: CGPoint(x: 15, y: 10)
)
}
}
// MARK: - Inversion
extension CGAffineTransformTest {
func testInversion() {
let transforms = [
CGAffineTransform(translationX: -30, y: 40),
CGAffineTransform(rotationAngle: .pi / 4),
CGAffineTransform(scaleX: 20, y: -10),
]
let composeTransform: CGAffineTransform = {
var transform = CGAffineTransform.identity
for component in transforms {
transform = transform.concatenating(component)
}
return transform
}()
let recoveredIdentity: CGAffineTransform = {
var transform = composeTransform
// Append inverse transformations in reverse order
for component in transforms.reversed() {
transform = transform.concatenating(
component.inverted()
)
}
return transform
}()
check(
point: CGPoint(x: 10, y: 10),
withTransform: recoveredIdentity,
mapsTo: CGPoint(x: 10, y: 10)
)
}
}
// MARK: - Concatenation
extension CGAffineTransformTest {
func testConcatenation() {
check(
point: CGPoint(x: 10, y: 15),
withTransform: CGAffineTransform.identity
.concatenating(.identity),
mapsTo: CGPoint(x: 10, y: 15)
)
check(
point: CGPoint(x: 10, y: 15),
// Scale by 2 then translate by (10, 0)
withTransform: CGAffineTransform(scaleX: 2, y: 2)
.concatenating(CGAffineTransform(
translationX: 10, y: 0
)),
mapsTo: CGPoint(x: 30, y: 30)
)
check(
point: CGPoint(x: 10, y: 15),
// Translate by (10, 0) then scale by 2
withTransform: CGAffineTransform(translationX: 10, y: 0)
.concatenating(CGAffineTransform(scaleX: 2, y: 2)),
mapsTo: CGPoint(x: 40, y: 30)
)
}
}
// MARK: - Coding
extension CGAffineTransformTest {
func testCoding() throws {
let transform = CGAffineTransform(
a: 1, b: 2,
c: 3, d: 4,
tx: 5, ty: 6
)
let encodedData = try JSONEncoder().encode(transform)
let encodedString = String(
data: encodedData, encoding: .utf8
)
let commaSeparatedNumbers = (1...6)
.map(String.init)
.joined(separator: ",")
XCTAssertEqual(
encodedString, "[\(commaSeparatedNumbers)]",
"Invalid coding representation"
)
let recovered = try JSONDecoder().decode(
CGAffineTransform.self, from: encodedData
)
XCTAssertEqual(
transform, recovered,
"Encoded and then decoded transform does not equal original"
)
}
}