├── .gitignore ├── Makefile ├── Package.resolved ├── Package.swift ├── README.md ├── Sources ├── DigitalLine │ └── DigitalLine.swift ├── Floodfill │ └── Floodfill.swift ├── IndexColor │ ├── IndexColorFilter.swift │ ├── Kernal.swift │ ├── MakeImage.swift │ ├── Shaders.metal │ └── Shaders.metallib └── PixelArtKit │ └── Include.swift └── Tests └── PixelArtKitTests ├── DrawTests.swift ├── FloodfillTests.swift ├── IndexColorTests.swift └── Resource ├── color_ellipse.png ├── color_floodfill.png ├── color_gradient.png ├── color_line.png ├── gray_ellipse.png ├── gray_floodfill.png ├── gray_gradient.png ├── gray_line.png └── index_color.png /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | DerivedData/ 7 | .swiftpm/config/registries.json 8 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 9 | .netrc 10 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | metallib: 2 | cd Sources/IndexColor && \ 3 | xcrun -sdk iphoneos metal -fcikernel -c Shaders.metal -o Shaders.air && \ 4 | xcrun -sdk iphoneos metallib -cikernel Shaders.air -o Shaders.metallib && \ 5 | rm Shaders.air 6 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "pins" : [ 3 | { 4 | "identity" : "bitmapcontext", 5 | "kind" : "remoteSourceControl", 6 | "location" : "https://github.com/noppefoxwolf/BitmapContext", 7 | "state" : { 8 | "revision" : "3426c253df2b7e20bf0dfff3554ed71ce0ccdd04", 9 | "version" : "0.0.20" 10 | } 11 | }, 12 | { 13 | "identity" : "swift-collections", 14 | "kind" : "remoteSourceControl", 15 | "location" : "https://github.com/apple/swift-collections", 16 | "state" : { 17 | "revision" : "48254824bb4248676bf7ce56014ff57b142b77eb", 18 | "version" : "1.0.2" 19 | } 20 | }, 21 | { 22 | "identity" : "swift-line-algorithms", 23 | "kind" : "remoteSourceControl", 24 | "location" : "https://github.com/noppefoxwolf/swift-line-algorithms", 25 | "state" : { 26 | "revision" : "47e972436b9c335b926766de965af8836044f414", 27 | "version" : "0.0.3" 28 | } 29 | } 30 | ], 31 | "version" : 2 32 | } 33 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.6 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "PixelArtKit", 8 | platforms: [.iOS(.v13)], 9 | products: [ 10 | .library( 11 | name: "PixelArtKit", 12 | targets: ["PixelArtKit"] 13 | ), 14 | .library( 15 | name: "Floodfill", 16 | targets: ["Floodfill"] 17 | ), 18 | .library( 19 | name: "DigitalLine", 20 | targets: ["DigitalLine"] 21 | ), 22 | ], 23 | dependencies: [ 24 | .package(url: "https://github.com/noppefoxwolf/swift-line-algorithms", from: "0.0.3"), 25 | .package(url: "https://github.com/noppefoxwolf/BitmapContext", from: "0.0.20"), 26 | .package(url: "https://github.com/apple/swift-collections", from: "1.0.2"), 27 | ], 28 | targets: [ 29 | .target( 30 | name: "PixelArtKit", 31 | dependencies: [ 32 | .product(name: "LineAlgorithms", package: "swift-line-algorithms"), 33 | "Floodfill", 34 | "DigitalLine", 35 | "IndexColor", 36 | ] 37 | ), 38 | .target( 39 | name: "Floodfill", 40 | dependencies: [ 41 | .product(name: "DequeModule", package: "swift-collections"), 42 | "BitmapContext" 43 | ] 44 | ), 45 | .target( 46 | name: "DigitalLine", 47 | dependencies: [ 48 | "BitmapContext" 49 | ] 50 | ), 51 | .target( 52 | name: "IndexColor", 53 | dependencies: [ 54 | "BitmapContext" 55 | ], 56 | exclude: [ 57 | "Shaders.metal" 58 | ], 59 | resources: [ 60 | .copy("Shaders.metallib") 61 | ] 62 | ), 63 | .testTarget( 64 | name: "PixelArtKitTests", 65 | dependencies: [ 66 | "PixelArtKit" 67 | ], 68 | resources: [ 69 | .copy("Resource") 70 | ] 71 | ) 72 | ] 73 | ) 74 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PixelArtKit 2 | 3 | A description of this package. 4 | -------------------------------------------------------------------------------- /Sources/DigitalLine/DigitalLine.swift: -------------------------------------------------------------------------------- 1 | import CoreGraphics 2 | import BitmapContext 3 | import LineAlgorithms 4 | 5 | extension BitmapContext { 6 | public func addIntegerLinePath(_ rect: Rect) { 7 | let standardizedRect = rect.cgRect.standardized 8 | let from = SIMD2( 9 | x: Int(standardizedRect.minX), 10 | y: Int(standardizedRect.minY) 11 | ) 12 | let to = SIMD2( 13 | x: Int(standardizedRect.maxX), 14 | y: Int(standardizedRect.maxY) 15 | ) 16 | let path = CGMutablePath() 17 | for point in SIMD2.protLine(from: from, to: to) { 18 | let origin = CGPoint(x: point.x, y: point.y) 19 | let size = CGSize(width: 1, height: 1) 20 | let rect = CGRect(origin: origin, size: size) 21 | path.addRect(rect) 22 | } 23 | addPath(path) 24 | } 25 | 26 | public func addIntegerEllipsePath(_ rect: Rect) { 27 | let standardizedRect = rect.cgRect.standardized 28 | let from = SIMD2( 29 | x: Int(standardizedRect.minX), 30 | y: Int(standardizedRect.minY) 31 | ) 32 | let to = SIMD2( 33 | x: Int(standardizedRect.maxX), 34 | y: Int(standardizedRect.maxY) 35 | ) 36 | let path = CGMutablePath() 37 | for point in SIMD2.plotEllipse(from: from, to: to) { 38 | let origin = CGPoint(x: point.x, y: point.y) 39 | let size = CGSize(width: 1, height: 1) 40 | let rect = CGRect(origin: origin, size: size) 41 | path.addRect(rect) 42 | } 43 | addPath(path) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Sources/Floodfill/Floodfill.swift: -------------------------------------------------------------------------------- 1 | import CoreGraphics 2 | import DequeModule 3 | import BitmapContext 4 | 5 | extension BitmapContext { 6 | public func closedPath(at beginPoint: Point) -> CGPath? { 7 | guard let originalColor: ColorSpaceType.ColorType = self[point: beginPoint] else { return nil } 8 | let fillColor = originalColor.next() 9 | let fillPath: CGMutablePath = CGMutablePath() 10 | 11 | func getColor(at point: Point) -> ColorSpaceType.ColorType { 12 | // workaround: pathのcontainsは右下も含まれる判定になるので半ドットズラして確認する 13 | let cgPoint = point.cgPoint.applying(CGAffineTransform(translationX: 0.5, y: 0.5)) 14 | return fillPath.contains(cgPoint, using: .winding) ? fillColor : self[point: point]! 15 | } 16 | var points = Deque() 17 | 18 | points.append(beginPoint) 19 | 20 | var color: ColorSpaceType.ColorType 21 | var spanLeft: Bool 22 | var spanRight: Bool 23 | 24 | while var point = points.popLast() { 25 | color = getColor(at: point) 26 | 27 | while point.y >= 0 && originalColor == color { 28 | point.y -= 1 29 | if point.y >= 0 { 30 | color = getColor(at: point) 31 | } 32 | } 33 | point.y += 1 34 | 35 | spanLeft = false 36 | spanRight = false 37 | 38 | color = getColor(at: point) 39 | 40 | while point.y < height && originalColor == color && fillColor != color { 41 | let rect = CGRect(origin: point.cgPoint, size: CGSize(width: 1, height: 1)) 42 | let path = CGPath(rect: rect, transform: nil) 43 | fillPath.addPath(path) 44 | 45 | if point.x > 0 { 46 | color = getColor(at: Point(x: point.x - 1, y: point.y)) 47 | 48 | if !spanLeft && point.x > 0 && originalColor == color { 49 | points.append(Point(x: point.x - 1, y: point.y)) 50 | spanLeft = true 51 | } else if spanLeft && point.x > 0 && originalColor != color { 52 | spanLeft = false 53 | } 54 | } 55 | 56 | if point.x < width - 1 { 57 | color = getColor(at: Point(x: point.x + 1, y: point.y)) 58 | 59 | if !spanRight && originalColor == color { 60 | points.append(Point(x: point.x + 1, y: point.y)) 61 | spanRight = true 62 | } else if spanRight && originalColor != color { 63 | spanRight = false 64 | } 65 | } 66 | 67 | point.y += 1 68 | 69 | if point.y < height { 70 | color = getColor(at: point) 71 | } 72 | } 73 | } 74 | return fillPath 75 | } 76 | 77 | public func addFloodFillPath(at point: Point) { 78 | if let path = closedPath(at: point) { 79 | addPath(path) 80 | } 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /Sources/IndexColor/IndexColorFilter.swift: -------------------------------------------------------------------------------- 1 | import CoreImage 2 | import CoreGraphics 3 | 4 | class IndexColorFilter: CIFilter { 5 | let kernel: CIKernel = .lookupTable() 6 | // 8bit grayscale image 7 | var inputImage: CIImage? 8 | // 16x16 256 color palette 9 | var colorMapImage: CIImage? 10 | 11 | override var outputImage: CIImage? { 12 | guard let inputImage = inputImage else { return nil } 13 | guard let colorMapImage = colorMapImage else { return nil } 14 | let roiCallback: CIKernelROICallback = { index, rect -> CGRect in 15 | switch index { 16 | case 0: 17 | return inputImage.extent 18 | case 1: 19 | return CGRect(x: 0, y: 0, width: 16, height: 16) 20 | default: 21 | fatalError() 22 | } 23 | } 24 | return kernel.apply( 25 | extent: inputImage.extent, 26 | roiCallback: roiCallback, 27 | arguments: [inputImage, colorMapImage] 28 | ) 29 | } 30 | } 31 | 32 | -------------------------------------------------------------------------------- /Sources/IndexColor/Kernal.swift: -------------------------------------------------------------------------------- 1 | import CoreImage 2 | 3 | extension CIKernel { 4 | static func lookupTable() -> CIKernel { 5 | let url = Bundle.module.url(forResource: "Shaders", withExtension: "metallib")! 6 | let data = try! Data(contentsOf: url) 7 | return try! CIKernel(functionName: "lookupTable", fromMetalLibraryData: data) 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /Sources/IndexColor/MakeImage.swift: -------------------------------------------------------------------------------- 1 | @_spi(BitmapExtension) import BitmapContext 2 | import CoreImage 3 | 4 | extension BitmapLayer { 5 | public func makeImage(_ colorMapContext: BitmapContext) -> BitmapImage? where ColorSpaceType == GrayColorSpace { 6 | guard let inputImage = self.makeImage() else { return nil } 7 | guard let colorMapImage = colorMapContext.makeImage() else { return nil } 8 | let filter = IndexColorFilter() 9 | filter.inputImage = CIImage(bitmap: inputImage) 10 | filter.colorMapImage = CIImage(bitmap: colorMapImage) 11 | guard let output = filter.outputImage else { return nil } 12 | 13 | let ciContext = CIContext(options: [ 14 | .outputColorSpace : colorMapImage.colorSpace!, 15 | ]) 16 | guard let cgImage = ciContext.createCGImage(output, from: output.extent) else { 17 | return nil 18 | } 19 | return BitmapImage(image: cgImage) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Sources/IndexColor/Shaders.metal: -------------------------------------------------------------------------------- 1 | #include 2 | using namespace metal; 3 | #include 4 | 5 | extern "C" { namespace coreimage { 6 | float4 lookupTable(sampler src, sampler lut) { 7 | float2 pos = src.coord(); 8 | float4 pixelColor = src.sample(pos); 9 | int index = pixelColor.r * 255; 10 | int x = index % int(lut.size().x); 11 | int y = index / int(lut.size().y); 12 | float2 lutPos = float2(x, y); 13 | float4 lutColor = lut.sample(lutPos); 14 | return lutColor; 15 | } 16 | }} 17 | -------------------------------------------------------------------------------- /Sources/IndexColor/Shaders.metallib: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noppefoxwolf/PixelArtKit/f94ece5e36a7e497b67984eb3b6f8e24e4e6f3fa/Sources/IndexColor/Shaders.metallib -------------------------------------------------------------------------------- /Sources/PixelArtKit/Include.swift: -------------------------------------------------------------------------------- 1 | @_exported import BitmapContext 2 | @_exported import Floodfill 3 | @_exported import DigitalLine 4 | @_exported import IndexColor 5 | -------------------------------------------------------------------------------- /Tests/PixelArtKitTests/DrawTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import PixelArtKit 3 | @testable import BitmapContext 4 | 5 | final class DrawTests: XCTestCase { 6 | func testGrayGradient() throws { 7 | var context = BitmapContext(width: 16, height: 16) 8 | for i in 0..<256 { 9 | context[index: i] = GrayColor(gray: UInt8(i)) 10 | } 11 | let image = UIImage(bitmap: context.makeImage()!) 12 | let diff = UIImage(named: "Resource/gray_gradient", in: .module, with: nil)! 13 | XCTAssertEqual(image.pngData()!, diff.pngData()!) 14 | } 15 | 16 | func testColorGradient() throws { 17 | var context = BitmapContext(width: 256, height: 256) 18 | for i in 0..<65536 { 19 | context[index: i] = RGBAColor(rawValue: UInt32(i)) 20 | } 21 | let image = UIImage(bitmap: context.makeImage()!) 22 | let diff = UIImage(named: "Resource/color_gradient", in: .module, with: nil)! 23 | XCTAssertEqual(image.pngData()!, diff.pngData()!) 24 | } 25 | 26 | func testColorLine() throws { 27 | let context = BitmapContext(width: 128, height: 128) 28 | context.addIntegerLinePath(Rect(x: 10, y: 10, width: 100, height: 100)) 29 | context.setFillColor(RGBAColor(red: 255, green: 0, blue: 0, alpha: 255)) 30 | context.fillPath() 31 | let image = UIImage(bitmap: context.makeImage()!) 32 | let diff = UIImage(named: "Resource/color_line", in: .module, with: nil)! 33 | XCTAssertEqual(image.pngData()!, diff.pngData()!) 34 | } 35 | 36 | func testGrayLine() throws { 37 | let context = BitmapContext(width: 128, height: 128) 38 | context.addIntegerLinePath(Rect(x: 10, y: 10, width: 100, height: 100)) 39 | context.setFillColor(GrayColor(gray: 255)) 40 | context.fillPath() 41 | let image = UIImage(bitmap: context.makeImage()!) 42 | let diff = UIImage(named: "Resource/gray_line", in: .module, with: nil)! 43 | XCTAssertEqual(image.pngData()!, diff.pngData()!) 44 | } 45 | 46 | func testColorEllipse() throws { 47 | let context = BitmapContext(width: 128, height: 128) 48 | context.addIntegerEllipsePath(Rect(x: 10, y: 10, width: 100, height: 100)) 49 | context.setFillColor(RGBAColor(red: 255, green: 0, blue: 0, alpha: 255)) 50 | context.fillPath() 51 | let image = UIImage(bitmap: context.makeImage()!) 52 | let diff = UIImage(named: "Resource/color_ellipse", in: .module, with: nil)! 53 | XCTAssertEqual(image.pngData()!, diff.pngData()!) 54 | } 55 | 56 | func testGrayEllipse() throws { 57 | let context = BitmapContext(width: 128, height: 128) 58 | context.addIntegerEllipsePath(Rect(x: 10, y: 10, width: 100, height: 100)) 59 | context.setFillColor(GrayColor(gray: 255)) 60 | context.fillPath() 61 | let image = UIImage(bitmap: context.makeImage()!) 62 | let diff = UIImage(named: "Resource/gray_ellipse", in: .module, with: nil)! 63 | XCTAssertEqual(image.pngData()!, diff.pngData()!) 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /Tests/PixelArtKitTests/FloodfillTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import PixelArtKit 3 | 4 | final class FloodfillTests: XCTestCase { 5 | func testRGBA() throws { 6 | let context = BitmapContext(width: 128, height: 128) 7 | context.addPath(UIBezierPath(ovalIn: CGRect(x: 0, y: 0, width: 64, height: 64)).cgPath) 8 | context.setStrokeColor(RGBAColor(red: 255, green: 0, blue: 0, alpha: 255)) 9 | context.strokePath() 10 | context.addFloodFillPath(at: .init(x: 32, y: 32)) 11 | context.setFillColor(RGBAColor(red: 0, green: 0, blue: 255, alpha: 255)) 12 | context.fillPath() 13 | let image = UIImage(bitmap: context.makeImage()!) 14 | let diff = UIImage(named: "Resource/color_floodfill", in: .module, with: nil)! 15 | XCTAssertEqual(image.pngData()!, diff.pngData()!) 16 | } 17 | 18 | func testGray() throws { 19 | let context = BitmapContext(width: 128, height: 128) 20 | context.addPath(UIBezierPath(ovalIn: CGRect(x: 0, y: 0, width: 64, height: 64)).cgPath) 21 | context.setStrokeColor(GrayColor(gray: 255)) 22 | context.strokePath() 23 | context.addFloodFillPath(at: .init(x: 32, y: 32)) 24 | context.setFillColor(GrayColor(gray: 255)) 25 | context.fillPath() 26 | let image = UIImage(bitmap: context.makeImage()!) 27 | let diff = UIImage(named: "Resource/gray_floodfill", in: .module, with: nil)! 28 | XCTAssertEqual(image.pngData()!, diff.pngData()!) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Tests/PixelArtKitTests/IndexColorTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import PixelArtKit 3 | @testable import IndexColor 4 | 5 | final class IndexColorTests: XCTestCase { 6 | func testShader() { 7 | var colorMapContext = BitmapContext(width: 16, height: 16) 8 | colorMapContext[index: 0] = RGBAColor(red: 0, green: 0, blue: 255, alpha: 255) 9 | colorMapContext[index: 255] = RGBAColor(red: 0, green: 255, blue: 0, alpha: 255) 10 | 11 | let context = BitmapContext(width: 128, height: 128) 12 | let layer = BitmapLayer(context: context) 13 | layer.setFillColor(GrayColor(gray: 255)) 14 | layer.addPath(CGPath(rect: CGRect(x: 20, y: 20, width: 20, height: 20), transform: nil)) 15 | layer.fillPath() 16 | 17 | let image = UIImage(bitmap: layer.makeImage(colorMapContext)!) 18 | let diff = UIImage(named: "Resource/index_color", in: .module, with: nil)! 19 | 20 | XCTAssertEqual(image.pngData()!, diff.pngData()!) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Tests/PixelArtKitTests/Resource/color_ellipse.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noppefoxwolf/PixelArtKit/f94ece5e36a7e497b67984eb3b6f8e24e4e6f3fa/Tests/PixelArtKitTests/Resource/color_ellipse.png -------------------------------------------------------------------------------- /Tests/PixelArtKitTests/Resource/color_floodfill.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noppefoxwolf/PixelArtKit/f94ece5e36a7e497b67984eb3b6f8e24e4e6f3fa/Tests/PixelArtKitTests/Resource/color_floodfill.png -------------------------------------------------------------------------------- /Tests/PixelArtKitTests/Resource/color_gradient.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noppefoxwolf/PixelArtKit/f94ece5e36a7e497b67984eb3b6f8e24e4e6f3fa/Tests/PixelArtKitTests/Resource/color_gradient.png -------------------------------------------------------------------------------- /Tests/PixelArtKitTests/Resource/color_line.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noppefoxwolf/PixelArtKit/f94ece5e36a7e497b67984eb3b6f8e24e4e6f3fa/Tests/PixelArtKitTests/Resource/color_line.png -------------------------------------------------------------------------------- /Tests/PixelArtKitTests/Resource/gray_ellipse.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noppefoxwolf/PixelArtKit/f94ece5e36a7e497b67984eb3b6f8e24e4e6f3fa/Tests/PixelArtKitTests/Resource/gray_ellipse.png -------------------------------------------------------------------------------- /Tests/PixelArtKitTests/Resource/gray_floodfill.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noppefoxwolf/PixelArtKit/f94ece5e36a7e497b67984eb3b6f8e24e4e6f3fa/Tests/PixelArtKitTests/Resource/gray_floodfill.png -------------------------------------------------------------------------------- /Tests/PixelArtKitTests/Resource/gray_gradient.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noppefoxwolf/PixelArtKit/f94ece5e36a7e497b67984eb3b6f8e24e4e6f3fa/Tests/PixelArtKitTests/Resource/gray_gradient.png -------------------------------------------------------------------------------- /Tests/PixelArtKitTests/Resource/gray_line.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noppefoxwolf/PixelArtKit/f94ece5e36a7e497b67984eb3b6f8e24e4e6f3fa/Tests/PixelArtKitTests/Resource/gray_line.png -------------------------------------------------------------------------------- /Tests/PixelArtKitTests/Resource/index_color.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noppefoxwolf/PixelArtKit/f94ece5e36a7e497b67984eb3b6f8e24e4e6f3fa/Tests/PixelArtKitTests/Resource/index_color.png --------------------------------------------------------------------------------