├── .gitignore ├── .swiftlint.yml ├── HandDrawingSwiftMetal.xcodeproj ├── project.pbxproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ └── IDEWorkspaceChecks.plist └── xcuserdata │ └── eisukekusachi.xcuserdatad │ └── xcschemes │ └── xcschememanagement.plist ├── HandDrawingSwiftMetal ├── AppDelegate.swift ├── Base.lproj │ └── LaunchScreen.storyboard ├── Canvas │ ├── Assets.xcassets │ │ ├── AccentColor.colorset │ │ │ └── Contents.json │ │ ├── AppIcon.appiconset │ │ │ └── Contents.json │ │ ├── Contents.json │ │ ├── DrawingEraser.imageset │ │ │ ├── Contents.json │ │ │ └── drawing-eraser.svg │ │ └── Layer.imageset │ │ │ ├── Contents.json │ │ │ └── square.2.layers.3d.svg │ ├── CanvasStorage.xcdatamodeld │ │ └── CanvasStorage.xcdatamodel │ │ │ └── contents │ ├── ColorAssets.xcassets │ │ ├── Contents.json │ │ ├── component.colorset │ │ │ └── Contents.json │ │ ├── reversalComponent.colorset │ │ │ └── Contents.json │ │ ├── snackbarBackground.colorset │ │ │ └── Contents.json │ │ └── trackColor.colorset │ │ │ └── Contents.json │ ├── Components │ │ ├── DialogPresenter.swift │ │ ├── IntSlider.swift │ │ ├── NewCanvasDialogPresenter.swift │ │ ├── RoundedRectangleWithArrow.swift │ │ ├── SliderStyle.swift │ │ ├── TextureLayerViewPresenter.swift │ │ └── TwoRowsSliderView.swift │ ├── Extensions │ │ ├── ArrayExtensions.swift │ │ ├── CGAffineTransformExtensions.swift │ │ ├── CGPointExtensions.swift │ │ ├── CGSizeExtensions.swift │ │ ├── CalendarExtensions.swift │ │ ├── CollectionExtensions.swift │ │ ├── DataExtensions.swift │ │ ├── DictionaryExtensions.swift │ │ ├── FileManagerExtensions.swift │ │ ├── ImageExtensions.swift │ │ ├── MTLTextureExtensions.swift │ │ ├── UIButtonExtensions.swift │ │ ├── UIColorExtensions.swift │ │ ├── UIImageExtensions.swift │ │ ├── UITouchExtensions.swift │ │ ├── UIViewExtensions.swift │ │ └── URLExtensions.swift │ ├── Metal │ │ ├── CommandQueueProtocol.swift │ │ ├── MTLBuffers.swift │ │ ├── MTLCommandBufferManager.swift │ │ ├── MTLCommandManager.swift │ │ ├── MTLPipelines.swift │ │ ├── MTLRenderer.swift │ │ ├── MTLTextureCreator.swift │ │ ├── MTLTextureNodes.swift │ │ └── Shaders │ │ │ ├── Drawing.metal │ │ │ └── Texture.metal │ ├── Models │ │ ├── BezierCurvePoints.swift │ │ ├── BlurredDotSize.swift │ │ ├── DrawingCurveFingerIterator.swift │ │ ├── DrawingCurveIterator.swift │ │ ├── DrawingCurvePencilIterator.swift │ │ ├── Entities │ │ │ ├── CanvasEntity.swift │ │ │ ├── OldCanvasEntity.swift │ │ │ ├── TextureLayerEntity.swift │ │ │ └── TextureRepositoryEntity.swift │ │ ├── Gestures │ │ │ ├── FingerInputGestureRecognizer.swift │ │ │ ├── FingerScreenStrokeData.swift │ │ │ ├── InputProtocol.swift │ │ │ ├── PencilInputGestureRecognizer.swift │ │ │ ├── PencilScreenStrokeData.swift │ │ │ └── TouchHashValue.swift │ │ ├── Points │ │ │ ├── DotPoint.swift │ │ │ ├── GrayscaleDotPoint.swift │ │ │ └── TouchPoint.swift │ │ └── Undo │ │ │ ├── UndoMoveData.swift │ │ │ ├── UndoStack.swift │ │ │ └── UndoStackObject.swift │ ├── Repositories │ │ ├── CoreData │ │ │ ├── CoreDataRepository.swift │ │ │ ├── DefaultCoreDataRepository.swift │ │ │ └── DefaultCoreDataSingletonRepository.swift │ │ ├── Local │ │ │ ├── DocumentsLocalRepository.swift │ │ │ ├── DocumentsLocalSingletonRepository.swift │ │ │ └── LocalRepository.swift │ │ └── Texture │ │ │ ├── TextureDocumentsDirectoryRepository.swift │ │ │ ├── TextureInMemoryRepository.swift │ │ │ ├── TextureLayer │ │ │ ├── TextureLayerDocumentsDirectoryRepository.swift │ │ │ ├── TextureLayerDocumentsDirectorySingletonRepository.swift │ │ │ ├── TextureLayerInMemoryRepository.swift │ │ │ ├── TextureLayerInMemorySingletonRepository.swift │ │ │ ├── TextureLayerMockRepository.swift │ │ │ ├── TextureLayerRepository.swift │ │ │ └── TextureLayerRepositoryWrapper.swift │ │ │ ├── TextureRepository.swift │ │ │ └── TextureRepositoryWrapper.swift │ ├── Utils │ │ ├── BezierCurve.swift │ │ ├── BezierCurveHandlePoints.swift │ │ ├── ButtonThrottle.swift │ │ ├── Calculate.swift │ │ ├── Constants.swift │ │ ├── Interpolator.swift │ │ ├── Iterator.swift │ │ ├── Logger.swift │ │ ├── ScaleManager.swift │ │ ├── TimeStampFormatter.swift │ │ ├── Toast.swift │ │ ├── ToastModel.swift │ │ └── ViewSize.swift │ └── Views │ │ ├── CanvasView │ │ ├── CanvasContentView.swift │ │ ├── CanvasContentView.xib │ │ ├── CanvasRenderer.swift │ │ ├── CanvasView.swift │ │ ├── CanvasViewController.swift │ │ ├── CanvasViewController.xib │ │ ├── CanvasViewModel.swift │ │ ├── CanvasViewProtocol.swift │ │ ├── Extensions │ │ │ └── IteratorExtensions.swift │ │ └── Models │ │ │ ├── CanvasConfiguration.swift │ │ │ ├── Drawing │ │ │ ├── CanvasDrawingDisplayLink.swift │ │ │ └── CanvasTransformer.swift │ │ │ ├── States │ │ │ ├── CanvasState.swift │ │ │ ├── CanvasStateStorage.swift │ │ │ └── DrawingTool │ │ │ │ ├── DrawingBrushToolState.swift │ │ │ │ ├── DrawingEraserToolState.swift │ │ │ │ ├── DrawingToolProtocol.swift │ │ │ │ ├── DrawingToolState.swift │ │ │ │ └── DrawingToolType.swift │ │ │ ├── Status │ │ │ ├── CanvasInputDeviceStatus.swift │ │ │ ├── CanvasInputDeviceType.swift │ │ │ ├── CanvasScreenTouchGestureStatus.swift │ │ │ └── CanvasScreenTouchGestureType.swift │ │ │ └── Textures │ │ │ ├── CanvasDrawingBrushTextureSet.swift │ │ │ ├── CanvasDrawingEraserTextureSet.swift │ │ │ └── CanvasDrawingTextureSet.swift │ │ ├── FileView │ │ ├── FileManager │ │ │ ├── FileInputManager.swift │ │ │ └── FileOutputManager.swift │ │ └── FileView.swift │ │ └── TextureLayerView │ │ ├── Models │ │ └── TextureLayerModel.swift │ │ ├── TextureLayerListView.swift │ │ ├── TextureLayerView.swift │ │ ├── TextureLayerViewModel.swift │ │ └── TextureLayerViewSettings.swift ├── Info.plist └── SceneDelegate.swift ├── HandDrawingSwiftMetalTests ├── Dummies │ ├── GrayscaleDotPointDummy.swift │ ├── TextureLayerModelDummy.swift │ ├── TouchPointDummy.swift │ └── UITouchDummy.swift ├── HandDrawingSwiftMetalTests.swift ├── Mocks │ ├── MockCanvasViewProtocol.swift │ ├── MockDrawingCurveIterator.swift │ ├── MockMTLRenderer.swift │ └── MockTextureRepository.swift ├── Models │ ├── DrawingCurveFingerIteratorTests.swift │ ├── DrawingCurvePencilIteratorTests.swift │ └── Gestures │ │ ├── FingerScreenStrokeData.swift │ │ └── PencilScreenStrokeDataTests.swift ├── Repositories │ └── TextureInMemoryRepositoryTests.swift └── Views │ └── CanvasView │ ├── CanvasRendererTests.swift │ └── Models │ ├── Drawing │ └── CanvasDrawingDisplayLinkTests.swift │ └── Textures │ ├── CanvasDrawingBrushTextureSetTests.swift │ └── CanvasDrawingEraserTextureSetTests.swift ├── HandDrawingSwiftMetalUITests ├── HandDrawingSwiftMetalUITests.swift └── HandDrawingSwiftMetalUITestsLaunchTests.swift └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | ## Mac OS X 3 | .DS_Store 4 | ## Xcode build files 5 | build/ 6 | DerivedData/ 7 | ## Xcode settings 8 | *.pbxuser 9 | *.mode1v3 10 | *.mode2v3 11 | *.perspectivev3 12 | !default.pbxuser 13 | !default.mode1v3 14 | !default.mode2v3 15 | !default.perspectivev3*.xcodeproj/* 16 | ## App packaging 17 | *.ipa 18 | *.dSYM.zip 19 | *.dSYM 20 | ## Xcode Patch 21 | *.xcodeproj/* 22 | !*.xcodeproj/project.pbxproj 23 | !*.xcodeproj/xcshareddata/ 24 | !*.xcworkspace/contents.xcworkspacedata 25 | ## library 26 | Pods/* 27 | Carthage/Build/ 28 | ## Other 29 | *.moved-aside 30 | *.xccheckout 31 | *.xcscmblueprint 32 | *.xcuserstate 33 | *.swp 34 | xcuserdata/ 35 | *.moved-aside 36 | !.gitkeep -------------------------------------------------------------------------------- /.swiftlint.yml: -------------------------------------------------------------------------------- 1 | disabled_rules: 2 | - unused_optional_binding 3 | - trailing_whitespace 4 | - vertical_whitespace 5 | - type_name 6 | 7 | excluded: 8 | - Pods 9 | - Carthage 10 | - SourcePackages 11 | - Generated 12 | 13 | line_length: 14 | warning: 300 15 | error: 500 16 | 17 | type_body_length: 18 | - 400 19 | - 2000 20 | 21 | file_length: 22 | - 1000 23 | - 1500 24 | 25 | function_body_length: 26 | - 100 27 | - 200 28 | 29 | large_tuple: # warn user when using 3 values in tuple, give error if there are 4 30 | - 4 31 | - 5 32 | 33 | function_parameter_count: 34 | - 6 35 | - 7 36 | 37 | identifier_name: 38 | min_length: 39 | warning: 1 40 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetal.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetal.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetal.xcodeproj/xcuserdata/eisukekusachi.xcuserdatad/xcschemes/xcschememanagement.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | SchemeUserState 6 | 7 | HandDrawingSwiftMetal.xcscheme_^#shared#^_ 8 | 9 | orderHint 10 | 0 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetal/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.swift 3 | // HandDrawingSwiftMetal 4 | // 5 | // Created by Eisuke Kusachi on 2021/11/27. 6 | // 7 | 8 | import UIKit 9 | 10 | @main 11 | class AppDelegate: UIResponder, UIApplicationDelegate { 12 | 13 | func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { 14 | // Override point for customization after application launch. 15 | 16 | return true 17 | } 18 | 19 | // MARK: UISceneSession Lifecycle 20 | 21 | func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { 22 | // Called when a new scene session is being created. 23 | // Use this method to select a configuration to create the new scene with. 24 | return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) 25 | } 26 | 27 | func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set) { 28 | // Called when the user discards a scene session. 29 | // If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions. 30 | // Use this method to release any resources that were specific to the discarded scenes, as they will not return. 31 | } 32 | 33 | } 34 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetal/Base.lproj/LaunchScreen.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetal/Canvas/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetal/Canvas/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "iphone", 5 | "scale" : "2x", 6 | "size" : "20x20" 7 | }, 8 | { 9 | "idiom" : "iphone", 10 | "scale" : "3x", 11 | "size" : "20x20" 12 | }, 13 | { 14 | "idiom" : "iphone", 15 | "scale" : "2x", 16 | "size" : "29x29" 17 | }, 18 | { 19 | "idiom" : "iphone", 20 | "scale" : "3x", 21 | "size" : "29x29" 22 | }, 23 | { 24 | "idiom" : "iphone", 25 | "scale" : "2x", 26 | "size" : "40x40" 27 | }, 28 | { 29 | "idiom" : "iphone", 30 | "scale" : "3x", 31 | "size" : "40x40" 32 | }, 33 | { 34 | "idiom" : "iphone", 35 | "scale" : "2x", 36 | "size" : "60x60" 37 | }, 38 | { 39 | "idiom" : "iphone", 40 | "scale" : "3x", 41 | "size" : "60x60" 42 | }, 43 | { 44 | "idiom" : "ipad", 45 | "scale" : "1x", 46 | "size" : "20x20" 47 | }, 48 | { 49 | "idiom" : "ipad", 50 | "scale" : "2x", 51 | "size" : "20x20" 52 | }, 53 | { 54 | "idiom" : "ipad", 55 | "scale" : "1x", 56 | "size" : "29x29" 57 | }, 58 | { 59 | "idiom" : "ipad", 60 | "scale" : "2x", 61 | "size" : "29x29" 62 | }, 63 | { 64 | "idiom" : "ipad", 65 | "scale" : "1x", 66 | "size" : "40x40" 67 | }, 68 | { 69 | "idiom" : "ipad", 70 | "scale" : "2x", 71 | "size" : "40x40" 72 | }, 73 | { 74 | "idiom" : "ipad", 75 | "scale" : "1x", 76 | "size" : "76x76" 77 | }, 78 | { 79 | "idiom" : "ipad", 80 | "scale" : "2x", 81 | "size" : "76x76" 82 | }, 83 | { 84 | "idiom" : "ipad", 85 | "scale" : "2x", 86 | "size" : "83.5x83.5" 87 | }, 88 | { 89 | "idiom" : "ios-marketing", 90 | "scale" : "1x", 91 | "size" : "1024x1024" 92 | } 93 | ], 94 | "info" : { 95 | "author" : "xcode", 96 | "version" : 1 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetal/Canvas/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetal/Canvas/Assets.xcassets/DrawingEraser.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "drawing-eraser.svg", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetal/Canvas/Assets.xcassets/DrawingEraser.imageset/drawing-eraser.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetal/Canvas/Assets.xcassets/Layer.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "square.2.layers.3d.svg", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetal/Canvas/Assets.xcassets/Layer.imageset/square.2.layers.3d.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetal/Canvas/CanvasStorage.xcdatamodeld/CanvasStorage.xcdatamodel/contents: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetal/Canvas/ColorAssets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetal/Canvas/ColorAssets.xcassets/component.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.000", 9 | "green" : "0.000", 10 | "red" : "0.000" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetal/Canvas/ColorAssets.xcassets/reversalComponent.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "1.000", 9 | "green" : "1.000", 10 | "red" : "1.000" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetal/Canvas/ColorAssets.xcassets/snackbarBackground.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "0.900", 8 | "blue" : "0.941", 9 | "green" : "0.941", 10 | "red" : "0.941" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetal/Canvas/ColorAssets.xcassets/trackColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.314", 9 | "green" : "0.314", 10 | "red" : "0.314" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetal/Canvas/Components/DialogPresenter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DialogPresenter.swift 3 | // HandDrawingSwiftMetal 4 | // 5 | // Created by Eisuke Kusachi on 2024/03/18. 6 | // 7 | 8 | import UIKit 9 | 10 | class DialogPresenter { 11 | 12 | struct Configuration { 13 | var title: String = "" 14 | var message: String = "" 15 | var buttonTitles: [String] = ["Cancel", "OK"] 16 | var buttonActions: [Int: (() -> Void)] = [0: {}] 17 | } 18 | 19 | var configuration: Configuration? 20 | 21 | func presentAlert(on viewController: UIViewController) { 22 | guard let configuration else { return } 23 | 24 | let alert = UIAlertController( 25 | title: configuration.title, 26 | message: configuration.message, 27 | preferredStyle: .alert) 28 | 29 | var allButtons: [String] = configuration.buttonTitles 30 | 31 | if allButtons.count == 0 { 32 | allButtons.append("OK") 33 | } 34 | 35 | for index in 0 ..< allButtons.count { 36 | let action = UIAlertAction( 37 | title: allButtons[index], 38 | style: .default, 39 | handler: { _ in 40 | configuration.buttonActions[index]?() 41 | } 42 | ) 43 | alert.addAction(action) 44 | } 45 | 46 | viewController.present(alert, animated: true) 47 | } 48 | 49 | } 50 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetal/Canvas/Components/NewCanvasDialogPresenter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NewCanvasDialogPresenter.swift 3 | // HandDrawingSwiftMetal 4 | // 5 | // Created by Eisuke Kusachi on 2024/03/18. 6 | // 7 | 8 | import Foundation 9 | 10 | final class NewCanvasDialogPresenter: DialogPresenter { 11 | 12 | var onTapButton: (() -> Void)? 13 | 14 | override init() { 15 | super.init() 16 | 17 | configuration = Configuration( 18 | title: "New Canvas", 19 | message: "Do you want to create a new canvas?", 20 | buttonTitles: ["Cancel", "OK"], 21 | buttonActions: [1: { [weak self] in self?.onTapButton?() }] 22 | ) 23 | } 24 | 25 | } 26 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetal/Canvas/Components/RoundedRectangleWithArrow.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RoundedRectangleWithArrow.swift 3 | // HandDrawingSwiftMetal 4 | // 5 | // Created by Eisuke Kusachi on 2024/04/26. 6 | // 7 | 8 | import SwiftUI 9 | 10 | /// A rounded rectangle model with an arrow at the top 11 | class RoundedRectangleWithArrow: ObservableObject { 12 | 13 | @Published 14 | var arrowPointX: CGFloat = 0.0 15 | 16 | let arrowSize: CGSize = .init(width: 18, height: 14) 17 | let roundedCorner: CGFloat = 12 18 | 19 | func edgeInsets() -> EdgeInsets { 20 | .init( 21 | top: roundedCorner + arrowSize.height, 22 | leading: roundedCorner, 23 | bottom: roundedCorner, 24 | trailing: roundedCorner 25 | ) 26 | } 27 | 28 | } 29 | 30 | extension RoundedRectangleWithArrow { 31 | 32 | func viewWithTopArrow( 33 | arrowSize: CGSize, 34 | roundedCorner: CGFloat 35 | ) -> some View { 36 | GeometryReader { geometry in 37 | let minX0 = 0.0 38 | let minX1 = roundedCorner 39 | let maxX1 = geometry.size.width - roundedCorner 40 | let maxX0 = geometry.size.width 41 | 42 | let minY0 = arrowSize.height 43 | let minY1 = arrowSize.height + roundedCorner 44 | let maxY1 = geometry.size.height - roundedCorner 45 | let maxY0 = geometry.size.height 46 | 47 | let pointMinX = minX1 + arrowSize.width * 0.5 48 | let pointMaxX = maxX1 - arrowSize.width * 0.5 49 | let pointX = min(max(pointMinX, self.arrowPointX), pointMaxX) 50 | 51 | let arrowStartX = pointX - arrowSize.width * 0.5 52 | let arrowEndX = pointX + arrowSize.width * 0.5 53 | 54 | let minX0minY1: CGPoint = .init(x: minX0, y: minY1) 55 | let minX1minY0: CGPoint = .init(x: minX1, y: minY0) 56 | let maxX1minY0: CGPoint = .init(x: maxX1, y: minY0) 57 | let maxX0minY1: CGPoint = .init(x: maxX0, y: minY1) 58 | 59 | let maxX0maxY1: CGPoint = .init(x: maxX0, y: maxY1) 60 | let maxX1maxY0: CGPoint = .init(x: maxX1, y: maxY0) 61 | let minX1maxY0: CGPoint = .init(x: minX1, y: maxY0) 62 | let minX0maxY1: CGPoint = .init(x: minX0, y: maxY1) 63 | 64 | Path { path in 65 | path.move(to: minX0minY1) 66 | path.addQuadCurve(to: minX1minY0, 67 | control: .init(x: minX0, y: minY0)) 68 | 69 | path.addLine(to: .init(x: arrowStartX, y: minY0)) 70 | path.addLine(to: .init(x: pointX, y: 0.0)) 71 | path.addLine(to: .init(x: arrowEndX, y: minY0)) 72 | 73 | path.addLine(to: maxX1minY0) 74 | path.addQuadCurve(to: maxX0minY1, 75 | control: .init(x: maxX0, y: minY0)) 76 | path.addLine(to: maxX0maxY1) 77 | path.addQuadCurve(to: maxX1maxY0, 78 | control: .init(x: maxX0, y: maxY0)) 79 | path.addLine(to: minX1maxY0) 80 | path.addQuadCurve(to: minX0maxY1, 81 | control: .init(x: minX0, y: maxY0)) 82 | path.closeSubpath() 83 | } 84 | .fill(Color.white.opacity(0.9)) 85 | 86 | // For iOS 15 compatibility 87 | Path { path in 88 | path.move(to: minX0minY1) 89 | path.addQuadCurve(to: minX1minY0, 90 | control: .init(x: minX0, y: minY0)) 91 | 92 | path.addLine(to: .init(x: arrowStartX, y: minY0)) 93 | path.addLine(to: .init(x: pointX, y: 0.0)) 94 | path.addLine(to: .init(x: arrowEndX, y: minY0)) 95 | 96 | path.addLine(to: maxX1minY0) 97 | path.addQuadCurve(to: maxX0minY1, 98 | control: .init(x: maxX0, y: minY0)) 99 | path.addLine(to: maxX0maxY1) 100 | path.addQuadCurve(to: maxX1maxY0, 101 | control: .init(x: maxX0, y: maxY0)) 102 | path.addLine(to: minX1maxY0) 103 | path.addQuadCurve(to: minX0maxY1, 104 | control: .init(x: minX0, y: maxY0)) 105 | path.closeSubpath() 106 | } 107 | .stroke(lineWidth: 0.5) 108 | .fill(Color.black) 109 | } 110 | } 111 | 112 | } 113 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetal/Canvas/Components/SliderStyle.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SliderStyle.swift 3 | // HandDrawingSwiftMetal 4 | // 5 | // Created by Eisuke Kusachi on 2024/01/02. 6 | // 7 | 8 | import SwiftUI 9 | 10 | protocol SliderStyleProtocol { 11 | var height: CGFloat { get } 12 | 13 | var track: AnyView { get } 14 | var trackThickness: CGFloat { get } 15 | var trackBorderColor: Color { get } 16 | var trackBorderWidth: CGFloat { get } 17 | var trackCornerRadius: CGFloat { get } 18 | 19 | var trackLeft: AnyView { get } 20 | var trackLeftColor: Color? { get } 21 | 22 | var trackRight: AnyView { get } 23 | var trackRightColor: Color? { get } 24 | 25 | var thumb: AnyView { get } 26 | var thumbThickness: CGFloat { get } 27 | var thumbColor: Color { get } 28 | var thumbCornerRadius: CGFloat { get } 29 | var thumbBorderColor: Color { get } 30 | var thumbBorderWidth: CGFloat { get } 31 | var thumbShadowColor: Color { get } 32 | var thumbShadowRadius: CGFloat { get } 33 | var thumbShadowX: CGFloat { get } 34 | var thumbShadowY: CGFloat { get } 35 | } 36 | 37 | struct DefaultSliderStyle: SliderStyleProtocol { 38 | var height: CGFloat 39 | 40 | var track: AnyView 41 | var trackThickness: CGFloat 42 | var trackBorderColor: Color 43 | var trackBorderWidth: CGFloat 44 | var trackCornerRadius: CGFloat 45 | 46 | var trackLeft: AnyView 47 | var trackLeftColor: Color? = .clear 48 | 49 | var trackRight: AnyView 50 | var trackRightColor: Color? = .clear 51 | 52 | var thumb: AnyView 53 | var thumbColor: Color 54 | var thumbThickness: CGFloat 55 | var thumbCornerRadius: CGFloat 56 | var thumbBorderColor: Color 57 | var thumbBorderWidth: CGFloat 58 | var thumbShadowColor: Color 59 | var thumbShadowRadius: CGFloat 60 | var thumbShadowX: CGFloat 61 | var thumbShadowY: CGFloat 62 | 63 | init(height: CGFloat? = nil, 64 | 65 | track: AnyView = AnyView(Rectangle().foregroundColor(.clear)), 66 | trackThickness: CGFloat = 8, 67 | trackBorderColor: UIColor = UIColor.gray.withAlphaComponent(0.5), 68 | trackBorderWidth: CGFloat = 1, 69 | trackCornerRadius: CGFloat? = nil, 70 | 71 | trackLeft: AnyView = AnyView(Rectangle()), 72 | trackLeftColor: UIColor? = .clear, 73 | 74 | trackRight: AnyView = AnyView(Rectangle()), 75 | trackRightColor: UIColor? = .clear, 76 | 77 | thumb: AnyView = AnyView(Rectangle()), 78 | thumbThickness: CGFloat = 16, 79 | thumbColor: UIColor = .white, 80 | thumbCornerRadius: CGFloat? = nil, 81 | thumbBorderColor: UIColor = .lightGray.withAlphaComponent(0.5), 82 | thumbBorderWidth: CGFloat = 1.0, 83 | 84 | thumbShadowColor: UIColor = UIColor.black.withAlphaComponent(0.25), 85 | thumbShadowRadius: CGFloat = 2, 86 | thumbShadowX: CGFloat = 1, 87 | thumbShadowY: CGFloat = 1 88 | ) { 89 | self.height = height ?? thumbThickness 90 | 91 | self.track = track 92 | self.trackThickness = trackThickness 93 | self.trackBorderColor = Color(uiColor: trackBorderColor) 94 | self.trackBorderWidth = trackBorderWidth 95 | self.trackCornerRadius = trackCornerRadius ?? trackThickness * 0.5 96 | 97 | self.trackLeft = trackLeft 98 | self.trackLeftColor = Color(uiColor: trackLeftColor ?? .clear) 99 | 100 | self.trackRight = trackRight 101 | self.trackRightColor = Color(uiColor: trackRightColor ?? .clear) 102 | 103 | self.thumb = thumb 104 | self.thumbThickness = thumbThickness 105 | self.thumbColor = Color(uiColor: thumbColor) 106 | self.thumbCornerRadius = thumbCornerRadius ?? thumbThickness * 0.5 107 | self.thumbBorderWidth = thumbBorderWidth 108 | self.thumbBorderColor = Color(uiColor: thumbBorderColor) 109 | self.thumbShadowColor = Color(uiColor: thumbShadowColor) 110 | self.thumbShadowRadius = thumbShadowRadius 111 | self.thumbShadowX = thumbShadowX 112 | self.thumbShadowY = thumbShadowY 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetal/Canvas/Components/TextureLayerViewPresenter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TextureLayerViewPresenter.swift 3 | // HandDrawingSwiftMetal 4 | // 5 | // Created by Eisuke Kusachi on 2024/03/20. 6 | // 7 | 8 | import UIKit 9 | import SwiftUI 10 | 11 | final class TextureLayerViewPresenter { 12 | 13 | private var layerViewController: UIHostingController! 14 | private var layerView: TextureLayerView! 15 | 16 | private let roundedRectangleWithArrow = RoundedRectangleWithArrow() 17 | 18 | func showView(_ isShown: Bool) { 19 | layerViewController.view.isHidden = !isShown 20 | } 21 | 22 | func setupLayerViewPresenter( 23 | canvasState: CanvasState, 24 | textureLayerRepository: TextureLayerRepository, 25 | using viewSettings: TextureLayerViewSettings 26 | ) { 27 | layerView = TextureLayerView( 28 | viewModel: .init( 29 | canvasState: canvasState, 30 | textureLayerRepository: textureLayerRepository 31 | ), 32 | roundedRectangleWithArrow: roundedRectangleWithArrow 33 | ) 34 | 35 | guard let layerView else { return } 36 | 37 | layerViewController = UIHostingController(rootView: layerView) 38 | layerViewController.view.backgroundColor = .clear 39 | layerViewController.view.isHidden = true 40 | 41 | viewSettings.configureViewLayout( 42 | sourceView: layerViewController.view 43 | ) 44 | 45 | roundedRectangleWithArrow.arrowPointX = viewSettings.arrowX() 46 | } 47 | 48 | } 49 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetal/Canvas/Components/TwoRowsSliderView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TwoRowsSliderView.swift 3 | // HandDrawingSwiftMetal 4 | // 5 | // Created by Eisuke Kusachi on 2024/01/02. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct TwoRowsSliderView: View { 11 | 12 | @Binding var value: Int 13 | @Binding var isPressed: Bool 14 | 15 | let title: String 16 | let range: ClosedRange 17 | 18 | var buttonSize: CGFloat = 20 19 | var valueLabelWidth: CGFloat = 64 20 | 21 | var body: some View { 22 | VStack(spacing: 4) { 23 | buttons 24 | IntSlider( 25 | value: $value, 26 | isPressed: $isPressed, 27 | in: range 28 | ) 29 | } 30 | } 31 | 32 | private var buttons: some View { 33 | HStack { 34 | minusButton 35 | Spacer() 36 | valueLabel 37 | Spacer() 38 | plusButton 39 | } 40 | } 41 | 42 | private var minusButton: some View { 43 | Button( 44 | action: { 45 | value = (max(value - 1, range.lowerBound)) 46 | }, 47 | label: { 48 | Image(systemName: "minus") 49 | .frame(width: buttonSize, height: buttonSize) 50 | .foregroundColor(Color(uiColor: .systemBlue)) 51 | } 52 | ) 53 | } 54 | 55 | private var valueLabel: some View { 56 | HStack { 57 | Spacer() 58 | Text("\(title):") 59 | .font(.footnote) 60 | .foregroundColor(Color(uiColor: .gray)) 61 | .frame(width: valueLabelWidth, alignment: .trailing) 62 | 63 | Spacer() 64 | .frame(width: 8) 65 | 66 | Text("\(value)") 67 | .font(.footnote) 68 | .foregroundColor(Color(uiColor: .gray)) 69 | .frame(width: valueLabelWidth, alignment: .leading) 70 | Spacer() 71 | } 72 | } 73 | 74 | private var plusButton: some View { 75 | Button( 76 | action: { 77 | value = (min(value + 1, range.upperBound)) 78 | }, 79 | label: { 80 | Image(systemName: "plus") 81 | .frame(width: buttonSize, height: buttonSize) 82 | .foregroundColor(Color(uiColor: .systemBlue)) 83 | } 84 | ) 85 | } 86 | } 87 | 88 | #Preview { 89 | PreviewView() 90 | } 91 | 92 | private struct PreviewView: View { 93 | 94 | @State var alpha: Int = 125 95 | @State var isPressed: Bool = false 96 | 97 | @State var alphaArray: [Int] = [25, 125, 225] 98 | @State var isPressedArray: [Bool] = [false, false, false] 99 | 100 | let sliderStyle = DefaultSliderStyle( 101 | trackLeftColor: UIColor(named: "trackColor") 102 | ) 103 | 104 | var body: some View { 105 | ForEach(alphaArray.indices, id: \.self) { index in 106 | TwoRowsSliderView( 107 | value: $alphaArray[index], 108 | isPressed: $isPressedArray[index], 109 | title: "Alpha", 110 | range: 0 ... 255 111 | ) 112 | .padding() 113 | } 114 | 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetal/Canvas/Extensions/ArrayExtensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ArrayExtensions.swift 3 | // HandDrawingSwiftMetal 4 | // 5 | // Created by Eisuke Kusachi on 2024/07/29. 6 | // 7 | 8 | import Foundation 9 | 10 | extension Array where Element: Equatable { 11 | 12 | /// Retrieves all elements starting from the element after the specified element. 13 | /// - Parameter element: The element to start after 14 | /// - Returns: A subarray starting from the element after the specified element, or `nil` if the element is not found. 15 | func elements(after element: Element?) -> [Element]? { 16 | guard 17 | let element, 18 | let index = self.lastIndex(of: element) 19 | else { return nil } 20 | 21 | // Ensure index + 1 does not exceed the array bounds 22 | guard (index + 1) < self.count else { return [] } 23 | 24 | return Array(self.suffix(from: index + 1)) 25 | } 26 | 27 | } 28 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetal/Canvas/Extensions/CGAffineTransformExtensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CGAffineTransformExtensions.swift 3 | // HandDrawingSwiftMetal 4 | // 5 | // Created by Eisuke Kusachi on 2023/03/31. 6 | // 7 | 8 | import Foundation 9 | 10 | extension CGAffineTransform { 11 | // Generate a matrix from a center point and two points 12 | static func makeMatrix( 13 | center: CGPoint, 14 | pointsA: (CGPoint?, CGPoint?), 15 | pointsB: (CGPoint?, CGPoint?), 16 | counterRotate: Bool = false, 17 | flipY: Bool = false 18 | ) -> Self? { 19 | guard 20 | let pt1: CGPoint = pointsA.0, 21 | let pt2: CGPoint = pointsB.0, 22 | let pt3: CGPoint = pointsA.1, 23 | let pt4: CGPoint = pointsB.1 24 | else { return nil } 25 | 26 | let layerX = center.x 27 | let layerY = center.y 28 | let x1 = pt1.x - layerX 29 | let y1 = pt1.y - layerY 30 | let x2 = pt2.x - layerX 31 | let y2 = pt2.y - layerY 32 | let x3 = pt3.x - layerX 33 | let y3 = pt3.y - layerY 34 | let x4 = pt4.x - layerX 35 | let y4 = pt4.y - layerY 36 | 37 | let distance = (y1 - y2) * (y1 - y2) + (x1 - x2) * (x1 - x2) 38 | if distance < 0.1 { 39 | return nil 40 | } 41 | 42 | let cos = ((y1 - y2) * (y3 - y4) + (x1 - x2) * (x3 - x4)) / distance 43 | let sin = ((y1 - y2) * (x3 - x4) - (x1 - x2) * (y3 - y4)) / distance 44 | let posx = ((y1 * x2 - x1 * y2) * (y4 - y3) - (x1 * x2 + y1 * y2) * (x3 + x4) + x3 * (y2 * y2 + x2 * x2) + x4 * (y1 * y1 + x1 * x1)) / distance 45 | let posy = ((x1 * x2 + y1 * y2) * (-y4 - y3) + (y1 * x2 - x1 * y2) * (x3 - x4) + y3 * (y2 * y2 + x2 * x2) + y4 * (y1 * y1 + x1 * x1)) / distance 46 | let a = cos 47 | let b = counterRotate == false ? -sin : sin 48 | let c = counterRotate == false ? sin : -sin 49 | let d = cos 50 | let tx = posx 51 | var ty = posy 52 | if flipY { 53 | ty *= -1.0 54 | } 55 | return .init( 56 | a: a, b: b, 57 | c: c, d: d, 58 | tx: tx, ty: ty 59 | ) 60 | } 61 | 62 | static func getInitialMatrix( 63 | scale: CGFloat, 64 | position: CGPoint 65 | ) -> Self { 66 | .init( 67 | a: scale, b: 0.0, 68 | c: 0.0, d: scale, 69 | tx: position.x, ty: position.y 70 | ) 71 | } 72 | 73 | func inverted( 74 | flipY: Bool = false 75 | ) -> Self { 76 | let currentScale = sqrt(a * a + c * c) 77 | let angle = atan2(b, a) 78 | let inverseScale = 1.0 / currentScale 79 | let newA = cos(angle) * inverseScale 80 | let newB = sin(angle) * inverseScale 81 | let newC = -sin(angle) * inverseScale 82 | let newD = cos(angle) * inverseScale 83 | let translationX = -tx 84 | var translationY = -ty 85 | if flipY { 86 | translationY *= -1.0 87 | } 88 | let newTx = (translationX * newA + translationY * newC) 89 | let newTy = (translationX * newB + translationY * newD) 90 | 91 | return .init( 92 | a: newA, b: newB, 93 | c: newC, d: newD, 94 | tx: newTx, ty: newTy 95 | ) 96 | } 97 | 98 | } 99 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetal/Canvas/Extensions/CGPointExtensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CGPointExtensions.swift 3 | // HandDrawingSwiftMetal 4 | // 5 | // Created by Eisuke Kusachi on 2024/04/02. 6 | // 7 | 8 | import UIKit 9 | 10 | extension CGPoint { 11 | 12 | func apply( 13 | with matrix: CGAffineTransform, 14 | textureSize: CGSize 15 | ) -> Self { 16 | 17 | var point = self 18 | 19 | point = .init( 20 | x: point.x - textureSize.width * 0.5, 21 | y: point.y - textureSize.height * 0.5 22 | ) 23 | point = .init( 24 | x: (point.x * matrix.a + point.y * matrix.c + matrix.tx), 25 | y: (point.x * matrix.b + point.y * matrix.d + matrix.ty) 26 | ) 27 | point = .init( 28 | x: point.x + textureSize.width * 0.5, 29 | y: point.y + textureSize.height * 0.5 30 | ) 31 | 32 | return point 33 | } 34 | 35 | func scale(_ sourceSize: CGSize, to destinationSize: CGSize) -> Self { 36 | let scaleFrameToTexture = ViewSize.getScaleToFit(sourceSize, to: destinationSize) 37 | 38 | return .init( 39 | x: self.x * scaleFrameToTexture, 40 | y: self.y * scaleFrameToTexture 41 | ) 42 | } 43 | 44 | func distance(_ to: CGPoint?) -> CGFloat { 45 | guard let value = to else { return 0.0 } 46 | return sqrt(pow(value.x - x, 2) + pow(value.y - y, 2)) 47 | } 48 | 49 | } 50 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetal/Canvas/Extensions/CGSizeExtensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CGSizeExtensions.swift 3 | // HandDrawingSwiftMetal 4 | // 5 | // Created by Eisuke Kusachi on 2025/02/07. 6 | // 7 | 8 | import UIKit 9 | 10 | extension CGSize { 11 | 12 | public static func < (lhs: CGSize, rhs: CGSize) -> Bool { 13 | return lhs.width * lhs.height < rhs.width * rhs.height 14 | } 15 | 16 | public static func > (lhs: CGSize, rhs: CGSize) -> Bool { 17 | return lhs.width * lhs.height > rhs.width * rhs.height 18 | } 19 | 20 | public static func <= (lhs: CGSize, rhs: CGSize) -> Bool { 21 | return lhs.width * lhs.height <= rhs.width * rhs.height 22 | } 23 | 24 | public static func >= (lhs: CGSize, rhs: CGSize) -> Bool { 25 | return lhs.width * lhs.height >= rhs.width * rhs.height 26 | } 27 | 28 | public static func == (lhs: CGSize, rhs: CGSize) -> Bool { 29 | return lhs.width == rhs.width && lhs.height == rhs.height 30 | } 31 | 32 | } 33 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetal/Canvas/Extensions/CalendarExtensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CalendarExtensions.swift 3 | // HandDrawingSwiftMetal 4 | // 5 | // Created by Eisuke Kusachi on 2023/11/04. 6 | // 7 | 8 | import Foundation 9 | 10 | extension Calendar { 11 | static var currentDate: String { 12 | let dateFormatter = DateFormatter() 13 | dateFormatter.dateStyle = .long 14 | dateFormatter.timeStyle = .long 15 | dateFormatter.timeZone = .current 16 | 17 | return dateFormatter.string(from: Date()) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetal/Canvas/Extensions/CollectionExtensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CollectionExtensions.swift 3 | // HandDrawingSwiftMetal 4 | // 5 | // Created by Eisuke Kusachi on 2025/04/06. 6 | // 7 | 8 | import Foundation 9 | 10 | extension Collection where Index == Int { 11 | 12 | func safeSlice(lower: Int, upper: Int) -> SubSequence { 13 | guard 14 | lower <= upper, 15 | indices.contains(lower), 16 | indices.contains(upper) 17 | else { 18 | return self[startIndex ..< startIndex] 19 | } 20 | 21 | return self[lower ... upper] 22 | } 23 | 24 | } 25 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetal/Canvas/Extensions/DataExtensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DataExtensions.swift 3 | // HandDrawingSwiftMetal 4 | // 5 | // Created by Eisuke Kusachi on 2023/11/05. 6 | // 7 | 8 | import Foundation 9 | 10 | extension Data { 11 | 12 | var encodedHexadecimals: [UInt8]? { 13 | let responseValues = self.withUnsafeBytes({ (pointer: UnsafeRawBufferPointer) -> [UInt8] in 14 | let unsafeBufferPointer = pointer.bindMemory(to: UInt8.self) 15 | let unsafePointer = unsafeBufferPointer.baseAddress! 16 | return [UInt8](UnsafeBufferPointer(start: unsafePointer, count: self.count)) 17 | }) 18 | return responseValues 19 | } 20 | 21 | } 22 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetal/Canvas/Extensions/DictionaryExtensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DictionaryExtension.swift 3 | // HandDrawingSwiftMetal 4 | // 5 | // Created by Eisuke Kusachi on 2023/01/09. 6 | // 7 | 8 | import Foundation 9 | 10 | extension Dictionary where Key == Int { 11 | 12 | var first: Value? { 13 | if let key = self.keys.sorted().first { 14 | return self[key] 15 | } 16 | return nil 17 | } 18 | var last: Value? { 19 | if let key = self.keys.sorted().last { 20 | return self[key] 21 | } 22 | return nil 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetal/Canvas/Extensions/FileManagerExtensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FileManagerExtensions.swift 3 | // HandDrawingSwiftMetal 4 | // 5 | // Created by Eisuke Kusachi on 2023/11/03. 6 | // 7 | 8 | import Foundation 9 | 10 | extension FileManager { 11 | 12 | static func createNewDirectory(url: URL) throws { 13 | if FileManager.default.fileExists(atPath: url.path) { 14 | do { 15 | try FileManager.default.removeItem(atPath: url.path) 16 | } catch { 17 | throw error 18 | } 19 | } 20 | 21 | do { 22 | try FileManager.default.createDirectory(atPath: url.path, withIntermediateDirectories: true, attributes: nil) 23 | } catch { 24 | throw error 25 | } 26 | } 27 | 28 | static func clearContents(of folder: URL) throws { 29 | let fileManager = FileManager.default 30 | let files = try fileManager.contentsOfDirectory(at: folder, includingPropertiesForKeys: nil) 31 | for file in files { 32 | try fileManager.removeItem(at: file) 33 | } 34 | } 35 | 36 | } 37 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetal/Canvas/Extensions/ImageExtensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ImageExtensions.swift 3 | // HandDrawingSwiftMetal 4 | // 5 | // Created by Eisuke Kusachi on 2023/12/31. 6 | // 7 | 8 | import SwiftUI 9 | 10 | extension Image { 11 | 12 | func buttonModifier(diameter: CGFloat, _ uiColor: UIColor = .systemBlue) -> some View { 13 | self 14 | .resizable() 15 | .scaledToFit() 16 | .frame(width: diameter, height: diameter) 17 | .foregroundColor(Color(uiColor: uiColor)) 18 | } 19 | 20 | } 21 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetal/Canvas/Extensions/MTLTextureExtensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MTLTextureExtensions.swift 3 | // HandDrawingSwiftMetal 4 | // 5 | // Created by Eisuke Kusachi on 2023/10/14. 6 | // 7 | 8 | import MetalKit 9 | import Accelerate 10 | 11 | extension MTLTexture { 12 | 13 | var size: CGSize { 14 | return CGSize(width: self.width, height: self.height) 15 | } 16 | 17 | var bytes: [UInt8] { 18 | let bytesPerPixel = 4 19 | 20 | let imageByteCount = self.width * self.height * bytesPerPixel 21 | let bytesPerRow = self.width * bytesPerPixel 22 | 23 | var result = [UInt8](repeating: 0, count: Int(imageByteCount)) 24 | let region = MTLRegionMake2D(0, 0, self.width, self.height) 25 | 26 | self.getBytes(&result, bytesPerRow: bytesPerRow, from: region, mipmapLevel: 0) 27 | 28 | return result 29 | } 30 | var uiImage: UIImage? { 31 | guard let data = UIImage.makeCFData(self, flipY: true), 32 | let image = UIImage.makeImage(cfData: data, 33 | width: self.width, 34 | height: self.height) else { return nil } 35 | return image 36 | } 37 | var upsideDownUIImage: UIImage? { 38 | let width = self.width 39 | let height = self.height 40 | let numComponents = 4 41 | let bytesPerRow = width * numComponents 42 | let totalBytes = bytesPerRow * height 43 | let region = MTLRegionMake2D(0, 0, width, height) 44 | var bgraBytes = [UInt8](repeating: 0, count: totalBytes) 45 | self.getBytes(&bgraBytes, bytesPerRow: bytesPerRow, from: region, mipmapLevel: 0) 46 | // use Accelerate framework to convert from BGRA to RGBA 47 | var bgraBuffer = vImage_Buffer(data: UnsafeMutableRawPointer(mutating: bgraBytes), 48 | height: vImagePixelCount(height), width: vImagePixelCount(width), rowBytes: bytesPerRow) 49 | let rgbaBytes = [UInt8](repeating: 0, count: totalBytes) 50 | var rgbaBuffer = vImage_Buffer(data: UnsafeMutableRawPointer(mutating: rgbaBytes), 51 | height: vImagePixelCount(height), width: vImagePixelCount(width), rowBytes: bytesPerRow) 52 | let map: [UInt8] = [2, 1, 0, 3] 53 | vImagePermuteChannels_ARGB8888(&bgraBuffer, &rgbaBuffer, map, 0) 54 | // flipping image vertically 55 | let flippedBytes = bgraBytes // share the buffer 56 | var flippedBuffer = vImage_Buffer(data: UnsafeMutableRawPointer(mutating: flippedBytes), 57 | height: vImagePixelCount(self.height), width: vImagePixelCount(self.width), rowBytes: bytesPerRow) 58 | vImageVerticalReflect_ARGB8888(&rgbaBuffer, &flippedBuffer, 0) 59 | // create CGImage with RGBA Flipped Bytes 60 | guard let data = CFDataCreate(nil, flippedBytes, totalBytes) else { return nil } 61 | guard let dataProvider = CGDataProvider(data: data) else { return nil } 62 | let cgImage = CGImage(width: self.width, 63 | height: self.height, 64 | bitsPerComponent: 8, 65 | bitsPerPixel: 8 * numComponents, 66 | bytesPerRow: bytesPerRow, 67 | space: CGColorSpaceCreateDeviceRGB(), 68 | bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue), 69 | provider: dataProvider, 70 | decode: nil, 71 | shouldInterpolate: true, 72 | intent: .defaultIntent) 73 | guard let cgImage = cgImage else { return nil } 74 | return UIImage(cgImage: cgImage) 75 | } 76 | 77 | func makeThumbnail(length: Int = 64) -> UIImage? { 78 | upsideDownUIImage?.resizeWithAspectRatio(width: CGFloat(length)) 79 | } 80 | 81 | } 82 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetal/Canvas/Extensions/UIButtonExtensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIButtonExtensions.swift 3 | // HandDrawingSwiftMetal 4 | // 5 | // Created by Eisuke Kusachi on 2024/03/10. 6 | // 7 | 8 | import UIKit 9 | 10 | extension UIButton { 11 | 12 | func debounce() { 13 | self.isUserInteractionEnabled = false 14 | DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 3.0) { [unowned self] in 15 | self.isUserInteractionEnabled = true 16 | } 17 | } 18 | 19 | } 20 | 21 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetal/Canvas/Extensions/UIColorExtensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIColorExtensions.swift 3 | // HandDrawingSwiftMetal 4 | // 5 | // Created by Eisuke Kusachi on 2023/03/28. 6 | // 7 | 8 | import UIKit 9 | 10 | extension UIColor { 11 | 12 | var alpha: Int { 13 | var alpha: CGFloat = 0 14 | getRed(nil, green: nil, blue: nil, alpha: &alpha) 15 | 16 | return Int(alpha * 255) 17 | } 18 | 19 | var rgb: (Int, Int, Int) { 20 | var red: CGFloat = 0 21 | var green: CGFloat = 0 22 | var blue: CGFloat = 0 23 | getRed(&red, green: &green, blue: &blue, alpha: nil) 24 | 25 | return (Int(red * 255), Int(green * 255), Int(blue * 255)) 26 | } 27 | 28 | var rgba: (Int, Int, Int, Int) { 29 | var red: CGFloat = 0 30 | var green: CGFloat = 0 31 | var blue: CGFloat = 0 32 | var alpha: CGFloat = 0 33 | getRed(&red, green: &green, blue: &blue, alpha: &alpha) 34 | 35 | return (Int(red * 255), Int(green * 255), Int(blue * 255), Int(alpha * 255)) 36 | } 37 | 38 | func hexString() -> String { 39 | var r: CGFloat = 0 40 | var g: CGFloat = 0 41 | var b: CGFloat = 0 42 | var a: CGFloat = 0 43 | getRed(&r, green: &g, blue: &b, alpha: &a) 44 | let rgb: Int = (Int)(r*255)<<24 | (Int)(g*255)<<16 | (Int)(b*255)<<8 | (Int)(a*255)<<0 45 | return String(format:"#%08x", rgb) 46 | } 47 | 48 | convenience init(_ red: Int, _ green: Int, _ blue: Int, _ alpha: Int = 255) { 49 | let red: Int = max(0, min(red, 255)) 50 | let green: Int = max(0, min(green, 255)) 51 | let blue: Int = max(0, min(blue, 255)) 52 | let alpha: Int = max(0, min(alpha, 255)) 53 | 54 | self.init( 55 | red: (CGFloat(red) / 255.0), 56 | green: (CGFloat(green) / 255.0), 57 | blue: (CGFloat(blue) / 255.0), 58 | alpha: (CGFloat(alpha) / 255.0) 59 | ) 60 | } 61 | 62 | convenience init(rgb: (Int, Int, Int)) { 63 | let red: Int = max(0, min(rgb.0, 255)) 64 | let green: Int = max(0, min(rgb.1, 255)) 65 | let blue: Int = max(0, min(rgb.2, 255)) 66 | 67 | self.init( 68 | red: (CGFloat(red) / 255.0), 69 | green: (CGFloat(green) / 255.0), 70 | blue: (CGFloat(blue) / 255.0), 71 | alpha: 1.0 72 | ) 73 | } 74 | 75 | convenience init(rgba: (Int, Int, Int, Int)) { 76 | let red: Int = max(0, min(rgba.0, 255)) 77 | let green: Int = max(0, min(rgba.1, 255)) 78 | let blue: Int = max(0, min(rgba.2, 255)) 79 | let alpha: Int = max(0, min(rgba.3, 255)) 80 | 81 | self.init( 82 | red: (CGFloat(red) / 255.0), 83 | green: (CGFloat(green) / 255.0), 84 | blue: (CGFloat(blue) / 255.0), 85 | alpha: (CGFloat(alpha) / 255.0) 86 | ) 87 | } 88 | 89 | convenience init(hex: String) { 90 | var hexSanitized = hex.trimmingCharacters(in: .whitespacesAndNewlines) 91 | hexSanitized = hexSanitized.replacingOccurrences(of: "#", with: "") 92 | 93 | var rgb: UInt64 = 0 94 | Scanner(string: hexSanitized).scanHexInt64(&rgb) 95 | 96 | let r = CGFloat((rgb & 0xFF000000) >> 24) / 255 97 | let g = CGFloat((rgb & 0x00FF0000) >> 16) / 255 98 | let b = CGFloat((rgb & 0x0000FF00) >> 8) / 255 99 | let a = CGFloat(rgb & 0x000000FF) / 255 100 | 101 | self.init(red: r, green: g, blue: b, alpha: a) 102 | } 103 | 104 | } 105 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetal/Canvas/Extensions/UITouchExtensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UITouchExtensions.swift 3 | // HandDrawingSwiftMetal 4 | // 5 | // Created by Eisuke Kusachi on 2024/10/21. 6 | // 7 | 8 | import UIKit 9 | 10 | extension UITouch { 11 | 12 | static func isAllFingersReleasedFromScreen( 13 | touches: Set, 14 | with event: UIEvent? 15 | ) -> Bool { 16 | touches.count == event?.allTouches?.count && 17 | touches.contains { $0.phase == .ended || $0.phase == .cancelled } 18 | } 19 | 20 | static func isTouchCompleted(_ touchPhase: UITouch.Phase) -> Bool { 21 | [UITouch.Phase.ended, UITouch.Phase.cancelled].contains(touchPhase) 22 | } 23 | 24 | static func getFingerTouches(event: UIEvent?) -> [UITouch] { 25 | var touches: [UITouch] = [] 26 | event?.allTouches?.forEach { touch in 27 | guard touch.type != .pencil else { return } 28 | touches.append(touch) 29 | } 30 | return touches 31 | } 32 | 33 | } 34 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetal/Canvas/Extensions/UIViewExtensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIViewExtensions.swift 3 | // HandDrawingSwiftMetal 4 | // 5 | // Created by Eisuke Kusachi on 2024/03/09. 6 | // 7 | 8 | import UIKit 9 | 10 | extension UIView { 11 | 12 | func instantiateNib() { 13 | let nibName = String(describing: type(of: self)) 14 | if let nib = Bundle.main.loadNibNamed(nibName, owner: self, options: nil)?.first as? UIView { 15 | nib.frame = bounds 16 | nib.autoresizingMask = [.flexibleWidth, .flexibleHeight] 17 | addSubview(nib) 18 | } 19 | } 20 | 21 | } 22 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetal/Canvas/Extensions/URLExtensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // URLExtensions.swift 3 | // HandDrawingSwiftMetal 4 | // 5 | // Created by Eisuke Kusachi on 2023/11/04. 6 | // 7 | 8 | import Foundation 9 | 10 | extension URL { 11 | 12 | static var documents: URL { 13 | URL(fileURLWithPath: NSHomeDirectory() + "/Documents") 14 | } 15 | 16 | /// A URL to store persistent and temporary data 17 | static var applicationSupport: URL { 18 | FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! 19 | } 20 | 21 | /// A temporary folder URL used for file input and output 22 | static let tmpFolderURL = URL.applicationSupport.appendingPathComponent("TmpFolder") 23 | 24 | static func zipFileURL(projectName: String) -> URL { 25 | URL.documents.appendingPathComponent(projectName + "." + URL.zipSuffix) 26 | } 27 | 28 | static var zipSuffix: String { 29 | "zip" 30 | } 31 | static var thumbnailPath: String { 32 | "thumbnail.png" 33 | } 34 | static var jsonFileName: String { 35 | "data" 36 | } 37 | 38 | var fileName: String { 39 | self.lastPathComponent.components(separatedBy: ".").first ?? self.lastPathComponent 40 | } 41 | 42 | func allFileURLs(suffix: String = "") -> [URL] { 43 | if FileManager.default.fileExists(atPath: self.path) { 44 | do { 45 | let urls = try FileManager.default.contentsOfDirectory(at: self, 46 | includingPropertiesForKeys: nil) 47 | return urls.filter { 48 | suffix.count == 0 || $0.lastPathComponent.hasSuffix(suffix) 49 | } 50 | 51 | } catch { 52 | print(error) 53 | } 54 | } 55 | return [] 56 | } 57 | 58 | } 59 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetal/Canvas/Metal/CommandQueueProtocol.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CommandQueueProtocol.swift 3 | // HandDrawingSwiftMetal 4 | // 5 | // Created by Eisuke Kusachi on 2023/10/14. 6 | // 7 | 8 | import MetalKit 9 | 10 | protocol CommandQueueProtocol { 11 | 12 | var queue: MTLCommandQueue { get } 13 | 14 | func getOrCreateCommandBuffer() -> MTLCommandBuffer 15 | func disposeCommandBuffer() 16 | } 17 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetal/Canvas/Metal/MTLBuffers.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MTLBuffers.swift 3 | // HandDrawingSwiftMetal 4 | // 5 | // Created by Eisuke Kusachi on 2024/07/28. 6 | // 7 | 8 | import MetalKit 9 | 10 | struct MTLGrayscalePointBuffers { 11 | let vertexBuffer: MTLBuffer 12 | let diameterIncludingBlurBuffer: MTLBuffer 13 | let brightnessBuffer: MTLBuffer 14 | let blurSizeBuffer: MTLBuffer 15 | let numberOfPoints: Int 16 | } 17 | 18 | struct MTLTextureBuffers { 19 | let vertexBuffer: MTLBuffer 20 | let texCoordsBuffer: MTLBuffer 21 | let indexBuffer: MTLBuffer 22 | let indicesCount: Int 23 | } 24 | 25 | enum MTLBuffers { 26 | static func makeGrayscalePointBuffers( 27 | points: [GrayscaleDotPoint], 28 | alpha: Int, 29 | textureSize: CGSize, 30 | with device: MTLDevice? 31 | ) -> MTLGrayscalePointBuffers? { 32 | guard points.count != .zero else { return nil } 33 | 34 | var vertexArray: [Float] = [] 35 | var alphaArray: [Float] = [] 36 | var blurSizeArray: [Float] = [] 37 | var diameterArray: [Float] = [] 38 | 39 | points.forEach { 40 | let vertexX: Float = Float($0.location.x / textureSize.width) * 2.0 - 1.0 41 | let vertexY: Float = Float($0.location.y / textureSize.height) * 2.0 - 1.0 42 | 43 | vertexArray.append(contentsOf: [vertexX, vertexY]) 44 | alphaArray.append(Float($0.brightness) * Float(alpha) / 255.0) 45 | diameterArray.append( 46 | BlurredDotSize(diameter: Float($0.diameter), blurSize: Float($0.blurSize)).diameterIncludingBlurSize 47 | ) 48 | blurSizeArray.append(Float($0.blurSize)) 49 | } 50 | 51 | guard 52 | let device, 53 | let vertexBuffer = device.makeBuffer(bytes: vertexArray, length: vertexArray.count * MemoryLayout.size), 54 | let diameterBuffer = device.makeBuffer(bytes: diameterArray, length: diameterArray.count * MemoryLayout.size), 55 | let alphaBuffer = device.makeBuffer(bytes: alphaArray, length: alphaArray.count * MemoryLayout.size), 56 | let blurSizeBuffer = device.makeBuffer(bytes: blurSizeArray, length: blurSizeArray.count * MemoryLayout.size) 57 | else { return nil } 58 | 59 | return .init( 60 | vertexBuffer: vertexBuffer, 61 | diameterIncludingBlurBuffer: diameterBuffer, 62 | brightnessBuffer: alphaBuffer, 63 | blurSizeBuffer: blurSizeBuffer, 64 | numberOfPoints: vertexArray.count / 2 65 | ) 66 | } 67 | 68 | static func makeTextureBuffers( 69 | nodes: MTLTextureNodes = .textureNodes, 70 | with device: MTLDevice? 71 | ) -> MTLTextureBuffers? { 72 | let vertices = nodes.vertices.getValues() 73 | let texCoords = nodes.textureCoord.getValues() 74 | let indices = nodes.indices.getValues() 75 | 76 | guard 77 | let device, 78 | let vertexBuffer = device.makeBuffer(bytes: vertices, length: vertices.count * MemoryLayout.size), 79 | let texCoordsBuffer = device.makeBuffer(bytes: texCoords, length: texCoords.count * MemoryLayout.size), 80 | let indexBuffer = device.makeBuffer(bytes: indices, length: indices.count * MemoryLayout.size) 81 | else { return nil } 82 | 83 | return .init( 84 | vertexBuffer: vertexBuffer, 85 | texCoordsBuffer: texCoordsBuffer, 86 | indexBuffer: indexBuffer, 87 | indicesCount: indices.count 88 | ) 89 | } 90 | 91 | static func makeCanvasTextureBuffers( 92 | matrix: CGAffineTransform?, 93 | frameSize: CGSize, 94 | sourceSize: CGSize, 95 | destinationSize: CGSize, 96 | textureCoord: MTLTextureCoordinates = .screenTextureCoordinates, 97 | indices: MTLTextureIndices = .init(), 98 | with device: MTLDevice? 99 | ) -> MTLTextureBuffers? { 100 | let vertices: [Float] = MTLTextureVertices.makeCenterAlignedTextureVertices( 101 | matrix: matrix, 102 | frameSize: frameSize, 103 | sourceSize: sourceSize, 104 | destinationSize: destinationSize 105 | ).getValues() 106 | let textureCoord: [Float] = textureCoord.getValues() 107 | let indices: [UInt16] = indices.getValues() 108 | 109 | guard 110 | let device, 111 | let vertexBuffer = device.makeBuffer(bytes: vertices, length: vertices.count * MemoryLayout.size), 112 | let texCoordsBuffer = device.makeBuffer(bytes: textureCoord, length: textureCoord.count * MemoryLayout.size), 113 | let indexBuffer = device.makeBuffer(bytes: indices, length: indices.count * MemoryLayout.size) 114 | else { 115 | return nil 116 | } 117 | 118 | return .init( 119 | vertexBuffer: vertexBuffer, 120 | texCoordsBuffer: texCoordsBuffer, 121 | indexBuffer: indexBuffer, 122 | indicesCount: indices.count 123 | ) 124 | } 125 | 126 | } 127 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetal/Canvas/Metal/MTLCommandBufferManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MTLCommandBufferManager.swift 3 | // HandDrawingSwiftMetal 4 | // 5 | // Created by Eisuke Kusachi on 2024/07/28. 6 | // 7 | 8 | import MetalKit 9 | 10 | final class MTLCommandBufferManager { 11 | 12 | private let queue: MTLCommandQueue 13 | 14 | /// Return the buffer if available, create the buffer if not. 15 | var currentCommandBuffer: MTLCommandBuffer { 16 | if storedCommandBuffer == nil { 17 | storedCommandBuffer = queue.makeCommandBuffer() 18 | } 19 | return storedCommandBuffer! 20 | } 21 | 22 | private var storedCommandBuffer: MTLCommandBuffer? 23 | 24 | init(device: MTLDevice) { 25 | self.queue = device.makeCommandQueue()! 26 | } 27 | 28 | func clearCurrentCommandBuffer() { 29 | self.storedCommandBuffer = nil 30 | } 31 | 32 | } 33 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetal/Canvas/Metal/MTLCommandManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MTLCommandManager.swift 3 | // HandDrawingSwiftMetal 4 | // 5 | // Created by Eisuke Kusachi on 2024/09/15. 6 | // 7 | 8 | import MetalKit 9 | 10 | final class MTLCommandManager { 11 | 12 | private let queue: MTLCommandQueue 13 | 14 | /// Return the buffer if available, create the buffer if not. 15 | var currentCommandBuffer: MTLCommandBuffer { 16 | if storedBuffer == nil { 17 | storedBuffer = queue.makeCommandBuffer() 18 | } 19 | return storedBuffer! 20 | } 21 | 22 | private var storedBuffer: MTLCommandBuffer? 23 | 24 | init(device: MTLDevice) { 25 | let newQueue = device.makeCommandQueue()! 26 | self.queue = newQueue 27 | } 28 | 29 | func clearCurrentCommandBuffer() { 30 | self.storedBuffer = nil 31 | } 32 | 33 | } 34 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetal/Canvas/Metal/Shaders/Drawing.metal: -------------------------------------------------------------------------------- 1 | // 2 | // Drawing.metal 3 | // HandDrawingSwiftMetal 4 | // 5 | // Created by Eisuke Kusachi on 2021/11/27. 6 | // 7 | 8 | #include 9 | using namespace metal; 10 | 11 | struct GrayscalePoint { 12 | float4 vertices[[ position ]]; 13 | float size[[ point_size ]]; 14 | float alpha; 15 | float diameterPlusBlurSize; 16 | float blurSize; 17 | }; 18 | 19 | vertex GrayscalePoint draw_gray_points_vertex(uint vid[[ vertex_id ]], 20 | constant float2 *position[[ buffer(0) ]], 21 | constant float *diameterPlusBlurSize[[ buffer(1) ]], 22 | constant float *grayscale[[ buffer(2) ]], 23 | constant float *blurSize[[ buffer(3) ]] 24 | ) { 25 | GrayscalePoint point; 26 | point.vertices = float4(position[vid], 0, 1); 27 | point.diameterPlusBlurSize = diameterPlusBlurSize[vid]; 28 | point.size = diameterPlusBlurSize[vid]; 29 | point.alpha = grayscale[vid]; 30 | point.blurSize = blurSize[vid]; 31 | return point; 32 | }; 33 | fragment float4 draw_gray_points_fragment(GrayscalePoint data [[ stage_in ]], 34 | float2 pointCoord [[ point_coord ]] 35 | ) { 36 | if (length(pointCoord - float2(0.5)) > 0.5) { 37 | discard_fragment(); 38 | } 39 | float distance = length(pointCoord - float2(0.5)); 40 | float radiusPlusBlurSize = data.diameterPlusBlurSize * 0.5; 41 | float blurRatio = data.blurSize / radiusPlusBlurSize; 42 | float x = 1.0 - (distance * 2); 43 | 44 | // The boundaries of the blur become more distinct as the power of squaring increases 45 | float alpha = data.alpha * pow(min(x / blurRatio, 1.0), 3); 46 | 47 | return float4(alpha, 48 | alpha, 49 | alpha, 50 | 1.0); 51 | } 52 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetal/Canvas/Metal/Shaders/Texture.metal: -------------------------------------------------------------------------------- 1 | // 2 | // Texture.metal 3 | // HandDrawingSwiftMetal 4 | // 5 | // Created by Eisuke Kusachi on 2021/11/27. 6 | // 7 | 8 | #include 9 | using namespace metal; 10 | 11 | struct TextureData { 12 | float4 vertices[[ position ]]; 13 | float2 texCoords; 14 | }; 15 | vertex TextureData draw_texture_vertex(uint vid[[vertex_id]], 16 | constant float2 *position [[ buffer(0) ]], 17 | constant float2 *texCoords [[ buffer(1) ]]) { 18 | TextureData data; 19 | data.vertices = float4(position[vid], 0, 1); 20 | data.texCoords = texCoords[vid]; 21 | return data; 22 | } 23 | fragment float4 draw_texture_fragment(TextureData data [[ stage_in ]], 24 | texture2d texture [[ texture(0) ]]) { 25 | constexpr sampler defaultSampler; 26 | float4 color = texture.sample(defaultSampler, data.texCoords); 27 | float a = color[3]; 28 | float b = color[2]; 29 | float g = color[1]; 30 | float r = color[0]; 31 | return float4(r, g, b, a); 32 | } 33 | 34 | kernel void colorize_grayscale_texture(uint2 gid [[ thread_position_in_grid ]], 35 | constant float4 &rgba [[ buffer(0) ]], 36 | texture2d srcTexture [[ texture(0) ]], 37 | texture2d resultTexture [[ texture(1) ]] 38 | ) { 39 | float4 src = srcTexture.read(gid); 40 | 41 | // The grayscale value is used as the alpha value. The array index can be any of 0 to 2. 42 | float a = src[0]; 43 | float r = rgba[0] * a; 44 | float g = rgba[1] * a; 45 | float b = rgba[2] * a; 46 | 47 | resultTexture.write(float4(r, g, b, a), gid); 48 | } 49 | kernel void merge_textures(uint2 gid [[ thread_position_in_grid ]], 50 | texture2d srcTexture [[ texture(0) ]], 51 | texture2d dstTexture [[ texture(1) ]], 52 | texture2d resultTexture [[ texture(2) ]], 53 | constant float &alpha [[ buffer(3) ]] 54 | ) { 55 | float4 src = srcTexture.read(gid); 56 | float4 dst = dstTexture.read(gid); 57 | 58 | float srcA = src[3] * alpha; 59 | float srcB = src[2] * alpha; 60 | float srcG = src[1] * alpha; 61 | float srcR = src[0] * alpha; 62 | float dstA = dst[3]; 63 | float dstB = dst[2]; 64 | float dstG = dst[1]; 65 | float dstR = dst[0]; 66 | float r = srcR + dstR * (1 - srcA); 67 | float g = srcG + dstG * (1 - srcA); 68 | float b = srcB + dstB * (1 - srcA); 69 | float a = srcA + dstA * (1 - srcA); 70 | 71 | resultTexture.write(float4(r, g, b, a), gid); 72 | } 73 | kernel void add_color_to_texture(uint2 gid [[ thread_position_in_grid ]], 74 | constant float4 &color [[ buffer(0) ]], 75 | texture2d resultTexture [[ texture(0) ]]) { 76 | resultTexture.write(color, gid); 77 | } 78 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetal/Canvas/Models/BezierCurvePoints.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BezierCurvePoints.swift 3 | // HandDrawingSwiftMetal 4 | // 5 | // Created by Eisuke Kusachi on 2024/10/19. 6 | // 7 | 8 | import Foundation 9 | 10 | /// A struct that defines the points needed to create a first Bézier curve 11 | struct BezierCurveFirstPoints { 12 | let previousPoint: GrayscaleDotPoint 13 | let startPoint: GrayscaleDotPoint 14 | let endPoint: GrayscaleDotPoint 15 | } 16 | 17 | /// A struct that defines the points needed to create a Bézier curve 18 | struct BezierCurveIntermediatePoints { 19 | let previousPoint: GrayscaleDotPoint 20 | let startPoint: GrayscaleDotPoint 21 | let endPoint: GrayscaleDotPoint 22 | let nextPoint: GrayscaleDotPoint 23 | } 24 | 25 | /// A struct that defines the points needed to create a last Bézier curve 26 | struct BezierCurveLastPoints { 27 | let previousPoint: GrayscaleDotPoint 28 | let startPoint: GrayscaleDotPoint 29 | let endPoint: GrayscaleDotPoint 30 | } 31 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetal/Canvas/Models/BlurredDotSize.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BlurredDotSize.swift 3 | // HandDrawingSwiftMetal 4 | // 5 | // Created by Eisuke Kusachi on 2023/10/15. 6 | // 7 | 8 | import Foundation 9 | 10 | struct BlurredDotSize { 11 | var diameter: Float 12 | var blurSize: Float = BlurredDotSize.initBlurSize 13 | } 14 | 15 | extension BlurredDotSize { 16 | 17 | static let initBlurSize: Float = 4.0 18 | 19 | var diameterIncludingBlurSize: Float { 20 | return diameter + blurSize * 2 21 | } 22 | 23 | } 24 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetal/Canvas/Models/DrawingCurveFingerIterator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DrawingCurveFingerIterator.swift 3 | // HandDrawingSwiftMetal 4 | // 5 | // Created by Eisuke Kusachi on 2024/07/28. 6 | // 7 | 8 | import UIKit 9 | 10 | /// An iterator for real-time finger drawing with `UITouch.Phase` 11 | final class DrawingCurveFingerIterator: Iterator, DrawingCurveIterator { 12 | 13 | var touchPhase: UITouch.Phase = .began 14 | 15 | private(set) var tmpIterator = Iterator() 16 | 17 | private var hasFirstCurveBeenCreated: Bool = false 18 | 19 | var latestCurvePoints: [GrayscaleDotPoint] { 20 | var array: [GrayscaleDotPoint] = [] 21 | 22 | if shouldGetFirstCurve { 23 | array.append(contentsOf: makeFirstCurvePoints()) 24 | } 25 | 26 | array.append(contentsOf: makeIntermediateCurvePoints(shouldIncludeEndPoint: false)) 27 | 28 | if isDrawingFinished { 29 | array.append(contentsOf: makeLastCurvePoints()) 30 | } 31 | 32 | return array 33 | } 34 | 35 | func append( 36 | points: [GrayscaleDotPoint], 37 | touchPhase: UITouch.Phase 38 | ) { 39 | tmpIterator.append(points) 40 | self.touchPhase = touchPhase 41 | 42 | makeSmoothCurve() 43 | } 44 | 45 | override func reset() { 46 | super.reset() 47 | 48 | tmpIterator.reset() 49 | 50 | touchPhase = .began 51 | hasFirstCurveBeenCreated = false 52 | } 53 | 54 | } 55 | 56 | extension DrawingCurveFingerIterator { 57 | 58 | var shouldGetFirstCurve: Bool { 59 | let isFirstCurveToBeCreated = self.array.count >= 3 && !hasFirstCurveBeenCreated 60 | 61 | if isFirstCurveToBeCreated { 62 | hasFirstCurveBeenCreated = true 63 | } 64 | 65 | return isFirstCurveToBeCreated 66 | } 67 | 68 | private func makeSmoothCurve() { 69 | if (tmpIterator.array.count != 0 && self.array.count == 0), 70 | let firstElement = tmpIterator.array.first { 71 | self.append(firstElement) 72 | } 73 | 74 | while let subsequence = tmpIterator.next(range: 2) { 75 | let dotPoint = GrayscaleDotPoint.average( 76 | subsequence[0], 77 | subsequence[1] 78 | ) 79 | self.append(dotPoint) 80 | } 81 | 82 | if touchPhase == .ended, 83 | let lastElement = tmpIterator.array.last { 84 | self.append(lastElement) 85 | } 86 | } 87 | 88 | } 89 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetal/Canvas/Models/DrawingCurveIterator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DrawingCurveIterator.swift 3 | // HandDrawingSwiftMetal 4 | // 5 | // Created by Eisuke Kusachi on 2024/07/28. 6 | // 7 | 8 | import UIKit 9 | 10 | /// An iterator for real-time drawing with `UITouch.Phase` 11 | protocol DrawingCurveIterator: Iterator { 12 | 13 | var touchPhase: UITouch.Phase { get } 14 | 15 | var latestCurvePoints: [GrayscaleDotPoint] { get } 16 | 17 | func append( 18 | points: [GrayscaleDotPoint], 19 | touchPhase: UITouch.Phase 20 | ) 21 | 22 | func reset() 23 | 24 | } 25 | 26 | extension DrawingCurveIterator { 27 | 28 | /// Is the drawing finished 29 | var isDrawingFinished: Bool { 30 | UITouch.isTouchCompleted(touchPhase) 31 | } 32 | 33 | var isCurrentlyDrawing: Bool { 34 | !isDrawingFinished 35 | } 36 | 37 | /// Makes an array of first curve points from an iterator 38 | func makeFirstCurvePoints() -> [GrayscaleDotPoint] { 39 | var curve: [GrayscaleDotPoint] = [] 40 | 41 | if self.array.count >= 3, 42 | let points = self.getBezierCurveFirstPoints() { 43 | 44 | let bezierCurvePoints = BezierCurve.makeFirstCurvePoints( 45 | pointA: points.previousPoint.location, 46 | pointB: points.startPoint.location, 47 | pointC: points.endPoint.location, 48 | shouldIncludeEndPoint: false 49 | ) 50 | curve.append( 51 | contentsOf: GrayscaleDotPoint.interpolateToMatchPointCount( 52 | targetPoints: bezierCurvePoints, 53 | interpolationStart: points.previousPoint, 54 | interpolationEnd: points.startPoint, 55 | shouldIncludeEndPoint: false 56 | ) 57 | ) 58 | } 59 | return curve 60 | } 61 | 62 | /// Makes an array of intermediate curve points from an iterator, setting the range to 4 63 | func makeIntermediateCurvePoints( 64 | shouldIncludeEndPoint: Bool 65 | ) -> [GrayscaleDotPoint] { 66 | var curve: [GrayscaleDotPoint] = [] 67 | 68 | let pointArray = self.getBezierCurveIntermediatePointsWithFixedRange4() 69 | 70 | pointArray.enumerated().forEach { (index, points) in 71 | let shouldIncludeEndPoint = index == pointArray.count - 1 ? shouldIncludeEndPoint : false 72 | 73 | let bezierCurvePoints = BezierCurve.makeIntermediateCurvePoints( 74 | previousPoint: points.previousPoint.location, 75 | startPoint: points.startPoint.location, 76 | endPoint: points.endPoint.location, 77 | nextPoint: points.nextPoint.location, 78 | shouldIncludeEndPoint: shouldIncludeEndPoint 79 | ) 80 | curve.append( 81 | contentsOf: GrayscaleDotPoint.interpolateToMatchPointCount( 82 | targetPoints: bezierCurvePoints, 83 | interpolationStart: points.startPoint, 84 | interpolationEnd: points.endPoint, 85 | shouldIncludeEndPoint: shouldIncludeEndPoint 86 | ) 87 | ) 88 | } 89 | return curve 90 | } 91 | 92 | /// Makes an array of last curve points from an iterator 93 | func makeLastCurvePoints() -> [GrayscaleDotPoint] { 94 | var curve: [GrayscaleDotPoint] = [] 95 | 96 | if self.array.count >= 3, 97 | let points = self.getBezierCurveLastPoints() { 98 | 99 | let bezierCurvePoints = BezierCurve.makeLastCurvePoints( 100 | pointA: points.previousPoint.location, 101 | pointB: points.startPoint.location, 102 | pointC: points.endPoint.location, 103 | shouldIncludeEndPoint: true 104 | ) 105 | curve.append( 106 | contentsOf: GrayscaleDotPoint.interpolateToMatchPointCount( 107 | targetPoints: bezierCurvePoints, 108 | interpolationStart: points.startPoint, 109 | interpolationEnd: points.endPoint, 110 | shouldIncludeEndPoint: true 111 | ) 112 | ) 113 | } 114 | return curve 115 | } 116 | 117 | } 118 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetal/Canvas/Models/DrawingCurvePencilIterator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DrawingCurvePencilIterator.swift 3 | // HandDrawingSwiftMetal 4 | // 5 | // Created by Eisuke Kusachi on 2024/07/28. 6 | // 7 | 8 | import UIKit 9 | 10 | /// An iterator for real-time pencil drawing with `UITouch.Phase` 11 | final class DrawingCurvePencilIterator: Iterator, DrawingCurveIterator { 12 | 13 | var touchPhase: UITouch.Phase = .began 14 | 15 | private var hasFirstCurveBeenCreated: Bool = false 16 | 17 | var latestCurvePoints: [GrayscaleDotPoint] { 18 | var array: [GrayscaleDotPoint] = [] 19 | 20 | if shouldGetFirstCurve { 21 | array.append(contentsOf: makeFirstCurvePoints()) 22 | } 23 | 24 | array.append(contentsOf: makeIntermediateCurvePoints(shouldIncludeEndPoint: false)) 25 | 26 | if isDrawingFinished { 27 | array.append(contentsOf: makeLastCurvePoints()) 28 | } 29 | 30 | return array 31 | } 32 | 33 | func append( 34 | points: [GrayscaleDotPoint], 35 | touchPhase: UITouch.Phase 36 | ) { 37 | self.append(points) 38 | self.touchPhase = touchPhase 39 | } 40 | 41 | override func reset() { 42 | super.reset() 43 | 44 | touchPhase = .began 45 | hasFirstCurveBeenCreated = false 46 | } 47 | 48 | } 49 | 50 | extension DrawingCurvePencilIterator { 51 | 52 | var shouldGetFirstCurve: Bool { 53 | let isFirstCurveToBeCreated = self.array.count >= 3 && !hasFirstCurveBeenCreated 54 | 55 | if isFirstCurveToBeCreated { 56 | hasFirstCurveBeenCreated = true 57 | } 58 | 59 | return isFirstCurveToBeCreated 60 | } 61 | 62 | } 63 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetal/Canvas/Models/Entities/CanvasEntity.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CanvasEntity.swift 3 | // HandDrawingSwiftMetal 4 | // 5 | // Created by Eisuke Kusachi on 2024/05/04. 6 | // 7 | 8 | import Foundation 9 | 10 | struct CanvasEntity: Codable, Equatable { 11 | 12 | let textureSize: CGSize 13 | let layerIndex: Int 14 | let layers: [TextureLayerEntity] 15 | 16 | let thumbnailName: String 17 | 18 | let drawingTool: Int 19 | 20 | let brushDiameter: Int 21 | let eraserDiameter: Int 22 | 23 | init( 24 | thumbnailName: String, 25 | textureSize: CGSize, 26 | layerIndex: Int, 27 | layers: [TextureLayerEntity], 28 | canvasState: CanvasState 29 | ) { 30 | self.thumbnailName = thumbnailName 31 | 32 | self.textureSize = textureSize 33 | 34 | self.layerIndex = layerIndex 35 | self.layers = layers 36 | 37 | self.drawingTool = canvasState.drawingToolState.drawingTool.rawValue 38 | self.brushDiameter = canvasState.drawingToolState.brush.diameter 39 | self.eraserDiameter = canvasState.drawingToolState.eraser.diameter 40 | } 41 | 42 | init(entity: OldCanvasEntity) { 43 | self.thumbnailName = entity.thumbnailName ?? "" 44 | 45 | self.textureSize = entity.textureSize ?? CGSize(width: 1, height: 1) 46 | 47 | self.layerIndex = 0 48 | self.layers = [.init( 49 | textureName: entity.textureName ?? "", 50 | title: "NewLayer", 51 | alpha: 255, 52 | isVisible: true) 53 | ] 54 | 55 | self.drawingTool = entity.drawingTool ?? 0 56 | self.brushDiameter = entity.brushDiameter ?? 8 57 | self.eraserDiameter = entity.eraserDiameter ?? 8 58 | } 59 | 60 | } 61 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetal/Canvas/Models/Entities/OldCanvasEntity.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OldCanvasEntity.swift 3 | // HandDrawingSwiftMetal 4 | // 5 | // Created by Eisuke Kusachi on 2024/05/04. 6 | // 7 | 8 | import Foundation 9 | 10 | struct OldCanvasEntity: Codable { 11 | let textureSize: CGSize? 12 | let textureName: String? 13 | 14 | let thumbnailName: String? 15 | 16 | let drawingTool: Int? 17 | 18 | let brushDiameter: Int? 19 | let eraserDiameter: Int? 20 | } 21 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetal/Canvas/Models/Entities/TextureLayerEntity.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TextureLayerEntity.swift 3 | // HandDrawingSwiftMetal 4 | // 5 | // Created by Eisuke Kusachi on 2025/04/06. 6 | // 7 | 8 | import Foundation 9 | 10 | struct TextureLayerEntity: Codable, Equatable { 11 | /// The filename of the texture 12 | /// MTLTexture cannot be encoded into JSON, 13 | /// the texture is saved as a file, and this struct holds the `textureName` of the texture. 14 | let textureName: String 15 | /// The name of the layer 16 | let title: String 17 | /// The opacity of the layer 18 | let alpha: Int 19 | /// Whether the layer is visible or not 20 | let isVisible: Bool 21 | 22 | } 23 | 24 | extension TextureLayerEntity { 25 | 26 | init(from model: TextureLayerModel) { 27 | self.textureName = model.id.uuidString 28 | self.title = model.title 29 | self.alpha = model.alpha 30 | self.isVisible = model.isVisible 31 | } 32 | 33 | /// Uses the filename as the ID, and generates a new one if it is not valid 34 | static func id(from entity: TextureLayerEntity) -> UUID { 35 | UUID.init(uuidString: entity.textureName) ?? UUID() 36 | } 37 | 38 | } 39 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetal/Canvas/Models/Entities/TextureRepositoryEntity.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TextureRepositoryEntity.swift 3 | // HandDrawingSwiftMetal 4 | // 5 | // Created by Eisuke Kusachi on 2025/05/17. 6 | // 7 | 8 | import MetalKit 9 | 10 | /// A struct that represents a texture entity with `UUID` and `MTLTexture` 11 | struct TextureRepositoryEntity { 12 | var uuid: UUID 13 | var texture: MTLTexture? 14 | } 15 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetal/Canvas/Models/Gestures/FingerInputGestureRecognizer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FingerInputGestureRecognizer.swift 3 | // HandDrawingSwiftMetal 4 | // 5 | // Created by Eisuke Kusachi on 2024/03/31. 6 | // 7 | 8 | import UIKit 9 | 10 | protocol FingerInputGestureRecognizerSender: AnyObject { 11 | func sendFingerTouches(_ touches: Set, with event: UIEvent?, on view: UIView) 12 | } 13 | 14 | final class FingerInputGestureRecognizer: UIGestureRecognizer { 15 | 16 | weak private var gestureDelegate: FingerInputGestureRecognizerSender? 17 | 18 | init(delegate: FingerInputGestureRecognizerSender) { 19 | super.init(target: nil, action: nil) 20 | allowedTouchTypes = [UITouch.TouchType.direct.rawValue as NSNumber] 21 | 22 | gestureDelegate = delegate 23 | } 24 | 25 | override func touchesBegan(_ touches: Set, with event: UIEvent?) { 26 | guard let view else { return } 27 | gestureDelegate?.sendFingerTouches(touches, with: event, on: view) 28 | } 29 | override func touchesMoved(_ touches: Set, with event: UIEvent?) { 30 | guard let view else { return } 31 | gestureDelegate?.sendFingerTouches(touches, with: event, on: view) 32 | } 33 | override func touchesEnded(_ touches: Set, with event: UIEvent?) { 34 | guard let view else { return } 35 | gestureDelegate?.sendFingerTouches(touches, with: event, on: view) 36 | } 37 | override func touchesCancelled(_ touches: Set, with event: UIEvent?) { 38 | guard let view else { return } 39 | gestureDelegate?.sendFingerTouches(touches, with: event, on: view) 40 | } 41 | 42 | } 43 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetal/Canvas/Models/Gestures/FingerScreenStrokeData.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FingerScreenStrokeData.swift 3 | // HandDrawingSwiftMetal 4 | // 5 | // Created by Eisuke Kusachi on 2024/07/29. 6 | // 7 | 8 | import UIKit 9 | 10 | /// A class that manages the finger position information sent from the device 11 | final class FingerScreenStrokeData { 12 | 13 | /// A dictionary that manages points input from multiple fingers 14 | private(set) var touchArrayDictionary: [TouchHashValue: [TouchPoint]] = [:] 15 | 16 | /// A key currently in use in the finger touch dictionary 17 | private(set) var activeDictionaryKey: TouchHashValue? 18 | 19 | /// A variable used to get elements from the array starting from the next element after this point 20 | private(set) var activeLatestTouchPoint: TouchPoint? 21 | 22 | convenience init( 23 | touchArrayDictionary: [TouchHashValue: [TouchPoint]], 24 | activeDictionaryKey: TouchHashValue? = nil, 25 | activeLatestTouchPoint: TouchPoint? = nil 26 | ) { 27 | self.init() 28 | self.touchArrayDictionary = touchArrayDictionary 29 | self.activeDictionaryKey = activeDictionaryKey 30 | self.activeLatestTouchPoint = activeLatestTouchPoint 31 | } 32 | 33 | } 34 | 35 | extension FingerScreenStrokeData { 36 | 37 | var isAllFingersOnScreen: Bool { 38 | !touchArrayDictionary.keys.contains { key in 39 | // If the last element of the array is `ended` or `cancelled`, it means that a finger has been lifted. 40 | UITouch.isTouchCompleted(touchArrayDictionary[key]?.last?.phase ?? .cancelled) 41 | } 42 | } 43 | 44 | var latestTouchPoints: [TouchPoint] { 45 | guard 46 | let activeDictionaryKey, 47 | let touchArray = touchArrayDictionary[activeDictionaryKey] 48 | else { return [] } 49 | 50 | var latestTouchArray: [TouchPoint] = [] 51 | 52 | if let activeLatestTouchPoint { 53 | latestTouchArray = touchArray.elements(after: activeLatestTouchPoint) ?? [] 54 | } else { 55 | latestTouchArray = touchArray 56 | } 57 | 58 | if let lastLatestTouchArray = latestTouchArray.last { 59 | activeLatestTouchPoint = lastLatestTouchArray 60 | } 61 | 62 | return latestTouchArray 63 | } 64 | 65 | func setActiveDictionaryKeyIfNil() { 66 | // If the gesture is determined to be drawing and `setActiveDictionaryKeyIfNil()` is called, 67 | // `touchArrayDictionary` should contain only one element, so the first key is simply set. 68 | guard 69 | // The first element of the sorted key array in the Dictionary is set as the active key. 70 | let firstKey = touchArrayDictionary.keys.sorted().first, 71 | activeDictionaryKey == nil 72 | else { return } 73 | 74 | activeDictionaryKey = firstKey 75 | } 76 | 77 | func appendTouchPointToDictionary(_ touchPoints: [TouchHashValue: TouchPoint]) { 78 | touchPoints.keys.forEach { key in 79 | if !touchArrayDictionary.keys.contains(key) { 80 | touchArrayDictionary[key] = [] 81 | } 82 | if let value = touchPoints[key] { 83 | touchArrayDictionary[key]?.append(value) 84 | } 85 | } 86 | } 87 | 88 | func removeEndedTouchArrayFromDictionary() { 89 | touchArrayDictionary.keys 90 | .filter { 91 | UITouch.isTouchCompleted(touchArrayDictionary[$0]?.lastTouchPhase ?? .cancelled) 92 | } 93 | .forEach { 94 | touchArrayDictionary.removeValue(forKey: $0) 95 | } 96 | } 97 | 98 | func reset() { 99 | touchArrayDictionary = [:] 100 | activeDictionaryKey = nil 101 | activeLatestTouchPoint = nil 102 | } 103 | 104 | } 105 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetal/Canvas/Models/Gestures/InputProtocol.swift: -------------------------------------------------------------------------------- 1 | // 2 | // InputProtocol.swift 3 | // HandDrawingSwiftMetal 4 | // 5 | // Created by Eisuke Kusachi on 2023/10/15. 6 | // 7 | 8 | import UIKit 9 | 10 | /// A protocol with UIGestureRecognizer and a TouchPoint storage 11 | protocol InputProtocol { 12 | var gestureRecognizer: UIGestureRecognizer? { get } 13 | var touchPointStorage: TouchPointStorageProtocol { get } 14 | 15 | init(view: UIView, delegate: AnyObject?) 16 | 17 | func clear() 18 | } 19 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetal/Canvas/Models/Gestures/PencilInputGestureRecognizer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PencilInputGestureRecognizer.swift 3 | // HandDrawingSwiftMetal 4 | // 5 | // Created by Eisuke Kusachi on 2024/03/31. 6 | // 7 | 8 | import UIKit 9 | 10 | protocol PencilInputGestureRecognizerSender { 11 | func sendPencilEstimatedTouches(_ touches: Set, with event: UIEvent?, on view: UIView) 12 | func sendPencilActualTouches(_ touches: Set, on view: UIView) 13 | } 14 | 15 | final class PencilInputGestureRecognizer: UIGestureRecognizer { 16 | 17 | private var gestureDelegate: PencilInputGestureRecognizerSender? 18 | 19 | init(delegate: PencilInputGestureRecognizerSender) { 20 | super.init(target: nil, action: nil) 21 | allowedTouchTypes = [UITouch.TouchType.pencil.rawValue as NSNumber] 22 | 23 | gestureDelegate = delegate 24 | } 25 | 26 | override func touchesBegan(_ touches: Set, with event: UIEvent?) { 27 | guard let view else { return } 28 | gestureDelegate?.sendPencilEstimatedTouches(touches, with: event, on: view) 29 | } 30 | override func touchesMoved(_ touches: Set, with event: UIEvent?) { 31 | guard let view else { return } 32 | gestureDelegate?.sendPencilEstimatedTouches(touches, with: event, on: view) 33 | } 34 | override func touchesEnded(_ touches: Set, with event: UIEvent?) { 35 | guard let view else { return } 36 | gestureDelegate?.sendPencilEstimatedTouches(touches, with: event, on: view) 37 | } 38 | override func touchesCancelled(_ touches: Set, with event: UIEvent?) { 39 | guard let view else { return } 40 | gestureDelegate?.sendPencilEstimatedTouches(touches, with: event, on: view) 41 | } 42 | 43 | /// https://developer.apple.com/documentation/uikit/apple_pencil_interactions/handling_input_from_apple_pencil/ 44 | override func touchesEstimatedPropertiesUpdated(_ touches: Set) { 45 | guard let view else { return } 46 | gestureDelegate?.sendPencilActualTouches(touches, on: view) 47 | } 48 | 49 | } 50 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetal/Canvas/Models/Gestures/PencilScreenStrokeData.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PencilScreenStrokeData.swift 3 | // HandDrawingSwiftMetal 4 | // 5 | // Created by Eisuke Kusachi on 2024/07/29. 6 | // 7 | 8 | import UIKit 9 | 10 | /// A class that manages the pen position information sent from the Apple Pencil 11 | final class PencilScreenStrokeData { 12 | 13 | /// An array that stores real values. 14 | /// https://developer.apple.com/documentation/uikit/apple_pencil_interactions/handling_input_from_apple_pencil/ 15 | /// The values sent from the Apple Pencil include both estimated values and real values, but only the real values are used. 16 | private(set) var actualTouchPointArray: [TouchPoint] = [] 17 | 18 | /// A variable that stores the latest real value, used to retrieve the latest values array from `actualTouchPointArray` 19 | private(set) var latestActualTouchPoint: TouchPoint? 20 | 21 | /// A variable that stores the latest estimated value, used for determining touch end 22 | private(set) var latestEstimatedTouchPoint: TouchPoint? 23 | 24 | /// A variable that stores the latest estimationUpdateIndex, used for determining touch end 25 | private(set) var latestEstimationUpdateIndex: NSNumber? 26 | 27 | init( 28 | actualTouchPointArray: [TouchPoint] = [], 29 | latestEstimatedTouchPoint: TouchPoint? = nil, 30 | latestActualTouchPoint: TouchPoint? = nil 31 | ) { 32 | self.actualTouchPointArray = actualTouchPointArray.sorted { $0.timestamp < $1.timestamp } 33 | self.latestEstimatedTouchPoint = latestEstimatedTouchPoint 34 | self.latestActualTouchPoint = latestActualTouchPoint 35 | } 36 | 37 | } 38 | 39 | extension PencilScreenStrokeData { 40 | 41 | /// Uses the elements of `actualTouchPointArray` after `latestActualTouchPoint` for line drawing. 42 | var latestActualTouchPoints: [TouchPoint] { 43 | let touchPoints = actualTouchPointArray.elements(after: latestActualTouchPoint) ?? actualTouchPointArray 44 | latestActualTouchPoint = actualTouchPointArray.last 45 | return touchPoints 46 | } 47 | 48 | func isPenOffScreen(actualTouches: [TouchPoint]) -> Bool { 49 | UITouch.isTouchCompleted(latestEstimatedTouchPoint?.phase ?? .cancelled) && 50 | actualTouches.contains(where: { $0.estimationUpdateIndex == latestEstimationUpdateIndex }) 51 | } 52 | 53 | func setLatestEstimatedTouchPoint(_ estimatedTouchPoint: TouchPoint?) { 54 | latestEstimatedTouchPoint = estimatedTouchPoint 55 | 56 | if let estimationUpdateIndex = estimatedTouchPoint?.estimationUpdateIndex { 57 | latestEstimationUpdateIndex = estimationUpdateIndex 58 | } 59 | } 60 | 61 | func appendActualTouches(actualTouches: [TouchPoint]) { 62 | actualTouchPointArray.append(contentsOf: actualTouches) 63 | 64 | if isPenOffScreen(actualTouches: actualTouches), 65 | let latestEstimatedTouchPoint { 66 | self.actualTouchPointArray.append(latestEstimatedTouchPoint) 67 | } 68 | } 69 | 70 | func reset() { 71 | actualTouchPointArray = [] 72 | latestActualTouchPoint = nil 73 | latestEstimatedTouchPoint = nil 74 | latestEstimationUpdateIndex = nil 75 | } 76 | 77 | } 78 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetal/Canvas/Models/Gestures/TouchHashValue.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TouchHashValue.swift 3 | // HandDrawingSwiftMetal 4 | // 5 | // Created by Eisuke Kusachi on 2024/07/30. 6 | // 7 | 8 | import Foundation 9 | 10 | typealias TouchHashValue = Int 11 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetal/Canvas/Models/Points/DotPoint.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DotPoint.swift 3 | // HandDrawingSwiftMetal 4 | // 5 | // Created by Eisuke Kusachi on 2025/02/22. 6 | // 7 | 8 | import Foundation 9 | 10 | protocol DotPoint: Equatable { 11 | 12 | var location: CGPoint { get } 13 | var diameter: CGFloat { get } 14 | 15 | } 16 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetal/Canvas/Models/Points/GrayscaleDotPoint.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GrayscaleDotPoint.swift 3 | // HandDrawingSwiftMetal 4 | // 5 | // Created by Eisuke Kusachi on 2024/07/28. 6 | // 7 | 8 | import UIKit 9 | 10 | struct GrayscaleDotPoint: DotPoint { 11 | 12 | let location: CGPoint 13 | let diameter: CGFloat 14 | 15 | /// Grayscale brightness (0.0 ~ 1.0) 16 | let brightness: CGFloat 17 | 18 | var blurSize: CGFloat = 2.0 19 | 20 | } 21 | 22 | extension GrayscaleDotPoint { 23 | 24 | init( 25 | touchPoint: TouchPoint, 26 | diameter: CGFloat 27 | ) { 28 | self.location = touchPoint.location 29 | self.diameter = diameter 30 | self.brightness = touchPoint.maximumPossibleForce != 0 ? min(touchPoint.force, 1.0) : 1.0 31 | } 32 | 33 | init( 34 | matrix: CGAffineTransform, 35 | touchPoint: TouchPoint, 36 | textureSize: CGSize, 37 | drawableSize: CGSize, 38 | frameSize: CGSize, 39 | diameter: CGFloat 40 | ) { 41 | let textureMatrix = ViewSize.convertScreenMatrixToTextureMatrix( 42 | matrix: matrix, 43 | drawableSize: drawableSize, 44 | textureSize: textureSize, 45 | frameSize: frameSize 46 | ) 47 | let textureLocation = ViewSize.convertScreenLocationToTextureLocation( 48 | touchLocation: touchPoint.location, 49 | frameSize: frameSize, 50 | drawableSize: drawableSize, 51 | textureSize: textureSize 52 | ) 53 | 54 | let touchPoint: TouchPoint = .init( 55 | location: textureLocation.apply( 56 | with: textureMatrix, 57 | textureSize: textureSize 58 | ), 59 | touch: touchPoint 60 | ) 61 | 62 | self.location = touchPoint.location 63 | self.diameter = diameter 64 | self.brightness = touchPoint.maximumPossibleForce != 0 ? min(touchPoint.force, 1.0) : 1.0 65 | } 66 | } 67 | 68 | extension GrayscaleDotPoint { 69 | 70 | static func average(_ left: Self, _ right: Self) -> Self { 71 | .init( 72 | location: left.location == right.location ? left.location : CGPoint( 73 | x: (left.location.x + right.location.x) * 0.5, 74 | y: (left.location.y + right.location.y) * 0.5 75 | ), 76 | diameter: left.diameter == right.diameter ? left.diameter : (left.diameter + right.diameter) * 0.5, 77 | brightness: left.brightness == right.brightness ? left.brightness : (left.brightness + right.brightness) * 0.5 78 | ) 79 | } 80 | 81 | /// Interpolates the values to match the number of elements in `targetPoints` array with that of the other elements array 82 | static func interpolateToMatchPointCount( 83 | targetPoints: [CGPoint], 84 | interpolationStart: Self, 85 | interpolationEnd: Self, 86 | shouldIncludeEndPoint: Bool 87 | ) -> [Self] { 88 | var curve: [Self] = [] 89 | 90 | var numberOfInterpolations = targetPoints.count 91 | 92 | if shouldIncludeEndPoint { 93 | // Subtract 1 from `numberOfInterpolations` because the last point will be added to the arrays 94 | numberOfInterpolations = numberOfInterpolations - 1 95 | } 96 | 97 | let brightnessArray = Interpolator.getLinearInterpolationValues( 98 | begin: interpolationStart.brightness, 99 | change: interpolationEnd.brightness, 100 | duration: numberOfInterpolations, 101 | shouldIncludeEndPoint: shouldIncludeEndPoint 102 | ) 103 | 104 | let diameterArray = Interpolator.getLinearInterpolationValues( 105 | begin: interpolationStart.diameter, 106 | change: interpolationEnd.diameter, 107 | duration: numberOfInterpolations, 108 | shouldIncludeEndPoint: shouldIncludeEndPoint 109 | ) 110 | 111 | let blurArray = Interpolator.getLinearInterpolationValues( 112 | begin: interpolationStart.blurSize, 113 | change: interpolationEnd.blurSize, 114 | duration: numberOfInterpolations, 115 | shouldIncludeEndPoint: shouldIncludeEndPoint 116 | ) 117 | 118 | for i in 0 ..< targetPoints.count { 119 | curve.append( 120 | .init( 121 | location: targetPoints[i], 122 | diameter: diameterArray[i], 123 | brightness: brightnessArray[i], 124 | blurSize: blurArray[i] 125 | ) 126 | ) 127 | } 128 | 129 | return curve 130 | } 131 | 132 | } 133 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetal/Canvas/Models/Points/TouchPoint.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TouchPoint.swift 3 | // HandDrawingSwiftMetal 4 | // 5 | // Created by Eisuke Kusachi on 2022/11/19. 6 | // 7 | 8 | import UIKit 9 | 10 | struct TouchPoint: Equatable { 11 | 12 | let location: CGPoint 13 | let phase: UITouch.Phase 14 | let force: CGFloat 15 | let maximumPossibleForce: CGFloat 16 | /// Index for identifying the estimated value 17 | var estimationUpdateIndex: NSNumber? = nil 18 | 19 | let timestamp: TimeInterval 20 | } 21 | 22 | extension TouchPoint { 23 | 24 | init( 25 | touch: UITouch, 26 | view: UIView 27 | ) { 28 | self.location = touch.preciseLocation(in: view) 29 | self.phase = touch.phase 30 | self.force = touch.force 31 | self.maximumPossibleForce = touch.maximumPossibleForce 32 | self.estimationUpdateIndex = touch.estimationUpdateIndex 33 | self.timestamp = touch.timestamp 34 | } 35 | 36 | init( 37 | location: CGPoint, 38 | touch: TouchPoint 39 | ) { 40 | self.location = location 41 | self.phase = touch.phase 42 | self.force = touch.force 43 | self.maximumPossibleForce = touch.maximumPossibleForce 44 | self.estimationUpdateIndex = touch.estimationUpdateIndex 45 | self.timestamp = touch.timestamp 46 | } 47 | 48 | } 49 | 50 | extension Array where Element == TouchPoint { 51 | 52 | var lastTouchPhase: UITouch.Phase { 53 | if self.last?.phase == .cancelled { 54 | .cancelled 55 | } else if self.last?.phase == .ended { 56 | .ended 57 | } else if self.first?.phase == .began { 58 | .began 59 | } else { 60 | .moved 61 | } 62 | } 63 | 64 | } 65 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetal/Canvas/Models/Undo/UndoMoveData.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UndoMoveData.swift 3 | // HandDrawingSwiftMetal 4 | // 5 | // Created by Eisuke Kusachi on 2024/12/31. 6 | // 7 | 8 | import Foundation 9 | 10 | /// A structure used for undoing array movements 11 | /// array movement is performed by duplicating the `fromIndex` element, inserting it at the `toIndex` position in the array, and then removing the `fromIndex` 12 | /// this structure holds the variables required for that process 13 | struct UndoMoveData { 14 | /// Index where an element is inserted during array movement 15 | let fromIndex: Int 16 | /// Index where an element is removed during array movement 17 | let toIndex: Int 18 | /// Selected index before the move 19 | let selectedIndex: Int 20 | /// Selected index after the move 21 | let selectedIndexAfterMove: Int 22 | } 23 | 24 | extension UndoMoveData { 25 | init( 26 | source: Int, 27 | destination: Int, 28 | selectedIndex: Int, 29 | selectedIndexAfterMove: Int 30 | ) { 31 | // Undoing the move operation, swap the insert and remove actions 32 | let undoSource = destination 33 | let undoDestination = source 34 | 35 | self.fromIndex = UndoMoveData.getMoveFromIndex(source: undoSource, destination: undoDestination) 36 | self.toIndex = UndoMoveData.getMoveToIndex(source: undoSource, destination: undoDestination) 37 | self.selectedIndex = selectedIndex 38 | self.selectedIndexAfterMove = selectedIndexAfterMove 39 | } 40 | 41 | /// Returns the adjusted `source` by adding 1 when moving an element from a larger index to a smaller index in an array 42 | /// to account for the duplication and insertion of the source element. 43 | static func getMoveFromIndex( 44 | source: Int, 45 | destination: Int 46 | ) -> Int { 47 | source > destination ? source + 1 : source 48 | } 49 | /// Returns the adjusted `destination` by adding 1 when moving an element from a smaller index to a larger index in an array 50 | /// to account for the duplication and insertion of the source element. 51 | static func getMoveToIndex( 52 | source: Int, 53 | destination: Int 54 | ) -> Int { 55 | destination > source ? destination + 1 : destination 56 | } 57 | 58 | /// Returns `toIndex`, subtracting 1 if `toIndex` is greater than `fromIndex` 59 | /// because the `toIndex` returned by `onMove(perform:)` has 1 added to account for the duplicated source element 60 | /// when `toIndex` is larger than `fromIndex`. 61 | static func getMoveDestination( 62 | fromIndex: Int, 63 | toIndex: Int 64 | ) -> Int { 65 | toIndex > fromIndex ? toIndex - 1: toIndex 66 | } 67 | 68 | /// Returns the selected index based on the source index, destination index, and the currently selected element’s index in the array 69 | static func makeSelectedIndexAfterMove( 70 | source: Int, 71 | destination: Int, 72 | selectedIndex: Int 73 | ) -> Int { 74 | var resultIndex = destination 75 | 76 | // Layer movement can be performed even on a layer that is not selected 77 | if selectedIndex != source { 78 | resultIndex = selectedIndex 79 | 80 | if destination <= selectedIndex && selectedIndex < source { 81 | // If the moving layer crosses over the selected layer and moves to a smaller index, add 1 to `resultIndex` 82 | resultIndex += 1 83 | 84 | } else if destination >= selectedIndex && selectedIndex > source { 85 | // If the moving layer crosses over the selected layer and moves to a larger index, subtract 1 from `resultIndex` 86 | resultIndex -= 1 87 | } 88 | } 89 | 90 | return resultIndex 91 | } 92 | 93 | } 94 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetal/Canvas/Models/Undo/UndoStack.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UndoStack.swift 3 | // HandDrawingSwiftMetal 4 | // 5 | // Created by Eisuke Kusachi on 2024/12/31. 6 | // 7 | 8 | import Foundation 9 | import Combine 10 | 11 | final class UndoStack { 12 | 13 | let undoManager: UndoManager 14 | 15 | var undoDataPublisher: AnyPublisher { 16 | undoDataSubject.eraseToAnyPublisher() 17 | } 18 | let undoDataSubject = PassthroughSubject() 19 | 20 | var undoObject: T? 21 | var redoObject: T? 22 | 23 | init(undoManager: UndoManager, undoCount: Int = 64) { 24 | self.undoManager = undoManager 25 | self.undoManager.levelsOfUndo = undoCount 26 | self.undoManager.groupsByEvent = false 27 | } 28 | 29 | var canUndo: Bool { 30 | undoManager.canUndo 31 | } 32 | var canRedo: Bool { 33 | undoManager.canRedo 34 | } 35 | 36 | func undo() { 37 | undoManager.undo() 38 | } 39 | func redo() { 40 | undoManager.redo() 41 | } 42 | func reset() { 43 | undoManager.removeAllActions() 44 | } 45 | 46 | func clearObjects() { 47 | undoObject = nil 48 | redoObject = nil 49 | } 50 | 51 | func pushUndoObject( 52 | _ undoStackObject: UndoStackObject 53 | ) { 54 | undoManager.beginUndoGrouping() 55 | undoManager.registerUndo(withTarget: self) { [weak self] _ in 56 | self?.undoDataSubject.send(undoStackObject.undoObject) 57 | 58 | // Redo Registration 59 | self?.pushUndoObject( 60 | undoStackObject.reversedObject 61 | ) 62 | } 63 | undoManager.endUndoGrouping() 64 | } 65 | 66 | } 67 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetal/Canvas/Models/Undo/UndoStackObject.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UndoStackObject.swift 3 | // HandDrawingSwiftMetal 4 | // 5 | // Created by Eisuke Kusachi on 2024/12/31. 6 | // 7 | 8 | import Foundation 9 | 10 | /// A structure that holds both `undo` and `redo` objects 11 | struct UndoStackObject { 12 | 13 | let undoObject: T 14 | let redoObject: T 15 | 16 | /// Alternate swapping between `undoObject` and `redoObject` 17 | var reversedObject: Self { 18 | .init( 19 | undoObject: redoObject, 20 | redoObject: undoObject 21 | ) 22 | } 23 | 24 | } 25 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetal/Canvas/Repositories/CoreData/CoreDataRepository.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CoreDataRepositoryProtocol.swift 3 | // HandDrawingSwiftMetal 4 | // 5 | // Created by Eisuke Kusachi on 2025/05/03. 6 | // 7 | 8 | import CoreData 9 | import UIKit 10 | 11 | protocol CoreDataRepository { 12 | var entityName: String { get } 13 | var persistentContainerName: String { get } 14 | 15 | var context: NSManagedObjectContext { get } 16 | 17 | func fetchEntity() throws -> NSManagedObject? 18 | func saveContext() throws 19 | } 20 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetal/Canvas/Repositories/CoreData/DefaultCoreDataRepository.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DefaultCoreDataRepository.swift 3 | // HandDrawingSwiftMetal 4 | // 5 | // Created by Eisuke Kusachi on 2025/05/03. 6 | // 7 | 8 | import Foundation 9 | import CoreData 10 | 11 | final class DefaultCoreDataRepository: CoreDataRepository { 12 | 13 | let entityName: String 14 | let persistentContainerName: String 15 | 16 | init( 17 | entityName: String, 18 | persistentContainerName: String 19 | ) { 20 | self.entityName = entityName 21 | self.persistentContainerName = persistentContainerName 22 | } 23 | 24 | private lazy var persistentContainer: NSPersistentContainer = { 25 | let container = NSPersistentContainer(name: persistentContainerName) 26 | container.loadPersistentStores { _, error in 27 | if let error = error as NSError? { 28 | fatalError("CoreData Load Error: \(error), \(error.userInfo)") 29 | } 30 | } 31 | return container 32 | }() 33 | 34 | var context: NSManagedObjectContext { 35 | persistentContainer.viewContext 36 | } 37 | 38 | func fetchEntity() throws -> NSManagedObject? { 39 | let request = NSFetchRequest(entityName: entityName) 40 | request.fetchLimit = 1 41 | return try context.fetch(request).first 42 | } 43 | 44 | func saveContext() throws { 45 | guard context.hasChanges else { return } 46 | try context.save() 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetal/Canvas/Repositories/CoreData/DefaultCoreDataSingletonRepository.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DefaultCoreDataSingletonRepository.swift 3 | // HandDrawingSwiftMetal 4 | // 5 | // Created by Eisuke Kusachi on 2025/05/03. 6 | // 7 | 8 | import Combine 9 | import CoreData 10 | import Foundation 11 | 12 | final class DefaultCoreDataSingletonRepository: CoreDataRepository { 13 | 14 | static let shared = DefaultCoreDataSingletonRepository() 15 | 16 | private let repository = DefaultCoreDataRepository( 17 | entityName: "CanvasStorageEntity", 18 | persistentContainerName: "CanvasStorage" 19 | ) 20 | 21 | var entityName: String { 22 | repository.entityName 23 | } 24 | 25 | var persistentContainerName: String { 26 | repository.persistentContainerName 27 | } 28 | 29 | var context: NSManagedObjectContext { 30 | repository.context 31 | } 32 | 33 | func fetchEntity() throws -> NSManagedObject? { 34 | try repository.fetchEntity() 35 | } 36 | 37 | func saveContext() throws { 38 | try repository.saveContext() 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetal/Canvas/Repositories/Local/DocumentsLocalSingletonRepository.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DocumentsLocalSingletonRepository.swift 3 | // HandDrawingSwiftMetal 4 | // 5 | // Created by Eisuke Kusachi on 2025/04/27. 6 | // 7 | 8 | import Combine 9 | import Foundation 10 | import MetalKit 11 | 12 | final class DocumentsLocalSingletonRepository: LocalRepository { 13 | 14 | static let shared = DocumentsLocalRepository() 15 | 16 | private let repository: any LocalRepository 17 | 18 | private init(repository: any LocalRepository = DocumentsLocalRepository()) { 19 | self.repository = repository 20 | } 21 | 22 | func loadDataFromDocuments( 23 | sourceURL: URL, 24 | textureRepository: TextureRepository 25 | ) -> AnyPublisher { 26 | repository.loadDataFromDocuments( 27 | sourceURL: sourceURL, 28 | textureRepository: textureRepository 29 | ) 30 | } 31 | 32 | func saveDataToDocuments( 33 | renderTexture: any MTLTexture, 34 | canvasState: CanvasState, 35 | textureRepository: any TextureRepository, 36 | to zipFileURL: URL 37 | ) -> AnyPublisher { 38 | repository.saveDataToDocuments( 39 | renderTexture: renderTexture, 40 | canvasState: canvasState, 41 | textureRepository: textureRepository, 42 | to: zipFileURL 43 | ) 44 | } 45 | 46 | } 47 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetal/Canvas/Repositories/Local/LocalRepository.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LocalRepository.swift 3 | // HandDrawingSwiftMetal 4 | // 5 | // Created by Eisuke Kusachi on 2024/05/04. 6 | // 7 | 8 | import Combine 9 | import MetalKit 10 | 11 | protocol LocalRepository { 12 | 13 | func loadDataFromDocuments( 14 | sourceURL: URL, 15 | textureRepository: any TextureRepository 16 | ) -> AnyPublisher 17 | 18 | func saveDataToDocuments( 19 | renderTexture: MTLTexture, 20 | canvasState: CanvasState, 21 | textureRepository: any TextureRepository, 22 | to zipFileURL: URL 23 | ) -> AnyPublisher 24 | 25 | } 26 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetal/Canvas/Repositories/Texture/TextureLayer/TextureLayerDocumentsDirectorySingletonRepository.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TextureLayerDocumentsDirectorySingletonRepository.swift 3 | // HandDrawingSwiftMetal 4 | // 5 | // Created by Eisuke Kusachi on 2025/05/04. 6 | // 7 | 8 | import Foundation 9 | 10 | final class TextureLayerDocumentsDirectorySingletonRepository: TextureLayerRepositoryWrapper { 11 | 12 | static let shared = TextureLayerDocumentsDirectorySingletonRepository() 13 | 14 | private init() { 15 | super.init(repository: TextureLayerDocumentsDirectoryRepository(directoryName: "TextureStorage")) 16 | } 17 | 18 | } 19 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetal/Canvas/Repositories/Texture/TextureLayer/TextureLayerInMemoryRepository.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TextureLayerInMemoryRepository.swift 3 | // HandDrawingSwiftMetal 4 | // 5 | // Created by Eisuke Kusachi on 2025/05/17. 6 | // 7 | 8 | import Combine 9 | import MetalKit 10 | import SwiftUI 11 | 12 | /// A repository that manages in-memory textures and thumbnails 13 | final class TextureLayerInMemoryRepository: TextureInMemoryRepository, TextureLayerRepository { 14 | 15 | @Published private(set) var thumbnails: [UUID: UIImage?] = [:] 16 | 17 | private let thumbnailUpdateRequestedSubject: PassthroughSubject = .init() 18 | 19 | private let device = MTLCreateSystemDefaultDevice()! 20 | 21 | override init( 22 | textures: [UUID: MTLTexture?] = [:], 23 | renderer: MTLRendering = MTLRenderer.shared 24 | ) { 25 | super.init(textures: textures, renderer: renderer) 26 | } 27 | 28 | /// Clears texture ID data and the thumbnails 29 | override func removeAll() { 30 | textures = [:] 31 | thumbnails = [:] 32 | } 33 | 34 | override func removeTexture(_ uuid: UUID) -> AnyPublisher { 35 | textures.removeValue(forKey: uuid) 36 | thumbnails.removeValue(forKey: uuid) 37 | return Just(uuid).setFailureType(to: Error.self).eraseToAnyPublisher() 38 | } 39 | 40 | override func updateAllTextures(uuids: [UUID], textureSize: CGSize, from sourceURL: URL) -> AnyPublisher { 41 | Future { [weak self] promise in 42 | do { 43 | // Delete all data 44 | self?.removeAll() 45 | 46 | try uuids.forEach { [weak self] uuid in 47 | let textureData = try Data( 48 | contentsOf: sourceURL.appendingPathComponent(uuid.uuidString) 49 | ) 50 | 51 | guard 52 | let device = self?.device, 53 | let hexadecimalData = textureData.encodedHexadecimals 54 | else { return } 55 | 56 | let texture = MTLTextureCreator.makeTexture( 57 | size: textureSize, 58 | colorArray: hexadecimalData, 59 | with: device 60 | ) 61 | 62 | self?.textures[uuid] = texture 63 | self?.setThumbnail(texture: texture, for: uuid) 64 | } 65 | promise(.success(())) 66 | } catch { 67 | promise(.failure(error)) 68 | } 69 | } 70 | .eraseToAnyPublisher() 71 | } 72 | 73 | override func updateTexture(texture: MTLTexture?, for uuid: UUID) -> AnyPublisher { 74 | Future { [weak self] promise in 75 | if let texture { 76 | self?.textures[uuid] = texture 77 | self?.setThumbnail(texture: texture, for: uuid) 78 | 79 | promise(.success(uuid)) 80 | } else { 81 | promise(.failure(TextureRepositoryError.failedToAddTexture)) 82 | } 83 | } 84 | .eraseToAnyPublisher() 85 | } 86 | 87 | } 88 | 89 | extension TextureLayerInMemoryRepository { 90 | 91 | var thumbnailUpdateRequestedPublisher: AnyPublisher { 92 | thumbnailUpdateRequestedSubject.eraseToAnyPublisher() 93 | } 94 | 95 | func getThumbnail(_ uuid: UUID) -> UIImage? { 96 | thumbnails[uuid]?.flatMap { $0 } 97 | } 98 | 99 | func updateAllThumbnails(textureSize: CGSize) -> AnyPublisher { 100 | Future { promise in 101 | DispatchQueue.global(qos: .userInitiated).async { [weak self] in 102 | guard let `self` else { return } 103 | 104 | for (uuid, texture) in self.textures { 105 | guard let texture else { return } 106 | self.setThumbnail(texture: texture, for: uuid) 107 | } 108 | 109 | promise(.success(())) 110 | } 111 | } 112 | .eraseToAnyPublisher() 113 | } 114 | 115 | } 116 | 117 | extension TextureLayerInMemoryRepository { 118 | 119 | private func setThumbnail(texture: MTLTexture?, for uuid: UUID) { 120 | thumbnails[uuid] = texture?.makeThumbnail() 121 | thumbnailUpdateRequestedSubject.send(uuid) 122 | } 123 | 124 | } 125 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetal/Canvas/Repositories/Texture/TextureLayer/TextureLayerInMemorySingletonRepository.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TextureLayerInMemorySingletonRepository.swift 3 | // HandDrawingSwiftMetal 4 | // 5 | // Created by Eisuke Kusachi on 2025/04/06. 6 | // 7 | 8 | import Foundation 9 | 10 | final class TextureLayerInMemorySingletonRepository: TextureLayerRepositoryWrapper { 11 | 12 | static let shared = TextureLayerInMemorySingletonRepository() 13 | 14 | private init() { 15 | super.init(repository: TextureLayerInMemoryRepository()) 16 | } 17 | 18 | } 19 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetal/Canvas/Repositories/Texture/TextureLayer/TextureLayerMockRepository.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TextureLayerMockRepository.swift 3 | // HandDrawingSwiftMetal 4 | // 5 | // Created by Eisuke Kusachi on 2025/04/21. 6 | // 7 | 8 | import Combine 9 | import UIKit 10 | import Metal 11 | 12 | final class TextureLayerMockRepository: TextureLayerRepository { 13 | 14 | private let device = MTLCreateSystemDefaultDevice()! 15 | 16 | var storageInitializationWithNewTexturePublisher: AnyPublisher { 17 | storageInitializationWithNewTextureSubject.eraseToAnyPublisher() 18 | } 19 | var storageInitializationCompletedPublisher: AnyPublisher { 20 | storageInitializationCompletedSubject.eraseToAnyPublisher() 21 | } 22 | 23 | /// Emit `UUID` when the thumbnail is updated 24 | var thumbnailUpdateRequestedPublisher: AnyPublisher { 25 | thumbnailUpdateRequestedSubject.eraseToAnyPublisher() 26 | } 27 | 28 | private let storageInitializationWithNewTextureSubject = PassthroughSubject() 29 | 30 | private let storageInitializationCompletedSubject = PassthroughSubject() 31 | 32 | private let thumbnailUpdateRequestedSubject: PassthroughSubject = .init() 33 | 34 | var textureNum: Int = 0 35 | 36 | var textureSize: CGSize = .zero 37 | 38 | var isInitialized: Bool = false 39 | 40 | func initializeStorage(from configuration: CanvasConfiguration) {} 41 | 42 | func hasAllTextures(fileNames: [String]) -> AnyPublisher { 43 | Just(true) 44 | .setFailureType(to: Error.self) 45 | .eraseToAnyPublisher() 46 | } 47 | 48 | func initializeStorageWithNewTexture(_ textureSize: CGSize) { 49 | 50 | } 51 | 52 | func createTextures(layers: [TextureLayerModel], textureSize: CGSize, folderURL: URL) -> AnyPublisher { 53 | Just(()) 54 | .setFailureType(to: Error.self) 55 | .eraseToAnyPublisher() 56 | } 57 | 58 | func getThumbnail(_ uuid: UUID) -> UIImage? { 59 | nil 60 | } 61 | 62 | func getTexture(uuid: UUID, textureSize: CGSize) -> AnyPublisher { 63 | Just(.init(uuid: uuid)) 64 | .setFailureType(to: Error.self) 65 | .eraseToAnyPublisher() 66 | } 67 | 68 | func getTextures(uuids: [UUID], textureSize: CGSize) -> AnyPublisher<[TextureRepositoryEntity], Error> { 69 | return Just([]) 70 | .setFailureType(to: Error.self) 71 | .eraseToAnyPublisher() 72 | } 73 | 74 | func updateAllTextures(uuids: [UUID], textureSize: CGSize, from sourceURL: URL) -> AnyPublisher { 75 | Just(()) 76 | .setFailureType(to: Error.self) 77 | .eraseToAnyPublisher() 78 | } 79 | 80 | func removeTexture(_ uuid: UUID) -> AnyPublisher { 81 | Just(uuid).setFailureType(to: Error.self) 82 | .eraseToAnyPublisher() 83 | } 84 | 85 | func removeAll() {} 86 | 87 | func setThumbnail(texture: MTLTexture?, for uuid: UUID) {} 88 | 89 | func updateTexture(texture: MTLTexture?, for uuid: UUID) -> AnyPublisher { 90 | Just(uuid) 91 | .setFailureType(to: Error.self) 92 | .eraseToAnyPublisher() 93 | } 94 | 95 | func updateAllThumbnails(textureSize: CGSize) -> AnyPublisher { 96 | Just(()) 97 | .setFailureType(to: Error.self) 98 | .eraseToAnyPublisher() 99 | } 100 | 101 | } 102 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetal/Canvas/Repositories/Texture/TextureLayer/TextureLayerRepository.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TextureLayerRepository.swift 3 | // HandDrawingSwiftMetal 4 | // 5 | // Created by Eisuke Kusachi on 2025/05/17. 6 | // 7 | 8 | import Combine 9 | import UIKit 10 | 11 | /// A protocol that defines a repository for managing textures and in-memory thumbnails 12 | protocol TextureLayerRepository: TextureRepository { 13 | 14 | /// A publisher that notifies SwiftUI about a thumbnail update for a specific layer 15 | var thumbnailUpdateRequestedPublisher: AnyPublisher { get } 16 | 17 | /// Gets the thumbnail image for UUID 18 | func getThumbnail(_ uuid: UUID) -> UIImage? 19 | 20 | /// Updates all thumbnails 21 | func updateAllThumbnails(textureSize: CGSize) -> AnyPublisher 22 | 23 | } 24 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetal/Canvas/Repositories/Texture/TextureLayer/TextureLayerRepositoryWrapper.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TextureLayerRepositoryWrapper.swift 3 | // HandDrawingSwiftMetal 4 | // 5 | // Created by Eisuke Kusachi on 2025/05/04. 6 | // 7 | 8 | import Combine 9 | import UIKit 10 | 11 | class TextureLayerRepositoryWrapper: ObservableObject, TextureLayerRepository { 12 | 13 | let repository: TextureLayerRepository 14 | 15 | init(repository: TextureLayerRepository) { 16 | self.repository = repository 17 | } 18 | 19 | var storageInitializationWithNewTexturePublisher: AnyPublisher { 20 | repository.storageInitializationWithNewTexturePublisher 21 | } 22 | var storageInitializationCompletedPublisher: AnyPublisher { 23 | repository.storageInitializationCompletedPublisher 24 | } 25 | 26 | var thumbnailUpdateRequestedPublisher: AnyPublisher { 27 | repository.thumbnailUpdateRequestedPublisher 28 | } 29 | 30 | var textureNum: Int { 31 | repository.textureNum 32 | } 33 | 34 | var textureSize: CGSize { 35 | repository.textureSize 36 | } 37 | 38 | var isInitialized: Bool { 39 | repository.isInitialized 40 | } 41 | 42 | func initializeStorage(from configuration: CanvasConfiguration) { 43 | repository.initializeStorage(from: configuration) 44 | } 45 | 46 | func initializeStorageWithNewTexture(_ textureSize: CGSize) { 47 | repository.initializeStorageWithNewTexture(textureSize) 48 | } 49 | 50 | func getThumbnail(_ uuid: UUID) -> UIImage? { 51 | repository.getThumbnail(uuid) 52 | } 53 | 54 | func getTexture(uuid: UUID, textureSize: CGSize) -> AnyPublisher { 55 | repository.getTexture(uuid: uuid, textureSize: textureSize) 56 | } 57 | 58 | func getTextures(uuids: [UUID], textureSize: CGSize) -> AnyPublisher<[TextureRepositoryEntity], Error> { 59 | repository.getTextures(uuids: uuids, textureSize: textureSize) 60 | } 61 | 62 | func removeTexture(_ uuid: UUID) -> AnyPublisher { 63 | repository.removeTexture(uuid) 64 | } 65 | 66 | func removeAll() { 67 | repository.removeAll() 68 | } 69 | 70 | func updateTexture(texture: (any MTLTexture)?, for uuid: UUID) -> AnyPublisher { 71 | repository.updateTexture(texture: texture, for: uuid) 72 | } 73 | 74 | func updateAllTextures(uuids: [UUID], textureSize: CGSize, from sourceURL: URL) -> AnyPublisher { 75 | repository.updateAllTextures(uuids: uuids, textureSize: textureSize, from: sourceURL) 76 | } 77 | 78 | func updateAllThumbnails(textureSize: CGSize) -> AnyPublisher { 79 | repository.updateAllThumbnails(textureSize: textureSize) 80 | } 81 | 82 | } 83 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetal/Canvas/Repositories/Texture/TextureRepository.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TextureRepository.swift 3 | // HandDrawingSwiftMetal 4 | // 5 | // Created by Eisuke Kusachi on 2025/04/06. 6 | // 7 | 8 | import Combine 9 | import Foundation 10 | import MetalKit 11 | 12 | /// A protocol that defines a repository for managing textures 13 | protocol TextureRepository { 14 | 15 | /// A publisher that emits to trigger initialization of the storage using `CanvasConfiguration` 16 | var storageInitializationWithNewTexturePublisher: AnyPublisher { get } 17 | 18 | /// A publisher that emits to trigger initialization of the canvas using `CanvasConfiguration` 19 | var storageInitializationCompletedPublisher: AnyPublisher { get } 20 | 21 | /// The number of textures currently managed 22 | var textureNum: Int { get } 23 | 24 | /// The size of the textures managed by this repository 25 | var textureSize: CGSize { get } 26 | 27 | /// Whether this repository has been initialized 28 | var isInitialized: Bool { get } 29 | 30 | /// Initializes the storage 31 | func initializeStorage(from configuration: CanvasConfiguration) 32 | 33 | /// Initializes the storage with a new texture 34 | func initializeStorageWithNewTexture(_ textureSize: CGSize) 35 | 36 | /// Gets a texture for the given UUID 37 | func getTexture(uuid: UUID, textureSize: CGSize) -> AnyPublisher 38 | 39 | /// Gets multiple textures for the given UUIDs 40 | func getTextures(uuids: [UUID], textureSize: CGSize) -> AnyPublisher<[TextureRepositoryEntity], Error> 41 | 42 | /// Removes all managed textures 43 | func removeAll() 44 | 45 | /// Removes a texture with UUID 46 | func removeTexture(_ uuid: UUID) -> AnyPublisher 47 | 48 | /// Updates all textures for the given uuids using a directory URL 49 | func updateAllTextures(uuids: [UUID], textureSize: CGSize, from sourceURL: URL) -> AnyPublisher 50 | 51 | /// Updates an existing texture for UUID 52 | func updateTexture(texture: MTLTexture?, for uuid: UUID) -> AnyPublisher 53 | 54 | } 55 | 56 | enum TextureRepositoryError: Error { 57 | case notFound 58 | case failedToUnwrap 59 | case failedToLoadTexture 60 | case failedToAddTexture 61 | case failedToUpdateTexture 62 | case failedToCommitCommandBuffer 63 | case invalidTexture 64 | case repositoryDeinitialized 65 | case repositoryUnavailable 66 | } 67 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetal/Canvas/Repositories/Texture/TextureRepositoryWrapper.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TextureRepositoryWrapper.swift 3 | // HandDrawingSwiftMetal 4 | // 5 | // Created by Eisuke Kusachi on 2025/05/17. 6 | // 7 | 8 | import Combine 9 | import UIKit 10 | 11 | class TextureRepositoryWrapper: ObservableObject, TextureRepository { 12 | 13 | let repository: TextureRepository 14 | 15 | init(repository: TextureRepository) { 16 | self.repository = repository 17 | } 18 | 19 | var storageInitializationWithNewTexturePublisher: AnyPublisher { 20 | repository.storageInitializationWithNewTexturePublisher 21 | } 22 | var storageInitializationCompletedPublisher: AnyPublisher { 23 | repository.storageInitializationCompletedPublisher 24 | } 25 | 26 | var textureNum: Int { 27 | repository.textureNum 28 | } 29 | 30 | var textureSize: CGSize { 31 | repository.textureSize 32 | } 33 | 34 | var isInitialized: Bool { 35 | repository.isInitialized 36 | } 37 | 38 | func initializeStorage(from configuration: CanvasConfiguration) { 39 | repository.initializeStorage(from: configuration) 40 | } 41 | 42 | func initializeStorageWithNewTexture(_ textureSize: CGSize) { 43 | repository.initializeStorageWithNewTexture(textureSize) 44 | } 45 | 46 | func getTexture(uuid: UUID, textureSize: CGSize) -> AnyPublisher { 47 | repository.getTexture(uuid: uuid, textureSize: textureSize) 48 | } 49 | 50 | func getTextures(uuids: [UUID], textureSize: CGSize) -> AnyPublisher<[TextureRepositoryEntity], Error> { 51 | repository.getTextures(uuids: uuids, textureSize: textureSize) 52 | } 53 | 54 | func removeTexture(_ uuid: UUID) -> AnyPublisher { 55 | repository.removeTexture(uuid) 56 | } 57 | 58 | func removeAll() { 59 | repository.removeAll() 60 | } 61 | 62 | func updateTexture(texture: MTLTexture?, for uuid: UUID) -> AnyPublisher { 63 | repository.updateTexture(texture: texture, for: uuid) 64 | } 65 | 66 | func updateAllTextures(uuids: [UUID], textureSize: CGSize, from sourceURL: URL) -> AnyPublisher { 67 | repository.updateAllTextures(uuids: uuids, textureSize: textureSize, from: sourceURL) 68 | } 69 | 70 | } 71 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetal/Canvas/Utils/BezierCurveHandlePoints.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BezierCurveHandlePoints.swift 3 | // HandDrawingSwiftMetal 4 | // 5 | // Created by Eisuke Kusachi on 2024/10/19. 6 | // 7 | 8 | import UIKit 9 | 10 | struct BezierCurveHandlePoints { 11 | let handleA: CGPoint 12 | let handleB: CGPoint 13 | } 14 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetal/Canvas/Utils/ButtonThrottle.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ButtonThrottle.swift 3 | // HandDrawingSwiftMetal 4 | // 5 | // Created by Eisuke Kusachi on 2025/05/11. 6 | // 7 | 8 | import SwiftUI 9 | 10 | final class ButtonThrottle { 11 | private var isLocked = [String: Bool]() 12 | private let queue = DispatchQueue(label: "com.hand-drawing-swift-metal.ButtonThrottleQueue") 13 | 14 | func throttle( 15 | id: String = "default", 16 | delay: TimeInterval = 0.8, 17 | action: @escaping () -> Void 18 | ) { 19 | queue.async { 20 | if self.isLocked[id] == true { 21 | return 22 | } 23 | 24 | self.isLocked[id] = true 25 | 26 | DispatchQueue.main.async { 27 | action() 28 | } 29 | 30 | self.queue.asyncAfter(deadline: .now() + delay) { 31 | self.isLocked[id] = false 32 | } 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetal/Canvas/Utils/Calculate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Calculate.swift 3 | // HandDrawingSwiftMetal 4 | // 5 | // Created by Eisuke Kusachi on 2022/02/05. 6 | // 7 | 8 | import Foundation 9 | 10 | enum Calculate { 11 | 12 | static func getLength(_ leftHandSide: CGPoint, to rightHandSide: CGPoint) -> CGFloat { 13 | sqrt(pow(rightHandSide.x - leftHandSide.x, 2) + pow(rightHandSide.y - leftHandSide.y, 2)) 14 | } 15 | 16 | static func getLength(_ vector: CGVector) -> CGFloat { 17 | sqrt(pow(vector.dx, 2) + pow(vector.dy, 2)) 18 | } 19 | 20 | /// Get the total distance by connecting points 21 | static func getTotalLength(points: [CGPoint]) -> CGFloat { 22 | var totalLength: CGFloat = 0.0 23 | for i in 0 ..< points.count - 1 { 24 | totalLength += getLength(points[i], to: points[i + 1]) 25 | } 26 | return totalLength 27 | } 28 | 29 | static func getRadian(_ leftHandSide: CGVector, _ rightHandSide: CGVector) -> CGFloat { 30 | let dotProduct = leftHandSide.dx * rightHandSide.dx + leftHandSide.dy * rightHandSide.dy 31 | let divisor = Calculate.getLength(leftHandSide) * Calculate.getLength(rightHandSide) 32 | 33 | return divisor != 0 ? acos(dotProduct / divisor) : 0.0 34 | } 35 | 36 | static func getReversedVector(_ vector: CGVector) -> CGVector { 37 | .init(dx: vector.dx * -1.0, dy: vector.dy * -1.0) 38 | } 39 | 40 | static func getResizedVector(_ vector: CGVector, length: CGFloat) -> CGVector { 41 | var vector = vector 42 | 43 | if vector.dx == 0 && vector.dy == 0 { 44 | return vector 45 | 46 | } else if vector.dx == 0 { 47 | vector.dy = length * (vector.dy / abs(vector.dy)) 48 | return vector 49 | 50 | } else if vector.dy == 0 { 51 | vector.dx = length * (vector.dx / abs(vector.dx)) 52 | return vector 53 | 54 | } else { 55 | let proportion = abs(vector.dy / vector.dx) 56 | let x = sqrt(pow(length, 2) / (1 + pow(proportion, 2))) 57 | let y = proportion * x 58 | vector.dx = x * round(vector.dx / abs(vector.dx)) 59 | vector.dy = y * round(vector.dy / abs(vector.dy)) 60 | return vector 61 | } 62 | } 63 | 64 | } 65 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetal/Canvas/Utils/Constants.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Constants.swift 3 | // HandDrawingSwiftMetal 4 | // 5 | // Created by Eisuke Kusachi on 2024/10/19. 6 | // 7 | 8 | import Foundation 9 | 10 | enum Constants { 11 | static let blankAreaBackgroundColor: (Int, Int, Int) = (230, 230, 230) 12 | } 13 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetal/Canvas/Utils/Iterator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Iterator.swift 3 | // HandDrawingSwiftMetal 4 | // 5 | // Created by Eisuke Kusachi on 2022/11/03. 6 | // 7 | 8 | import Foundation 9 | 10 | class Iterator: IteratorProtocol { 11 | 12 | typealias Element = T 13 | 14 | private(set) var array: [Element] = [] 15 | private(set) var index: Int = 0 16 | 17 | var count: Int { 18 | return array.count 19 | } 20 | var currentIndex: Int { 21 | return index - 1 22 | } 23 | var isFirstProcessing: Bool { 24 | return index == 1 25 | } 26 | 27 | func next() -> Element? { 28 | if index < array.count { 29 | let element = array[index] 30 | index += 1 31 | return element 32 | } else { 33 | return nil 34 | } 35 | } 36 | 37 | func next(range: Int = 1, _ results: ([Element]) -> Void) { 38 | if range <= 0 { return } 39 | 40 | while (index + range) <= array.count { 41 | results(Array(array[index ..< index + range])) 42 | index += 1 43 | } 44 | } 45 | func next(range: Int) -> [Element]? { 46 | if range <= 0 { return nil } 47 | 48 | if (index + range) <= array.count { 49 | 50 | let elements = array[index ..< index + range] 51 | index += 1 52 | 53 | return Array(elements) 54 | 55 | } else { 56 | return nil 57 | } 58 | } 59 | func append(_ element: Element) { 60 | array.append(element) 61 | } 62 | func append(_ elements: [Element]) { 63 | array.append(contentsOf: elements) 64 | } 65 | 66 | func replace(index: Int, element: Element) { 67 | array[index] = element 68 | } 69 | 70 | func reset() { 71 | index = 0 72 | array = [] 73 | } 74 | 75 | } 76 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetal/Canvas/Utils/Logger.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Logger.swift 3 | // HandDrawingSwiftMetal 4 | // 5 | // Created by Eisuke Kusachi on 2024/12/21. 6 | // 7 | 8 | import Foundation 9 | import os 10 | 11 | public enum Logger { 12 | public static let standard: os.Logger = .init( 13 | subsystem: Bundle.main.bundleIdentifier!, 14 | category: LogCategory.standard.rawValue 15 | ) 16 | } 17 | 18 | private enum LogCategory: String { 19 | case standard = "Standard" 20 | } 21 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetal/Canvas/Utils/ScaleManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ScaleManager.swift 3 | // HandDrawingSwiftMetal 4 | // 5 | // Created by Eisuke Kusachi on 2024/07/28. 6 | // 7 | 8 | import Foundation 9 | 10 | enum ScaleManager { 11 | 12 | static func getAspectFitFactor(sourceSize: CGSize, destinationSize: CGSize) -> CGFloat { 13 | 14 | let ratioWidth = destinationSize.width / sourceSize.width 15 | let ratioHeight = destinationSize.height / sourceSize.height 16 | 17 | return ratioWidth < ratioHeight ? ratioWidth : ratioHeight 18 | } 19 | 20 | static func getAspectFillFactor(sourceSize: CGSize, destinationSize: CGSize) -> CGFloat { 21 | 22 | let ratioWidth = destinationSize.width / sourceSize.width 23 | let ratioHeight = destinationSize.height / sourceSize.height 24 | 25 | return ratioWidth > ratioHeight ? ratioWidth : ratioHeight 26 | } 27 | 28 | } 29 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetal/Canvas/Utils/TimeStampFormatter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TimeStampFormatter.swift 3 | // HandDrawingSwiftMetal 4 | // 5 | // Created by Eisuke Kusachi on 2023/12/31. 6 | // 7 | 8 | import Foundation 9 | 10 | enum TimeStampFormatter { 11 | 12 | static func currentDate() -> String { 13 | TimeStampFormatter.current(template: "MMM dd HH mm ss") 14 | } 15 | 16 | static func current(template: String) -> String { 17 | let dateFormatter = DateFormatter() 18 | dateFormatter.dateFormat = DateFormatter.dateFormat(fromTemplate: template, options: 0, locale: .current) 19 | return dateFormatter.string(from: Date()) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetal/Canvas/Utils/ToastModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ToastModel.swift 3 | // HandDrawingSwiftMetal 4 | // 5 | // Created by Eisuke Kusachi on 2024/05/04. 6 | // 7 | 8 | import Foundation 9 | 10 | struct ToastModel { 11 | let title: String 12 | let systemName: String 13 | let duration: Double 14 | 15 | init( 16 | title: String, 17 | systemName: String, 18 | duration: Double = 2.0 19 | ) { 20 | self.title = title 21 | self.systemName = systemName 22 | self.duration = duration 23 | } 24 | 25 | } 26 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetal/Canvas/Utils/ViewSize.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ViewSize.swift 3 | // HandDrawingSwiftMetal 4 | // 5 | // Created by Eisuke Kusachi on 2024/09/06. 6 | // 7 | 8 | import Foundation 9 | 10 | enum ViewSize { 11 | 12 | static func getScaleToFit(_ source: CGSize, to destination: CGSize) -> CGFloat { 13 | let widthRatio = destination.width / source.width 14 | let heightRatio = destination.height / source.height 15 | 16 | return min(widthRatio, heightRatio) 17 | } 18 | 19 | static func getScaleToFill(_ source: CGSize, to destination: CGSize) -> CGFloat { 20 | let widthRatio = destination.width / source.width 21 | let heightRatio = destination.height / source.height 22 | 23 | return max(widthRatio, heightRatio) 24 | } 25 | 26 | static func convertScreenMatrixToTextureMatrix( 27 | matrix: CGAffineTransform, 28 | drawableSize: CGSize, 29 | textureSize: CGSize, 30 | frameSize: CGSize 31 | ) -> CGAffineTransform { 32 | 33 | let drawableScale = ViewSize.getScaleToFit(textureSize, to: drawableSize) 34 | let drawableTextureSize: CGSize = .init( 35 | width: textureSize.width * drawableScale, 36 | height: textureSize.height * drawableScale 37 | ) 38 | 39 | let frameToTextureFitScale = ViewSize.getScaleToFit(frameSize, to: textureSize) 40 | let drawableTextureToDrawableFillScale = ViewSize.getScaleToFill(drawableTextureSize, to: drawableSize) 41 | 42 | var matrix = matrix 43 | matrix.tx *= (frameToTextureFitScale * drawableTextureToDrawableFillScale) 44 | matrix.ty *= (frameToTextureFitScale * drawableTextureToDrawableFillScale) 45 | return matrix 46 | } 47 | 48 | static func convertScreenLocationToTextureLocation( 49 | touchLocation: CGPoint, 50 | frameSize: CGSize, 51 | drawableSize: CGSize, 52 | textureSize: CGSize 53 | ) -> CGPoint { 54 | if textureSize != drawableSize { 55 | let drawableToTextureFillScale = ViewSize.getScaleToFill(drawableSize, to: textureSize) 56 | let drawableLocation: CGPoint = .init( 57 | x: touchLocation.x * (drawableSize.width / frameSize.width), 58 | y: touchLocation.y * (drawableSize.width / frameSize.width) 59 | ) 60 | return .init( 61 | x: drawableLocation.x * drawableToTextureFillScale + (textureSize.width - drawableSize.width * drawableToTextureFillScale) * 0.5, 62 | y: drawableLocation.y * drawableToTextureFillScale + (textureSize.height - drawableSize.height * drawableToTextureFillScale) * 0.5 63 | ) 64 | } else { 65 | return .init( 66 | x: touchLocation.x * (textureSize.width / frameSize.width), 67 | y: touchLocation.y * (textureSize.width / frameSize.width) 68 | ) 69 | } 70 | } 71 | 72 | } 73 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetal/Canvas/Views/CanvasView/CanvasView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CanvasView.swift 3 | // HandDrawingSwiftMetal 4 | // 5 | // Created by Eisuke Kusachi on 2023/10/14. 6 | // 7 | 8 | import MetalKit 9 | import Combine 10 | 11 | /// A custom view for displaying textures with Metal support. 12 | class CanvasView: MTKView, MTKViewDelegate, CanvasViewProtocol { 13 | 14 | var renderTexture: MTLTexture? { 15 | _renderTexture 16 | } 17 | 18 | var needsTextureRefreshPublisher: AnyPublisher { 19 | needsTextureRefreshSubject.eraseToAnyPublisher() 20 | } 21 | 22 | private(set) var commandBuffer: MTLCommandBuffer? 23 | 24 | /// A texture that is rendered on the screen. 25 | /// Its size changes when the device is rotated. 26 | private var _renderTexture: MTLTexture? { 27 | didSet { 28 | needsTextureRefreshSubject.send(()) 29 | } 30 | } 31 | 32 | private var flippedTextureBuffers: MTLTextureBuffers? 33 | 34 | private let needsTextureRefreshSubject = PassthroughSubject() 35 | 36 | private var commandQueue: MTLCommandQueue! 37 | 38 | override init(frame frameRect: CGRect, device: MTLDevice?) { 39 | super.init(frame: frameRect, device: device) 40 | commonInit() 41 | } 42 | required init(coder: NSCoder) { 43 | super.init(coder: coder) 44 | commonInit() 45 | } 46 | 47 | private func commonInit() { 48 | self.device = MTLCreateSystemDefaultDevice() 49 | 50 | assert(self.device != nil, "Device is nil.") 51 | 52 | commandQueue = self.device!.makeCommandQueue() 53 | resetCommandBuffer() 54 | 55 | flippedTextureBuffers = MTLBuffers.makeTextureBuffers( 56 | nodes: .flippedTextureNodes, 57 | with: device 58 | ) 59 | 60 | self.delegate = self 61 | self.enableSetNeedsDisplay = true 62 | self.autoResizeDrawable = true 63 | self.isMultipleTouchEnabled = true 64 | self.backgroundColor = .white 65 | 66 | if let device, let textureSize: CGSize = currentDrawable?.texture.size { 67 | _renderTexture = MTLTextureCreator.makeBlankTexture(size: textureSize, with: device) 68 | } 69 | } 70 | 71 | // MARK: - DrawTexture 72 | func draw(in view: MTKView) { 73 | guard 74 | let commandBuffer, 75 | let flippedTextureBuffers, 76 | let renderTexture, 77 | let drawable = view.currentDrawable 78 | else { return } 79 | 80 | // Draw `renderTexture` directly onto `drawable.texture` 81 | MTLRenderer.shared.drawTexture( 82 | texture: renderTexture, 83 | buffers: flippedTextureBuffers, 84 | on: drawable.texture, 85 | with: commandBuffer 86 | ) 87 | 88 | commandBuffer.present(drawable) 89 | commandBuffer.commit() 90 | commandBuffer.waitUntilCompleted() 91 | 92 | resetCommandBuffer() 93 | } 94 | 95 | func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) { 96 | guard let device else { return } 97 | 98 | // Align the size of `_renderTexture` with `drawableSize` 99 | _renderTexture = MTLTextureCreator.makeBlankTexture(size: size, with: device) 100 | } 101 | 102 | } 103 | 104 | extension CanvasView { 105 | func resetCommandBuffer() { 106 | commandBuffer = commandQueue?.makeCommandBuffer() 107 | } 108 | 109 | } 110 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetal/Canvas/Views/CanvasView/CanvasViewProtocol.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CanvasViewProtocol.swift 3 | // HandDrawingSwiftMetal 4 | // 5 | // Created by Eisuke Kusachi on 2024/07/06. 6 | // 7 | 8 | import MetalKit 9 | 10 | protocol CanvasViewProtocol { 11 | var commandBuffer: MTLCommandBuffer? { get } 12 | 13 | var renderTexture: MTLTexture? { get } 14 | 15 | func resetCommandBuffer() 16 | 17 | func setNeedsDisplay() 18 | } 19 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetal/Canvas/Views/CanvasView/Extensions/IteratorExtensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // IteratorExtensions.swift 3 | // HandDrawingSwiftMetal 4 | // 5 | // Created by Eisuke Kusachi on 2024/10/19. 6 | // 7 | 8 | import Foundation 9 | 10 | extension Iterator { 11 | 12 | func getBezierCurveFirstPoints() -> BezierCurveFirstPoints? { 13 | guard array.count >= 3 else { return nil } 14 | return .init( 15 | previousPoint: array[0], 16 | startPoint: array[1], 17 | endPoint: array[2] 18 | ) 19 | } 20 | 21 | func getBezierCurveIntermediatePointsWithFixedRange4() -> [BezierCurveIntermediatePoints] { 22 | var array: [BezierCurveIntermediatePoints] = [] 23 | while let subsequence = next(range: 4) { 24 | array.append( 25 | .init( 26 | previousPoint: subsequence[0], 27 | startPoint: subsequence[1], 28 | endPoint: subsequence[2], 29 | nextPoint: subsequence[3] 30 | ) 31 | ) 32 | } 33 | return array 34 | } 35 | 36 | func getBezierCurveLastPoints() -> BezierCurveLastPoints? { 37 | guard array.count >= 3 else { return nil } 38 | return .init( 39 | previousPoint: array[array.count - 3], 40 | startPoint: array[array.count - 2], 41 | endPoint: array[array.count - 1] 42 | ) 43 | } 44 | 45 | } 46 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetal/Canvas/Views/CanvasView/Models/CanvasConfiguration.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CanvasConfiguration.swift 3 | // HandDrawingSwiftMetal 4 | // 5 | // Created by Eisuke Kusachi on 2024/07/10. 6 | // 7 | 8 | import UIKit 9 | 10 | struct CanvasConfiguration { 11 | 12 | var projectName: String = Calendar.currentDate 13 | 14 | var textureSize: CGSize? 15 | 16 | var layerIndex: Int = 0 17 | var layers: [TextureLayerModel] = [] 18 | 19 | var drawingTool: DrawingToolType = .brush 20 | 21 | var brushColor: UIColor = UIColor.black.withAlphaComponent(0.75) 22 | var brushDiameter: Int = 8 23 | 24 | var eraserAlpha: Int = 155 25 | var eraserDiameter: Int = 44 26 | 27 | } 28 | 29 | extension CanvasConfiguration { 30 | 31 | init( 32 | projectName: String, 33 | entity: CanvasEntity 34 | ) { 35 | // Since the project name is the same as the folder name, it will not be managed in `CanvasEntity` 36 | self.projectName = projectName 37 | 38 | self.layerIndex = entity.layerIndex 39 | self.layers = entity.layers.map { .init(from: $0) } 40 | 41 | self.textureSize = entity.textureSize 42 | 43 | self.drawingTool = .init(rawValue: entity.drawingTool) 44 | 45 | self.brushDiameter = entity.brushDiameter 46 | self.eraserDiameter = entity.eraserDiameter 47 | } 48 | 49 | init( 50 | entity: CanvasStorageEntity 51 | ) { 52 | self.projectName = entity.projectName ?? "" 53 | 54 | self.textureSize = .init( 55 | width: CGFloat(entity.textureWidth), 56 | height: CGFloat(entity.textureHeight) 57 | ) 58 | 59 | if let brush = entity.drawingTool?.brush, 60 | let colorHexString = brush.colorHex { 61 | self.brushColor = UIColor(hex: colorHexString) 62 | self.brushDiameter = Int(brush.diameter) 63 | } 64 | 65 | if let eraser = entity.drawingTool?.eraser { 66 | self.eraserAlpha = Int(eraser.alpha) 67 | self.eraserDiameter = Int(eraser.diameter) 68 | } 69 | 70 | if let layers = entity.textureLayers as? Set { 71 | self.layers = layers 72 | .sorted { $0.orderIndex < $1.orderIndex } 73 | .enumerated() 74 | .map { index, layer in 75 | TextureLayerModel( 76 | id: TextureLayerModel.id(from: layer.fileName), 77 | title: layer.title ?? "", 78 | alpha: Int(layer.alpha), 79 | isVisible: layer.isVisible 80 | ) 81 | } 82 | } 83 | 84 | self.layerIndex = self.layers.firstIndex(where: { $0.id == entity.selectedLayerId }) ?? 0 85 | } 86 | 87 | func createConfigurationWithValidTextureSize(_ newTextureSize: CGSize) -> Self { 88 | var configuration = self 89 | if configuration.textureSize?.width ?? .zero < MTLRenderer.minimumTextureSize.width || 90 | configuration.textureSize?.height ?? .zero < MTLRenderer.minimumTextureSize.height 91 | { 92 | configuration.textureSize = newTextureSize 93 | } 94 | return configuration 95 | } 96 | 97 | } 98 | 99 | struct CanvasViewControllerConfiguration { 100 | var canvasState: CanvasState 101 | var textureLayerRepository: TextureLayerRepository 102 | } 103 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetal/Canvas/Views/CanvasView/Models/Drawing/CanvasDrawingDisplayLink.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CanvasDrawingDisplayLink.swift 3 | // HandDrawingSwiftMetal 4 | // 5 | // Created by Eisuke Kusachi on 2025/02/04. 6 | // 7 | 8 | import UIKit 9 | import Combine 10 | 11 | /// A class that manages the displayLink for real-time drawing 12 | final class CanvasDrawingDisplayLink { 13 | 14 | // Requesting to draw a line on the canvas emits `Void` 15 | var canvasDrawingPublisher: AnyPublisher { 16 | canvasDrawingSubject.eraseToAnyPublisher() 17 | } 18 | 19 | private let canvasDrawingSubject = PassthroughSubject() 20 | 21 | private(set) var displayLink: CADisplayLink? 22 | 23 | init() { 24 | setupDisplayLink() 25 | } 26 | 27 | func updateCanvasWithDrawing( 28 | isCurrentlyDrawing: Bool 29 | ) { 30 | if isCurrentlyDrawing { 31 | displayLink?.isPaused = false 32 | } else { 33 | displayLink?.isPaused = true 34 | 35 | // When stopping the displayLink upon finger release, 36 | // the rendering process does not complete, so `updateCanvasWhileDrawing()` is executed once. 37 | updateCanvasWhileDrawing() 38 | } 39 | } 40 | 41 | } 42 | 43 | extension CanvasDrawingDisplayLink { 44 | private func setupDisplayLink() { 45 | // Configure the display link for drawing 46 | displayLink = CADisplayLink(target: self, selector: #selector(updateCanvasWhileDrawing)) 47 | displayLink?.add(to: .current, forMode: .common) 48 | displayLink?.isPaused = true 49 | } 50 | 51 | @objc private func updateCanvasWhileDrawing() { 52 | canvasDrawingSubject.send(()) 53 | } 54 | 55 | } 56 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetal/Canvas/Views/CanvasView/Models/Drawing/CanvasTransformer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CanvasTransformer.swift 3 | // HandDrawingSwiftMetal 4 | // 5 | // Created by Eisuke Kusachi on 2023/10/15. 6 | // 7 | 8 | import UIKit 9 | import Combine 10 | 11 | /// A class for canvas rotation 12 | final class CanvasTransformer { 13 | 14 | var matrix: CGAffineTransform { 15 | matrixSubject.value 16 | } 17 | 18 | var matrixPublisher: AnyPublisher { 19 | matrixSubject.eraseToAnyPublisher() 20 | } 21 | private let matrixSubject = CurrentValueSubject(.identity) 22 | 23 | private var storedMatrix: CGAffineTransform = CGAffineTransform.identity 24 | 25 | private var keyA: TouchHashValue? 26 | private var keyB: TouchHashValue? 27 | private var firstTouchPointA: CGPoint? 28 | private var firstTouchPointB: CGPoint? 29 | 30 | } 31 | 32 | extension CanvasTransformer { 33 | 34 | var isKeysInitialized: Bool { 35 | keyA != nil && keyB != nil 36 | } 37 | 38 | func initTransformingIfNeeded(_ dictionary: [TouchHashValue: [TouchPoint]]) { 39 | guard 40 | !isKeysInitialized, 41 | dictionary.count == 2, 42 | let keyA = dictionary.keys.sorted().first, 43 | let keyB = dictionary.keys.sorted().last, 44 | let pointA = dictionary[keyA]?.first?.location, 45 | let pointB = dictionary[keyB]?.first?.location 46 | else { return } 47 | 48 | self.keyA = keyA 49 | self.keyB = keyB 50 | self.firstTouchPointA = pointA 51 | self.firstTouchPointB = pointB 52 | } 53 | 54 | func transformCanvas(screenCenter: CGPoint, _ dictionary: [TouchHashValue: [TouchPoint]]) { 55 | guard 56 | dictionary.count == 2, 57 | let keyA, 58 | let keyB, 59 | let firstTouchPointA, 60 | let firstTouchPointB, 61 | let lastTouchPointA = dictionary[keyA]?.last?.location, 62 | let lastTouchPointB = dictionary[keyB]?.last?.location, 63 | let newMatrix = CGAffineTransform.makeMatrix( 64 | center: screenCenter, 65 | pointsA: (firstTouchPointA, lastTouchPointA), 66 | pointsB: (firstTouchPointB, lastTouchPointB), 67 | counterRotate: true, 68 | flipY: true 69 | ) 70 | else { return } 71 | 72 | matrixSubject.send( 73 | storedMatrix.concatenating(newMatrix) 74 | ) 75 | } 76 | 77 | func setMatrix(_ matrix: CGAffineTransform) { 78 | matrixSubject.send(matrix) 79 | storedMatrix = matrix 80 | resetParameters() 81 | } 82 | 83 | func resetMatrix() { 84 | matrixSubject.value = storedMatrix 85 | resetParameters() 86 | } 87 | 88 | func finishTransforming() { 89 | storedMatrix = matrixSubject.value 90 | resetParameters() 91 | } 92 | 93 | } 94 | 95 | extension CanvasTransformer { 96 | 97 | private func resetParameters() { 98 | keyA = nil 99 | keyB = nil 100 | firstTouchPointA = nil 101 | firstTouchPointB = nil 102 | } 103 | 104 | } 105 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetal/Canvas/Views/CanvasView/Models/States/CanvasState.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CanvasState.swift 3 | // HandDrawingSwiftMetal 4 | // 5 | // Created by Eisuke Kusachi on 2025/04/13. 6 | // 7 | 8 | import Combine 9 | import UIKit 10 | 11 | /// Manage the state of the canvas 12 | final class CanvasState: ObservableObject { 13 | 14 | /// A name of the file to be saved 15 | @Published var projectName: String = Calendar.currentDate 16 | 17 | @Published var textureSize: CGSize = CanvasState.defaultTextureSize 18 | 19 | let drawingToolState = DrawingToolState( 20 | configuration: CanvasConfiguration() 21 | ) 22 | 23 | /// If `layers` is empty, a new layer is created and added to `layers` 24 | /// when `initializeStorage(from:)` is called in `TextureRepository` 25 | @Published var layers: [TextureLayerModel] = [] 26 | 27 | @Published var selectedLayerId: UUID? 28 | 29 | @Published var backgroundColor: UIColor = .white 30 | 31 | /// Subject to publish updates for the canvas 32 | let canvasUpdateSubject = PassthroughSubject() 33 | 34 | /// Subject to publish updates for the entire canvas, including all textures 35 | let fullCanvasUpdateSubject = PassthroughSubject() 36 | 37 | private static let defaultTextureSize: CGSize = .init(width: 768, height: 1024) 38 | 39 | init(_ configuration: CanvasConfiguration) { 40 | setData(configuration) 41 | } 42 | 43 | } 44 | 45 | extension CanvasState { 46 | 47 | var selectedLayer: TextureLayerModel? { 48 | guard let selectedLayerId else { return nil } 49 | return layers.first(where: { $0.id == selectedLayerId }) 50 | } 51 | 52 | var selectedIndex: Int? { 53 | guard let selectedLayerId else { return nil } 54 | return layers.firstIndex(where: { $0.id == selectedLayerId }) 55 | } 56 | 57 | var drawingToolDiameter: Int? { 58 | drawingToolState.currentDrawingTool.diameter 59 | } 60 | 61 | func getLayer(_ selectedLayerId: UUID) -> TextureLayerModel? { 62 | layers.first(where: { $0.id == selectedLayerId }) 63 | } 64 | 65 | func setData(_ configuration: CanvasConfiguration) { 66 | projectName = configuration.projectName 67 | textureSize = configuration.textureSize ?? CanvasState.defaultTextureSize 68 | layers.removeAll() 69 | layers = configuration.layers 70 | selectedLayerId = layers.isEmpty ? nil : layers[configuration.layerIndex].id 71 | drawingToolState.setData(configuration) 72 | } 73 | 74 | } 75 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetal/Canvas/Views/CanvasView/Models/States/DrawingTool/DrawingBrushToolState.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DrawingBrushToolState.swift 3 | // HandDrawingSwiftMetal 4 | // 5 | // Created by Eisuke Kusachi on 2025/04/19. 6 | // 7 | 8 | import UIKit 9 | 10 | final class DrawingBrushToolState: ObservableObject, DrawingToolProtocol { 11 | 12 | @Published var diameter: Int = 0 13 | 14 | @Published var color: UIColor = .clear 15 | 16 | } 17 | 18 | extension DrawingBrushToolState { 19 | 20 | func sliderValue() -> Float { 21 | DrawingBrushToolState.diameterFloatValue(diameter) 22 | } 23 | 24 | func setDiameter(_ value: Float) { 25 | diameter = DrawingBrushToolState.diameterIntValue(value) 26 | } 27 | func setDiameter(_ value: Int) { 28 | diameter = value 29 | } 30 | 31 | } 32 | 33 | extension DrawingBrushToolState { 34 | static private let minDiameter: Int = 1 35 | static private let maxDiameter: Int = 64 36 | 37 | static private let initBrushSize: Int = 8 38 | 39 | static func diameterIntValue(_ value: Float) -> Int { 40 | Int(value * Float(maxDiameter - minDiameter)) + minDiameter 41 | } 42 | static func diameterFloatValue(_ value: Int) -> Float { 43 | Float(value - minDiameter) / Float(maxDiameter - minDiameter) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetal/Canvas/Views/CanvasView/Models/States/DrawingTool/DrawingEraserToolState.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DrawingEraserToolState.swift 3 | // HandDrawingSwiftMetal 4 | // 5 | // Created by Eisuke Kusachi on 2025/04/19. 6 | // 7 | 8 | import UIKit 9 | 10 | final class DrawingEraserToolState: ObservableObject, DrawingToolProtocol { 11 | 12 | @Published var diameter: Int = 0 13 | 14 | @Published var alpha: Int = 0 { 15 | didSet { 16 | let clamped = max(0, min(alpha, 255)) 17 | if alpha != clamped { 18 | alpha = clamped 19 | } 20 | } 21 | } 22 | 23 | } 24 | 25 | extension DrawingEraserToolState { 26 | 27 | func sliderValue() -> Float { 28 | DrawingEraserToolState.diameterFloatValue(diameter) 29 | } 30 | 31 | func setDiameter(_ value: Float) { 32 | diameter = DrawingEraserToolState.diameterIntValue(value) 33 | } 34 | func setDiameter(_ value: Int) { 35 | diameter = value 36 | } 37 | 38 | } 39 | 40 | extension DrawingEraserToolState { 41 | static private let minDiameter: Int = 1 42 | static private let maxDiameter: Int = 64 43 | 44 | static private let initEraserSize: Int = 8 45 | 46 | static func diameterIntValue(_ value: Float) -> Int { 47 | Int(value * Float(maxDiameter - minDiameter)) + minDiameter 48 | } 49 | static func diameterFloatValue(_ value: Int) -> Float { 50 | Float(value - minDiameter) / Float(maxDiameter - minDiameter) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetal/Canvas/Views/CanvasView/Models/States/DrawingTool/DrawingToolProtocol.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DrawingToolProtocol.swift 3 | // HandDrawingSwiftMetal 4 | // 5 | // Created by Eisuke Kusachi on 2025/04/19. 6 | // 7 | 8 | import Foundation 9 | 10 | protocol DrawingToolProtocol { 11 | var diameter: Int { get set } 12 | } 13 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetal/Canvas/Views/CanvasView/Models/States/DrawingTool/DrawingToolState.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DrawingToolState.swift 3 | // HandDrawingSwiftMetal 4 | // 5 | // Created by Eisuke Kusachi on 2024/03/09. 6 | // 7 | 8 | import UIKit 9 | 10 | /// Manage the state of drawing tools 11 | final class DrawingToolState: ObservableObject { 12 | 13 | @Published var drawingTool: DrawingToolType = .brush { 14 | didSet { 15 | switch drawingTool { 16 | case .brush: currentDrawingTool = brush 17 | case .eraser: currentDrawingTool = eraser 18 | } 19 | } 20 | } 21 | 22 | private(set) lazy var brush = DrawingBrushToolState() 23 | 24 | private(set) lazy var eraser = DrawingEraserToolState() 25 | 26 | private(set) var currentDrawingTool: DrawingToolProtocol! 27 | 28 | convenience init( 29 | configuration: CanvasConfiguration 30 | ) { 31 | self.init() 32 | 33 | self.brush.color = configuration.brushColor 34 | self.brush.setDiameter(configuration.brushDiameter) 35 | 36 | self.eraser.alpha = configuration.eraserAlpha 37 | self.eraser.setDiameter(configuration.eraserDiameter) 38 | 39 | self.drawingTool = configuration.drawingTool 40 | } 41 | convenience init( 42 | brushColor: UIColor, 43 | brushDiameter: Int, 44 | eraserAlpha: Int, 45 | eraserDiameter: Int, 46 | drawingTool: DrawingToolType 47 | ) { 48 | self.init() 49 | 50 | self.brush.color = brushColor 51 | self.brush.setDiameter(brushDiameter) 52 | 53 | self.eraser.alpha = eraserAlpha 54 | self.eraser.setDiameter(eraserDiameter) 55 | 56 | self.drawingTool = drawingTool 57 | } 58 | 59 | } 60 | 61 | extension DrawingToolState { 62 | 63 | func setData(_ configuration: CanvasConfiguration) { 64 | 65 | brush.setDiameter(configuration.brushDiameter) 66 | eraser.setDiameter(configuration.eraserDiameter) 67 | 68 | drawingTool = configuration.drawingTool 69 | } 70 | 71 | } 72 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetal/Canvas/Views/CanvasView/Models/States/DrawingTool/DrawingToolType.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DrawingToolType.swift 3 | // HandDrawingSwiftMetal 4 | // 5 | // Created by Eisuke Kusachi on 2025/04/17. 6 | // 7 | 8 | import Foundation 9 | 10 | enum DrawingToolType: Int { 11 | case brush = 0 12 | case eraser = 1 13 | 14 | init(rawValue: Int) { 15 | switch rawValue { 16 | case 1: self = .eraser 17 | default: self = .brush 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetal/Canvas/Views/CanvasView/Models/Status/CanvasInputDeviceStatus.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CanvasInputDeviceStatus.swift 3 | // HandDrawingSwiftMetal 4 | // 5 | // Created by Eisuke Kusachi on 2024/08/03. 6 | // 7 | 8 | import Foundation 9 | 10 | final class CanvasInputDeviceStatus { 11 | 12 | typealias T = CanvasInputDeviceType 13 | 14 | private(set) var status: T = .undetermined 15 | 16 | /// Update the status if it is not .pencil 17 | @discardableResult 18 | func update(_ newStatus: T) -> T { 19 | if status != .pencil { 20 | status = newStatus 21 | } 22 | return status 23 | } 24 | 25 | func reset() { 26 | status = .undetermined 27 | } 28 | 29 | } 30 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetal/Canvas/Views/CanvasView/Models/Status/CanvasInputDeviceType.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CanvasInputDeviceType.swift 3 | // HandDrawingSwiftMetal 4 | // 5 | // Created by Eisuke Kusachi on 2024/08/03. 6 | // 7 | 8 | import Foundation 9 | 10 | enum CanvasInputDeviceType { 11 | /// The status is still undetermined 12 | case undetermined 13 | 14 | case pencil 15 | 16 | case finger 17 | } 18 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetal/Canvas/Views/CanvasView/Models/Status/CanvasScreenTouchGestureStatus.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CanvasScreenTouchGestureStatus.swift 3 | // HandDrawingSwiftMetal 4 | // 5 | // Created by Eisuke Kusachi on 2024/08/03. 6 | // 7 | 8 | import Foundation 9 | 10 | final class CanvasScreenTouchGestureStatus { 11 | 12 | private(set) var status: CanvasScreenTouchGestureType = .undetermined 13 | 14 | func update( 15 | _ touchArrayDictionary: [TouchHashValue: [TouchPoint]] 16 | ) -> CanvasScreenTouchGestureType { 17 | update(.init(from: touchArrayDictionary)) 18 | } 19 | 20 | /// Update the status if the status is not yet determined. 21 | func update( 22 | _ newStatus: CanvasScreenTouchGestureType 23 | ) -> CanvasScreenTouchGestureType { 24 | if status == .undetermined { 25 | status = newStatus 26 | } 27 | return status 28 | } 29 | 30 | func reset() { 31 | status = .undetermined 32 | } 33 | 34 | } 35 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetal/Canvas/Views/CanvasView/Models/Status/CanvasScreenTouchGestureType.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CanvasScreenTouchGestureType.swift 3 | // HandDrawingSwiftMetal 4 | // 5 | // Created by Eisuke Kusachi on 2024/08/03. 6 | // 7 | 8 | import Foundation 9 | 10 | enum CanvasScreenTouchGestureType: Int { 11 | /// The status is still undetermined 12 | case undetermined 13 | 14 | case drawing 15 | 16 | case transforming 17 | 18 | init(from touchPointsDictionary: [TouchHashValue: [TouchPoint]]) { 19 | var result: CanvasScreenTouchGestureType = .undetermined 20 | 21 | if let actionState = CanvasScreenTouchGestureType.isDrawingGesture(touchPointsDictionary) { 22 | result = actionState 23 | 24 | } else if let actionState = CanvasScreenTouchGestureType.isTransformingGesture(touchPointsDictionary) { 25 | result = actionState 26 | } 27 | 28 | self = result 29 | } 30 | 31 | } 32 | 33 | extension CanvasScreenTouchGestureType { 34 | 35 | static let activatingDrawingCount: Int = 6 36 | static let activatingTransformingCount: Int = 2 37 | 38 | static func isDrawingGesture(_ touchPointsDictionary: [TouchHashValue: [TouchPoint]]) -> Self? { 39 | if touchPointsDictionary.count != 1 { return nil } 40 | 41 | if let count = touchPointsDictionary.first?.count, count > activatingDrawingCount { 42 | return .drawing 43 | } 44 | return nil 45 | } 46 | static func isTransformingGesture(_ touchPointsDictionary: [TouchHashValue: [TouchPoint]]) -> Self? { 47 | if touchPointsDictionary.count != 2 { return nil } 48 | 49 | if let countA = touchPointsDictionary.first?.count, countA > activatingTransformingCount, 50 | let countB = touchPointsDictionary.last?.count, countB > activatingTransformingCount { 51 | return .transforming 52 | } 53 | return nil 54 | } 55 | 56 | } 57 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetal/Canvas/Views/CanvasView/Models/Textures/CanvasDrawingTextureSet.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CanvasDrawingTextureSet.swift 3 | // HandDrawingSwiftMetal 4 | // 5 | // Created by Eisuke Kusachi on 2023/12/10. 6 | // 7 | 8 | import Combine 9 | import MetalKit 10 | 11 | /// A protocol for a set of textures for real-time drawing 12 | protocol CanvasDrawingTextureSet { 13 | 14 | /// A publisher that emits `Void` when the drawing process is finished 15 | var canvasDrawFinishedPublisher: AnyPublisher { get } 16 | 17 | var drawingSelectedTexture: MTLTexture { get } 18 | 19 | /// Initializes the textures for drawing with the specified texture size. 20 | func initTextures(_ textureSize: CGSize) 21 | 22 | /// Draws a curve points on `destinationTexture` using the selected texture 23 | func drawCurvePoints( 24 | drawingCurveIterator: DrawingCurveIterator, 25 | withBackgroundTexture backgroundTexture: MTLTexture?, 26 | withBackgroundColor backgroundColor: UIColor, 27 | with commandBuffer: MTLCommandBuffer 28 | ) 29 | 30 | /// Resets the real-time drawing textures 31 | func clearDrawingTextures(with commandBuffer: MTLCommandBuffer) 32 | 33 | } 34 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetal/Canvas/Views/FileView/FileManager/FileInputManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FileInputManager.swift 3 | // HandDrawingSwiftMetal 4 | // 5 | // Created by Eisuke Kusachi on 2024/05/04. 6 | // 7 | 8 | import UIKit 9 | import ZipArchive 10 | 11 | enum FileInputManager { 12 | @Sendable 13 | static func getCanvasEntity(fileURL: URL) throws -> CanvasEntity { 14 | if let jsonData: CanvasEntity = try FileInputManager.loadJson(fileURL) { 15 | return jsonData 16 | 17 | } else if let jsonData: OldCanvasEntity = try FileInputManager.loadJson(fileURL) { 18 | return CanvasEntity.init(entity: jsonData) 19 | } 20 | 21 | throw FileInputError.cannotFindFile 22 | } 23 | 24 | static func loadTexture(url: URL, textureSize: CGSize, device: MTLDevice) throws -> MTLTexture? { 25 | let textureData = try Data(contentsOf: url) 26 | guard 27 | let hexadecimalData = textureData.encodedHexadecimals 28 | else { return nil } 29 | 30 | return MTLTextureCreator.makeTexture( 31 | size: textureSize, 32 | colorArray: hexadecimalData, 33 | with: device 34 | ) 35 | } 36 | 37 | static func loadJson(_ url: URL) throws -> T? { 38 | let jsonString: String = try String(contentsOf: url, encoding: .utf8) 39 | let dataJson: Data? = jsonString.data(using: .utf8) 40 | 41 | guard let dataJson else { 42 | throw FileInputError.failedToConvertData 43 | } 44 | 45 | return try JSONDecoder().decode(T.self, from: dataJson) 46 | } 47 | 48 | static func unzip(_ sourceZipURL: URL, to destinationFolderURL: URL) async throws { 49 | if !SSZipArchive.unzipFile( 50 | atPath: sourceZipURL.path, 51 | toDestination: destinationFolderURL.path 52 | ) { 53 | throw FileInputError.failedToUnzip 54 | } 55 | } 56 | 57 | } 58 | 59 | enum FileInputError: Error { 60 | case cannotFindFile 61 | case failedToUnzip 62 | case failedToConvertData 63 | case failedToLoadJson 64 | case failedToApplyData 65 | } 66 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetal/Canvas/Views/FileView/FileManager/FileOutputManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FileOutputManager.swift 3 | // HandDrawingSwiftMetal 4 | // 5 | // Created by Eisuke Kusachi on 2024/05/04. 6 | // 7 | 8 | import UIKit 9 | import ZipArchive 10 | 11 | enum FileOutputManager { 12 | 13 | static func createDirectory(_ directoryUrl: URL) throws { 14 | do { 15 | try FileManager.createNewDirectory(url: directoryUrl) 16 | } catch { 17 | throw FileOutputError.failedToCreateDirectory 18 | } 19 | } 20 | 21 | static func saveTextureAsData( 22 | bytes: [UInt8], 23 | to url: URL 24 | ) throws { 25 | try Data(bytes).write(to: url) 26 | } 27 | 28 | static func saveImage( 29 | image: UIImage?, 30 | to url: URL 31 | ) throws { 32 | try image?.pngData()?.write(to: url) 33 | } 34 | 35 | static func saveJson( 36 | _ data: T, 37 | to jsonURL: URL 38 | ) throws { 39 | let jsonData = try JSONEncoder().encode(data) 40 | let jsonString = String(data: jsonData, encoding: .utf8) 41 | try jsonString?.write( 42 | to: jsonURL, 43 | atomically: true, 44 | encoding: .utf8 45 | ) 46 | } 47 | 48 | static func zip( 49 | _ sourceFolderURL: URL, 50 | to destinationZipURL: URL 51 | ) throws { 52 | let success = SSZipArchive.createZipFile( 53 | atPath: destinationZipURL.path, 54 | withContentsOfDirectory: sourceFolderURL.path 55 | ) 56 | 57 | if !success { 58 | throw FileOutputError.failedToZip 59 | } 60 | } 61 | 62 | } 63 | 64 | enum FileOutputError: Error { 65 | case failedToZip 66 | case failedToSaveImage 67 | case filedToMove 68 | case failedToUpdateTexture 69 | case failedToCreateDirectory 70 | } 71 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetal/Canvas/Views/FileView/FileView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FileView.swift 3 | // HandDrawingSwiftMetal 4 | // 5 | // Created by Eisuke Kusachi on 2023/11/04. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct FileView: View { 11 | 12 | var zipFileList: [String] = [] 13 | var didTapItem: ((String) -> Void)? 14 | 15 | var body: some View { 16 | ForEach(0 ..< zipFileList.count, id: \.self) { index in 17 | Text(zipFileList[index]) 18 | .onTapGesture { 19 | didTapItem?(zipFileList[index]) 20 | } 21 | } 22 | } 23 | } 24 | 25 | #Preview { 26 | FileView(zipFileList: ["test1.zip", 27 | "test2.zip"]) 28 | } 29 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetal/Canvas/Views/TextureLayerView/Models/TextureLayerModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TextureLayerModel.swift 3 | // HandDrawingSwiftMetal 4 | // 5 | // Created by Eisuke Kusachi on 2025/04/06. 6 | // 7 | 8 | import Foundation 9 | 10 | struct TextureLayerModel: Identifiable, Equatable { 11 | /// The unique identifier for the layer 12 | var id: UUID = UUID() 13 | /// The name of the layer 14 | var title: String 15 | /// The opacity of the layer 16 | var alpha: Int = 255 17 | /// Whether the layer is visible or not 18 | var isVisible: Bool = true 19 | 20 | } 21 | 22 | extension TextureLayerModel { 23 | 24 | init( 25 | from textureLayerEntity: TextureLayerEntity 26 | ) { 27 | self.init( 28 | id: TextureLayerModel.id(from: textureLayerEntity.textureName), 29 | title: textureLayerEntity.title, 30 | alpha: textureLayerEntity.alpha, 31 | isVisible: textureLayerEntity.isVisible 32 | ) 33 | } 34 | 35 | static func == (lhs: Self, rhs: Self) -> Bool { 36 | lhs.id == rhs.id 37 | } 38 | 39 | /// Uses the ID as the filename 40 | var fileName: String { 41 | id.uuidString 42 | } 43 | 44 | /// Uses the filename as the ID, and generates a new one if it is not valid 45 | static func id(from string: String?) -> UUID { 46 | UUID.init(uuidString: string ?? "") ?? UUID() 47 | } 48 | 49 | } 50 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetal/Canvas/Views/TextureLayerView/TextureLayerViewSettings.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TextureLayerViewSettings.swift 3 | // HandDrawingSwiftMetal 4 | // 5 | // Created by Eisuke Kusachi on 2025/04/26. 6 | // 7 | 8 | import UIKit 9 | 10 | /// A settings container for configuring the layout and positioning of `TextureLayerView` 11 | struct TextureLayerViewSettings { 12 | 13 | /// The button that serves as an anchor point, positioning the layer view directly below it 14 | let anchorButton: UIView 15 | 16 | /// The view where the layer view will be added as a subview 17 | let destinationView: UIView 18 | 19 | /// The size of the layer view 20 | let size: CGSize 21 | 22 | init( 23 | anchorButton: UIView, 24 | destinationView: UIView, 25 | size: CGSize 26 | ) { 27 | precondition(size.width > 0 && size.height > 0, "Width and height must be positive values") 28 | 29 | self.anchorButton = anchorButton 30 | self.destinationView = destinationView 31 | self.size = size 32 | } 33 | 34 | func configureViewLayout( 35 | sourceView: UIView 36 | ) { 37 | destinationView.addSubview(sourceView) 38 | 39 | sourceView.translatesAutoresizingMaskIntoConstraints = false 40 | 41 | NSLayoutConstraint.activate([ 42 | sourceView.topAnchor.constraint(equalTo: anchorButton.bottomAnchor), 43 | sourceView.centerXAnchor.constraint(equalTo: anchorButton.centerXAnchor), 44 | sourceView.widthAnchor.constraint(equalToConstant: size.width), 45 | sourceView.heightAnchor.constraint(equalToConstant: size.height) 46 | ]) 47 | 48 | sourceView.setNeedsLayout() 49 | } 50 | 51 | func arrowX() -> CGFloat { 52 | let targetViewCenterX = anchorButton.convert(anchorButton.bounds, to: destinationView).midX 53 | let layerViewX = targetViewCenterX - size.width * 0.5 54 | let centerX = targetViewCenterX - layerViewX 55 | return centerX 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetal/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | UIApplicationSceneManifest 6 | 7 | UIApplicationSupportsMultipleScenes 8 | 9 | UISceneConfigurations 10 | 11 | UIWindowSceneSessionRoleApplication 12 | 13 | 14 | UISceneConfigurationName 15 | Default Configuration 16 | UISceneDelegateClassName 17 | $(PRODUCT_MODULE_NAME).SceneDelegate 18 | 19 | 20 | 21 | 22 | UIFileSharingEnabled 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetal/SceneDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SceneDelegate.swift 3 | // HandDrawingSwiftMetal 4 | // 5 | // Created by Eisuke Kusachi on 2021/11/27. 6 | // 7 | 8 | import UIKit 9 | 10 | class SceneDelegate: UIResponder, UIWindowSceneDelegate { 11 | 12 | var window: UIWindow? 13 | 14 | func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { 15 | // Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`. 16 | // If using a storyboard, the `window` property will automatically be initialized and attached to the scene. 17 | // This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead). 18 | guard let scene = (scene as? UIWindowScene) else { return } 19 | 20 | let window = UIWindow(windowScene: scene) 21 | window.rootViewController = CanvasViewController.create() 22 | self.window = window 23 | self.window?.makeKeyAndVisible() 24 | } 25 | 26 | func sceneDidDisconnect(_ scene: UIScene) { 27 | // Called as the scene is being released by the system. 28 | // This occurs shortly after the scene enters the background, or when its session is discarded. 29 | // Release any resources associated with this scene that can be re-created the next time the scene connects. 30 | // The scene may re-connect later, as its session was not necessarily discarded (see `application:didDiscardSceneSessions` instead). 31 | } 32 | 33 | func sceneDidBecomeActive(_ scene: UIScene) { 34 | // Called when the scene has moved from an inactive state to an active state. 35 | // Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive. 36 | } 37 | 38 | func sceneWillResignActive(_ scene: UIScene) { 39 | // Called when the scene will move from an active state to an inactive state. 40 | // This may occur due to temporary interruptions (ex. an incoming phone call). 41 | } 42 | 43 | func sceneWillEnterForeground(_ scene: UIScene) { 44 | // Called as the scene transitions from the background to the foreground. 45 | // Use this method to undo the changes made on entering the background. 46 | } 47 | 48 | func sceneDidEnterBackground(_ scene: UIScene) { 49 | // Called as the scene transitions from the foreground to the background. 50 | // Use this method to save data, release shared resources, and store enough scene-specific state information 51 | // to restore the scene back to its current state. 52 | } 53 | 54 | } 55 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetalTests/Dummies/GrayscaleDotPointDummy.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GrayscaleDotPointDummy.swift 3 | // HandDrawingSwiftMetalTests 4 | // 5 | // Created by Eisuke Kusachi on 2024/10/19. 6 | // 7 | 8 | import UIKit 9 | @testable import HandDrawingSwiftMetal 10 | 11 | extension GrayscaleDotPoint { 12 | 13 | static func generate( 14 | location: CGPoint = .zero, 15 | diameter: CGFloat = 0.0, 16 | brightness: CGFloat = 0.0, 17 | blurSize: CGFloat = 0.0 18 | ) -> GrayscaleDotPoint { 19 | .init( 20 | location: location, 21 | diameter: diameter, 22 | brightness: brightness, 23 | blurSize: blurSize 24 | ) 25 | } 26 | 27 | } 28 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetalTests/Dummies/TextureLayerModelDummy.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TextureLayerModelDummy.swift 3 | // HandDrawingSwiftMetalTests 4 | // 5 | // Created by Eisuke Kusachi on 2025/04/06. 6 | // 7 | 8 | import MetalKit 9 | @testable import HandDrawingSwiftMetal 10 | 11 | extension TextureLayerModel { 12 | 13 | static func generate( 14 | id: UUID = UUID(), 15 | title: String = "", 16 | alpha: Int = 255, 17 | isVisible: Bool = true 18 | ) -> Self { 19 | .init( 20 | id: id, 21 | title: title, 22 | alpha: alpha, 23 | isVisible: isVisible 24 | ) 25 | } 26 | 27 | } 28 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetalTests/Dummies/TouchPointDummy.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TouchPointDummy.swift 3 | // HandDrawingSwiftMetalTests 4 | // 5 | // Created by Eisuke Kusachi on 2024/09/07. 6 | // 7 | 8 | import UIKit 9 | @testable import HandDrawingSwiftMetal 10 | 11 | extension TouchPoint { 12 | 13 | static func generate( 14 | location: CGPoint = .zero, 15 | phase: UITouch.Phase = .cancelled, 16 | force: CGFloat = 0, 17 | maximumPossibleForce: CGFloat = 0, 18 | estimationUpdateIndex: NSNumber? = nil, 19 | timestamp: TimeInterval = 0 20 | ) -> TouchPoint { 21 | .init( 22 | location: location, 23 | phase: phase, 24 | force: force, 25 | maximumPossibleForce: maximumPossibleForce, 26 | estimationUpdateIndex: estimationUpdateIndex, 27 | timestamp: timestamp 28 | ) 29 | } 30 | 31 | } 32 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetalTests/Dummies/UITouchDummy.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UITouchDummy.swift 3 | // HandDrawingSwiftMetalTests 4 | // 5 | // Created by Eisuke Kusachi on 2024/09/07. 6 | // 7 | 8 | import UIKit 9 | 10 | final class UITouchDummy: UITouch { 11 | 12 | override var phase: UITouch.Phase { _phase } 13 | override var force: CGFloat { _force } 14 | override var estimationUpdateIndex: NSNumber? { _estimationUpdateIndex } 15 | override var timestamp: TimeInterval { _timestamp } 16 | 17 | private let _phase: UITouch.Phase 18 | private let _force: CGFloat 19 | private let _estimationUpdateIndex: NSNumber? 20 | private let _timestamp: TimeInterval 21 | 22 | init( 23 | phase: UITouch.Phase = .cancelled, 24 | force: CGFloat = 0.0, 25 | estimationUpdateIndex: NSNumber? = nil, 26 | timestamp: TimeInterval = 0.0 27 | ) { 28 | self._phase = phase 29 | self._force = force 30 | self._estimationUpdateIndex = estimationUpdateIndex 31 | self._timestamp = timestamp 32 | super.init() 33 | } 34 | 35 | } 36 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetalTests/HandDrawingSwiftMetalTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HandDrawingSwiftMetalTests.swift 3 | // HandDrawingSwiftMetalTests 4 | // 5 | // Created by Eisuke Kusachi on 2021/11/27. 6 | // 7 | 8 | import XCTest 9 | @testable import HandDrawingSwiftMetal 10 | 11 | class HandDrawingSwiftMetalTests: XCTestCase { 12 | 13 | override func setUpWithError() throws { 14 | // Put setup code here. This method is called before the invocation of each test method in the class. 15 | } 16 | 17 | override func tearDownWithError() throws { 18 | // Put teardown code here. This method is called after the invocation of each test method in the class. 19 | } 20 | 21 | func testExample() throws { 22 | // This is an example of a functional test case. 23 | // Use XCTAssert and related functions to verify your tests produce the correct results. 24 | } 25 | 26 | func testPerformanceExample() throws { 27 | // This is an example of a performance test case. 28 | self.measure { 29 | // Put the code you want to measure the time of here. 30 | } 31 | } 32 | 33 | } 34 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetalTests/Mocks/MockCanvasViewProtocol.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MockCanvasViewProtocol.swift 3 | // HandDrawingSwiftMetalTests 4 | // 5 | // Created by Eisuke Kusachi on 2025/05/11. 6 | // 7 | 8 | import MetalKit 9 | @testable import HandDrawingSwiftMetal 10 | 11 | final class MockCanvasViewProtocol: CanvasViewProtocol { 12 | var commandBuffer: MTLCommandBuffer? { nil } 13 | 14 | var renderTexture: MTLTexture? { nil } 15 | 16 | func resetCommandBuffer() {} 17 | 18 | func setNeedsDisplay() {} 19 | } 20 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetalTests/Mocks/MockDrawingCurveIterator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MockDrawingCurveIterator.swift 3 | // HandDrawingSwiftMetalTests 4 | // 5 | // Created by Eisuke Kusachi on 2025/01/25. 6 | // 7 | 8 | import XCTest 9 | import Foundation 10 | @testable import HandDrawingSwiftMetal 11 | 12 | final class MockDrawingCurveIterator: Iterator, DrawingCurveIterator { 13 | 14 | var iterator: Iterator = .init() 15 | 16 | var touchPhase: UITouch.Phase = .began 17 | 18 | var latestCurvePoints: [GrayscaleDotPoint] = [] 19 | 20 | func append(points: [GrayscaleDotPoint], touchPhase: UITouch.Phase) {} 21 | 22 | override func reset() {} 23 | 24 | } 25 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetalTests/Mocks/MockTextureRepository.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MockTextureRepository.swift 3 | // HandDrawingSwiftMetalTests 4 | // 5 | // Created by Eisuke Kusachi on 2025/04/06. 6 | // 7 | 8 | import Combine 9 | import Metal 10 | import UIKit 11 | @testable import HandDrawingSwiftMetal 12 | 13 | final class MockTextureRepository: TextureRepository { 14 | 15 | private let device = MTLCreateSystemDefaultDevice()! 16 | 17 | private let storageInitializationWithNewTextureSubject = PassthroughSubject() 18 | 19 | private let storageInitializationCompletedSubject = PassthroughSubject() 20 | 21 | private let thumbnailUpdateRequestedSubject = PassthroughSubject() 22 | 23 | var storageInitializationWithNewTexturePublisher: AnyPublisher { 24 | storageInitializationWithNewTextureSubject.eraseToAnyPublisher() 25 | } 26 | 27 | var storageInitializationCompletedPublisher: AnyPublisher { 28 | storageInitializationCompletedSubject.eraseToAnyPublisher() 29 | } 30 | 31 | var thumbnailUpdateRequestedPublisher: AnyPublisher { 32 | thumbnailUpdateRequestedSubject.eraseToAnyPublisher() 33 | } 34 | 35 | var textures: [UUID: MTLTexture?] = [:] 36 | 37 | var callHistory: [String] = [] 38 | 39 | var textureSize: CGSize = .zero 40 | 41 | var textureNum: Int = 0 42 | 43 | var isInitialized: Bool { false } 44 | 45 | init(textures: [UUID : MTLTexture?] = [:]) { 46 | self.textures = textures 47 | } 48 | 49 | func resolveCanvasView(from configuration: CanvasConfiguration, drawableSize: CGSize) { 50 | callHistory.append("resolveCanvasView(from: \(configuration), drawableSize: \(drawableSize))") 51 | } 52 | 53 | func hasAllTextures(fileNames: [String]) -> AnyPublisher { 54 | return Just(true) 55 | .setFailureType(to: Error.self) 56 | .eraseToAnyPublisher() 57 | } 58 | 59 | func initializeStorageWithNewTexture(_ textureSize: CGSize) { 60 | callHistory.append("initializeStorageWithNewTexture(\(textureSize)") 61 | } 62 | 63 | func initializeTexture(uuid: UUID, textureSize: CGSize) -> AnyPublisher { 64 | return Just(()) 65 | .setFailureType(to: Error.self) 66 | .eraseToAnyPublisher() 67 | } 68 | 69 | func initializeTextures(layers: [TextureLayerModel], textureSize: CGSize, folderURL: URL) -> AnyPublisher { 70 | return Just(()) 71 | .setFailureType(to: Error.self) 72 | .eraseToAnyPublisher() 73 | } 74 | 75 | func initializeStorage(from configuration: HandDrawingSwiftMetal.CanvasConfiguration) { 76 | 77 | } 78 | 79 | func getTexture(uuid: UUID, textureSize: CGSize) -> AnyPublisher { 80 | return Just(.init(uuid: uuid, texture: textures[uuid] ?? MTLTextureCreator.makeBlankTexture(size: textureSize, with: device))) 81 | .setFailureType(to: Error.self) 82 | .eraseToAnyPublisher() 83 | } 84 | 85 | func getTextures(uuids: [UUID], textureSize: CGSize) -> AnyPublisher<[TextureRepositoryEntity], Error> { 86 | return Just([]) 87 | .setFailureType(to: Error.self) 88 | .eraseToAnyPublisher() 89 | } 90 | 91 | func getThumbnail(_ uuid: UUID) -> UIImage? { 92 | callHistory.append("getThumbnail(\(uuid))") 93 | return nil 94 | } 95 | 96 | func loadTexture(_ uuid: UUID) -> AnyPublisher { 97 | callHistory.append("loadTexture(\(uuid))") 98 | let resultTexture: MTLTexture? = textures[uuid]?.flatMap { $0 } 99 | return Just(resultTexture) 100 | .setFailureType(to: Error.self) 101 | .eraseToAnyPublisher() 102 | } 103 | 104 | func removeTexture(_ uuid: UUID) -> AnyPublisher { 105 | callHistory.append("removeTexture(\(uuid))") 106 | return Just(uuid).setFailureType(to: Error.self) 107 | .eraseToAnyPublisher() 108 | } 109 | 110 | func removeAll() { 111 | callHistory.append("removeAll()") 112 | } 113 | 114 | func setThumbnail(texture: MTLTexture?, for uuid: UUID) { 115 | callHistory.append("setThumbnail(texture: \(texture?.label ?? "nil"), for: \(uuid))") 116 | } 117 | 118 | func updateTexture(texture: MTLTexture?, for uuid: UUID) -> AnyPublisher { 119 | callHistory.append("updateTexture(texture: \(texture?.label ?? "nil"), for: \(uuid))") 120 | return Just(uuid) 121 | .setFailureType(to: Error.self) 122 | .eraseToAnyPublisher() 123 | } 124 | 125 | func updateAllTextures(uuids: [UUID], textureSize: CGSize, from sourceURL: URL) -> AnyPublisher { 126 | return Just(()) 127 | .setFailureType(to: Error.self) 128 | .eraseToAnyPublisher() 129 | } 130 | 131 | func updateAllThumbnails(textureSize: CGSize) -> AnyPublisher { 132 | return Just(()) 133 | .setFailureType(to: Error.self) 134 | .eraseToAnyPublisher() 135 | } 136 | 137 | } 138 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetalTests/Models/DrawingCurveFingerIteratorTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DrawingCurveFingerIteratorTests.swift 3 | // HandDrawingSwiftMetalTests 4 | // 5 | // Created by Eisuke Kusachi on 2024/10/21. 6 | // 7 | 8 | import XCTest 9 | @testable import HandDrawingSwiftMetal 10 | 11 | final class DrawingCurveFingerIteratorTests: XCTestCase { 12 | 13 | func testIsDrawingFinished() { 14 | let subject = DrawingCurveFingerIterator() 15 | 16 | subject.touchPhase = .began 17 | XCTAssertFalse(subject.isDrawingFinished) 18 | XCTAssertTrue(subject.isCurrentlyDrawing) 19 | 20 | subject.touchPhase = .moved 21 | XCTAssertFalse(subject.isDrawingFinished) 22 | XCTAssertTrue(subject.isCurrentlyDrawing) 23 | 24 | subject.touchPhase = .ended 25 | XCTAssertTrue(subject.isDrawingFinished) 26 | XCTAssertFalse(subject.isCurrentlyDrawing) 27 | 28 | subject.touchPhase = .cancelled 29 | XCTAssertTrue(subject.isDrawingFinished) 30 | XCTAssertFalse(subject.isCurrentlyDrawing) 31 | } 32 | 33 | func testShouldGetFirstCurve() { 34 | let subject = DrawingCurveFingerIterator() 35 | 36 | subject.append([ 37 | .generate(), 38 | .generate() 39 | ]) 40 | XCTAssertFalse(subject.shouldGetFirstCurve) 41 | 42 | // After creating the instance, it becomes `true` when three elements are stored in the array. 43 | subject.append([ 44 | .generate() 45 | ]) 46 | XCTAssertTrue(subject.shouldGetFirstCurve) 47 | 48 | // The value of the first curve is retrieved only once 49 | _ = subject.latestCurvePoints 50 | XCTAssertFalse(subject.shouldGetFirstCurve) 51 | } 52 | 53 | func testAppendToIterator() { 54 | let subject = DrawingCurveFingerIterator() 55 | 56 | subject.append(points: [.generate(location: .init(x: 0, y: 0))], touchPhase: .began) 57 | subject.append(points: [.generate(location: .init(x: 2, y: 2))], touchPhase: .moved) 58 | subject.append(points: [.generate(location: .init(x: 4, y: 4))], touchPhase: .moved) 59 | subject.append(points: [.generate(location: .init(x: 6, y: 6))], touchPhase: .ended) 60 | 61 | XCTAssertEqual(subject.tmpIterator.array[0].location, .init(x: 0, y: 0)) 62 | XCTAssertEqual(subject.tmpIterator.array[1].location, .init(x: 2, y: 2)) 63 | XCTAssertEqual(subject.tmpIterator.array[2].location, .init(x: 4, y: 4)) 64 | XCTAssertEqual(subject.tmpIterator.array[3].location, .init(x: 6, y: 6)) 65 | 66 | /// The first point is added as it is 67 | XCTAssertEqual(subject.array[0].location, .init(x: 0, y: 0)) 68 | 69 | /// The average of the two points is added for all other points 70 | XCTAssertEqual(subject.array[1].location, .init(x: 1, y: 1)) 71 | XCTAssertEqual(subject.array[2].location, .init(x: 3, y: 3)) 72 | XCTAssertEqual(subject.array[3].location, .init(x: 5, y: 5)) 73 | 74 | /// The last point is added as it is 75 | XCTAssertEqual(subject.array[4].location, .init(x: 6, y: 6)) 76 | } 77 | 78 | } 79 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetalTests/Models/DrawingCurvePencilIteratorTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DrawingCurvePencilIteratorTests.swift 3 | // HandDrawingSwiftMetalTests 4 | // 5 | // Created by Eisuke Kusachi on 2024/09/07. 6 | // 7 | 8 | import XCTest 9 | @testable import HandDrawingSwiftMetal 10 | 11 | final class DrawingCurvePencilIteratorTests: XCTestCase { 12 | 13 | func testIsDrawingFinished() { 14 | let subject = DrawingCurveFingerIterator() 15 | 16 | subject.touchPhase = .began 17 | XCTAssertFalse(subject.isDrawingFinished) 18 | XCTAssertTrue(subject.isCurrentlyDrawing) 19 | 20 | subject.touchPhase = .moved 21 | XCTAssertFalse(subject.isDrawingFinished) 22 | XCTAssertTrue(subject.isCurrentlyDrawing) 23 | 24 | subject.touchPhase = .ended 25 | XCTAssertTrue(subject.isDrawingFinished) 26 | XCTAssertFalse(subject.isCurrentlyDrawing) 27 | 28 | subject.touchPhase = .cancelled 29 | XCTAssertTrue(subject.isDrawingFinished) 30 | XCTAssertFalse(subject.isCurrentlyDrawing) 31 | } 32 | 33 | func testShouldGetFirstCurve() { 34 | let subject = DrawingCurveFingerIterator() 35 | 36 | subject.append([ 37 | .generate(), 38 | .generate() 39 | ]) 40 | XCTAssertFalse(subject.shouldGetFirstCurve) 41 | 42 | // After creating the instance, it becomes `true` when three elements are stored in the array. 43 | subject.append([ 44 | .generate() 45 | ]) 46 | XCTAssertTrue(subject.shouldGetFirstCurve) 47 | 48 | // The value of the first curve is retrieved only once 49 | _ = subject.latestCurvePoints 50 | XCTAssertFalse(subject.shouldGetFirstCurve) 51 | } 52 | 53 | } 54 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetalTests/Models/Gestures/PencilScreenStrokeDataTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PencilScreenStrokeDataTests.swift 3 | // HandDrawingSwiftMetalTests 4 | // 5 | // Created by Eisuke Kusachi on 2025/02/09. 6 | // 7 | 8 | import XCTest 9 | @testable import HandDrawingSwiftMetal 10 | 11 | final class PencilScreenStrokeDataTests: XCTestCase { 12 | /// Confirms pencil input 13 | func testActualTouchPointArray() { 14 | let subject = PencilScreenStrokeData() 15 | 16 | subject.appendActualTouches(actualTouches: [ 17 | .generate(location: .init(x: 0, y: 0), phase: .began, estimationUpdateIndex: 0) 18 | ]) 19 | 20 | XCTAssertEqual( 21 | subject.latestActualTouchPoints.map { $0.location }, 22 | [ 23 | .init(x: 0, y: 0) 24 | ] 25 | ) 26 | 27 | // After `latestTouchPoints` is called once, it returns empty. 28 | XCTAssertEqual(subject.latestActualTouchPoints, []) 29 | 30 | subject.appendActualTouches(actualTouches: [ 31 | .generate(location: .init(x: 1, y: 1), phase: .moved, estimationUpdateIndex: 1), 32 | .generate(location: .init(x: 2, y: 2), phase: .moved, estimationUpdateIndex: 2) 33 | ]) 34 | 35 | XCTAssertEqual( 36 | subject.latestActualTouchPoints.map { $0.location }, 37 | [ 38 | .init(x: 1, y: 1), 39 | .init(x: 2, y: 2) 40 | ] 41 | ) 42 | 43 | subject.setLatestEstimatedTouchPoint( 44 | .generate(location: .init(x: 3, y: 3), phase: .moved, estimationUpdateIndex: 3) 45 | ) 46 | subject.setLatestEstimatedTouchPoint( 47 | .generate(location: .init(x: 4, y: 4), phase: .ended, estimationUpdateIndex: nil) 48 | ) 49 | 50 | // When values are sent from the Apple Pencil, 51 | // when the `phase` is `ended`, `estimationUpdateIndex` becomes `nil`, 52 | // so the previous `estimationUpdateIndex` is retained. 53 | XCTAssertEqual(subject.latestEstimationUpdateIndex, 3) 54 | XCTAssertEqual(subject.latestEstimatedTouchPoint?.phase, .ended) 55 | 56 | // Since the phase of `actualTouches` does not become `ended`, 57 | // the pen is considered to have left 58 | // when `latestEstimatedTouchPoint?.phase` is `ended`, 59 | // `latestEstimationUpdateIndex` matches the `estimationUpdateIndex` of `actualTouches`. 60 | subject.appendActualTouches(actualTouches: [ 61 | .generate(location: .init(x: 3, y: 3), phase: .moved, estimationUpdateIndex: 3) 62 | ]) 63 | 64 | // When the pen leaves the screen, 65 | // `latestEstimatedTouchPoint` is added to `actualTouchPointArray`, 66 | // and the drawing termination process is executed. 67 | XCTAssertEqual( 68 | subject.latestActualTouchPoints.map { $0.location }, 69 | [ 70 | .init(x: 3, y: 3), 71 | .init(x: 4, y: 4) 72 | ] 73 | ) 74 | } 75 | 76 | /// Confirms that a pen has left the screen 77 | func testIsPenOffScreen() { 78 | let subject = PencilScreenStrokeData() 79 | 80 | let actualTouches: [TouchPoint] = [ 81 | .generate(phase: .moved, estimationUpdateIndex: 1) 82 | ] 83 | 84 | subject.setLatestEstimatedTouchPoint( 85 | .generate(phase: .moved, estimationUpdateIndex: 1) 86 | ) 87 | XCTAssertEqual(subject.latestEstimatedTouchPoint?.phase, .moved) 88 | XCTAssertEqual(subject.latestEstimationUpdateIndex, 1) 89 | 90 | XCTAssertFalse(subject.isPenOffScreen(actualTouches: actualTouches)) 91 | 92 | // If `latestEstimatedTouchPoint?.phase` is `ended`, 93 | // `latestEstimationUpdateIndex` matches the `estimationUpdateIndex` of `actualTouches`, 94 | // then `isPenOffScreen` returns `true`. 95 | subject.setLatestEstimatedTouchPoint( 96 | .generate(phase: .ended, estimationUpdateIndex: nil) 97 | ) 98 | XCTAssertEqual(subject.latestEstimatedTouchPoint?.phase, .ended) 99 | XCTAssertEqual(subject.latestEstimationUpdateIndex, actualTouches.last?.estimationUpdateIndex) 100 | 101 | XCTAssertTrue(subject.isPenOffScreen(actualTouches: actualTouches)) 102 | } 103 | 104 | func testReset() { 105 | let subject = PencilScreenStrokeData( 106 | actualTouchPointArray: [ 107 | .generate(location: .init(x: 0, y: 0)), 108 | .generate(location: .init(x: 1, y: 1)) 109 | ], 110 | latestEstimatedTouchPoint: .generate(location: .init(x: 2, y: 2)), 111 | latestActualTouchPoint: .generate(location: .init(x: 3, y: 3)) 112 | ) 113 | 114 | XCTAssertFalse(subject.actualTouchPointArray.isEmpty) 115 | XCTAssertNotNil(subject.latestEstimatedTouchPoint) 116 | XCTAssertNotNil(subject.latestActualTouchPoint) 117 | 118 | subject.reset() 119 | 120 | XCTAssertTrue(subject.actualTouchPointArray.isEmpty) 121 | XCTAssertNil(subject.latestEstimatedTouchPoint) 122 | XCTAssertNil(subject.latestActualTouchPoint) 123 | } 124 | 125 | } 126 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetalTests/Views/CanvasView/Models/Drawing/CanvasDrawingDisplayLinkTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CanvasDrawingDisplayLinkTests.swift 3 | // HandDrawingSwiftMetalTests 4 | // 5 | // Created by Eisuke Kusachi on 2025/02/05. 6 | // 7 | 8 | import XCTest 9 | import Combine 10 | @testable import HandDrawingSwiftMetal 11 | 12 | final class CanvasDrawingDisplayLinkTests: XCTestCase { 13 | var commandBuffer: MTLCommandBuffer! 14 | let device = MTLCreateSystemDefaultDevice()! 15 | 16 | var cancellables = Set() 17 | 18 | override func setUp() { 19 | commandBuffer = device.makeCommandQueue()!.makeCommandBuffer()! 20 | } 21 | 22 | /// Confirms that the displayLink is running and `requestDrawingOnCanvasPublisher` emits `Void` 23 | func testEmitRequestDrawingOnCanvasPublisherWhenTouchingScreen() { 24 | let subject = CanvasDrawingDisplayLink() 25 | 26 | let publisherExpectation = XCTestExpectation() 27 | 28 | // Confirm that `canvasDrawingPublisher` emits `Void` 29 | subject.canvasDrawingPublisher 30 | .sink { 31 | publisherExpectation.fulfill() 32 | } 33 | .store(in: &cancellables) 34 | 35 | subject.updateCanvasWithDrawing(isCurrentlyDrawing: true) 36 | 37 | XCTAssertEqual(subject.displayLink?.isPaused, false) 38 | 39 | wait(for: [publisherExpectation], timeout: 1.0) 40 | } 41 | 42 | /// Confirms that the displayLink stops and `requestDrawingOnCanvasPublisher` emits `Void` once 43 | func testEmitRequestDrawingOnCanvasPublisherWhenFingerIsLifted() { 44 | let subject = CanvasDrawingDisplayLink() 45 | 46 | let publisherExpectation = XCTestExpectation() 47 | 48 | // `canvasDrawingPublisher` emits `Void` to perform the final processing 49 | subject.canvasDrawingPublisher 50 | .sink { 51 | publisherExpectation.fulfill() 52 | } 53 | .store(in: &cancellables) 54 | 55 | subject.updateCanvasWithDrawing(isCurrentlyDrawing: false) 56 | 57 | XCTAssertEqual(subject.displayLink?.isPaused, true) 58 | 59 | wait(for: [publisherExpectation], timeout: 1.0) 60 | } 61 | 62 | } 63 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetalTests/Views/CanvasView/Models/Textures/CanvasDrawingBrushTextureSetTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CanvasDrawingBrushTextureSetTests.swift 3 | // HandDrawingSwiftMetalTests 4 | // 5 | // Created by Eisuke Kusachi on 2025/01/25. 6 | // 7 | 8 | import XCTest 9 | import Combine 10 | @testable import HandDrawingSwiftMetal 11 | 12 | final class CanvasDrawingBrushTextureSetTests: XCTestCase { 13 | 14 | var subject: CanvasDrawingBrushTextureSet! 15 | 16 | var commandBuffer: MTLCommandBuffer! 17 | let device = MTLCreateSystemDefaultDevice()! 18 | 19 | var backgroundTexture: MTLTexture! 20 | 21 | var renderer = MockMTLRenderer() 22 | 23 | var cancellables = Set() 24 | 25 | override func setUp() { 26 | commandBuffer = device.makeCommandQueue()!.makeCommandBuffer()! 27 | commandBuffer.label = "commandBuffer" 28 | 29 | subject = CanvasDrawingBrushTextureSet(renderer: renderer) 30 | subject.initTextures(.init(width: 1, height: 1)) 31 | renderer.callHistory.removeAll() 32 | 33 | backgroundTexture = MTLTextureCreator.makeBlankTexture( 34 | size: .init(width: MTLRenderer.threadGroupLength, height: MTLRenderer.threadGroupLength), 35 | with: device 36 | )! 37 | backgroundTexture.label = "backgroundTexture" 38 | } 39 | 40 | /// Confirms the process in which the brush curve is drawn on `resultTexture` using `backgroundTexture` 41 | func testDrawBrushCurvePoints() { 42 | 43 | struct Condition: Hashable { 44 | let touchPhase: UITouch.Phase 45 | } 46 | struct Expectation { 47 | let result: [String] 48 | let isDrawingFinished: Bool 49 | } 50 | 51 | // Draw the point buffers in opaque grayscale with the max blend mode on grayscaleTexture. 52 | // Draw the color-applied `grayscaleTexture` on `drawingTexture`. 53 | let drawingCurve: [String] = [ 54 | "drawGrayPointBuffersWithMaxBlendMode(buffers: buffers, onGrayscaleTexture: grayscaleTexture, with: commandBuffer)", 55 | "drawTexture(grayscaleTexture: grayscaleTexture, color: (255, 0, 0), on: drawingTexture, with: commandBuffer)" 56 | ] 57 | 58 | // `backgroundTexture` and `drawingTexture` are layered and drawn on `resultTexture`. 59 | let drawingTexture: [String] = [ 60 | "drawTexture(texture: backgroundTexture, buffers: buffers, withBackgroundColor: (0, 0, 0, 0), on: resultTexture, with: commandBuffer)", 61 | "mergeTexture(texture: drawingTexture, into: resultTexture, with: commandBuffer)" 62 | ] 63 | 64 | // Merge `drawingTexture` on `backgroundTexture`. 65 | // Clear the textures used for drawing to prepare for the next drawing. 66 | let drawingCompletionProcess: [String] = [ 67 | "mergeTexture(texture: drawingTexture, into: backgroundTexture, with: commandBuffer)", 68 | "clearTextures(textures: [drawingTexture, grayscaleTexture], with: commandBuffer)" 69 | ] 70 | 71 | let testCases: [Condition: Expectation] = [ 72 | .init(touchPhase: .began): .init(result: drawingCurve + drawingTexture, isDrawingFinished: false), 73 | .init(touchPhase: .moved): .init(result: drawingCurve + drawingTexture, isDrawingFinished: false), 74 | .init(touchPhase: .ended): .init(result: drawingCurve + drawingTexture + drawingCompletionProcess, isDrawingFinished: true), 75 | .init(touchPhase: .cancelled): .init(result: drawingCurve + drawingTexture + drawingCompletionProcess, isDrawingFinished: true) 76 | ] 77 | 78 | testCases.forEach { testCase in 79 | let drawingIterator = MockDrawingCurveIterator() 80 | 81 | drawingIterator.touchPhase = testCase.key.touchPhase 82 | 83 | let publisherExpectation = XCTestExpectation() 84 | if !testCase.value.isDrawingFinished { 85 | publisherExpectation.isInverted = true 86 | } 87 | 88 | // Confirm that `canvasDrawFinishedPublisher` emits `Void` at the end of the drawing process 89 | subject.canvasDrawFinishedPublisher 90 | .sink { 91 | publisherExpectation.fulfill() 92 | } 93 | .store(in: &cancellables) 94 | 95 | subject.setBlushColor(.red) 96 | 97 | subject.drawCurvePoints( 98 | drawingCurveIterator: drawingIterator, 99 | withBackgroundTexture: backgroundTexture, 100 | withBackgroundColor: .clear, 101 | with: commandBuffer 102 | ) 103 | 104 | XCTAssertEqual(renderer.callHistory, testCase.value.result) 105 | renderer.callHistory.removeAll() 106 | 107 | wait(for: [publisherExpectation], timeout: 1.0) 108 | } 109 | } 110 | 111 | } 112 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetalUITests/HandDrawingSwiftMetalUITests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HandDrawingSwiftMetalUITests.swift 3 | // HandDrawingSwiftMetalUITests 4 | // 5 | // Created by Eisuke Kusachi on 2021/11/27. 6 | // 7 | 8 | import XCTest 9 | 10 | class HandDrawingSwiftMetalUITests: XCTestCase { 11 | 12 | override func setUpWithError() throws { 13 | // Put setup code here. This method is called before the invocation of each test method in the class. 14 | 15 | // In UI tests it is usually best to stop immediately when a failure occurs. 16 | continueAfterFailure = false 17 | 18 | // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. 19 | } 20 | 21 | override func tearDownWithError() throws { 22 | // Put teardown code here. This method is called after the invocation of each test method in the class. 23 | } 24 | 25 | func testExample() throws { 26 | // UI tests must launch the application that they test. 27 | let app = XCUIApplication() 28 | app.launch() 29 | 30 | // Use recording to get started writing UI tests. 31 | // Use XCTAssert and related functions to verify your tests produce the correct results. 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /HandDrawingSwiftMetalUITests/HandDrawingSwiftMetalUITestsLaunchTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HandDrawingSwiftMetalUITestsLaunchTests.swift 3 | // HandDrawingSwiftMetalUITests 4 | // 5 | // Created by Eisuke Kusachi on 2021/11/27. 6 | // 7 | 8 | import XCTest 9 | 10 | class HandDrawingSwiftMetalUITestsLaunchTests: XCTestCase { 11 | 12 | override class var runsForEachTargetApplicationUIConfiguration: Bool { 13 | true 14 | } 15 | 16 | override func setUpWithError() throws { 17 | continueAfterFailure = false 18 | } 19 | 20 | func testLaunch() throws { 21 | let app = XCUIApplication() 22 | app.launch() 23 | 24 | // Insert steps here to perform after app launch but before taking a screenshot, 25 | // such as logging into a test account or navigating somewhere in the app 26 | 27 | let attachment = XCTAttachment(screenshot: app.screenshot()) 28 | attachment.name = "Launch Screen" 29 | attachment.lifetime = .keepAlways 30 | add(attachment) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # HandDrawingSwiftMetal 2 | 3 | This is a drawing app using [jSignature](https://willowsystems.github.io/jSignature/#/about/linesmoothing/) as a reference. Depending on the strength of the brushstroke, inflections can be added to the lines as well. 4 | 5 | It is used in [Rollcanvas](https://rollcanvas.org) app. 6 | 7 | If the `Missing package product ZipArchive` error occurs, please select `Product`-> `Clean Build Folder...` then restart Xcode. 8 | 9 | The repository from which only the drawing functionality has been extracted is [here](https://github.com/eisukekusachi/SimpleApplePencilDrawing) . 10 | 11 | 12 | --------------------------------------------------------------------------------