diff --git a/Source/CoreGraphicsPolyfill.swift b/Source/CoreGraphicsPolyfill.swift index 65dbfacc..959d3ea8 100644 --- a/Source/CoreGraphicsPolyfill.swift +++ b/Source/CoreGraphicsPolyfill.swift @@ -311,7 +311,7 @@ import Foundation } public func translatedBy(x: CGFloat, y: CGFloat) -> CGAffineTransform { - return self.concatenating(CGAffineTransform(translationX: x, y: y)) + return CGAffineTransform(translationX: x, y: y).concatenating(self) } public func concatenating(_ t: CGAffineTransform) -> CGAffineTransform { @@ -326,11 +326,11 @@ import Foundation } public func scaledBy(x: CGFloat, y: CGFloat) -> CGAffineTransform { - return self.concatenating(CGAffineTransform(scaleX: x, y: y)) + return CGAffineTransform(scaleX: x, y: y).concatenating(self) } public func rotated(by angle: CGFloat) -> CGAffineTransform { - return self.concatenating(CGAffineTransform(rotationAngle: angle)) + return CGAffineTransform(rotationAngle: angle).concatenating(self) } } @@ -451,17 +451,39 @@ import Foundation let initialPoint = CGPoint( x: center.x + radius * cos(currentAngle), y: center.y + radius * sin(currentAngle)) + // `UIBezierPath.addArc(withCenter:…)` documents that it implicitly + // sets the current point to the arc's starting point before + // emitting any cubics. With a non-empty path whose previous + // segment ends elsewhere, that "set current point" is a `move`, + // i.e. it opens a NEW subpath at the arc's start — Apple does not + // stitch the previous subpath to the arc with an implicit line. + // + // The original polyfill emitted `addLine(to: initialPoint)` here, + // which fuses the previous subpath and the arc into a single big + // subpath. When the SVG fixture uses evenodd-rule fills built up + // from many `M…A…` subpaths (animal-music body outlines stitched + // from 15+ arcs), that fusion flips which regions the fill rule + // considers "inside", which is exactly the cream/white blob over + // the pelican beak and otter forelock on Web. Always `move` to + // match Apple's UIBezierPath semantics. if path.elements.isEmpty || (path.elements.last?.isCloseSubpath ?? false) { path.move(to: initialPoint) } else if let lastElement = path.elements.last, let lastPoint = lastElement.lastPoint, lastPoint != initialPoint { - path.addLine(to: initialPoint) + path.move(to: initialPoint) } for _ in 0..= 0) } else { - let maxSize = CGFloat(max(w, h)) #if os(WASI) || os(Linux) || os(Android) - var path = MBezierPath(arcCenter: CGPoint.zero, radius: maxSize / 2, startAngle: extent, endAngle: end, clockwise: arcAngle >= 0) + SVGPath.appendEllipticalArcAsPolyline( + to: bezierPath, + cx: cx, cy: cy, w: w, h: h, + rotation: rotation, + startAngle: extent, arcAngle: arcAngle + ) #else - let path = MBezierPath(arcCenter: CGPoint.zero, radius: maxSize / 2, startAngle: extent, endAngle: end, clockwise: arcAngle >= 0) + SVGPath.appendEllipticalArcViaTransform( + to: bezierPath, + cx: cx, cy: cy, w: w, h: h, + rotation: rotation, + startAngle: extent, arcAngle: arcAngle + ) #endif - - var transform = CGAffineTransform(translationX: cx, y: cy) - transform = transform.rotated(by: CGFloat(rotation)) - path.apply(transform.scaledBy(x: CGFloat(w) / maxSize, y: CGFloat(h) / maxSize)) - - bezierPath.append(path) } } @@ -695,5 +698,61 @@ extension SVGPath { return bezierPath } + /// Apple/CoreGraphics implementation: build a unit-arc-radius MBezierPath + /// at the origin and apply a translate · rotate · scale transform. + /// This is the path used on iOS/macOS/tvOS/watchOS where + /// `MBezierPath.apply` is `UIBezierPath.apply` / `NSBezierPath.transform` + /// and renders the arc identically to native UIKit/AppKit. + static func appendEllipticalArcViaTransform( + to bezierPath: MBezierPath, + cx: CGFloat, cy: CGFloat, + w: CGFloat, h: CGFloat, + rotation: CGFloat, + startAngle: CGFloat, arcAngle: CGFloat + ) { + let maxSize = max(w, h) + let end = startAngle + arcAngle + let path = MBezierPath(arcCenter: CGPoint.zero, radius: maxSize / 2, startAngle: startAngle, endAngle: end, clockwise: arcAngle >= 0) + var transform = CGAffineTransform(translationX: cx, y: cy) + transform = transform.rotated(by: rotation) + path.apply(transform.scaledBy(x: w / maxSize, y: h / maxSize)) + bezierPath.append(path) + } + + /// Polyfill implementation: emit the elliptical arc as a 1°/segment + /// polyline in target coordinates. Used on WASI/Linux/Android because + /// the polyfill `MBezierPath.apply` does literal per-point math under + /// row-vector convention, which composes `T * R * S` incorrectly for + /// arcs whose centers are deep inside an SVG viewBox. + static func appendEllipticalArcAsPolyline( + to bezierPath: MBezierPath, + cx: CGFloat, cy: CGFloat, + w: CGFloat, h: CGFloat, + rotation: CGFloat, + startAngle: CGFloat, arcAngle: CGFloat + ) { + let rx = w / 2 + let ry = h / 2 + let cosA = cos(rotation) + let sinA = sin(rotation) + + func transformedPoint(_ a: CGFloat) -> CGPoint { + let xs = cos(a) * rx + let ys = sin(a) * ry + return CGPoint(x: cx + cosA * xs - sinA * ys, y: cy + sinA * xs + cosA * ys) + } + + let absSweep = abs(arcAngle) + let stepRadians = CGFloat.pi / 180 + let segmentCount = Swift.max(1, Int(ceil(absSweep / stepRadians))) + let perSegmentSweep = arcAngle / CGFloat(segmentCount) + + var currentAngle = startAngle + bezierPath.move(to: transformedPoint(currentAngle)) + for _ in 0 ..< segmentCount { + currentAngle += perSegmentSweep + bezierPath.addLine(to: transformedPoint(currentAngle)) + } + } } diff --git a/Tests/CoreGraphicsPolyfillTests/PolyfillTests.swift b/Tests/CoreGraphicsPolyfillTests/PolyfillTests.swift index 075ed768..859d517c 100644 --- a/Tests/CoreGraphicsPolyfillTests/PolyfillTests.swift +++ b/Tests/CoreGraphicsPolyfillTests/PolyfillTests.swift @@ -98,6 +98,49 @@ final class PolyfillTests: XCTestCase { XCTAssertNotEqual(transform.tx, 0) XCTAssertNotEqual(transform.ty, 0) } + + func testTranslatedByMatchesCoreGraphicsPrependingSemantics() { + let transform = CGAffineTransform(scaleX: 2, y: 3).translatedBy(x: 5, y: 7) + + XCTAssertEqual(transform.a, 2) + XCTAssertEqual(transform.d, 3) + XCTAssertEqual(transform.tx, 10) + XCTAssertEqual(transform.ty, 21) + } + + func testScaledByMatchesCoreGraphicsPrependingSemantics() { + let transform = CGAffineTransform(translationX: 5, y: 7).scaledBy(x: 2, y: 3) + + XCTAssertEqual(transform.a, 2) + XCTAssertEqual(transform.d, 3) + XCTAssertEqual(transform.tx, 5) + XCTAssertEqual(transform.ty, 7) + } + + func testRotatedByMatchesCoreGraphicsPrependingSemantics() { + let transform = CGAffineTransform(translationX: 20, y: 125).rotated(by: -.pi / 2) + + XCTAssertEqual(transform.a, cos(-.pi / 2), accuracy: 1e-10) + XCTAssertEqual(transform.b, sin(-.pi / 2), accuracy: 1e-10) + XCTAssertEqual(transform.c, -sin(-.pi / 2), accuracy: 1e-10) + XCTAssertEqual(transform.d, cos(-.pi / 2), accuracy: 1e-10) + XCTAssertEqual(transform.tx, 20, accuracy: 1e-10) + XCTAssertEqual(transform.ty, 125, accuracy: 1e-10) + } + + func testRotateAroundPointKeepsPivotFixed() { + let pivot = CGPoint(x: 20, y: 125) + let transform = CGAffineTransform.identity + .translatedBy(x: pivot.x, y: pivot.y) + .rotated(by: -.pi / 2) + .translatedBy(x: -pivot.x, y: -pivot.y) + + let transformedPivot = pivot.applying(transform) + XCTAssertEqual(transformedPivot.x, pivot.x, accuracy: 1e-10) + XCTAssertEqual(transformedPivot.y, pivot.y, accuracy: 1e-10) + XCTAssertEqual(transform.tx, -105, accuracy: 1e-10) + XCTAssertEqual(transform.ty, 145, accuracy: 1e-10) + } func testComplexTransform() { let point = CGPoint(x: 5, y: 5) diff --git a/Tests/SVGViewTests/SVGPathArcEquivalenceTests.swift b/Tests/SVGViewTests/SVGPathArcEquivalenceTests.swift new file mode 100644 index 00000000..68590d3d --- /dev/null +++ b/Tests/SVGViewTests/SVGPathArcEquivalenceTests.swift @@ -0,0 +1,310 @@ +import XCTest +@testable import SVGView + +#if canImport(CoreGraphics) +import CoreGraphics + +#if canImport(UIKit) +import UIKit +#elseif canImport(AppKit) +import AppKit +#endif + +/// Verifies that on Apple, the polyfill polyline emission (used on WASI/ +/// Linux/Android) produces the same elliptical arc geometry as the +/// transform-based emission used natively on iOS/macOS. Both must trace +/// the same SVG arc — same start point, same end point, same bounding +/// box within a small tolerance dictated by the polyline's 1°/segment +/// chord error. +final class SVGPathArcEquivalenceTests: XCTestCase { + + private struct ArcCase { + let label: String + let cx: CGFloat + let cy: CGFloat + let w: CGFloat + let h: CGFloat + let rotation: CGFloat + let startAngle: CGFloat + let arcAngle: CGFloat + } + + /// Battery of arc parameters covering the regressions PR #16 fixed: + /// small arcs deep in a viewBox (the "exploded streaks" case), + /// rotated ellipses, both CW and CCW sweeps, near-full sweeps. + private static let cases: [ArcCase] = [ + ArcCase(label: "small ellipse near origin, CW half", cx: 50, cy: 50, w: 40, h: 20, rotation: 0, startAngle: 0, arcAngle: .pi), + ArcCase(label: "rotated ellipse, CW half", cx: 100, cy: 100, w: 60, h: 30, rotation: .pi / 6, startAngle: 0, arcAngle: .pi), + ArcCase(label: "small arc deep in viewBox (regression case)", cx: 800, cy: 600, w: 10, h: 6, rotation: 0, startAngle: 0, arcAngle: 2 * .pi), + ArcCase(label: "CCW half sweep", cx: 200, cy: 200, w: 100, h: 50, rotation: 0, startAngle: 0, arcAngle: -.pi), + ArcCase(label: "rotated small arc, π/3 sweep", cx: 500, cy: 400, w: 16, h: 8, rotation: .pi / 3, startAngle: .pi / 4, arcAngle: .pi / 3), + ArcCase(label: "rotated CCW arc, π/2 sweep", cx: 300, cy: 300, w: 80, h: 40, rotation: .pi / 4, startAngle: .pi / 2, arcAngle: -.pi / 2), + ArcCase(label: "near-full sweep", cx: 100, cy: 100, w: 50, h: 30, rotation: 0, startAngle: 0, arcAngle: 1.9 * .pi), + ] + + func testApplePathAndPolyfillPolylineStartPointsMatch() { + for c in Self.cases { + let appleStart = firstPoint(of: applePath(for: c)) + let polylineStart = firstPoint(of: polylinePath(for: c)) + let expected = analyticPoint(c, at: c.startAngle) + XCTAssertEqual(appleStart.x, expected.x, accuracy: 1e-6, "Apple start.x — \(c.label)") + XCTAssertEqual(appleStart.y, expected.y, accuracy: 1e-6, "Apple start.y — \(c.label)") + XCTAssertEqual(polylineStart.x, expected.x, accuracy: 1e-6, "Polyline start.x — \(c.label)") + XCTAssertEqual(polylineStart.y, expected.y, accuracy: 1e-6, "Polyline start.y — \(c.label)") + } + } + + func testApplePathAndPolyfillPolylineEndPointsMatch() { + for c in Self.cases { + let appleEnd = lastPoint(of: applePath(for: c)) + let polylineEnd = lastPoint(of: polylinePath(for: c)) + let expected = analyticPoint(c, at: c.startAngle + c.arcAngle) + XCTAssertEqual(appleEnd.x, expected.x, accuracy: 1e-6, "Apple end.x — \(c.label)") + XCTAssertEqual(appleEnd.y, expected.y, accuracy: 1e-6, "Apple end.y — \(c.label)") + XCTAssertEqual(polylineEnd.x, expected.x, accuracy: 1e-6, "Polyline end.x — \(c.label)") + XCTAssertEqual(polylineEnd.y, expected.y, accuracy: 1e-6, "Polyline end.y — \(c.label)") + } + } + + func testBoundingBoxesMatchWithinChordTolerance() { + for c in Self.cases { + let appleBounds = applePath(for: c).cgPath.boundingBoxOfPath + let polyBounds = polylinePath(for: c).cgPath.boundingBoxOfPath + + // 1° chord error on an ellipse of radius R is bounded by + // ~R · (1 − cos(0.5°)) ≈ R · 3.8e-5. We allow 0.05 user units — + // many orders of magnitude over the geometric error, but tight + // enough to catch real divergence (the original T·R·S bug + // produced hundreds-of-units drift). + let r = Swift.max(c.w, c.h) / 2 + let tolerance = Swift.max(0.05, r * 1e-4) + + XCTAssertEqual(appleBounds.minX, polyBounds.minX, accuracy: tolerance, "minX — \(c.label)") + XCTAssertEqual(appleBounds.minY, polyBounds.minY, accuracy: tolerance, "minY — \(c.label)") + XCTAssertEqual(appleBounds.maxX, polyBounds.maxX, accuracy: tolerance, "maxX — \(c.label)") + XCTAssertEqual(appleBounds.maxY, polyBounds.maxY, accuracy: tolerance, "maxY — \(c.label)") + } + } + + func testPolylineVerticesLieOnEllipse() { + for c in Self.cases { + let path = polylinePath(for: c) + let points = allEndpoints(of: path) + XCTAssertFalse(points.isEmpty, "Polyline empty — \(c.label)") + + // Every emitted polyline vertex must satisfy the ellipse + // equation in the un-rotated, un-translated frame: + // ((p − c) · R⁻¹ / (rx, ry))² sum to 1. + let cosA = cos(c.rotation) + let sinA = sin(c.rotation) + let rx = c.w / 2 + let ry = c.h / 2 + for (i, p) in points.enumerated() { + let dx = p.x - c.cx + let dy = p.y - c.cy + let xs = cosA * dx + sinA * dy + let ys = -sinA * dx + cosA * dy + let normSq = (xs / rx) * (xs / rx) + (ys / ry) * (ys / ry) + XCTAssertEqual(normSq, 1, accuracy: 1e-6, "Vertex \(i) off-ellipse for \(c.label)") + } + } + } + + func testApplePathInteriorLiesOnEllipse() { + for c in Self.cases { + let apple = applePath(for: c) + let samples = densePathSamples(of: apple, samplesPerCurve: 50) + XCTAssertFalse(samples.isEmpty, "Apple path empty — \(c.label)") + + let cosA = cos(c.rotation) + let sinA = sin(c.rotation) + let rx = c.w / 2 + let ry = c.h / 2 + for (i, p) in samples.enumerated() { + let dx = p.x - c.cx + let dy = p.y - c.cy + let xs = cosA * dx + sinA * dy + let ys = -sinA * dx + cosA * dy + let normSq = (xs / rx) * (xs / rx) + (ys / ry) * (ys / ry) + XCTAssertEqual(normSq, 1, accuracy: 2e-3, + "Apple cubic sample \(i) off-ellipse for \(c.label) (normSq=\(normSq))") + } + } + } + + func testApplePathAndPolylineHausdorffWithinTolerance() { + for c in Self.cases { + let apple = applePath(for: c) + let poly = polylinePath(for: c) + + let appleSamples = densePathSamples(of: apple, samplesPerCurve: 50) + let polylineSamples = allEndpoints(of: poly) + let appleSegments = consecutivePairs(appleSamples) + let polylineSegments = consecutivePairs(polylineSamples) + + XCTAssertFalse(appleSegments.isEmpty, "Apple has no segments — \(c.label)") + XCTAssertFalse(polylineSegments.isEmpty, "Polyline has no segments — \(c.label)") + + let h_AtoP = appleSamples + .map { p in polylineSegments.map { pointToSegmentDistance(p, $0.0, $0.1) }.min() ?? .infinity } + .max() ?? 0 + let h_PtoA = polylineSamples + .map { p in appleSegments.map { pointToSegmentDistance(p, $0.0, $0.1) }.min() ?? .infinity } + .max() ?? 0 + let hausdorff = Swift.max(h_AtoP, h_PtoA) + + let r = Swift.max(c.w, c.h) / 2 + let tolerance = Swift.max(0.05, 0.005 * r) + XCTAssertLessThan(hausdorff, tolerance, + "Hausdorff \(hausdorff) exceeds tolerance \(tolerance) for \(c.label)") + } + } + + // MARK: - Helpers + + private func applePath(for c: ArcCase) -> MBezierPath { + let path = MBezierPath() + SVGPath.appendEllipticalArcViaTransform( + to: path, + cx: c.cx, cy: c.cy, w: c.w, h: c.h, + rotation: c.rotation, + startAngle: c.startAngle, arcAngle: c.arcAngle + ) + return path + } + + private func polylinePath(for c: ArcCase) -> MBezierPath { + let path = MBezierPath() + SVGPath.appendEllipticalArcAsPolyline( + to: path, + cx: c.cx, cy: c.cy, w: c.w, h: c.h, + rotation: c.rotation, + startAngle: c.startAngle, arcAngle: c.arcAngle + ) + return path + } + + private func analyticPoint(_ c: ArcCase, at angle: CGFloat) -> CGPoint { + let cosA = cos(c.rotation) + let sinA = sin(c.rotation) + let xs = cos(angle) * (c.w / 2) + let ys = sin(angle) * (c.h / 2) + return CGPoint(x: c.cx + cosA * xs - sinA * ys, y: c.cy + sinA * xs + cosA * ys) + } + + private func firstPoint(of path: MBezierPath) -> CGPoint { + allEndpoints(of: path).first ?? .zero + } + + private func lastPoint(of path: MBezierPath) -> CGPoint { + allEndpoints(of: path).last ?? .zero + } + + /// Collects the endpoint of every path element. On Apple this iterates + /// CGPath via `applyWithBlock`; it works uniformly for paths made of + /// move/line/quad/cubic segments. + private func allEndpoints(of path: MBezierPath) -> [CGPoint] { + var points: [CGPoint] = [] + path.cgPath.applyWithBlock { elemPtr in + let elem = elemPtr.pointee + switch elem.type { + case .moveToPoint, .addLineToPoint: + points.append(elem.points[0]) + case .addQuadCurveToPoint: + points.append(elem.points[1]) + case .addCurveToPoint: + points.append(elem.points[2]) + case .closeSubpath: + break + @unknown default: + break + } + } + return points + } + + private static func quadBezier(_ p0: CGPoint, _ cp: CGPoint, _ p1: CGPoint, at t: CGFloat) -> CGPoint { + let mt = 1 - t + let x = mt * mt * p0.x + 2 * mt * t * cp.x + t * t * p1.x + let y = mt * mt * p0.y + 2 * mt * t * cp.y + t * t * p1.y + return CGPoint(x: x, y: y) + } + + private static func cubicBezier(_ p0: CGPoint, _ p1: CGPoint, _ p2: CGPoint, _ p3: CGPoint, at t: CGFloat) -> CGPoint { + let mt = 1 - t + let mt2 = mt * mt + let mt3 = mt2 * mt + let t2 = t * t + let t3 = t2 * t + let x = mt3 * p0.x + 3 * mt2 * t * p1.x + 3 * mt * t2 * p2.x + t3 * p3.x + let y = mt3 * p0.y + 3 * mt2 * t * p1.y + 3 * mt * t2 * p2.y + t3 * p3.y + return CGPoint(x: x, y: y) + } + + /// Dense samples along every drawing element of `path`. Move/line endpoints + /// produce 1 sample; quad/cubic curves produce `samplesPerCurve` interior + /// samples (t ∈ (0, 1]) plus the starting endpoint is captured by the + /// previous element. Used by both Apple-interior-on-ellipse and Hausdorff + /// tests. + private func densePathSamples(of path: MBezierPath, samplesPerCurve: Int) -> [CGPoint] { + var samples: [CGPoint] = [] + var current = CGPoint.zero + var subpathStart = CGPoint.zero + path.cgPath.applyWithBlock { elemPtr in + let elem = elemPtr.pointee + switch elem.type { + case .moveToPoint: + let p = elem.points[0] + samples.append(p) + current = p + subpathStart = p + case .addLineToPoint: + let p = elem.points[0] + samples.append(p) + current = p + case .addQuadCurveToPoint: + let cp = elem.points[0] + let next = elem.points[1] + for i in 1...samplesPerCurve { + let t = CGFloat(i) / CGFloat(samplesPerCurve) + samples.append(Self.quadBezier(current, cp, next, at: t)) + } + current = next + case .addCurveToPoint: + let cp1 = elem.points[0] + let cp2 = elem.points[1] + let next = elem.points[2] + for i in 1...samplesPerCurve { + let t = CGFloat(i) / CGFloat(samplesPerCurve) + samples.append(Self.cubicBezier(current, cp1, cp2, next, at: t)) + } + current = next + case .closeSubpath: + current = subpathStart + @unknown default: + break + } + } + return samples + } + + private func consecutivePairs(_ points: [CGPoint]) -> [(CGPoint, CGPoint)] { + guard points.count >= 2 else { return [] } + return (1.. CGFloat { + let dx = b.x - a.x + let dy = b.y - a.y + let lenSq = dx * dx + dy * dy + if lenSq < 1e-12 { + return hypot(p.x - a.x, p.y - a.y) + } + let t = ((p.x - a.x) * dx + (p.y - a.y) * dy) / lenSq + let clamped = Swift.min(Swift.max(t, 0), 1) + let cx = a.x + clamped * dx + let cy = a.y + clamped * dy + return hypot(p.x - cx, p.y - cy) + } +} +#endif