From 46330b9a7d281c0ffa98f09d496a207d8baf1503 Mon Sep 17 00:00:00 2001 From: dscyrescotti Date: Sat, 15 Jun 2024 20:53:45 +0700 Subject: [PATCH] feat: implement photo insertion --- Memola.xcodeproj/project.pbxproj | 16 +++++---- Memola/Canvas/Contexts/GraphicContext.swift | 4 +-- Memola/Canvas/Core/Canvas.swift | 27 ++++++++++++-- Memola/Canvas/Core/Textures.swift | 12 +++++++ Memola/Canvas/Elements/Photo/Photo.swift | 36 ++++++++++++++----- Memola/Canvas/Shaders/Photo.metal | 11 +++--- Memola/Canvas/Tool/Core/Tool.swift | 2 +- .../ViewController/CanvasViewController.swift | 6 +++- .../Views/CameraView/CameraView.swift | 9 +++-- Memola/Extensions/UIImage++.swift | 24 +++++++++++++ Memola/Features/Memo/Memo/MemoView.swift | 4 +-- .../Memo/PhotoPreview/PhotoPreview.swift | 10 ++++-- Memola/Features/Memo/Toolbar/Toolbar.swift | 7 ++-- 13 files changed, 132 insertions(+), 36 deletions(-) create mode 100644 Memola/Extensions/UIImage++.swift diff --git a/Memola.xcodeproj/project.pbxproj b/Memola.xcodeproj/project.pbxproj index 861bf2f..c07e954 100644 --- a/Memola.xcodeproj/project.pbxproj +++ b/Memola.xcodeproj/project.pbxproj @@ -84,7 +84,8 @@ ECA738FC2BE61C5200A4542E /* Persistence.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECA738FB2BE61C5200A4542E /* Persistence.swift */; }; ECA739082BE623F300A4542E /* PenDock.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECA739072BE623F300A4542E /* PenDock.swift */; }; ECBE52962C1D5900006BDB3D /* PhotoPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECBE52952C1D5900006BDB3D /* PhotoPreview.swift */; }; - ECBE52992C1D60E5006BDB3D /* CameraView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECBE52982C1D60E5006BDB3D /* CameraView.swift */; }; + ECBE529C2C1D94A4006BDB3D /* CameraView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECBE529A2C1D94A4006BDB3D /* CameraView.swift */; }; + ECBE529E2C1DAB21006BDB3D /* UIImage++.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECBE529D2C1DAB21006BDB3D /* UIImage++.swift */; }; ECD12A862C19EE3900B96E12 /* ElementObject.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECD12A852C19EE3900B96E12 /* ElementObject.swift */; }; ECD12A8A2C19EFB000B96E12 /* Element.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECD12A892C19EFB000B96E12 /* Element.swift */; }; ECD12A8C2C1AEAA900B96E12 /* PhotoObject.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECD12A8B2C1AEAA900B96E12 /* PhotoObject.swift */; }; @@ -184,7 +185,8 @@ ECA738FB2BE61C5200A4542E /* Persistence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Persistence.swift; sourceTree = ""; }; ECA739072BE623F300A4542E /* PenDock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PenDock.swift; sourceTree = ""; }; ECBE52952C1D5900006BDB3D /* PhotoPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoPreview.swift; sourceTree = ""; }; - ECBE52982C1D60E5006BDB3D /* CameraView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CameraView.swift; sourceTree = ""; }; + ECBE529A2C1D94A4006BDB3D /* CameraView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CameraView.swift; sourceTree = ""; }; + ECBE529D2C1DAB21006BDB3D /* UIImage++.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIImage++.swift"; sourceTree = ""; }; ECD12A852C19EE3900B96E12 /* ElementObject.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ElementObject.swift; sourceTree = ""; }; ECD12A892C19EFB000B96E12 /* Element.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Element.swift; sourceTree = ""; }; ECD12A8B2C1AEAA900B96E12 /* PhotoObject.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoObject.swift; sourceTree = ""; }; @@ -234,7 +236,7 @@ EC1B783A2BF9C68C005A34E2 /* Views */ = { isa = PBXGroup; children = ( - ECBE52972C1D6087006BDB3D /* CameraView */, + ECBE529B2C1D94A4006BDB3D /* CameraView */, ECFC51252BF8885000D0D051 /* ColorPicker */, ); path = Views; @@ -509,6 +511,7 @@ EC3565532BEFC6AD00A4E0BF /* View++.swift */, EC3565552BEFC7B300A4E0BF /* NSManagedObject++.swift */, EC35655B2BF0712A00A4E0BF /* Float++.swift */, + ECBE529D2C1DAB21006BDB3D /* UIImage++.swift */, ); path = Extensions; sourceTree = ""; @@ -679,10 +682,10 @@ path = PhotoPreview; sourceTree = ""; }; - ECBE52972C1D6087006BDB3D /* CameraView */ = { + ECBE529B2C1D94A4006BDB3D /* CameraView */ = { isa = PBXGroup; children = ( - ECBE52982C1D60E5006BDB3D /* CameraView.swift */, + ECBE529A2C1D94A4006BDB3D /* CameraView.swift */, ); path = CameraView; sourceTree = ""; @@ -863,6 +866,7 @@ ECA738A62BE6023F00A4542E /* GridUniforms.swift in Sources */, ECA738D72BE60FC100A4542E /* SolidPointStrokeGenerator.swift in Sources */, EC3565542BEFC6AD00A4E0BF /* View++.swift in Sources */, + ECBE529E2C1DAB21006BDB3D /* UIImage++.swift in Sources */, EC2BEBF62C0F600D005DB0AF /* Box.swift in Sources */, ECA738832BE5FEFE00A4542E /* RenderPass.swift in Sources */, ECEC01A82BEE11BA006DA24C /* QuadShape.swift in Sources */, @@ -901,7 +905,7 @@ EC0D14212BF79C73009BFE5F /* ToolObject.swift in Sources */, EC50500D2BF6674400B4D86E /* OnDragViewModifier.swift in Sources */, EC9AB09F2C1401A40076AF58 /* EraserObject.swift in Sources */, - ECBE52992C1D60E5006BDB3D /* CameraView.swift in Sources */, + ECBE529C2C1D94A4006BDB3D /* CameraView.swift in Sources */, ECA7389E2BE601CB00A4542E /* QuadVertex.swift in Sources */, ECA738B32BE60D9E00A4542E /* CanvasView.swift in Sources */, ECA738C42BE60E8800A4542E /* MarkerPenStyle.swift in Sources */, diff --git a/Memola/Canvas/Contexts/GraphicContext.swift b/Memola/Canvas/Contexts/GraphicContext.swift index 7aca567..33c24bc 100644 --- a/Memola/Canvas/Contexts/GraphicContext.swift +++ b/Memola/Canvas/Contexts/GraphicContext.swift @@ -304,11 +304,11 @@ extension GraphicContext { // MARK: - Photo extension GraphicContext { - func insertPhoto(at point: CGPoint) { + func insertPhoto(at point: CGPoint, url: URL) { let size = CGSize(width: 100, height: 100) let origin = point let bounds = [origin.x - size.width / 2, origin.y - size.height / 2, origin.x + size.width / 2, origin.y + size.height / 2] - let photo = Photo(size: size, origin: origin, bounds: bounds, createdAt: .now) + let photo = Photo(url: url, size: size, origin: origin, bounds: bounds, createdAt: .now) tree.insert(.photo(photo), in: photo.photoBox) self.previousElement = .photo(photo) } diff --git a/Memola/Canvas/Core/Canvas.swift b/Memola/Canvas/Core/Canvas.swift index f7f11dc..f6c373e 100644 --- a/Memola/Canvas/Core/Canvas.swift +++ b/Memola/Canvas/Core/Canvas.swift @@ -128,8 +128,31 @@ extension Canvas { // MARK: - Photo extension Canvas { - func insertPhoto(at point: CGPoint) { - graphicContext.insertPhoto(at: point) + func insertPhoto(at point: CGPoint, url: URL) { + graphicContext.insertPhoto(at: point, url: url) + } + + func savePhoto(_ data: Data) -> URL? { + let fileManager = FileManager.default + guard let directory = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first else { return nil } + let fileName = "\(UUID().uuidString)-\(Date.now.timeIntervalSince1970)" + let folder = directory.appendingPathComponent(canvasID.uriRepresentation().lastPathComponent, conformingTo: .folder) + if !fileManager.fileExists(atPath: folder.path()) { + do { + try fileManager.createDirectory(at: folder, withIntermediateDirectories: true) + } catch { + NSLog("[Memola] - \(error.localizedDescription)") + return nil + } + } + let file = folder.appendingPathComponent(fileName, conformingTo: .jpeg) + do { + try data.write(to: file) + } catch { + NSLog("[Memola] - \(error.localizedDescription)") + return nil + } + return file } } diff --git a/Memola/Canvas/Core/Textures.swift b/Memola/Canvas/Core/Textures.swift index 57e3d8f..9cd1106 100644 --- a/Memola/Canvas/Core/Textures.swift +++ b/Memola/Canvas/Core/Textures.swift @@ -26,6 +26,18 @@ class Textures { return penTexture } + @discardableResult + static func createPhotoTexture(for url: URL, on device: MTLDevice) -> MTLTexture? { + let textureLoader = MTKTextureLoader(device: device) + do { + let photoTexture = try textureLoader.newTexture(URL: url, options: [.SRGB: false]) + return photoTexture + } catch { + NSLog("[Memola] - \(error.localizedDescription)") + return nil + } + } + static func createGraphicTexture( from renderer: Renderer, size: CGSize, diff --git a/Memola/Canvas/Elements/Photo/Photo.swift b/Memola/Canvas/Elements/Photo/Photo.swift index 004c8ec..0a7902f 100644 --- a/Memola/Canvas/Elements/Photo/Photo.swift +++ b/Memola/Canvas/Elements/Photo/Photo.swift @@ -12,18 +12,31 @@ final class Photo: @unchecked Sendable, Equatable, Comparable { var id: UUID = UUID() var size: CGSize var origin: CGPoint + var image: UIImage? + var url: URL? var bounds: [CGFloat] var createdAt: Date var object: PhotoObject? + var texture: MTLTexture? var vertices: [PhotoVertex] = [] var vertexCount: Int = 0 var vertexBuffer: MTLBuffer? - init(size: CGSize, origin: CGPoint, bounds: [CGFloat], createdAt: Date) { + init(image: UIImage?, size: CGSize, origin: CGPoint, bounds: [CGFloat], createdAt: Date) { self.size = size self.origin = origin + self.image = image + self.bounds = bounds + self.createdAt = createdAt + generateVertices() + } + + init(url: URL?, size: CGSize, origin: CGPoint, bounds: [CGFloat], createdAt: Date) { + self.size = size + self.origin = origin + self.url = url self.bounds = bounds self.createdAt = createdAt generateVertices() @@ -31,6 +44,7 @@ final class Photo: @unchecked Sendable, Equatable, Comparable { convenience init(object: PhotoObject) { self.init( + image: UIImage(data: object.image ?? .init()), size: .init(width: object.width, height: object.height), origin: .init(x: object.originX, y: object.originY), bounds: object.bounds, @@ -45,25 +59,31 @@ final class Photo: @unchecked Sendable, Equatable, Comparable { let minY = origin.y - size.height / 2 let maxY = origin.y + size.height / 2 vertices = [ - PhotoVertex(x: minX, y: minY, textCoord: CGPoint(x: 0, y: 1)), - PhotoVertex(x: minX, y: maxY, textCoord: CGPoint(x: 0, y: 0)), - PhotoVertex(x: maxX, y: minY, textCoord: CGPoint(x: 1, y: 1)), - PhotoVertex(x: maxX, y: maxY, textCoord: CGPoint(x: 1, y: 0)), + PhotoVertex(x: minX, y: minY, textCoord: CGPoint(x: 0, y: 0)), + PhotoVertex(x: minX, y: maxY, textCoord: CGPoint(x: 0, y: 1)), + PhotoVertex(x: maxX, y: minY, textCoord: CGPoint(x: 1, y: 0)), + PhotoVertex(x: maxX, y: maxY, textCoord: CGPoint(x: 1, y: 1)), ] } } extension Photo: Drawable { func prepare(device: any MTLDevice) { - guard vertexBuffer == nil else { return } - vertexCount = vertices.endIndex - vertexBuffer = device.makeBuffer(bytes: vertices, length: vertexCount * MemoryLayout.stride, options: []) + if vertexBuffer == nil { + vertexCount = vertices.endIndex + vertexBuffer = device.makeBuffer(bytes: vertices, length: vertexCount * MemoryLayout.stride, options: []) + } + if texture == nil, let url { + texture = Textures.createPhotoTexture(for: url, on: device) + } } func draw(device: any MTLDevice, renderEncoder: any MTLRenderCommandEncoder) { prepare(device: device) + renderEncoder.setFragmentTexture(texture, index: 0) renderEncoder.setVertexBuffer(vertexBuffer, offset: 0, index: 0) renderEncoder.drawPrimitives(type: .triangleStrip, vertexStart: 0, vertexCount: vertices.count) + texture = nil } } diff --git a/Memola/Canvas/Shaders/Photo.metal b/Memola/Canvas/Shaders/Photo.metal index 9d2507d..f585554 100644 --- a/Memola/Canvas/Shaders/Photo.metal +++ b/Memola/Canvas/Shaders/Photo.metal @@ -37,11 +37,10 @@ vertex VertexOut vertex_photo( } fragment float4 fragment_photo( - VertexOut out [[stage_in]] -// texture2d texture [[texture(0)]] + VertexOut out [[stage_in]], + texture2d texture [[texture(0)]] ) { -// constexpr sampler textureSampler(mag_filter::linear, min_filter::linear); -// float4 color = float4(texture.sample(textureSampler, out.textCoord)); -// return color; - return float4(1, 0, 1, 1); + constexpr sampler textureSampler(mag_filter::linear, min_filter::linear); + float4 color = float4(texture.sample(textureSampler, out.textCoord)); + return color; } diff --git a/Memola/Canvas/Tool/Core/Tool.swift b/Memola/Canvas/Tool/Core/Tool.swift index 958afe8..547d059 100644 --- a/Memola/Canvas/Tool/Core/Tool.swift +++ b/Memola/Canvas/Tool/Core/Tool.swift @@ -19,7 +19,7 @@ public class Tool: NSObject, ObservableObject { @Published var selectedPen: Pen? @Published var draggedPen: Pen? // MARK: - Photo - @Published var selectedImage: UIImage? + @Published var selectedImageURL: URL? @Published var selection: ToolSelection = .none diff --git a/Memola/Canvas/View/Bridge/ViewController/CanvasViewController.swift b/Memola/Canvas/View/Bridge/ViewController/CanvasViewController.swift index 9367cf7..96dfebb 100644 --- a/Memola/Canvas/View/Bridge/ViewController/CanvasViewController.swift +++ b/Memola/Canvas/View/Bridge/ViewController/CanvasViewController.swift @@ -233,8 +233,12 @@ extension CanvasViewController { } @objc func recognizeTapGesture(_ gesture: UITapGestureRecognizer) { + guard let url = tool.selectedImageURL else { return } + withAnimation { + tool.selectedImageURL = nil + } let point = gesture.location(in: drawingView) - canvas.insertPhoto(at: point.muliply(by: drawingView.ratio)) + canvas.insertPhoto(at: point.muliply(by: drawingView.ratio), url: url) drawingView.draw() } } diff --git a/Memola/Components/Views/CameraView/CameraView.swift b/Memola/Components/Views/CameraView/CameraView.swift index 712f87d..8cafeef 100644 --- a/Memola/Components/Views/CameraView/CameraView.swift +++ b/Memola/Components/Views/CameraView/CameraView.swift @@ -8,7 +8,9 @@ import SwiftUI struct CameraView: UIViewControllerRepresentable { - @Binding var image: UIImage? + @Binding var url: URL? + + @ObservedObject var canvas: Canvas @Environment(\.dismiss) private var dismiss @@ -33,7 +35,10 @@ struct CameraView: UIViewControllerRepresentable { } func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) { - parent.image = info[.originalImage] as? UIImage + let image = (info[.originalImage] as? UIImage)?.imageWithUpOrientation() + if let image, let data = image.jpegData(compressionQuality: 1) { + parent.url = parent.canvas.savePhoto(data) + } parent.dismiss() } diff --git a/Memola/Extensions/UIImage++.swift b/Memola/Extensions/UIImage++.swift new file mode 100644 index 0000000..983ad0f --- /dev/null +++ b/Memola/Extensions/UIImage++.swift @@ -0,0 +1,24 @@ +// +// UIImage++.swift +// Memola +// +// Created by Dscyre Scotti on 6/15/24. +// + +import UIKit +import Foundation + +extension UIImage { + func imageWithUpOrientation() -> UIImage? { + switch imageOrientation { + case .up: + return self + default: + UIGraphicsBeginImageContextWithOptions(size, false, scale) + draw(in: CGRect(origin: .zero, size: size)) + let result = UIGraphicsGetImageFromCurrentImageContext() + UIGraphicsEndImageContext() + return result + } + } +} diff --git a/Memola/Features/Memo/Memo/MemoView.swift b/Memola/Features/Memo/Memo/MemoView.swift index c20cb2b..f2547e1 100644 --- a/Memola/Features/Memo/Memo/MemoView.swift +++ b/Memola/Features/Memo/Memo/MemoView.swift @@ -35,8 +35,8 @@ struct MemoView: View { PenDock(tool: tool, canvas: canvas) .transition(.move(edge: .trailing)) case .photo: - if let image = tool.selectedImage { - PhotoPreview(image: image, tool: tool) + if let url = tool.selectedImageURL { + PhotoPreview(url: url, tool: tool) .transition(.move(edge: .trailing)) } default: diff --git a/Memola/Features/Memo/PhotoPreview/PhotoPreview.swift b/Memola/Features/Memo/PhotoPreview/PhotoPreview.swift index 837c331..7696b8b 100644 --- a/Memola/Features/Memo/PhotoPreview/PhotoPreview.swift +++ b/Memola/Features/Memo/PhotoPreview/PhotoPreview.swift @@ -8,11 +8,15 @@ import SwiftUI struct PhotoPreview: View { - let image: UIImage + let url: URL @ObservedObject var tool: Tool + var data: Data { + (try? Data(contentsOf: url)) ?? Data() + } + var body: some View { - Image(uiImage: image) + Image(uiImage: UIImage(data: data) ?? UIImage()) .resizable() .scaledToFill() .frame(width: 100, height: 100) @@ -27,7 +31,7 @@ struct PhotoPreview: View { .overlay(alignment: .topLeading) { Button { withAnimation { - tool.selectedImage = nil + tool.selectedImageURL = nil } } label: { Image(systemName: "xmark.circle.fill") diff --git a/Memola/Features/Memo/Toolbar/Toolbar.swift b/Memola/Features/Memo/Toolbar/Toolbar.swift index f09d011..2a40b36 100644 --- a/Memola/Features/Memo/Toolbar/Toolbar.swift +++ b/Memola/Features/Memo/Toolbar/Toolbar.swift @@ -61,8 +61,9 @@ struct Toolbar: View { Task { let data = try? await newValue?.loadTransferable(type: Data.self) if let data { + let url = canvas.savePhoto(data) withAnimation { - tool.selectedImage = UIImage(data: data) + tool.selectedImageURL = url } } photoItem = nil @@ -70,7 +71,7 @@ struct Toolbar: View { } } .fullScreenCover(isPresented: $opensCamera) { - CameraView(image: $tool.selectedImage) + CameraView(url: $tool.selectedImageURL, canvas: canvas) .ignoresSafeArea() } .alert("Camera Access Denied", isPresented: $isCameraAccessDenied) { @@ -166,7 +167,7 @@ struct Toolbar: View { .clipShape(.rect(cornerRadius: 8)) } .hoverEffect(.lift) - PhotosPicker(selection: $photoItem, matching: .images) { + PhotosPicker(selection: $photoItem, matching: .images, preferredItemEncoding: .compatible) { Image(systemName: "photo.fill.on.rectangle.fill") .contentShape(.circle) .frame(width: size, height: size)