├── .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 |
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 |
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 |
--------------------------------------------------------------------------------