From 2d0ca3478b99acc3a22e00bd84846f4fd44d348d Mon Sep 17 00:00:00 2001 From: dscyrescotti Date: Thu, 13 Jun 2024 00:28:42 +0700 Subject: [PATCH 01/13] feat: wrap stroke object with element object --- Memola.xcodeproj/project.pbxproj | 26 ++++++- Memola/Canvas/Contexts/GraphicContext.swift | 71 +++++++++++-------- Memola/Canvas/Elements/Core/Element.swift | 20 ++++++ .../Geometries/Primitives/Quad.swift | 0 .../Geometries/Primitives/QuadShape.swift | 0 .../Stroke/Algorithms/MovingAverage.swift | 0 .../Geometries/Stroke/Core/AnyStroke.swift | 0 .../Geometries/Stroke/Core/Stroke.swift | 4 ++ .../Stroke/Core/StrokeGenerator.swift | 0 .../Geometries/Stroke/Core/StrokeStyle.swift | 0 .../SolidPointStrokeGenerator.swift | 0 .../Stroke/Strokes/EraserStroke.swift | 0 .../Geometries/Stroke/Strokes/PenStroke.swift | 0 .../RenderPasses/GraphicRenderPass.swift | 49 +++++++------ Memola/Features/Memos/MemosView.swift | 2 +- Memola/Persistence/Objects/CanvasObject.swift | 1 - .../Persistence/Objects/ElementObject.swift | 17 +++++ .../Objects/GraphicContextObject.swift | 2 +- Memola/Persistence/Objects/StrokeObject.swift | 2 +- .../MemolaModel.xcdatamodel/contents | 10 ++- 20 files changed, 147 insertions(+), 57 deletions(-) create mode 100644 Memola/Canvas/Elements/Core/Element.swift rename Memola/Canvas/{ => Elements}/Geometries/Primitives/Quad.swift (100%) rename Memola/Canvas/{ => Elements}/Geometries/Primitives/QuadShape.swift (100%) rename Memola/Canvas/{ => Elements}/Geometries/Stroke/Algorithms/MovingAverage.swift (100%) rename Memola/Canvas/{ => Elements}/Geometries/Stroke/Core/AnyStroke.swift (100%) rename Memola/Canvas/{ => Elements}/Geometries/Stroke/Core/Stroke.swift (98%) rename Memola/Canvas/{ => Elements}/Geometries/Stroke/Core/StrokeGenerator.swift (100%) rename Memola/Canvas/{ => Elements}/Geometries/Stroke/Core/StrokeStyle.swift (100%) rename Memola/Canvas/{ => Elements}/Geometries/Stroke/Generators/SolidPointStrokeGenerator.swift (100%) rename Memola/Canvas/{ => Elements}/Geometries/Stroke/Strokes/EraserStroke.swift (100%) rename Memola/Canvas/{ => Elements}/Geometries/Stroke/Strokes/PenStroke.swift (100%) create mode 100644 Memola/Persistence/Objects/ElementObject.swift diff --git a/Memola.xcodeproj/project.pbxproj b/Memola.xcodeproj/project.pbxproj index 83a5b4b..7de7d5d 100644 --- a/Memola.xcodeproj/project.pbxproj +++ b/Memola.xcodeproj/project.pbxproj @@ -82,6 +82,8 @@ ECA738F62BE612B700A4542E /* MTLDevice++.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECA738F52BE612B700A4542E /* MTLDevice++.swift */; }; ECA738FC2BE61C5200A4542E /* Persistence.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECA738FB2BE61C5200A4542E /* Persistence.swift */; }; ECA739082BE623F300A4542E /* PenDock.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECA739072BE623F300A4542E /* PenDock.swift */; }; + ECD12A862C19EE3900B96E12 /* ElementObject.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECD12A852C19EE3900B96E12 /* ElementObject.swift */; }; + ECD12A8A2C19EFB000B96E12 /* Element.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECD12A892C19EFB000B96E12 /* Element.swift */; }; ECE883BD2C00AA170045C53D /* EraserStroke.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECE883BC2C00AA170045C53D /* EraserStroke.swift */; }; ECE883BF2C00AB440045C53D /* Stroke.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECE883BE2C00AB440045C53D /* Stroke.swift */; }; ECE883C12C00C9CB0045C53D /* StrokeStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECE883C02C00C9CB0045C53D /* StrokeStyle.swift */; }; @@ -172,6 +174,8 @@ ECA738F52BE612B700A4542E /* MTLDevice++.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MTLDevice++.swift"; sourceTree = ""; }; ECA738FB2BE61C5200A4542E /* Persistence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Persistence.swift; sourceTree = ""; }; ECA739072BE623F300A4542E /* PenDock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PenDock.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 = ""; }; ECE883BC2C00AA170045C53D /* EraserStroke.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EraserStroke.swift; sourceTree = ""; }; ECE883BE2C00AB440045C53D /* Stroke.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Stroke.swift; sourceTree = ""; }; ECE883C02C00C9CB0045C53D /* StrokeStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StrokeStyle.swift; sourceTree = ""; }; @@ -382,8 +386,8 @@ ECA7387E2BE5FE4200A4542E /* Canvas */ = { isa = PBXGroup; children = ( + ECD12A872C19EF8700B96E12 /* Elements */, EC2BEBF22C0F5FE1005DB0AF /* RTree */, - ECA738F92BE6130000A4542E /* Geometries */, ECA738812BE5FEEE00A4542E /* Abstracts */, ECA738992BE6018900A4542E /* Buffers */, ECA738C72BE60EE200A4542E /* Contexts */, @@ -645,6 +649,23 @@ path = Core; sourceTree = ""; }; + ECD12A872C19EF8700B96E12 /* Elements */ = { + isa = PBXGroup; + children = ( + ECD12A882C19EF9500B96E12 /* Core */, + ECA738F92BE6130000A4542E /* Geometries */, + ); + path = Elements; + sourceTree = ""; + }; + ECD12A882C19EF9500B96E12 /* Core */ = { + isa = PBXGroup; + children = ( + ECD12A892C19EFB000B96E12 /* Element.swift */, + ); + path = Core; + sourceTree = ""; + }; ECE883B82C009DC30045C53D /* Strokes */ = { isa = PBXGroup; children = ( @@ -676,6 +697,7 @@ EC0D14202BF79C73009BFE5F /* ToolObject.swift */, EC0D14252BF7A8C9009BFE5F /* PenObject.swift */, EC9AB09E2C1401A40076AF58 /* EraserObject.swift */, + ECD12A852C19EE3900B96E12 /* ElementObject.swift */, ); path = Objects; sourceTree = ""; @@ -805,6 +827,7 @@ ECA738B82BE60DDC00A4542E /* HistoryEvent.swift in Sources */, EC0D14262BF7A8C9009BFE5F /* PenObject.swift in Sources */, ECA738952BE6012D00A4542E /* ViewPort.metal in Sources */, + ECD12A862C19EE3900B96E12 /* ElementObject.swift in Sources */, EC0D14242BF79C98009BFE5F /* MemolaModel.xcdatamodeld in Sources */, ECA738F02BE6127700A4542E /* CGSize++.swift in Sources */, ECFA15242BEF223300455818 /* GraphicContextObject.swift in Sources */, @@ -840,6 +863,7 @@ EC7F6BEC2BE5E6E300A34A7B /* MemolaApp.swift in Sources */, EC2BEBF82C0F601A005DB0AF /* Node.swift in Sources */, ECA738A02BE601E400A4542E /* ViewPortVertex.swift in Sources */, + ECD12A8A2C19EFB000B96E12 /* Element.swift in Sources */, EC0D14282BF7BF20009BFE5F /* ContextMenuViewModifier.swift in Sources */, EC2106AD2C10C2A700FBE27C /* AnyStroke.swift in Sources */, ECA738BC2BE60E0300A4542E /* Tool.swift in Sources */, diff --git a/Memola/Canvas/Contexts/GraphicContext.swift b/Memola/Canvas/Contexts/GraphicContext.swift index 37c637c..a370df3 100644 --- a/Memola/Canvas/Contexts/GraphicContext.swift +++ b/Memola/Canvas/Contexts/GraphicContext.swift @@ -11,7 +11,7 @@ import CoreData import Foundation final class GraphicContext: @unchecked Sendable { - var tree: RTree = RTree(maxEntries: 8) + var tree: RTree = RTree(maxEntries: 8) var eraserStrokes: Set = [] var object: GraphicContextObject? @@ -48,9 +48,9 @@ final class GraphicContext: @unchecked Sendable { switch stroke.style { case .marker: guard let penStroke = stroke.stroke(as: PenStroke.self) else { return } - tree.remove(penStroke.anyStroke, in: penStroke.strokeBox) + tree.remove(penStroke.element, in: penStroke.strokeBox) withPersistence(\.backgroundContext) { [weak penStroke] context in - penStroke?.object?.graphicContext = nil + penStroke?.object?.element?.graphicContext = nil try context.saveIfNeeded() context.refreshAllObjects() } @@ -81,9 +81,9 @@ final class GraphicContext: @unchecked Sendable { guard let penStroke = stroke.stroke(as: PenStroke.self) else { break } - tree.insert(penStroke.anyStroke, in: penStroke.strokeBox) + tree.insert(penStroke.element, in: penStroke.strokeBox) withPersistence(\.backgroundContext) { [weak self, weak penStroke] context in - penStroke?.object?.graphicContext = self?.object + penStroke?.object?.element?.graphicContext = self?.object try context.saveIfNeeded() context.refreshAllObjects() } @@ -114,32 +114,43 @@ extension GraphicContext { guard let object else { return } let queue = OperationQueue() queue.qualityOfService = .userInteractive - object.strokes.forEach { stroke in - guard let stroke = stroke as? StrokeObject, stroke.style == 0 else { return } - let _stroke = PenStroke(object: stroke) - tree.insert(_stroke.anyStroke, in: _stroke.strokeBox) - if _stroke.isVisible(in: bounds) { - let id = stroke.objectID - queue.addOperation { [weak self] in - guard let self else { return } - withPersistenceSync(\.newBackgroundContext) { [weak _stroke] context in - guard let stroke = try? context.existingObject(with: id) as? StrokeObject else { return } - _stroke?.loadQuads(from: stroke, with: self) + object.elements.forEach { element in + guard let element = element as? ElementObject else { return } + switch element.type { + case 0: + guard let stroke = element.stroke, stroke.style == 0 else { return } + let _stroke = PenStroke(object: stroke) + tree.insert(_stroke.element, in: _stroke.strokeBox) + if _stroke.isVisible(in: bounds) { + let id = stroke.objectID + queue.addOperation { [weak self] in + guard let self else { return } + withPersistenceSync(\.newBackgroundContext) { [weak _stroke] context in + guard let stroke = try? context.existingObject(with: id) as? StrokeObject else { return } + _stroke?.loadQuads(from: stroke, with: self) + context.refreshAllObjects() + } + } + } else { + withPersistence(\.backgroundContext) { [weak self, weak _stroke] context in + guard let self else { return } + _stroke?.loadQuads(with: self) context.refreshAllObjects() } } - } else { - withPersistence(\.backgroundContext) { [weak self, weak _stroke] context in - guard let self else { return } - _stroke?.loadQuads(with: self) - context.refreshAllObjects() - } + case 1: + #warning("TODO: implement photo") + break + default: + break } + } queue.waitUntilAllOperationsAreFinished() } func loadQuads(_ bounds: CGRect, on context: NSManagedObjectContext) { + #warning("TODO: implement photo") for _stroke in self.tree.search(box: bounds.box) { guard let stroke = _stroke.stroke(as: PenStroke.self), stroke.isEmpty else { continue } stroke.loadQuads(with: self) @@ -185,8 +196,13 @@ extension GraphicContext { stroke.createdAt = _stroke.createdAt stroke.quads = [] stroke.erasers = .init() - stroke.graphicContext = graphicContext - graphicContext?.strokes.add(stroke) + let element = ElementObject(\.backgroundContext) + element.createdAt = _stroke.createdAt + element.type = 0 + element.graphicContext = graphicContext + stroke.element = element + element.stroke = stroke + graphicContext?.elements.add(element) _stroke.object = stroke try context.saveIfNeeded() } @@ -235,7 +251,7 @@ extension GraphicContext { currentStroke.finish(at: point) if let penStroke = currentStroke.stroke(as: PenStroke.self) { penStroke.saveQuads() - tree.insert(currentStroke.anyStroke, in: currentStroke.strokeBox) + tree.insert(currentStroke.element, in: currentStroke.strokeBox) withPersistence(\.backgroundContext) { [weak penStroke] context in guard let penStroke else { return } penStroke.object?.bounds = penStroke.bounds @@ -264,9 +280,8 @@ extension GraphicContext { guard let _stroke = stroke.stroke(as: PenStroke.self) else { break } withPersistence(\.backgroundContext) { [weak graphicContext = object, weak _stroke] context in guard let _stroke else { return } - if let stroke = _stroke.object { - graphicContext?.strokes.remove(stroke) - context.delete(stroke) + if let element = _stroke.object?.element { + graphicContext?.elements.remove(element) } try context.saveIfNeeded() } diff --git a/Memola/Canvas/Elements/Core/Element.swift b/Memola/Canvas/Elements/Core/Element.swift new file mode 100644 index 0000000..a59da0b --- /dev/null +++ b/Memola/Canvas/Elements/Core/Element.swift @@ -0,0 +1,20 @@ +// +// Element.swift +// Memola +// +// Created by Dscyre Scotti on 6/12/24. +// + +import Foundation + +enum Element: Equatable, Comparable { + case stroke(AnyStroke) + case photo + + func stroke(as type: S.Type) -> S? { + guard case let .stroke(anyStroke) = self else { + return nil + } + return anyStroke.stroke(as: type) + } +} diff --git a/Memola/Canvas/Geometries/Primitives/Quad.swift b/Memola/Canvas/Elements/Geometries/Primitives/Quad.swift similarity index 100% rename from Memola/Canvas/Geometries/Primitives/Quad.swift rename to Memola/Canvas/Elements/Geometries/Primitives/Quad.swift diff --git a/Memola/Canvas/Geometries/Primitives/QuadShape.swift b/Memola/Canvas/Elements/Geometries/Primitives/QuadShape.swift similarity index 100% rename from Memola/Canvas/Geometries/Primitives/QuadShape.swift rename to Memola/Canvas/Elements/Geometries/Primitives/QuadShape.swift diff --git a/Memola/Canvas/Geometries/Stroke/Algorithms/MovingAverage.swift b/Memola/Canvas/Elements/Geometries/Stroke/Algorithms/MovingAverage.swift similarity index 100% rename from Memola/Canvas/Geometries/Stroke/Algorithms/MovingAverage.swift rename to Memola/Canvas/Elements/Geometries/Stroke/Algorithms/MovingAverage.swift diff --git a/Memola/Canvas/Geometries/Stroke/Core/AnyStroke.swift b/Memola/Canvas/Elements/Geometries/Stroke/Core/AnyStroke.swift similarity index 100% rename from Memola/Canvas/Geometries/Stroke/Core/AnyStroke.swift rename to Memola/Canvas/Elements/Geometries/Stroke/Core/AnyStroke.swift diff --git a/Memola/Canvas/Geometries/Stroke/Core/Stroke.swift b/Memola/Canvas/Elements/Geometries/Stroke/Core/Stroke.swift similarity index 98% rename from Memola/Canvas/Geometries/Stroke/Core/Stroke.swift rename to Memola/Canvas/Elements/Geometries/Stroke/Core/Stroke.swift index 6eb7526..f00857c 100644 --- a/Memola/Canvas/Geometries/Stroke/Core/Stroke.swift +++ b/Memola/Canvas/Elements/Geometries/Stroke/Core/Stroke.swift @@ -125,4 +125,8 @@ extension Stroke { var anyStroke: AnyStroke { AnyStroke(self) } + + var element: Element { + .stroke(anyStroke) + } } diff --git a/Memola/Canvas/Geometries/Stroke/Core/StrokeGenerator.swift b/Memola/Canvas/Elements/Geometries/Stroke/Core/StrokeGenerator.swift similarity index 100% rename from Memola/Canvas/Geometries/Stroke/Core/StrokeGenerator.swift rename to Memola/Canvas/Elements/Geometries/Stroke/Core/StrokeGenerator.swift diff --git a/Memola/Canvas/Geometries/Stroke/Core/StrokeStyle.swift b/Memola/Canvas/Elements/Geometries/Stroke/Core/StrokeStyle.swift similarity index 100% rename from Memola/Canvas/Geometries/Stroke/Core/StrokeStyle.swift rename to Memola/Canvas/Elements/Geometries/Stroke/Core/StrokeStyle.swift diff --git a/Memola/Canvas/Geometries/Stroke/Generators/SolidPointStrokeGenerator.swift b/Memola/Canvas/Elements/Geometries/Stroke/Generators/SolidPointStrokeGenerator.swift similarity index 100% rename from Memola/Canvas/Geometries/Stroke/Generators/SolidPointStrokeGenerator.swift rename to Memola/Canvas/Elements/Geometries/Stroke/Generators/SolidPointStrokeGenerator.swift diff --git a/Memola/Canvas/Geometries/Stroke/Strokes/EraserStroke.swift b/Memola/Canvas/Elements/Geometries/Stroke/Strokes/EraserStroke.swift similarity index 100% rename from Memola/Canvas/Geometries/Stroke/Strokes/EraserStroke.swift rename to Memola/Canvas/Elements/Geometries/Stroke/Strokes/EraserStroke.swift diff --git a/Memola/Canvas/Geometries/Stroke/Strokes/PenStroke.swift b/Memola/Canvas/Elements/Geometries/Stroke/Strokes/PenStroke.swift similarity index 100% rename from Memola/Canvas/Geometries/Stroke/Strokes/PenStroke.swift rename to Memola/Canvas/Elements/Geometries/Stroke/Strokes/PenStroke.swift diff --git a/Memola/Canvas/RenderPasses/GraphicRenderPass.swift b/Memola/Canvas/RenderPasses/GraphicRenderPass.swift index aca8d71..80dd01d 100644 --- a/Memola/Canvas/RenderPasses/GraphicRenderPass.swift +++ b/Memola/Canvas/RenderPasses/GraphicRenderPass.swift @@ -45,32 +45,37 @@ class GraphicRenderPass: RenderPass { let graphicContext = canvas.graphicContext if renderer.redrawsGraphicRender { canvas.setGraphicRenderType(.finished) - for _stroke in graphicContext.tree.search(box: canvas.bounds.box) { - let stroke = _stroke.value - if graphicContext.previousStroke === stroke || graphicContext.currentStroke === stroke { - continue - } - guard stroke.isVisible(in: canvas.bounds) else { continue } - descriptor.colorAttachments[0].loadAction = clearsTexture ? .clear : .load - clearsTexture = false - switch stroke.style { - case .eraser: - eraserRenderPass.stroke = stroke - eraserRenderPass.descriptor = descriptor - eraserRenderPass.draw(on: canvas, with: renderer) - case .marker: - canvas.setGraphicRenderType(.finished) - strokeRenderPass.stroke = stroke - strokeRenderPass.graphicDescriptor = descriptor - strokeRenderPass.graphicPipelineState = graphicPipelineState - strokeRenderPass.draw(on: canvas, with: renderer) - - if let stroke = stroke as? PenStroke, !stroke.isEmptyErasedQuads { - descriptor.colorAttachments[0].loadAction = .load + for _element in graphicContext.tree.search(box: canvas.bounds.box) { + switch _element { + case .stroke(let _stroke): + let stroke = _stroke.value + if graphicContext.previousStroke === stroke || graphicContext.currentStroke === stroke { + continue + } + guard stroke.isVisible(in: canvas.bounds) else { continue } + descriptor.colorAttachments[0].loadAction = clearsTexture ? .clear : .load + clearsTexture = false + switch stroke.style { + case .eraser: eraserRenderPass.stroke = stroke eraserRenderPass.descriptor = descriptor eraserRenderPass.draw(on: canvas, with: renderer) + case .marker: + canvas.setGraphicRenderType(.finished) + strokeRenderPass.stroke = stroke + strokeRenderPass.graphicDescriptor = descriptor + strokeRenderPass.graphicPipelineState = graphicPipelineState + strokeRenderPass.draw(on: canvas, with: renderer) + + if let stroke = stroke as? PenStroke, !stroke.isEmptyErasedQuads { + descriptor.colorAttachments[0].loadAction = .load + eraserRenderPass.stroke = stroke + eraserRenderPass.descriptor = descriptor + eraserRenderPass.draw(on: canvas, with: renderer) + } } + case .photo: + break } } renderer.redrawsGraphicRender = false diff --git a/Memola/Features/Memos/MemosView.swift b/Memola/Features/Memos/MemosView.swift index 99e87c5..1e721da 100644 --- a/Memola/Features/Memos/MemosView.swift +++ b/Memola/Features/Memos/MemosView.swift @@ -85,7 +85,7 @@ struct MemosView: View { } let graphicContextObject = GraphicContextObject(\.viewContext) - graphicContextObject.strokes = [] + graphicContextObject.elements = [] memoObject.canvas = canvasObject memoObject.tool = toolObject diff --git a/Memola/Persistence/Objects/CanvasObject.swift b/Memola/Persistence/Objects/CanvasObject.swift index 6204db1..66fc646 100644 --- a/Memola/Persistence/Objects/CanvasObject.swift +++ b/Memola/Persistence/Objects/CanvasObject.swift @@ -8,7 +8,6 @@ import CoreData import Foundation - @objc(CanvasObject) final class CanvasObject: NSManagedObject { @NSManaged var width: CGFloat diff --git a/Memola/Persistence/Objects/ElementObject.swift b/Memola/Persistence/Objects/ElementObject.swift new file mode 100644 index 0000000..83009c8 --- /dev/null +++ b/Memola/Persistence/Objects/ElementObject.swift @@ -0,0 +1,17 @@ +// +// ElementObject.swift +// Memola +// +// Created by Dscyre Scotti on 6/12/24. +// + +import CoreData +import Foundation + +@objc(ElementObject) +final class ElementObject: NSManagedObject { + @NSManaged var type: Int16 + @NSManaged var createdAt: Date? + @NSManaged var stroke: StrokeObject? + @NSManaged var graphicContext: GraphicContextObject? +} diff --git a/Memola/Persistence/Objects/GraphicContextObject.swift b/Memola/Persistence/Objects/GraphicContextObject.swift index f62a765..ea26216 100644 --- a/Memola/Persistence/Objects/GraphicContextObject.swift +++ b/Memola/Persistence/Objects/GraphicContextObject.swift @@ -11,5 +11,5 @@ import Foundation @objc(GraphicContextObject) final class GraphicContextObject: NSManagedObject { @NSManaged var canvas: CanvasObject? - @NSManaged var strokes: NSMutableOrderedSet + @NSManaged var elements: NSMutableOrderedSet } diff --git a/Memola/Persistence/Objects/StrokeObject.swift b/Memola/Persistence/Objects/StrokeObject.swift index e304f46..0307920 100644 --- a/Memola/Persistence/Objects/StrokeObject.swift +++ b/Memola/Persistence/Objects/StrokeObject.swift @@ -17,5 +17,5 @@ final class StrokeObject: NSManagedObject { @NSManaged var thickness: CGFloat @NSManaged var quads: NSMutableOrderedSet @NSManaged var erasers: NSMutableSet - @NSManaged var graphicContext: GraphicContextObject? + @NSManaged var element: ElementObject? } diff --git a/Memola/Resources/Models/MemolaModel.xcdatamodeld/MemolaModel.xcdatamodel/contents b/Memola/Resources/Models/MemolaModel.xcdatamodeld/MemolaModel.xcdatamodel/contents index c9ca0bf..9b5fada 100644 --- a/Memola/Resources/Models/MemolaModel.xcdatamodeld/MemolaModel.xcdatamodel/contents +++ b/Memola/Resources/Models/MemolaModel.xcdatamodeld/MemolaModel.xcdatamodel/contents @@ -6,6 +6,12 @@ + + + + + + @@ -17,7 +23,7 @@ - + @@ -50,8 +56,8 @@ + - From 4d637977e1b367c457ad6fed95c66d7c173b3959 Mon Sep 17 00:00:00 2001 From: dscyrescotti Date: Fri, 14 Jun 2024 23:19:16 +0700 Subject: [PATCH 02/13] feat: add photo render pass --- Memola.xcodeproj/project.pbxproj | 36 +++++++ .../Canvas/Buffers/Vertices/PhotoVertex.swift | 21 ++++ Memola/Canvas/Contexts/GraphicContext.swift | 37 ++++--- Memola/Canvas/Core/Canvas.swift | 13 ++- Memola/Canvas/Core/PipelineStates.swift | 22 ++++ Memola/Canvas/Core/Renderer.swift | 5 + Memola/Canvas/Elements/Core/Element.swift | 25 ++++- Memola/Canvas/Elements/Photo/Photo.swift | 100 ++++++++++++++++++ .../Canvas/RenderPasses/CacheRenderPass.swift | 35 +++--- .../RenderPasses/GraphicRenderPass.swift | 57 ++++++---- .../Canvas/RenderPasses/PhotoRenderPass.swift | 45 ++++++++ Memola/Canvas/Shaders/Photo.metal | 47 ++++++++ Memola/Canvas/Tool/Core/Tool.swift | 2 + Memola/Canvas/Tool/Core/ToolSelection.swift | 14 +++ .../ViewController/CanvasViewController.swift | 52 ++++++++- .../View/Bridge/Views/DrawingView.swift | 12 ++- Memola/Features/Memo/Memo/MemoView.swift | 7 +- Memola/Features/Memo/PenDock/PenDock.swift | 4 +- Memola/Features/Memo/Toolbar/Toolbar.swift | 58 ++++++++-- .../Persistence/Objects/ElementObject.swift | 1 + Memola/Persistence/Objects/PhotoObject.swift | 21 ++++ .../MemolaModel.xcdatamodel/contents | 11 ++ 22 files changed, 553 insertions(+), 72 deletions(-) create mode 100644 Memola/Canvas/Buffers/Vertices/PhotoVertex.swift create mode 100644 Memola/Canvas/Elements/Photo/Photo.swift create mode 100644 Memola/Canvas/RenderPasses/PhotoRenderPass.swift create mode 100644 Memola/Canvas/Shaders/Photo.metal create mode 100644 Memola/Canvas/Tool/Core/ToolSelection.swift create mode 100644 Memola/Persistence/Objects/PhotoObject.swift diff --git a/Memola.xcodeproj/project.pbxproj b/Memola.xcodeproj/project.pbxproj index 7de7d5d..922f77a 100644 --- a/Memola.xcodeproj/project.pbxproj +++ b/Memola.xcodeproj/project.pbxproj @@ -21,6 +21,7 @@ EC3565562BEFC7B300A4E0BF /* NSManagedObject++.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC3565552BEFC7B300A4E0BF /* NSManagedObject++.swift */; }; EC35655A2BF060D900A4E0BF /* Quad.metal in Sources */ = {isa = PBXBuildFile; fileRef = EC3565592BF060D900A4E0BF /* Quad.metal */; }; EC35655C2BF0712A00A4E0BF /* Float++.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC35655B2BF0712A00A4E0BF /* Float++.swift */; }; + EC37FB122C1B2DD90008D976 /* ToolSelection.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC37FB112C1B2DD90008D976 /* ToolSelection.swift */; }; EC4538892BEBCAE000A86FEC /* Quad.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC4538882BEBCAE000A86FEC /* Quad.swift */; }; EC5050072BF65CED00B4D86E /* PenDropDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC5050062BF65CED00B4D86E /* PenDropDelegate.swift */; }; EC50500D2BF6674400B4D86E /* OnDragViewModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC50500C2BF6674400B4D86E /* OnDragViewModifier.swift */; }; @@ -84,6 +85,11 @@ ECA739082BE623F300A4542E /* PenDock.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECA739072BE623F300A4542E /* PenDock.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 */; }; + ECD12A8F2C1AEBA400B96E12 /* Photo.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECD12A8E2C1AEBA400B96E12 /* Photo.swift */; }; + ECD12A912C1B04EA00B96E12 /* PhotoRenderPass.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECD12A902C1B04EA00B96E12 /* PhotoRenderPass.swift */; }; + ECD12A932C1B062000B96E12 /* Photo.metal in Sources */ = {isa = PBXBuildFile; fileRef = ECD12A922C1B062000B96E12 /* Photo.metal */; }; + ECD12A952C1B1FA200B96E12 /* PhotoVertex.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECD12A942C1B1FA200B96E12 /* PhotoVertex.swift */; }; ECE883BD2C00AA170045C53D /* EraserStroke.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECE883BC2C00AA170045C53D /* EraserStroke.swift */; }; ECE883BF2C00AB440045C53D /* Stroke.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECE883BE2C00AB440045C53D /* Stroke.swift */; }; ECE883C12C00C9CB0045C53D /* StrokeStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECE883C02C00C9CB0045C53D /* StrokeStyle.swift */; }; @@ -111,6 +117,7 @@ EC3565552BEFC7B300A4E0BF /* NSManagedObject++.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSManagedObject++.swift"; sourceTree = ""; }; EC3565592BF060D900A4E0BF /* Quad.metal */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.metal; path = Quad.metal; sourceTree = ""; }; EC35655B2BF0712A00A4E0BF /* Float++.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Float++.swift"; sourceTree = ""; }; + EC37FB112C1B2DD90008D976 /* ToolSelection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToolSelection.swift; sourceTree = ""; }; EC4538882BEBCAE000A86FEC /* Quad.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Quad.swift; sourceTree = ""; }; EC5050062BF65CED00B4D86E /* PenDropDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PenDropDelegate.swift; sourceTree = ""; }; EC50500C2BF6674400B4D86E /* OnDragViewModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnDragViewModifier.swift; sourceTree = ""; }; @@ -176,6 +183,11 @@ ECA739072BE623F300A4542E /* PenDock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PenDock.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 = ""; }; + ECD12A8E2C1AEBA400B96E12 /* Photo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Photo.swift; sourceTree = ""; }; + ECD12A902C1B04EA00B96E12 /* PhotoRenderPass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoRenderPass.swift; sourceTree = ""; }; + ECD12A922C1B062000B96E12 /* Photo.metal */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.metal; path = Photo.metal; sourceTree = ""; }; + ECD12A942C1B1FA200B96E12 /* PhotoVertex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoVertex.swift; sourceTree = ""; }; ECE883BC2C00AA170045C53D /* EraserStroke.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EraserStroke.swift; sourceTree = ""; }; ECE883BE2C00AB440045C53D /* Stroke.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Stroke.swift; sourceTree = ""; }; ECE883C02C00C9CB0045C53D /* StrokeStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StrokeStyle.swift; sourceTree = ""; }; @@ -245,6 +257,7 @@ isa = PBXGroup; children = ( ECA738BB2BE60E0300A4542E /* Tool.swift */, + EC37FB112C1B2DD90008D976 /* ToolSelection.swift */, ); path = Core; sourceTree = ""; @@ -439,6 +452,7 @@ ECA738942BE6012D00A4542E /* ViewPort.metal */, ECA738962BE6014200A4542E /* Graphic.metal */, EC3565592BF060D900A4E0BF /* Quad.metal */, + ECD12A922C1B062000B96E12 /* Photo.metal */, ); path = Shaders; sourceTree = ""; @@ -467,6 +481,7 @@ ECA7389B2BE601AF00A4542E /* GridVertex.swift */, ECA7389D2BE601CB00A4542E /* QuadVertex.swift */, ECA7389F2BE601E400A4542E /* ViewPortVertex.swift */, + ECD12A942C1B1FA200B96E12 /* PhotoVertex.swift */, ); path = Vertices; sourceTree = ""; @@ -594,6 +609,7 @@ ECA738DD2BE610A000A4542E /* ViewPortRenderPass.swift */, ECA738DF2BE610B900A4542E /* EraserRenderPass.swift */, ECA738E12BE610D000A4542E /* GraphicRenderPass.swift */, + ECD12A902C1B04EA00B96E12 /* PhotoRenderPass.swift */, ); path = RenderPasses; sourceTree = ""; @@ -652,6 +668,7 @@ ECD12A872C19EF8700B96E12 /* Elements */ = { isa = PBXGroup; children = ( + ECD12A8D2C1AEB8000B96E12 /* Photo */, ECD12A882C19EF9500B96E12 /* Core */, ECA738F92BE6130000A4542E /* Geometries */, ); @@ -666,6 +683,14 @@ path = Core; sourceTree = ""; }; + ECD12A8D2C1AEB8000B96E12 /* Photo */ = { + isa = PBXGroup; + children = ( + ECD12A8E2C1AEBA400B96E12 /* Photo.swift */, + ); + path = Photo; + sourceTree = ""; + }; ECE883B82C009DC30045C53D /* Strokes */ = { isa = PBXGroup; children = ( @@ -698,6 +723,7 @@ EC0D14252BF7A8C9009BFE5F /* PenObject.swift */, EC9AB09E2C1401A40076AF58 /* EraserObject.swift */, ECD12A852C19EE3900B96E12 /* ElementObject.swift */, + ECD12A8B2C1AEAA900B96E12 /* PhotoObject.swift */, ); path = Objects; sourceTree = ""; @@ -785,6 +811,7 @@ buildActionMask = 2147483647; files = ( ECA738B02BE60D0B00A4542E /* CanvasViewController.swift in Sources */, + ECD12A912C1B04EA00B96E12 /* PhotoRenderPass.swift in Sources */, ECA738E42BE6110800A4542E /* Drawable.swift in Sources */, ECA738AD2BE60CC600A4542E /* DrawingView.swift in Sources */, EC1B783D2BFA0AC9005A34E2 /* Toolbar.swift in Sources */, @@ -808,6 +835,7 @@ ECA738CD2BE60F2F00A4542E /* GridContext.swift in Sources */, ECFA15202BEF21EF00455818 /* MemoObject.swift in Sources */, ECE883C12C00C9CB0045C53D /* StrokeStyle.swift in Sources */, + EC37FB122C1B2DD90008D976 /* ToolSelection.swift in Sources */, ECA738C62BE60E9D00A4542E /* EraserPenStyle.swift in Sources */, ECA738EA2BE6122E00A4542E /* CGPoint++.swift in Sources */, ECA738A62BE6023F00A4542E /* GridUniforms.swift in Sources */, @@ -827,6 +855,8 @@ ECA738B82BE60DDC00A4542E /* HistoryEvent.swift in Sources */, EC0D14262BF7A8C9009BFE5F /* PenObject.swift in Sources */, ECA738952BE6012D00A4542E /* ViewPort.metal in Sources */, + ECD12A952C1B1FA200B96E12 /* PhotoVertex.swift in Sources */, + ECD12A8C2C1AEAA900B96E12 /* PhotoObject.swift in Sources */, ECD12A862C19EE3900B96E12 /* ElementObject.swift in Sources */, EC0D14242BF79C98009BFE5F /* MemolaModel.xcdatamodeld in Sources */, ECA738F02BE6127700A4542E /* CGSize++.swift in Sources */, @@ -840,6 +870,8 @@ ECA738F42BE612A000A4542E /* Array++.swift in Sources */, EC4538892BEBCAE000A86FEC /* Quad.swift in Sources */, ECE883BD2C00AA170045C53D /* EraserStroke.swift in Sources */, + ECD12A8F2C1AEBA400B96E12 /* Photo.swift in Sources */, + ECD12A932C1B062000B96E12 /* Photo.metal in Sources */, ECA7388F2BE600DA00A4542E /* Grid.metal in Sources */, EC2BEBF42C0F5FF7005DB0AF /* RTree.swift in Sources */, ECA738C92BE60EF700A4542E /* GraphicContext.swift in Sources */, @@ -1016,6 +1048,8 @@ "@executable_path/Frameworks", ); MARKETING_VERSION = 1.0; + MTLLINKER_FLAGS = ""; + MTL_COMPILER_FLAGS = ""; PRODUCT_BUNDLE_IDENTIFIER = com.example.Memola; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -1049,6 +1083,8 @@ "@executable_path/Frameworks", ); MARKETING_VERSION = 1.0; + MTLLINKER_FLAGS = ""; + MTL_COMPILER_FLAGS = ""; PRODUCT_BUNDLE_IDENTIFIER = com.example.Memola; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; diff --git a/Memola/Canvas/Buffers/Vertices/PhotoVertex.swift b/Memola/Canvas/Buffers/Vertices/PhotoVertex.swift new file mode 100644 index 0000000..8e1f118 --- /dev/null +++ b/Memola/Canvas/Buffers/Vertices/PhotoVertex.swift @@ -0,0 +1,21 @@ +// +// PhotoVertex.swift +// Memola +// +// Created by Dscyre Scotti on 6/13/24. +// + +import MetalKit +import Foundation + +struct PhotoVertex { + var position: vector_float4 + var textCoord: vector_float2 +} + +extension PhotoVertex { + init(x: CGFloat, y: CGFloat, textCoord: CGPoint) { + self.position = [x.float, y.float, 0, 1] + self.textCoord = [textCoord.x.float, textCoord.y.float] + } +} diff --git a/Memola/Canvas/Contexts/GraphicContext.swift b/Memola/Canvas/Contexts/GraphicContext.swift index a370df3..7aca567 100644 --- a/Memola/Canvas/Contexts/GraphicContext.swift +++ b/Memola/Canvas/Contexts/GraphicContext.swift @@ -15,8 +15,8 @@ final class GraphicContext: @unchecked Sendable { var eraserStrokes: Set = [] var object: GraphicContextObject? - var currentStroke: (any Stroke)? - var previousStroke: (any Stroke)? + var currentElement: Element? + var previousElement: Element? var currentPoint: CGPoint? var renderType: RenderType = .finished @@ -69,7 +69,7 @@ final class GraphicContext: @unchecked Sendable { context.refreshAllObjects() } } - previousStroke = nil + previousElement = nil } } @@ -104,7 +104,7 @@ final class GraphicContext: @unchecked Sendable { context.refreshAllObjects() } } - previousStroke = nil + previousElement = nil } } } @@ -174,6 +174,7 @@ extension GraphicContext: Drawable { } } +// MARK: - Stroke extension GraphicContext { func beginStroke(at point: CGPoint, pen: Pen) -> any Stroke { let stroke: any Stroke @@ -231,14 +232,14 @@ extension GraphicContext { } stroke = eraserStroke } - currentStroke = stroke + currentElement = .stroke(stroke.anyStroke) currentPoint = point - currentStroke?.begin(at: point) + currentElement?.stroke()?.begin(at: point) return stroke } func appendStroke(with point: CGPoint) { - guard let currentStroke else { return } + guard let currentStroke = currentElement?.stroke() else { return } guard let currentPoint, point.distance(to: currentPoint) > currentStroke.thickness * currentStroke.penStyle.stepRate else { return } @@ -247,7 +248,7 @@ extension GraphicContext { } func endStroke(at point: CGPoint) { - guard currentPoint != nil, let currentStroke = currentStroke else { return } + guard currentPoint != nil, let currentStroke = currentElement?.stroke() else { return } currentStroke.finish(at: point) if let penStroke = currentStroke.stroke(as: PenStroke.self) { penStroke.saveQuads() @@ -268,13 +269,13 @@ extension GraphicContext { context.refreshAllObjects() } } - previousStroke = currentStroke - self.currentStroke = nil + previousElement = currentElement + self.currentElement = nil self.currentPoint = nil } func cancelStroke() { - if let stroke = currentStroke { + if let stroke = currentElement?.stroke() { switch stroke.style { case .marker: guard let _stroke = stroke.stroke(as: PenStroke.self) else { break } @@ -296,11 +297,23 @@ extension GraphicContext { } } } - currentStroke = nil + currentElement = nil currentPoint = nil } } +// MARK: - Photo +extension GraphicContext { + func insertPhoto(at point: CGPoint) { + 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) + tree.insert(.photo(photo), in: photo.photoBox) + self.previousElement = .photo(photo) + } +} + extension GraphicContext { enum RenderType { case inProgress diff --git a/Memola/Canvas/Core/Canvas.swift b/Memola/Canvas/Core/Canvas.swift index 821d953..f7f11dc 100644 --- a/Memola/Canvas/Core/Canvas.swift +++ b/Memola/Canvas/Core/Canvas.swift @@ -39,8 +39,8 @@ final class Canvas: ObservableObject, Identifiable, @unchecked Sendable { } var hasValidStroke: Bool { - if let currentStroke = graphicContext.currentStroke { - return Date.now.timeIntervalSince(currentStroke.createdAt) * 1000 > 80 + if let currentElement = graphicContext.currentElement { + return Date.now.timeIntervalSince(currentElement.createdAt) * 1000 > 80 } return false } @@ -103,7 +103,7 @@ extension Canvas { } } -// MARK: - Graphic Context +// MARK: - Stroke extension Canvas { func beginTouch(at point: CGPoint, pen: Pen) -> any Stroke { graphicContext.beginStroke(at: point, pen: pen) @@ -126,6 +126,13 @@ extension Canvas { } } +// MARK: - Photo +extension Canvas { + func insertPhoto(at point: CGPoint) { + graphicContext.insertPhoto(at: point) + } +} + // MARK: - Rendering extension Canvas { func renderGrid(device: MTLDevice, renderEncoder: MTLRenderCommandEncoder) { diff --git a/Memola/Canvas/Core/PipelineStates.swift b/Memola/Canvas/Core/PipelineStates.swift index 8082693..7d68936 100644 --- a/Memola/Canvas/Core/PipelineStates.swift +++ b/Memola/Canvas/Core/PipelineStates.swift @@ -93,6 +93,28 @@ struct PipelineStates { return try? device.makeRenderPipelineState(descriptor: pipelineDescriptor) } + static func createPhotoPipelineState(from renderer: Renderer, pixelFormat: MTLPixelFormat? = nil) -> MTLRenderPipelineState? { + let device = renderer.device + let library = renderer.library + let pipelineDescriptor = MTLRenderPipelineDescriptor() + + pipelineDescriptor.vertexFunction = library.makeFunction(name: "vertex_photo") + pipelineDescriptor.fragmentFunction = library.makeFunction(name: "fragment_photo") + pipelineDescriptor.colorAttachments[0].pixelFormat = pixelFormat ?? renderer.pixelFormat + pipelineDescriptor.label = "Photo Pipeline State" + + let attachment = pipelineDescriptor.colorAttachments[0] + attachment?.isBlendingEnabled = true + attachment?.rgbBlendOperation = .add + attachment?.sourceRGBBlendFactor = .sourceAlpha + attachment?.destinationRGBBlendFactor = .oneMinusSourceAlpha + attachment?.alphaBlendOperation = .add + attachment?.sourceAlphaBlendFactor = .one + attachment?.destinationAlphaBlendFactor = .oneMinusSourceAlpha + + return try? device.makeRenderPipelineState(descriptor: pipelineDescriptor) + } + static func createViewPortPipelineState(from renderer: Renderer, pixelFormat: MTLPixelFormat? = nil, isUpdate: Bool = false) -> MTLRenderPipelineState? { var label: String var vertexName: String diff --git a/Memola/Canvas/Core/Renderer.swift b/Memola/Canvas/Core/Renderer.swift index 2853673..d8cd644 100644 --- a/Memola/Canvas/Core/Renderer.swift +++ b/Memola/Canvas/Core/Renderer.swift @@ -25,6 +25,9 @@ final class Renderer { lazy var eraserRenderPass: EraserRenderPass = { EraserRenderPass(renderer: self) }() + lazy var photoRenderPass: PhotoRenderPass = { + PhotoRenderPass(renderer: self) + }() lazy var graphicRenderPass: GraphicRenderPass = { GraphicRenderPass(renderer: self) }() @@ -66,12 +69,14 @@ final class Renderer { func draw(in view: MTKView, on canvas: Canvas) { if !updatesViewPort { + graphicRenderPass.photoRenderPass = photoRenderPass graphicRenderPass.strokeRenderPass = strokeRenderPass graphicRenderPass.eraserRenderPass = eraserRenderPass graphicRenderPass.draw(on: canvas, with: self) } cacheRenderPass.clearsTexture = graphicRenderPass.clearsTexture + cacheRenderPass.photoRenderPass = photoRenderPass cacheRenderPass.strokeRenderPass = strokeRenderPass cacheRenderPass.eraserRenderPass = eraserRenderPass cacheRenderPass.graphicTexture = graphicRenderPass.graphicTexture diff --git a/Memola/Canvas/Elements/Core/Element.swift b/Memola/Canvas/Elements/Core/Element.swift index a59da0b..bde3e5b 100644 --- a/Memola/Canvas/Elements/Core/Element.swift +++ b/Memola/Canvas/Elements/Core/Element.swift @@ -9,7 +9,14 @@ import Foundation enum Element: Equatable, Comparable { case stroke(AnyStroke) - case photo + case photo(Photo) + + func stroke() -> (any Stroke)? { + guard case let .stroke(anyStroke) = self else { + return nil + } + return anyStroke.value + } func stroke(as type: S.Type) -> S? { guard case let .stroke(anyStroke) = self else { @@ -17,4 +24,20 @@ enum Element: Equatable, Comparable { } return anyStroke.stroke(as: type) } + + func photo() -> Photo? { + guard case let .photo(photo) = self else { + return nil + } + return photo + } + + var createdAt: Date { + switch self { + case .stroke(let anyStroke): + anyStroke.value.createdAt + case .photo(let photo): + photo.createdAt + } + } } diff --git a/Memola/Canvas/Elements/Photo/Photo.swift b/Memola/Canvas/Elements/Photo/Photo.swift new file mode 100644 index 0000000..004c8ec --- /dev/null +++ b/Memola/Canvas/Elements/Photo/Photo.swift @@ -0,0 +1,100 @@ +// +// Photo.swift +// Memola +// +// Created by Dscyre Scotti on 6/13/24. +// + +import MetalKit +import Foundation + +final class Photo: @unchecked Sendable, Equatable, Comparable { + var id: UUID = UUID() + var size: CGSize + var origin: CGPoint + var bounds: [CGFloat] + var createdAt: Date + + var object: PhotoObject? + + var vertices: [PhotoVertex] = [] + var vertexCount: Int = 0 + var vertexBuffer: MTLBuffer? + + init(size: CGSize, origin: CGPoint, bounds: [CGFloat], createdAt: Date) { + self.size = size + self.origin = origin + self.bounds = bounds + self.createdAt = createdAt + generateVertices() + } + + convenience init(object: PhotoObject) { + self.init( + size: .init(width: object.width, height: object.height), + origin: .init(x: object.originX, y: object.originY), + bounds: object.bounds, + createdAt: object.createdAt ?? .now + ) + self.object = object + } + + func generateVertices() { + let minX = origin.x - size.width / 2 + let maxX = origin.x + size.width / 2 + 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)), + ] + } +} + +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: []) + } + + func draw(device: any MTLDevice, renderEncoder: any MTLRenderCommandEncoder) { + prepare(device: device) + renderEncoder.setVertexBuffer(vertexBuffer, offset: 0, index: 0) + renderEncoder.drawPrimitives(type: .triangleStrip, vertexStart: 0, vertexCount: vertices.count) + } +} + +extension Photo { + static func == (lhs: Photo, rhs: Photo) -> Bool { + lhs.id == rhs.id + } + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } + + static func < (lhs: Photo, rhs: Photo) -> Bool { + lhs.createdAt < rhs.createdAt + } +} + +extension Photo { + var photoBounds: CGRect { + let x = bounds[0] + let y = bounds[1] + let width = bounds[2] - x + let height = bounds[3] - y + return CGRect(x: x, y: y, width: width, height: height) + } + + var photoBox: Box { + Box(minX: bounds[0], minY: bounds[1], maxX: bounds[2], maxY: bounds[3]) + } + + func isVisible(in bounds: CGRect) -> Bool { + bounds.contains(photoBounds) || bounds.intersects(photoBounds) + } +} diff --git a/Memola/Canvas/RenderPasses/CacheRenderPass.swift b/Memola/Canvas/RenderPasses/CacheRenderPass.swift index 20b3b27..7c5f152 100644 --- a/Memola/Canvas/RenderPasses/CacheRenderPass.swift +++ b/Memola/Canvas/RenderPasses/CacheRenderPass.swift @@ -19,6 +19,7 @@ class CacheRenderPass: RenderPass { weak var graphicTexture: MTLTexture? var cacheTexture: MTLTexture? + weak var photoRenderPass: PhotoRenderPass? weak var strokeRenderPass: StrokeRenderPass? weak var eraserRenderPass: EraserRenderPass? var clearsTexture: Bool = true @@ -34,7 +35,7 @@ class CacheRenderPass: RenderPass { } func draw(on canvas: Canvas, with renderer: Renderer) { - guard let descriptor, let strokeRenderPass, let eraserRenderPass else { return } + guard let descriptor, let strokeRenderPass, let eraserRenderPass, let photoRenderPass else { return } copyTexture(on: canvas, with: renderer) @@ -45,18 +46,26 @@ class CacheRenderPass: RenderPass { descriptor.colorAttachments[0].storeAction = .store let graphicContext = canvas.graphicContext - if let stroke = graphicContext.currentStroke { - switch stroke.style { - case .eraser: - eraserRenderPass.stroke = stroke - eraserRenderPass.descriptor = descriptor - eraserRenderPass.draw(on: canvas, with: renderer) - case .marker: - canvas.setGraphicRenderType(.inProgress) - strokeRenderPass.stroke = stroke - strokeRenderPass.graphicDescriptor = descriptor - strokeRenderPass.graphicPipelineState = graphicPipelineState - strokeRenderPass.draw(on: canvas, with: renderer) + if let element = graphicContext.currentElement { + switch element { + case .stroke(let anyStroke): + let stroke = anyStroke.value + switch stroke.style { + case .eraser: + eraserRenderPass.stroke = stroke + eraserRenderPass.descriptor = descriptor + eraserRenderPass.draw(on: canvas, with: renderer) + case .marker: + canvas.setGraphicRenderType(.inProgress) + strokeRenderPass.stroke = stroke + strokeRenderPass.graphicDescriptor = descriptor + strokeRenderPass.graphicPipelineState = graphicPipelineState + strokeRenderPass.draw(on: canvas, with: renderer) + } + case .photo(let photo): + photoRenderPass.photo = photo + photoRenderPass.descriptor = descriptor + photoRenderPass.draw(on: canvas, with: renderer) } clearsTexture = false } diff --git a/Memola/Canvas/RenderPasses/GraphicRenderPass.swift b/Memola/Canvas/RenderPasses/GraphicRenderPass.swift index 80dd01d..a21ec42 100644 --- a/Memola/Canvas/RenderPasses/GraphicRenderPass.swift +++ b/Memola/Canvas/RenderPasses/GraphicRenderPass.swift @@ -15,6 +15,7 @@ class GraphicRenderPass: RenderPass { var graphicPipelineState: MTLRenderPipelineState? + weak var photoRenderPass: PhotoRenderPass? weak var strokeRenderPass: StrokeRenderPass? weak var eraserRenderPass: EraserRenderPass? @@ -32,7 +33,7 @@ class GraphicRenderPass: RenderPass { } func draw(on canvas: Canvas, with renderer: Renderer) { - guard let strokeRenderPass, let eraserRenderPass else { return } + guard let strokeRenderPass, let eraserRenderPass, let photoRenderPass else { return } guard let descriptor else { return } guard let graphicPipelineState else { return } @@ -46,12 +47,12 @@ class GraphicRenderPass: RenderPass { if renderer.redrawsGraphicRender { canvas.setGraphicRenderType(.finished) for _element in graphicContext.tree.search(box: canvas.bounds.box) { + if graphicContext.previousElement == _element || graphicContext.currentElement == _element { + continue + } switch _element { case .stroke(let _stroke): let stroke = _stroke.value - if graphicContext.previousStroke === stroke || graphicContext.currentStroke === stroke { - continue - } guard stroke.isVisible(in: canvas.bounds) else { continue } descriptor.colorAttachments[0].loadAction = clearsTexture ? .clear : .load clearsTexture = false @@ -74,36 +75,48 @@ class GraphicRenderPass: RenderPass { eraserRenderPass.draw(on: canvas, with: renderer) } } - case .photo: - break + case .photo(let photo): + descriptor.colorAttachments[0].loadAction = clearsTexture ? .clear : .load + clearsTexture = false + photoRenderPass.photo = photo + photoRenderPass.descriptor = descriptor + photoRenderPass.draw(on: canvas, with: renderer) } } renderer.redrawsGraphicRender = false } - if let stroke = graphicContext.previousStroke { + if let element = graphicContext.previousElement { descriptor.colorAttachments[0].loadAction = clearsTexture ? .clear : .load clearsTexture = false - switch stroke.style { - case .eraser: - eraserRenderPass.stroke = stroke - eraserRenderPass.descriptor = descriptor - eraserRenderPass.draw(on: canvas, with: renderer) - case .marker: - canvas.setGraphicRenderType(.newlyFinished) - strokeRenderPass.stroke = stroke - strokeRenderPass.graphicDescriptor = descriptor - strokeRenderPass.graphicPipelineState = graphicPipelineState - strokeRenderPass.draw(on: canvas, with: renderer) - - if let stroke = stroke as? PenStroke, !stroke.isEmptyErasedQuads { - descriptor.colorAttachments[0].loadAction = .load + switch element { + case .stroke(let anyStroke): + let stroke = anyStroke.value + switch stroke.style { + case .eraser: eraserRenderPass.stroke = stroke eraserRenderPass.descriptor = descriptor eraserRenderPass.draw(on: canvas, with: renderer) + case .marker: + canvas.setGraphicRenderType(.newlyFinished) + strokeRenderPass.stroke = stroke + strokeRenderPass.graphicDescriptor = descriptor + strokeRenderPass.graphicPipelineState = graphicPipelineState + strokeRenderPass.draw(on: canvas, with: renderer) + + if let stroke = stroke as? PenStroke, !stroke.isEmptyErasedQuads { + descriptor.colorAttachments[0].loadAction = .load + eraserRenderPass.stroke = stroke + eraserRenderPass.descriptor = descriptor + eraserRenderPass.draw(on: canvas, with: renderer) + } } + case .photo(let photo): + photoRenderPass.photo = photo + photoRenderPass.descriptor = descriptor + photoRenderPass.draw(on: canvas, with: renderer) } - graphicContext.previousStroke = nil + graphicContext.previousElement = nil } let eraserStrokes = graphicContext.eraserStrokes diff --git a/Memola/Canvas/RenderPasses/PhotoRenderPass.swift b/Memola/Canvas/RenderPasses/PhotoRenderPass.swift new file mode 100644 index 0000000..49b9f6f --- /dev/null +++ b/Memola/Canvas/RenderPasses/PhotoRenderPass.swift @@ -0,0 +1,45 @@ +// +// PhotoRenderPass.swift +// Memola +// +// Created by Dscyre Scotti on 6/13/24. +// + +import MetalKit +import Foundation + +class PhotoRenderPass: RenderPass { + var label: String = "Photo Render Pass" + + var descriptor: MTLRenderPassDescriptor? + + var photoPipelineState: MTLRenderPipelineState? + weak var graphicTexture: MTLTexture? + + var photo: Photo? + + init(renderer: Renderer) { + photoPipelineState = PipelineStates.createPhotoPipelineState(from: renderer) + } + + func resize(on view: MTKView, to size: CGSize, with renderer: Renderer) { } + + func draw(on canvas: Canvas, with renderer: Renderer) { + guard let descriptor else { return } + + guard let commandBuffer = renderer.commandQueue.makeCommandBuffer() else { return } + commandBuffer.label = "Photo Command Buffer" + + guard let renderEncoder = commandBuffer.makeRenderCommandEncoder(descriptor: descriptor) else { return } + renderEncoder.label = label + + guard let photoPipelineState else { return } + renderEncoder.setRenderPipelineState(photoPipelineState) + + canvas.setUniformsBuffer(device: renderer.device, renderEncoder: renderEncoder) + photo?.draw(device: renderer.device, renderEncoder: renderEncoder) + + renderEncoder.endEncoding() + commandBuffer.commit() + } +} diff --git a/Memola/Canvas/Shaders/Photo.metal b/Memola/Canvas/Shaders/Photo.metal new file mode 100644 index 0000000..9d2507d --- /dev/null +++ b/Memola/Canvas/Shaders/Photo.metal @@ -0,0 +1,47 @@ +// +// Photo.metal +// Memola +// +// Created by Dscyre Scotti on 6/13/24. +// + +#include +using namespace metal; + +struct VertexIn { + float4 position [[position]]; + float2 textCoord; +}; + +struct VertexOut { + float4 position [[position]]; + float2 textCoord; +}; + +struct Uniforms { + float4x4 transform; +}; + +vertex VertexOut vertex_photo( + constant VertexIn *vertices [[buffer(0)]], + constant Uniforms &uniforms [[buffer(11)]], + uint vertexId [[vertex_id]] +) { + VertexIn in = vertices[vertexId]; + + VertexOut out; + out.position = uniforms.transform * in.position; + out.textCoord = in.textCoord; + + return out; +} + +fragment float4 fragment_photo( + 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); +} diff --git a/Memola/Canvas/Tool/Core/Tool.swift b/Memola/Canvas/Tool/Core/Tool.swift index 5d05c80..8edaa92 100644 --- a/Memola/Canvas/Tool/Core/Tool.swift +++ b/Memola/Canvas/Tool/Core/Tool.swift @@ -17,6 +17,8 @@ public class Tool: NSObject, ObservableObject { @Published var selectedPen: Pen? @Published var draggedPen: Pen? + @Published var selection: ToolSelection = .none + let scrollPublisher = PassthroughSubject() var markers: [Pen] { pens.filter { $0.strokeStyle == .marker } diff --git a/Memola/Canvas/Tool/Core/ToolSelection.swift b/Memola/Canvas/Tool/Core/ToolSelection.swift new file mode 100644 index 0000000..76c3589 --- /dev/null +++ b/Memola/Canvas/Tool/Core/ToolSelection.swift @@ -0,0 +1,14 @@ +// +// ToolSelection.swift +// Memola +// +// Created by Dscyre Scotti on 6/13/24. +// + +import Foundation + +enum ToolSelection: Equatable { + case none + case pen + case photo +} diff --git a/Memola/Canvas/View/Bridge/ViewController/CanvasViewController.swift b/Memola/Canvas/View/Bridge/ViewController/CanvasViewController.swift index e2d60a4..9367cf7 100644 --- a/Memola/Canvas/View/Bridge/ViewController/CanvasViewController.swift +++ b/Memola/Canvas/View/Bridge/ViewController/CanvasViewController.swift @@ -17,6 +17,8 @@ class CanvasViewController: UIViewController { drawingView.renderView } + var photoInsertGesture: UITapGestureRecognizer? + let tool: Tool let canvas: Canvas let history: History @@ -40,6 +42,7 @@ class CanvasViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() configureViews() + configureGestures() configureListeners() } @@ -176,6 +179,11 @@ extension CanvasViewController { self?.penChanged(to: pen) } .store(in: &cancellables) + tool.$selection + .sink { [weak self] selection in + self?.toolSelectionChanged(to: selection) + } + .store(in: &cancellables) history.historyPublisher .sink { [weak self] action in @@ -216,6 +224,21 @@ extension CanvasViewController: MTKViewDelegate { } } +extension CanvasViewController { + func configureGestures() { + let photoInsertGesture = UITapGestureRecognizer(target: self, action: #selector(recognizeTapGesture)) + photoInsertGesture.numberOfTapsRequired = 1 + self.photoInsertGesture = photoInsertGesture + scrollView.addGestureRecognizer(photoInsertGesture) + } + + @objc func recognizeTapGesture(_ gesture: UITapGestureRecognizer) { + let point = gesture.location(in: drawingView) + canvas.insertPhoto(at: point.muliply(by: drawingView.ratio)) + drawingView.draw() + } +} + extension CanvasViewController: UIScrollViewDelegate { func viewForZooming(in scrollView: UIScrollView) -> UIView? { drawingView @@ -300,10 +323,31 @@ extension CanvasViewController { if let pen, let device = drawingView.renderView.device { pen.style.loadTexture(on: device) } - let isPenSelected = pen != nil - scrollView.isScrollEnabled = !isPenSelected - drawingView.isUserInteractionEnabled = isPenSelected - isPenSelected ? drawingView.enableUserInteraction() : drawingView.disableUserInteraction() + } + + func toolSelectionChanged(to selection: ToolSelection) { + let enablesScrolling: Bool + let enablesDrawing: Bool + let enablesPhotoInsertion: Bool + switch selection { + case .none: + enablesScrolling = true + enablesDrawing = false + enablesPhotoInsertion = false + case .pen: + enablesScrolling = false + enablesDrawing = true + enablesPhotoInsertion = false + penChanged(to: tool.selectedPen) + case .photo: + enablesScrolling = true + enablesDrawing = false + enablesPhotoInsertion = true + } + scrollView.isScrollEnabled = enablesScrolling + drawingView.isUserInteractionEnabled = enablesDrawing + photoInsertGesture?.isEnabled = enablesPhotoInsertion + enablesDrawing ? drawingView.enableUserInteraction() : drawingView.disableUserInteraction() } } diff --git a/Memola/Canvas/View/Bridge/Views/DrawingView.swift b/Memola/Canvas/View/Bridge/Views/DrawingView.swift index 84b58d5..f48f270 100644 --- a/Memola/Canvas/View/Bridge/Views/DrawingView.swift +++ b/Memola/Canvas/View/Bridge/Views/DrawingView.swift @@ -87,20 +87,20 @@ class DrawingView: UIView { guard !disablesUserInteraction else { return } canvas.moveTouch(to: point.muliply(by: ratio)) if canvas.hasValidStroke { - renderView.draw() + draw() } } func touchEnded(at point: CGPoint) { guard !disablesUserInteraction else { return } canvas.endTouch(at: point.muliply(by: ratio)) - renderView.draw() + draw() } func touchCancelled() { - if canvas.graphicContext.currentStroke != nil { + if canvas.graphicContext.currentElement != nil { canvas.cancelTouch() - renderView.draw() + draw() history.restoreUndo() } } @@ -114,4 +114,8 @@ class DrawingView: UIView { self?.disablesUserInteraction = false } } + + func draw() { + renderView.draw() + } } diff --git a/Memola/Features/Memo/Memo/MemoView.swift b/Memola/Features/Memo/Memo/MemoView.swift index b12e1ed..1d6ed58 100644 --- a/Memola/Features/Memo/Memo/MemoView.swift +++ b/Memola/Features/Memo/Memo/MemoView.swift @@ -30,14 +30,17 @@ struct MemoView: View { CanvasView(tool: tool, canvas: canvas, history: history) .ignoresSafeArea() .overlay(alignment: .trailing) { - PenDock(tool: tool, canvas: canvas) + if tool.selection == .pen { + PenDock(tool: tool, canvas: canvas) + .transition(.move(edge: .trailing)) + } } .overlay(alignment: .bottomLeading) { zoomControl } .disabled(textFieldState) .overlay(alignment: .top) { - Toolbar(size: size, memo: memo, canvas: canvas, history: history) + Toolbar(size: size, memo: memo, tool: tool, canvas: canvas, history: history) } .disabled(canvas.state == .loading || canvas.state == .closing) .overlay { diff --git a/Memola/Features/Memo/PenDock/PenDock.swift b/Memola/Features/Memo/PenDock/PenDock.swift index bcaa02c..cbf4432 100644 --- a/Memola/Features/Memo/PenDock/PenDock.swift +++ b/Memola/Features/Memo/PenDock/PenDock.swift @@ -86,9 +86,7 @@ struct PenDock: View { .padding(.vertical, 5) .contentShape(.rect(cornerRadii: .init(topLeading: 10, bottomLeading: 10))) .onTapGesture { - if tool.selectedPen === pen { - tool.unselectPen(pen) - } else { + if tool.selectedPen !== pen { tool.selectPen(pen) } } diff --git a/Memola/Features/Memo/Toolbar/Toolbar.swift b/Memola/Features/Memo/Toolbar/Toolbar.swift index 24c7cba..baae7f1 100644 --- a/Memola/Features/Memo/Toolbar/Toolbar.swift +++ b/Memola/Features/Memo/Toolbar/Toolbar.swift @@ -11,6 +11,7 @@ import Foundation struct Toolbar: View { @Environment(\.dismiss) var dismiss + @ObservedObject var tool: Tool @ObservedObject var canvas: Canvas @ObservedObject var history: History @@ -20,9 +21,10 @@ struct Toolbar: View { let size: CGFloat - init(size: CGFloat, memo: MemoObject, canvas: Canvas, history: History) { + init(size: CGFloat, memo: MemoObject, tool: Tool, canvas: Canvas, history: History) { self.size = size self.memo = memo + self.tool = tool self.canvas = canvas self.history = history self.title = memo.title @@ -30,15 +32,21 @@ struct Toolbar: View { var body: some View { HStack(spacing: 5) { - if !canvas.locksCanvas { - closeButton - titleField + HStack(spacing: 5) { + if !canvas.locksCanvas { + closeButton + titleField + } } - Spacer() - if !canvas.locksCanvas { - historyControl + .frame(maxWidth: .infinity, alignment: .leading) + elementTool + HStack(spacing: 5) { + if !canvas.locksCanvas { + historyControl + } + lockButton } - lockButton + .frame(maxWidth: .infinity, alignment: .trailing) } .font(.subheadline) .padding(10) @@ -82,6 +90,39 @@ struct Toolbar: View { .transition(.move(edge: .top).combined(with: .blurReplace)) } + var elementTool: some View { + HStack(spacing: 0) { + Button { + withAnimation { + tool.selection = tool.selection == .pen ? .none : .pen + } + } label: { + Image(systemName: "pencil") + .contentShape(.circle) + .frame(width: size, height: size) + .background(tool.selection == .pen ? Color.accentColor : Color.clear) + .foregroundStyle(tool.selection == .pen ? Color.white : Color.accentColor) + .clipShape(.rect(cornerRadius: 8)) + } + .hoverEffect(.lift) + Button { + withAnimation { + tool.selection = tool.selection == .photo ? .none : .photo + } + } label: { + Image(systemName: "photo") + .contentShape(.circle) + .frame(width: size, height: size) + .background(tool.selection == .photo ? Color.accentColor : Color.clear) + .foregroundStyle(tool.selection == .photo ? Color.white : Color.accentColor) + .clipShape(.rect(cornerRadius: 8)) + } + .hoverEffect(.lift) + } + .background(.regularMaterial) + .clipShape(.rect(cornerRadius: 8)) + } + var historyControl: some View { HStack { Button { @@ -111,6 +152,7 @@ struct Toolbar: View { var lockButton: some View { Button { + #warning("TODO: need to revisit toggale logic") withAnimation { canvas.locksCanvas.toggle() } diff --git a/Memola/Persistence/Objects/ElementObject.swift b/Memola/Persistence/Objects/ElementObject.swift index 83009c8..45171be 100644 --- a/Memola/Persistence/Objects/ElementObject.swift +++ b/Memola/Persistence/Objects/ElementObject.swift @@ -12,6 +12,7 @@ import Foundation final class ElementObject: NSManagedObject { @NSManaged var type: Int16 @NSManaged var createdAt: Date? + @NSManaged var photo: PhotoObject? @NSManaged var stroke: StrokeObject? @NSManaged var graphicContext: GraphicContextObject? } diff --git a/Memola/Persistence/Objects/PhotoObject.swift b/Memola/Persistence/Objects/PhotoObject.swift new file mode 100644 index 0000000..8595ba7 --- /dev/null +++ b/Memola/Persistence/Objects/PhotoObject.swift @@ -0,0 +1,21 @@ +// +// PhotoObject.swift +// Memola +// +// Created by Dscyre Scotti on 6/13/24. +// + +import CoreData +import Foundation + +@objc(PhotoObject) +class PhotoObject: NSManagedObject { + @NSManaged var width: CGFloat + @NSManaged var originY: CGFloat + @NSManaged var originX: CGFloat + @NSManaged var height: CGFloat + @NSManaged var bounds: [CGFloat] + @NSManaged var createdAt: Date? + @NSManaged var image: Data? + @NSManaged var element: ElementObject? +} diff --git a/Memola/Resources/Models/MemolaModel.xcdatamodeld/MemolaModel.xcdatamodel/contents b/Memola/Resources/Models/MemolaModel.xcdatamodeld/MemolaModel.xcdatamodel/contents index 9b5fada..a310e6c 100644 --- a/Memola/Resources/Models/MemolaModel.xcdatamodeld/MemolaModel.xcdatamodel/contents +++ b/Memola/Resources/Models/MemolaModel.xcdatamodeld/MemolaModel.xcdatamodel/contents @@ -10,6 +10,7 @@ + @@ -40,6 +41,16 @@ + + + + + + + + + + From 49e878d0dde2f88449df9df73e63065002b1217e Mon Sep 17 00:00:00 2001 From: dscyrescotti Date: Sat, 15 Jun 2024 12:37:41 +0700 Subject: [PATCH 03/13] feat: add photo picker to select photo from library --- Memola.xcodeproj/project.pbxproj | 12 +++ Memola/Canvas/Tool/Core/Tool.swift | 4 + Memola/Features/Memo/Memo/MemoView.swift | 12 ++- .../Memo/PhotoPreview/PhotoPreview.swift | 48 ++++++++++++ Memola/Features/Memo/Toolbar/Toolbar.swift | 74 +++++++++++++++---- 5 files changed, 134 insertions(+), 16 deletions(-) create mode 100644 Memola/Features/Memo/PhotoPreview/PhotoPreview.swift diff --git a/Memola.xcodeproj/project.pbxproj b/Memola.xcodeproj/project.pbxproj index 922f77a..f0f51e5 100644 --- a/Memola.xcodeproj/project.pbxproj +++ b/Memola.xcodeproj/project.pbxproj @@ -83,6 +83,7 @@ ECA738F62BE612B700A4542E /* MTLDevice++.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECA738F52BE612B700A4542E /* MTLDevice++.swift */; }; 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 */; }; 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 */; }; @@ -181,6 +182,7 @@ ECA738F52BE612B700A4542E /* MTLDevice++.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MTLDevice++.swift"; sourceTree = ""; }; 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 = ""; }; 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 = ""; }; @@ -389,6 +391,7 @@ ECA7387B2BE5EF3500A4542E /* Memo */ = { isa = PBXGroup; children = ( + ECBE52942C1D58F5006BDB3D /* PhotoPreview */, EC1B783B2BFA0AAC005A34E2 /* Toolbar */, EC5050082BF65D0500B4D86E /* Memo */, EC5050052BF65CCD00B4D86E /* PenDock */, @@ -665,6 +668,14 @@ path = Core; sourceTree = ""; }; + ECBE52942C1D58F5006BDB3D /* PhotoPreview */ = { + isa = PBXGroup; + children = ( + ECBE52952C1D5900006BDB3D /* PhotoPreview.swift */, + ); + path = PhotoPreview; + sourceTree = ""; + }; ECD12A872C19EF8700B96E12 /* Elements */ = { isa = PBXGroup; children = ( @@ -891,6 +902,7 @@ EC5050072BF65CED00B4D86E /* PenDropDelegate.swift in Sources */, ECA738A32BE6020A00A4542E /* CGFloat++.swift in Sources */, ECA738C12BE60E5300A4542E /* PenStyle.swift in Sources */, + ECBE52962C1D5900006BDB3D /* PhotoPreview.swift in Sources */, ECA738DE2BE610A000A4542E /* ViewPortRenderPass.swift in Sources */, EC7F6BEC2BE5E6E300A34A7B /* MemolaApp.swift in Sources */, EC2BEBF82C0F601A005DB0AF /* Node.swift in Sources */, diff --git a/Memola/Canvas/Tool/Core/Tool.swift b/Memola/Canvas/Tool/Core/Tool.swift index 8edaa92..958afe8 100644 --- a/Memola/Canvas/Tool/Core/Tool.swift +++ b/Memola/Canvas/Tool/Core/Tool.swift @@ -14,8 +14,12 @@ public class Tool: NSObject, ObservableObject { let object: ToolObject @Published var pens: [Pen] = [] + + // MARK: - Pen @Published var selectedPen: Pen? @Published var draggedPen: Pen? + // MARK: - Photo + @Published var selectedImage: UIImage? @Published var selection: ToolSelection = .none diff --git a/Memola/Features/Memo/Memo/MemoView.swift b/Memola/Features/Memo/Memo/MemoView.swift index 1d6ed58..c20cb2b 100644 --- a/Memola/Features/Memo/Memo/MemoView.swift +++ b/Memola/Features/Memo/Memo/MemoView.swift @@ -29,10 +29,18 @@ struct MemoView: View { var body: some View { CanvasView(tool: tool, canvas: canvas, history: history) .ignoresSafeArea() - .overlay(alignment: .trailing) { - if tool.selection == .pen { + .overlay(alignment: .bottomTrailing) { + switch tool.selection { + case .pen: PenDock(tool: tool, canvas: canvas) .transition(.move(edge: .trailing)) + case .photo: + if let image = tool.selectedImage { + PhotoPreview(image: image, tool: tool) + .transition(.move(edge: .trailing)) + } + default: + EmptyView() } } .overlay(alignment: .bottomLeading) { diff --git a/Memola/Features/Memo/PhotoPreview/PhotoPreview.swift b/Memola/Features/Memo/PhotoPreview/PhotoPreview.swift new file mode 100644 index 0000000..837c331 --- /dev/null +++ b/Memola/Features/Memo/PhotoPreview/PhotoPreview.swift @@ -0,0 +1,48 @@ +// +// PhotoPreview.swift +// Memola +// +// Created by Dscyre Scotti on 6/15/24. +// + +import SwiftUI + +struct PhotoPreview: View { + let image: UIImage + @ObservedObject var tool: Tool + + var body: some View { + Image(uiImage: image) + .resizable() + .scaledToFill() + .frame(width: 100, height: 100) + .cornerRadius(5) + .overlay { + RoundedRectangle(cornerRadius: 5) + .stroke(Color.gray, lineWidth: 0.2) + } + .padding(10) + .background(.regularMaterial) + .cornerRadius(5) + .overlay(alignment: .topLeading) { + Button { + withAnimation { + tool.selectedImage = nil + } + } label: { + Image(systemName: "xmark.circle.fill") + .font(.title2) + .padding(1) + .contentShape(.circle) + .background { + Circle() + .fill(.white) + } + } + .foregroundStyle(.red) + .hoverEffect(.lift) + .offset(x: -12, y: -12) + } + .padding(10) + } +} diff --git a/Memola/Features/Memo/Toolbar/Toolbar.swift b/Memola/Features/Memo/Toolbar/Toolbar.swift index baae7f1..446096c 100644 --- a/Memola/Features/Memo/Toolbar/Toolbar.swift +++ b/Memola/Features/Memo/Toolbar/Toolbar.swift @@ -6,6 +6,7 @@ // import SwiftUI +import PhotosUI import Foundation struct Toolbar: View { @@ -15,8 +16,10 @@ struct Toolbar: View { @ObservedObject var canvas: Canvas @ObservedObject var history: History - @State var memo: MemoObject @State var title: String + @State var memo: MemoObject + @State var photoItem: PhotosPickerItem? + @FocusState var textFieldState: Bool let size: CGFloat @@ -50,6 +53,19 @@ struct Toolbar: View { } .font(.subheadline) .padding(10) + .onChange(of: photoItem) { oldValue, newValue in + if newValue != nil { + Task { + let data = try? await newValue?.loadTransferable(type: Data.self) + if let data { + withAnimation { + tool.selectedImage = UIImage(data: data) + } + } + photoItem = nil + } + } + } } var closeButton: some View { @@ -105,22 +121,52 @@ struct Toolbar: View { .clipShape(.rect(cornerRadius: 8)) } .hoverEffect(.lift) - Button { - withAnimation { - tool.selection = tool.selection == .photo ? .none : .photo + HStack(spacing: 0) { + Button { + withAnimation { + tool.selection = tool.selection == .photo ? .none : .photo + } + } label: { + Image(systemName: "photo") + .contentShape(.circle) + .frame(width: size, height: size) + .background(tool.selection == .photo ? Color.accentColor : Color.clear) + .foregroundStyle(tool.selection == .photo ? Color.white : Color.accentColor) + .clipShape(.rect(cornerRadius: 8)) + } + .hoverEffect(.lift) + if tool.selection == .photo { + HStack(spacing: 0) { + Button { + + } label: { + Image(systemName: "camera.fill") + .contentShape(.circle) + .frame(width: size, height: size) + .clipShape(.rect(cornerRadius: 8)) + } + .hoverEffect(.lift) + PhotosPicker(selection: $photoItem, matching: .images) { + Image(systemName: "photo.fill.on.rectangle.fill") + .contentShape(.circle) + .frame(width: size, height: size) + .clipShape(.rect(cornerRadius: 8)) + } + .hoverEffect(.lift) + } + } + } + .background { + if tool.selection == .photo { + RoundedRectangle(cornerRadius: 8) + .fill(Color.white.tertiary) } - } label: { - Image(systemName: "photo") - .contentShape(.circle) - .frame(width: size, height: size) - .background(tool.selection == .photo ? Color.accentColor : Color.clear) - .foregroundStyle(tool.selection == .photo ? Color.white : Color.accentColor) - .clipShape(.rect(cornerRadius: 8)) } - .hoverEffect(.lift) } - .background(.regularMaterial) - .clipShape(.rect(cornerRadius: 8)) + .background { + RoundedRectangle(cornerRadius: 8) + .fill(.regularMaterial) + } } var historyControl: some View { From 5203f39f96e8c257fe01241627b578b37e36606a Mon Sep 17 00:00:00 2001 From: dscyrescotti Date: Sat, 15 Jun 2024 13:25:36 +0700 Subject: [PATCH 04/13] feat: add camera view --- Memola.xcodeproj/project.pbxproj | 12 +++++ .../Views/CameraView/CameraView.swift | 44 +++++++++++++++++++ Memola/Config/Info.plist | 2 + Memola/Features/Memo/Toolbar/Toolbar.swift | 43 +++++++++++++++++- 4 files changed, 99 insertions(+), 2 deletions(-) create mode 100644 Memola/Components/Views/CameraView/CameraView.swift diff --git a/Memola.xcodeproj/project.pbxproj b/Memola.xcodeproj/project.pbxproj index f0f51e5..861bf2f 100644 --- a/Memola.xcodeproj/project.pbxproj +++ b/Memola.xcodeproj/project.pbxproj @@ -84,6 +84,7 @@ 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 */; }; 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 */; }; @@ -183,6 +184,7 @@ 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 = ""; }; 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 = ""; }; @@ -232,6 +234,7 @@ EC1B783A2BF9C68C005A34E2 /* Views */ = { isa = PBXGroup; children = ( + ECBE52972C1D6087006BDB3D /* CameraView */, ECFC51252BF8885000D0D051 /* ColorPicker */, ); path = Views; @@ -676,6 +679,14 @@ path = PhotoPreview; sourceTree = ""; }; + ECBE52972C1D6087006BDB3D /* CameraView */ = { + isa = PBXGroup; + children = ( + ECBE52982C1D60E5006BDB3D /* CameraView.swift */, + ); + path = CameraView; + sourceTree = ""; + }; ECD12A872C19EF8700B96E12 /* Elements */ = { isa = PBXGroup; children = ( @@ -890,6 +901,7 @@ EC0D14212BF79C73009BFE5F /* ToolObject.swift in Sources */, EC50500D2BF6674400B4D86E /* OnDragViewModifier.swift in Sources */, EC9AB09F2C1401A40076AF58 /* EraserObject.swift in Sources */, + ECBE52992C1D60E5006BDB3D /* CameraView.swift in Sources */, ECA7389E2BE601CB00A4542E /* QuadVertex.swift in Sources */, ECA738B32BE60D9E00A4542E /* CanvasView.swift in Sources */, ECA738C42BE60E8800A4542E /* MarkerPenStyle.swift in Sources */, diff --git a/Memola/Components/Views/CameraView/CameraView.swift b/Memola/Components/Views/CameraView/CameraView.swift new file mode 100644 index 0000000..712f87d --- /dev/null +++ b/Memola/Components/Views/CameraView/CameraView.swift @@ -0,0 +1,44 @@ +// +// CameraView.swift +// Memola +// +// Created by Dscyre Scotti on 6/15/24. +// + +import SwiftUI + +struct CameraView: UIViewControllerRepresentable { + @Binding var image: UIImage? + + @Environment(\.dismiss) private var dismiss + + func makeUIViewController(context: Context) -> UIImagePickerController { + let picker = UIImagePickerController() + picker.sourceType = .camera + picker.delegate = context.coordinator + return picker + } + + func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) { } + + func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + class Coordinator: NSObject, UINavigationControllerDelegate, UIImagePickerControllerDelegate { + let parent: CameraView + + init(_ parent: CameraView) { + self.parent = parent + } + + func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) { + parent.image = info[.originalImage] as? UIImage + parent.dismiss() + } + + func imagePickerControllerDidCancel(_ picker: UIImagePickerController) { + parent.dismiss() + } + } +} diff --git a/Memola/Config/Info.plist b/Memola/Config/Info.plist index 11d6956..5c10a78 100644 --- a/Memola/Config/Info.plist +++ b/Memola/Config/Info.plist @@ -2,6 +2,8 @@ + NSCameraUsageDescription + Memola requires access to the camera to capture photos. CFBundleShortVersionString $(MARKETING_VERSION) UISupportedInterfaceOrientations~ipad diff --git a/Memola/Features/Memo/Toolbar/Toolbar.swift b/Memola/Features/Memo/Toolbar/Toolbar.swift index 446096c..f09d011 100644 --- a/Memola/Features/Memo/Toolbar/Toolbar.swift +++ b/Memola/Features/Memo/Toolbar/Toolbar.swift @@ -8,6 +8,7 @@ import SwiftUI import PhotosUI import Foundation +import AVFoundation struct Toolbar: View { @Environment(\.dismiss) var dismiss @@ -19,6 +20,8 @@ struct Toolbar: View { @State var title: String @State var memo: MemoObject @State var photoItem: PhotosPickerItem? + @State var opensCamera: Bool = false + @State var isCameraAccessDenied: Bool = false @FocusState var textFieldState: Bool @@ -66,6 +69,22 @@ struct Toolbar: View { } } } + .fullScreenCover(isPresented: $opensCamera) { + CameraView(image: $tool.selectedImage) + .ignoresSafeArea() + } + .alert("Camera Access Denied", isPresented: $isCameraAccessDenied) { + Button { + if let url = URL(string: UIApplication.openSettingsURLString + "&path=CAMERA/\(String(describing: Bundle.main.bundleIdentifier))") { + UIApplication.shared.open(url) + } + } label: { + Text("Open Settings") + } + Button("Cancel", role: .cancel) { } + } message: { + Text("Memola requires access to the camera to capture photos. Please open Settings and enable camera access.") + } } var closeButton: some View { @@ -114,6 +133,7 @@ struct Toolbar: View { } } label: { Image(systemName: "pencil") + .fontWeight(.heavy) .contentShape(.circle) .frame(width: size, height: size) .background(tool.selection == .pen ? Color.accentColor : Color.clear) @@ -138,7 +158,7 @@ struct Toolbar: View { if tool.selection == .photo { HStack(spacing: 0) { Button { - + openCamera() } label: { Image(systemName: "camera.fill") .contentShape(.circle) @@ -175,7 +195,6 @@ struct Toolbar: View { history.historyPublisher.send(.undo) } label: { Image(systemName: "arrow.uturn.backward.circle") - .contentShape(.circle) } .hoverEffect(.lift) @@ -220,6 +239,26 @@ struct Toolbar: View { .hoverEffect(.lift) } + func openCamera() { + let status = AVCaptureDevice.authorizationStatus(for: .video) + switch status { + case .notDetermined: + AVCaptureDevice.requestAccess(for: .video) { status in + withAnimation { + if status { + opensCamera = true + } else { + isCameraAccessDenied = true + } + } + } + case .authorized: + opensCamera = true + default: + isCameraAccessDenied = true + } + } + func closeMemo() { withAnimation { canvas.state = .closing From 46330b9a7d281c0ffa98f09d496a207d8baf1503 Mon Sep 17 00:00:00 2001 From: dscyrescotti Date: Sat, 15 Jun 2024 20:53:45 +0700 Subject: [PATCH 05/13] 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) From 333a57da2fa45e6d51d5787fde38efff6847270e Mon Sep 17 00:00:00 2001 From: dscyrescotti Date: Sat, 15 Jun 2024 21:07:17 +0700 Subject: [PATCH 06/13] feat: save photo in persistence --- Memola/Canvas/Contexts/GraphicContext.swift | 31 +++++++++++++++---- Memola/Canvas/Elements/Photo/Photo.swift | 15 +++------ Memola/Persistence/Objects/PhotoObject.swift | 2 +- .../MemolaModel.xcdatamodel/contents | 2 +- 4 files changed, 32 insertions(+), 18 deletions(-) diff --git a/Memola/Canvas/Contexts/GraphicContext.swift b/Memola/Canvas/Contexts/GraphicContext.swift index 33c24bc..9862b5c 100644 --- a/Memola/Canvas/Contexts/GraphicContext.swift +++ b/Memola/Canvas/Contexts/GraphicContext.swift @@ -139,8 +139,9 @@ extension GraphicContext { } } case 1: - #warning("TODO: implement photo") - break + guard let photo = element.photo, photo.imageURL != nil else { return } + let _photo = Photo(object: photo) + tree.insert(_photo.element, in: _photo.photoBox) default: break } @@ -150,9 +151,8 @@ extension GraphicContext { } func loadQuads(_ bounds: CGRect, on context: NSManagedObjectContext) { - #warning("TODO: implement photo") - for _stroke in self.tree.search(box: bounds.box) { - guard let stroke = _stroke.stroke(as: PenStroke.self), stroke.isEmpty else { continue } + for element in self.tree.search(box: bounds.box) { + guard let stroke = element.stroke(as: PenStroke.self), stroke.isEmpty else { continue } stroke.loadQuads(with: self) } } @@ -309,7 +309,26 @@ extension GraphicContext { 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(url: url, size: size, origin: origin, bounds: bounds, createdAt: .now) - tree.insert(.photo(photo), in: photo.photoBox) + tree.insert(photo.element, in: photo.photoBox) + withPersistence(\.backgroundContext) { [_photo = photo, graphicContext = object] context in + let photo = PhotoObject(\.backgroundContext) + photo.imageURL = _photo.url + photo.bounds = _photo.bounds + photo.width = _photo.size.width + photo.originY = _photo.origin.y + photo.originX = _photo.origin.x + photo.height = _photo.size.height + photo.createdAt = _photo.createdAt + let element = ElementObject(\.backgroundContext) + element.createdAt = _photo.createdAt + element.type = 1 + element.graphicContext = graphicContext + photo.element = element + element.photo = photo + graphicContext?.elements.add(element) + _photo.object = photo + try context.saveIfNeeded() + } self.previousElement = .photo(photo) } } diff --git a/Memola/Canvas/Elements/Photo/Photo.swift b/Memola/Canvas/Elements/Photo/Photo.swift index 0a7902f..b20055f 100644 --- a/Memola/Canvas/Elements/Photo/Photo.swift +++ b/Memola/Canvas/Elements/Photo/Photo.swift @@ -24,15 +24,6 @@ final class Photo: @unchecked Sendable, Equatable, Comparable { var vertexCount: Int = 0 var vertexBuffer: MTLBuffer? - 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 @@ -44,7 +35,7 @@ final class Photo: @unchecked Sendable, Equatable, Comparable { convenience init(object: PhotoObject) { self.init( - image: UIImage(data: object.image ?? .init()), + url: object.imageURL, size: .init(width: object.width, height: object.height), origin: .init(x: object.originX, y: object.originY), bounds: object.bounds, @@ -117,4 +108,8 @@ extension Photo { func isVisible(in bounds: CGRect) -> Bool { bounds.contains(photoBounds) || bounds.intersects(photoBounds) } + + var element: Element { + .photo(self) + } } diff --git a/Memola/Persistence/Objects/PhotoObject.swift b/Memola/Persistence/Objects/PhotoObject.swift index 8595ba7..e43de2c 100644 --- a/Memola/Persistence/Objects/PhotoObject.swift +++ b/Memola/Persistence/Objects/PhotoObject.swift @@ -16,6 +16,6 @@ class PhotoObject: NSManagedObject { @NSManaged var height: CGFloat @NSManaged var bounds: [CGFloat] @NSManaged var createdAt: Date? - @NSManaged var image: Data? + @NSManaged var imageURL: URL? @NSManaged var element: ElementObject? } diff --git a/Memola/Resources/Models/MemolaModel.xcdatamodeld/MemolaModel.xcdatamodel/contents b/Memola/Resources/Models/MemolaModel.xcdatamodeld/MemolaModel.xcdatamodel/contents index a310e6c..f12e8a8 100644 --- a/Memola/Resources/Models/MemolaModel.xcdatamodeld/MemolaModel.xcdatamodel/contents +++ b/Memola/Resources/Models/MemolaModel.xcdatamodeld/MemolaModel.xcdatamodel/contents @@ -45,7 +45,7 @@ - + From ec486bf412ce11049db3c9147d41c8fcfd44ddc8 Mon Sep 17 00:00:00 2001 From: dscyrescotti Date: Sun, 16 Jun 2024 11:50:21 +0700 Subject: [PATCH 07/13] feat: bookmark image file for secure access --- Memola.xcodeproj/project.pbxproj | 4 +++ Memola/Canvas/Contexts/GraphicContext.swift | 5 ++-- Memola/Canvas/Core/Canvas.swift | 25 ++++++++++++++----- Memola/Canvas/Elements/Photo/Photo.swift | 20 ++++++++++++--- Memola/Canvas/Tool/Core/Tool.swift | 2 +- .../ViewController/CanvasViewController.swift | 6 ++--- .../Views/CameraView/CameraView.swift | 7 ++---- Memola/Features/Memo/Memo/MemoView.swift | 4 +-- .../Memo/PhotoPreview/PhotoItem.swift | 15 +++++++++++ .../Memo/PhotoPreview/PhotoPreview.swift | 10 +++----- Memola/Features/Memo/Toolbar/Toolbar.swift | 22 ++++++++++------ Memola/Persistence/Objects/PhotoObject.swift | 1 + .../MemolaModel.xcdatamodel/contents | 1 + 13 files changed, 85 insertions(+), 37 deletions(-) create mode 100644 Memola/Features/Memo/PhotoPreview/PhotoItem.swift diff --git a/Memola.xcodeproj/project.pbxproj b/Memola.xcodeproj/project.pbxproj index c07e954..797e920 100644 --- a/Memola.xcodeproj/project.pbxproj +++ b/Memola.xcodeproj/project.pbxproj @@ -86,6 +86,7 @@ ECBE52962C1D5900006BDB3D /* PhotoPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECBE52952C1D5900006BDB3D /* PhotoPreview.swift */; }; ECBE529C2C1D94A4006BDB3D /* CameraView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECBE529A2C1D94A4006BDB3D /* CameraView.swift */; }; ECBE529E2C1DAB21006BDB3D /* UIImage++.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECBE529D2C1DAB21006BDB3D /* UIImage++.swift */; }; + ECC995A32C1E8F2800B2699A /* PhotoItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECC995A22C1E8F2800B2699A /* PhotoItem.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 */; }; @@ -187,6 +188,7 @@ ECBE52952C1D5900006BDB3D /* PhotoPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoPreview.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 = ""; }; + ECC995A22C1E8F2800B2699A /* PhotoItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoItem.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 = ""; }; @@ -678,6 +680,7 @@ isa = PBXGroup; children = ( ECBE52952C1D5900006BDB3D /* PhotoPreview.swift */, + ECC995A22C1E8F2800B2699A /* PhotoItem.swift */, ); path = PhotoPreview; sourceTree = ""; @@ -926,6 +929,7 @@ ECD12A8A2C19EFB000B96E12 /* Element.swift in Sources */, EC0D14282BF7BF20009BFE5F /* ContextMenuViewModifier.swift in Sources */, EC2106AD2C10C2A700FBE27C /* AnyStroke.swift in Sources */, + ECC995A32C1E8F2800B2699A /* PhotoItem.swift in Sources */, ECA738BC2BE60E0300A4542E /* Tool.swift in Sources */, ECA738972BE6014200A4542E /* Graphic.metal in Sources */, ECA7388A2BE6006A00A4542E /* PipelineStates.swift in Sources */, diff --git a/Memola/Canvas/Contexts/GraphicContext.swift b/Memola/Canvas/Contexts/GraphicContext.swift index 9862b5c..38db14e 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, url: URL) { + func insertPhoto(at point: CGPoint, photoItem: PhotoItem) { 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(url: url, size: size, origin: origin, bounds: bounds, createdAt: .now) + let photo = Photo(url: photoItem.id, size: size, origin: origin, bounds: bounds, createdAt: .now, bookmark: photoItem.bookmark) tree.insert(photo.element, in: photo.photoBox) withPersistence(\.backgroundContext) { [_photo = photo, graphicContext = object] context in let photo = PhotoObject(\.backgroundContext) @@ -319,6 +319,7 @@ extension GraphicContext { photo.originX = _photo.origin.x photo.height = _photo.size.height photo.createdAt = _photo.createdAt + photo.bookmark = _photo.bookmark let element = ElementObject(\.backgroundContext) element.createdAt = _photo.createdAt element.type = 1 diff --git a/Memola/Canvas/Core/Canvas.swift b/Memola/Canvas/Core/Canvas.swift index f6c373e..774de81 100644 --- a/Memola/Canvas/Core/Canvas.swift +++ b/Memola/Canvas/Core/Canvas.swift @@ -128,20 +128,26 @@ extension Canvas { // MARK: - Photo extension Canvas { - func insertPhoto(at point: CGPoint, url: URL) { - graphicContext.insertPhoto(at: point, url: url) + func insertPhoto(at point: CGPoint, photoItem: PhotoItem) { + graphicContext.insertPhoto(at: point, photoItem: photoItem) } - func savePhoto(_ data: Data) -> URL? { + func bookmarkPhoto(of image: UIImage) -> PhotoItem? { + guard let data = image.jpegData(compressionQuality: 1) else { return nil } let fileManager = FileManager.default - guard let directory = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first else { return nil } + 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()) { + + if folder.startAccessingSecurityScopedResource(), !fileManager.fileExists(atPath: folder.path()) { do { try fileManager.createDirectory(at: folder, withIntermediateDirectories: true) + folder.stopAccessingSecurityScopedResource() } catch { NSLog("[Memola] - \(error.localizedDescription)") + folder.stopAccessingSecurityScopedResource() return nil } } @@ -152,7 +158,14 @@ extension Canvas { NSLog("[Memola] - \(error.localizedDescription)") return nil } - return file + var photoBookmark: PhotoItem? + do { + let bookmark = try file.bookmarkData(options: .minimalBookmark) + photoBookmark = PhotoItem(id: file, image: image, bookmark: bookmark) + } catch { + NSLog("[Memola] - \(error.localizedDescription)") + } + return photoBookmark } } diff --git a/Memola/Canvas/Elements/Photo/Photo.swift b/Memola/Canvas/Elements/Photo/Photo.swift index b20055f..15df4e3 100644 --- a/Memola/Canvas/Elements/Photo/Photo.swift +++ b/Memola/Canvas/Elements/Photo/Photo.swift @@ -16,6 +16,7 @@ final class Photo: @unchecked Sendable, Equatable, Comparable { var url: URL? var bounds: [CGFloat] var createdAt: Date + var bookmark: Data? var object: PhotoObject? @@ -24,12 +25,13 @@ final class Photo: @unchecked Sendable, Equatable, Comparable { var vertexCount: Int = 0 var vertexBuffer: MTLBuffer? - init(url: URL?, size: CGSize, origin: CGPoint, bounds: [CGFloat], createdAt: Date) { + init(url: URL?, size: CGSize, origin: CGPoint, bounds: [CGFloat], createdAt: Date, bookmark: Data?) { self.size = size self.origin = origin self.url = url self.bounds = bounds self.createdAt = createdAt + self.bookmark = bookmark generateVertices() } @@ -39,7 +41,8 @@ final class Photo: @unchecked Sendable, Equatable, Comparable { size: .init(width: object.width, height: object.height), origin: .init(x: object.originX, y: object.originY), bounds: object.bounds, - createdAt: object.createdAt ?? .now + createdAt: object.createdAt ?? .now, + bookmark: object.bookmark ) self.object = object } @@ -56,6 +59,17 @@ final class Photo: @unchecked Sendable, Equatable, Comparable { PhotoVertex(x: maxX, y: maxY, textCoord: CGPoint(x: 1, y: 1)), ] } + + func getBookmarkURL() -> URL? { + var isStale = false + guard let bookmark else { + return nil + } + guard let bookmarkURL = try? URL(resolvingBookmarkData: bookmark, options: .withoutUI, relativeTo: nil, bookmarkDataIsStale: &isStale) else { + return nil + } + return bookmarkURL + } } extension Photo: Drawable { @@ -64,7 +78,7 @@ extension Photo: Drawable { vertexCount = vertices.endIndex vertexBuffer = device.makeBuffer(bytes: vertices, length: vertexCount * MemoryLayout.stride, options: []) } - if texture == nil, let url { + if texture == nil, let url = getBookmarkURL() { texture = Textures.createPhotoTexture(for: url, on: device) } } diff --git a/Memola/Canvas/Tool/Core/Tool.swift b/Memola/Canvas/Tool/Core/Tool.swift index 547d059..92ffda8 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 selectedImageURL: URL? + @Published var selectedPhotoItem: PhotoItem? @Published var selection: ToolSelection = .none diff --git a/Memola/Canvas/View/Bridge/ViewController/CanvasViewController.swift b/Memola/Canvas/View/Bridge/ViewController/CanvasViewController.swift index 96dfebb..c995c08 100644 --- a/Memola/Canvas/View/Bridge/ViewController/CanvasViewController.swift +++ b/Memola/Canvas/View/Bridge/ViewController/CanvasViewController.swift @@ -233,12 +233,12 @@ extension CanvasViewController { } @objc func recognizeTapGesture(_ gesture: UITapGestureRecognizer) { - guard let url = tool.selectedImageURL else { return } + guard let photoItem = tool.selectedPhotoItem else { return } withAnimation { - tool.selectedImageURL = nil + tool.selectedPhotoItem = nil } let point = gesture.location(in: drawingView) - canvas.insertPhoto(at: point.muliply(by: drawingView.ratio), url: url) + canvas.insertPhoto(at: point.muliply(by: drawingView.ratio), photoItem: photoItem) drawingView.draw() } } diff --git a/Memola/Components/Views/CameraView/CameraView.swift b/Memola/Components/Views/CameraView/CameraView.swift index 8cafeef..4be3e8b 100644 --- a/Memola/Components/Views/CameraView/CameraView.swift +++ b/Memola/Components/Views/CameraView/CameraView.swift @@ -8,7 +8,7 @@ import SwiftUI struct CameraView: UIViewControllerRepresentable { - @Binding var url: URL? + @Binding var image: UIImage? @ObservedObject var canvas: Canvas @@ -35,10 +35,7 @@ struct CameraView: UIViewControllerRepresentable { } func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) { - let image = (info[.originalImage] as? UIImage)?.imageWithUpOrientation() - if let image, let data = image.jpegData(compressionQuality: 1) { - parent.url = parent.canvas.savePhoto(data) - } + parent.image = (info[.originalImage] as? UIImage)?.imageWithUpOrientation() parent.dismiss() } diff --git a/Memola/Features/Memo/Memo/MemoView.swift b/Memola/Features/Memo/Memo/MemoView.swift index f2547e1..362bffb 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 url = tool.selectedImageURL { - PhotoPreview(url: url, tool: tool) + if let photoItem = tool.selectedPhotoItem { + PhotoPreview(photoItem: photoItem, tool: tool) .transition(.move(edge: .trailing)) } default: diff --git a/Memola/Features/Memo/PhotoPreview/PhotoItem.swift b/Memola/Features/Memo/PhotoPreview/PhotoItem.swift new file mode 100644 index 0000000..a857da5 --- /dev/null +++ b/Memola/Features/Memo/PhotoPreview/PhotoItem.swift @@ -0,0 +1,15 @@ +// +// PhotoItem.swift +// Memola +// +// Created by Dscyre Scotti on 6/16/24. +// + +import UIKit +import Foundation + +struct PhotoItem: Identifiable, Equatable { + var id: URL + let image: UIImage + let bookmark: Data +} diff --git a/Memola/Features/Memo/PhotoPreview/PhotoPreview.swift b/Memola/Features/Memo/PhotoPreview/PhotoPreview.swift index 7696b8b..ccdbc57 100644 --- a/Memola/Features/Memo/PhotoPreview/PhotoPreview.swift +++ b/Memola/Features/Memo/PhotoPreview/PhotoPreview.swift @@ -8,15 +8,11 @@ import SwiftUI struct PhotoPreview: View { - let url: URL + let photoItem: PhotoItem @ObservedObject var tool: Tool - var data: Data { - (try? Data(contentsOf: url)) ?? Data() - } - var body: some View { - Image(uiImage: UIImage(data: data) ?? UIImage()) + Image(uiImage: photoItem.image) .resizable() .scaledToFill() .frame(width: 100, height: 100) @@ -31,7 +27,7 @@ struct PhotoPreview: View { .overlay(alignment: .topLeading) { Button { withAnimation { - tool.selectedImageURL = nil + tool.selectedPhotoItem = nil } } label: { Image(systemName: "xmark.circle.fill") diff --git a/Memola/Features/Memo/Toolbar/Toolbar.swift b/Memola/Features/Memo/Toolbar/Toolbar.swift index 2a40b36..427d9aa 100644 --- a/Memola/Features/Memo/Toolbar/Toolbar.swift +++ b/Memola/Features/Memo/Toolbar/Toolbar.swift @@ -19,8 +19,8 @@ struct Toolbar: View { @State var title: String @State var memo: MemoObject - @State var photoItem: PhotosPickerItem? @State var opensCamera: Bool = false + @State var photosPickerItem: PhotosPickerItem? @State var isCameraAccessDenied: Bool = false @FocusState var textFieldState: Bool @@ -56,22 +56,28 @@ struct Toolbar: View { } .font(.subheadline) .padding(10) - .onChange(of: photoItem) { oldValue, newValue in + .onChange(of: photosPickerItem) { oldValue, newValue in if newValue != nil { Task { let data = try? await newValue?.loadTransferable(type: Data.self) - if let data { - let url = canvas.savePhoto(data) + if let data, let image = UIImage(data: data) { + let photoItem = canvas.bookmarkPhoto(of: image) withAnimation { - tool.selectedImageURL = url + tool.selectedPhotoItem = photoItem } } - photoItem = nil + photosPickerItem = nil } } } .fullScreenCover(isPresented: $opensCamera) { - CameraView(url: $tool.selectedImageURL, canvas: canvas) + let image: Binding = Binding { + tool.selectedPhotoItem?.image + } set: { image in + guard let image else { return } + tool.selectedPhotoItem = canvas.bookmarkPhoto(of: image) + } + CameraView(image: image, canvas: canvas) .ignoresSafeArea() } .alert("Camera Access Denied", isPresented: $isCameraAccessDenied) { @@ -167,7 +173,7 @@ struct Toolbar: View { .clipShape(.rect(cornerRadius: 8)) } .hoverEffect(.lift) - PhotosPicker(selection: $photoItem, matching: .images, preferredItemEncoding: .compatible) { + PhotosPicker(selection: $photosPickerItem, matching: .images, preferredItemEncoding: .compatible) { Image(systemName: "photo.fill.on.rectangle.fill") .contentShape(.circle) .frame(width: size, height: size) diff --git a/Memola/Persistence/Objects/PhotoObject.swift b/Memola/Persistence/Objects/PhotoObject.swift index e43de2c..10283e8 100644 --- a/Memola/Persistence/Objects/PhotoObject.swift +++ b/Memola/Persistence/Objects/PhotoObject.swift @@ -17,5 +17,6 @@ class PhotoObject: NSManagedObject { @NSManaged var bounds: [CGFloat] @NSManaged var createdAt: Date? @NSManaged var imageURL: URL? + @NSManaged var bookmark: Data? @NSManaged var element: ElementObject? } diff --git a/Memola/Resources/Models/MemolaModel.xcdatamodeld/MemolaModel.xcdatamodel/contents b/Memola/Resources/Models/MemolaModel.xcdatamodeld/MemolaModel.xcdatamodel/contents index f12e8a8..0388678 100644 --- a/Memola/Resources/Models/MemolaModel.xcdatamodeld/MemolaModel.xcdatamodel/contents +++ b/Memola/Resources/Models/MemolaModel.xcdatamodeld/MemolaModel.xcdatamodel/contents @@ -42,6 +42,7 @@ + From f93dbdc6a83fdd50e86ec114741bf15fe76e6518 Mon Sep 17 00:00:00 2001 From: dscyrescotti Date: Sun, 16 Jun 2024 14:20:14 +0700 Subject: [PATCH 08/13] feat: delete photo if it is inserted --- Memola.xcodeproj/project.pbxproj | 4 ++ Memola/Canvas/Core/Canvas.swift | 36 ------------ Memola/Canvas/Elements/Photo/Photo.swift | 13 +---- Memola/Canvas/Tool/Core/Tool.swift | 58 +++++++++++++++++++ Memola/Extensions/Data++.swift | 18 ++++++ .../Memo/PhotoPreview/PhotoPreview.swift | 4 +- Memola/Features/Memo/Toolbar/Toolbar.swift | 7 +-- 7 files changed, 84 insertions(+), 56 deletions(-) create mode 100644 Memola/Extensions/Data++.swift diff --git a/Memola.xcodeproj/project.pbxproj b/Memola.xcodeproj/project.pbxproj index 797e920..4eb8613 100644 --- a/Memola.xcodeproj/project.pbxproj +++ b/Memola.xcodeproj/project.pbxproj @@ -87,6 +87,7 @@ ECBE529C2C1D94A4006BDB3D /* CameraView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECBE529A2C1D94A4006BDB3D /* CameraView.swift */; }; ECBE529E2C1DAB21006BDB3D /* UIImage++.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECBE529D2C1DAB21006BDB3D /* UIImage++.swift */; }; ECC995A32C1E8F2800B2699A /* PhotoItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECC995A22C1E8F2800B2699A /* PhotoItem.swift */; }; + ECC995A52C1EB4CC00B2699A /* Data++.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECC995A42C1EB4CC00B2699A /* Data++.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 */; }; @@ -189,6 +190,7 @@ 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 = ""; }; ECC995A22C1E8F2800B2699A /* PhotoItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoItem.swift; sourceTree = ""; }; + ECC995A42C1EB4CC00B2699A /* Data++.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Data++.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 = ""; }; @@ -514,6 +516,7 @@ EC3565552BEFC7B300A4E0BF /* NSManagedObject++.swift */, EC35655B2BF0712A00A4E0BF /* Float++.swift */, ECBE529D2C1DAB21006BDB3D /* UIImage++.swift */, + ECC995A42C1EB4CC00B2699A /* Data++.swift */, ); path = Extensions; sourceTree = ""; @@ -925,6 +928,7 @@ ECA738DE2BE610A000A4542E /* ViewPortRenderPass.swift in Sources */, EC7F6BEC2BE5E6E300A34A7B /* MemolaApp.swift in Sources */, EC2BEBF82C0F601A005DB0AF /* Node.swift in Sources */, + ECC995A52C1EB4CC00B2699A /* Data++.swift in Sources */, ECA738A02BE601E400A4542E /* ViewPortVertex.swift in Sources */, ECD12A8A2C19EFB000B96E12 /* Element.swift in Sources */, EC0D14282BF7BF20009BFE5F /* ContextMenuViewModifier.swift in Sources */, diff --git a/Memola/Canvas/Core/Canvas.swift b/Memola/Canvas/Core/Canvas.swift index 774de81..595fa79 100644 --- a/Memola/Canvas/Core/Canvas.swift +++ b/Memola/Canvas/Core/Canvas.swift @@ -131,42 +131,6 @@ extension Canvas { func insertPhoto(at point: CGPoint, photoItem: PhotoItem) { graphicContext.insertPhoto(at: point, photoItem: photoItem) } - - func bookmarkPhoto(of image: UIImage) -> PhotoItem? { - guard let data = image.jpegData(compressionQuality: 1) else { return nil } - 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 folder.startAccessingSecurityScopedResource(), !fileManager.fileExists(atPath: folder.path()) { - do { - try fileManager.createDirectory(at: folder, withIntermediateDirectories: true) - folder.stopAccessingSecurityScopedResource() - } catch { - NSLog("[Memola] - \(error.localizedDescription)") - folder.stopAccessingSecurityScopedResource() - return nil - } - } - let file = folder.appendingPathComponent(fileName, conformingTo: .jpeg) - do { - try data.write(to: file) - } catch { - NSLog("[Memola] - \(error.localizedDescription)") - return nil - } - var photoBookmark: PhotoItem? - do { - let bookmark = try file.bookmarkData(options: .minimalBookmark) - photoBookmark = PhotoItem(id: file, image: image, bookmark: bookmark) - } catch { - NSLog("[Memola] - \(error.localizedDescription)") - } - return photoBookmark - } } // MARK: - Rendering diff --git a/Memola/Canvas/Elements/Photo/Photo.swift b/Memola/Canvas/Elements/Photo/Photo.swift index 15df4e3..65ffb01 100644 --- a/Memola/Canvas/Elements/Photo/Photo.swift +++ b/Memola/Canvas/Elements/Photo/Photo.swift @@ -59,17 +59,6 @@ final class Photo: @unchecked Sendable, Equatable, Comparable { PhotoVertex(x: maxX, y: maxY, textCoord: CGPoint(x: 1, y: 1)), ] } - - func getBookmarkURL() -> URL? { - var isStale = false - guard let bookmark else { - return nil - } - guard let bookmarkURL = try? URL(resolvingBookmarkData: bookmark, options: .withoutUI, relativeTo: nil, bookmarkDataIsStale: &isStale) else { - return nil - } - return bookmarkURL - } } extension Photo: Drawable { @@ -78,7 +67,7 @@ extension Photo: Drawable { vertexCount = vertices.endIndex vertexBuffer = device.makeBuffer(bytes: vertices, length: vertexCount * MemoryLayout.stride, options: []) } - if texture == nil, let url = getBookmarkURL() { + if texture == nil, let url = bookmark?.getBookmarkURL() { texture = Textures.createPhotoTexture(for: url, on: device) } } diff --git a/Memola/Canvas/Tool/Core/Tool.swift b/Memola/Canvas/Tool/Core/Tool.swift index 92ffda8..1901c9d 100644 --- a/Memola/Canvas/Tool/Core/Tool.swift +++ b/Memola/Canvas/Tool/Core/Tool.swift @@ -112,4 +112,62 @@ public class Tool: NSObject, ObservableObject { } } } + + func selectPhoto(_ image: UIImage, for canvasID: NSManagedObjectID) { + let photoItem = bookmarkPhoto(of: image, with: canvasID) + withAnimation { + selectedPhotoItem = photoItem + } + } + + private func bookmarkPhoto(of image: UIImage, with canvasID: NSManagedObjectID) -> PhotoItem? { + guard let data = image.jpegData(compressionQuality: 1) else { return nil } + 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 folder.startAccessingSecurityScopedResource(), !fileManager.fileExists(atPath: folder.path()) { + do { + try fileManager.createDirectory(at: folder, withIntermediateDirectories: true) + folder.stopAccessingSecurityScopedResource() + } catch { + NSLog("[Memola] - \(error.localizedDescription)") + folder.stopAccessingSecurityScopedResource() + return nil + } + } + let file = folder.appendingPathComponent(fileName, conformingTo: .jpeg) + do { + try data.write(to: file) + } catch { + NSLog("[Memola] - \(error.localizedDescription)") + return nil + } + var photoBookmark: PhotoItem? + do { + let bookmark = try file.bookmarkData(options: .minimalBookmark) + photoBookmark = PhotoItem(id: file, image: image, bookmark: bookmark) + } catch { + NSLog("[Memola] - \(error.localizedDescription)") + } + return photoBookmark + } + + func unselectPhoto() { + guard let photoItem = selectedPhotoItem else { return } + let fileManager = FileManager.default + if let url = photoItem.bookmark.getBookmarkURL() { + do { + try fileManager.removeItem(at: url) + } catch { + NSLog("[Memola] - \(error.localizedDescription)") + } + } + withAnimation { + selectedPhotoItem = nil + } + } } diff --git a/Memola/Extensions/Data++.swift b/Memola/Extensions/Data++.swift new file mode 100644 index 0000000..97b4182 --- /dev/null +++ b/Memola/Extensions/Data++.swift @@ -0,0 +1,18 @@ +// +// Data++.swift +// Memola +// +// Created by Dscyre Scotti on 6/16/24. +// + +import Foundation + +extension Data { + func getBookmarkURL() -> URL? { + var isStale = false + guard let bookmarkURL = try? URL(resolvingBookmarkData: self, options: .withoutUI, relativeTo: nil, bookmarkDataIsStale: &isStale) else { + return nil + } + return bookmarkURL + } +} diff --git a/Memola/Features/Memo/PhotoPreview/PhotoPreview.swift b/Memola/Features/Memo/PhotoPreview/PhotoPreview.swift index ccdbc57..1fcf43e 100644 --- a/Memola/Features/Memo/PhotoPreview/PhotoPreview.swift +++ b/Memola/Features/Memo/PhotoPreview/PhotoPreview.swift @@ -26,9 +26,7 @@ struct PhotoPreview: View { .cornerRadius(5) .overlay(alignment: .topLeading) { Button { - withAnimation { - tool.selectedPhotoItem = nil - } + tool.unselectPhoto() } label: { Image(systemName: "xmark.circle.fill") .font(.title2) diff --git a/Memola/Features/Memo/Toolbar/Toolbar.swift b/Memola/Features/Memo/Toolbar/Toolbar.swift index 427d9aa..980f2a9 100644 --- a/Memola/Features/Memo/Toolbar/Toolbar.swift +++ b/Memola/Features/Memo/Toolbar/Toolbar.swift @@ -61,10 +61,7 @@ struct Toolbar: View { Task { let data = try? await newValue?.loadTransferable(type: Data.self) if let data, let image = UIImage(data: data) { - let photoItem = canvas.bookmarkPhoto(of: image) - withAnimation { - tool.selectedPhotoItem = photoItem - } + tool.selectPhoto(image, for: canvas.canvasID) } photosPickerItem = nil } @@ -75,7 +72,7 @@ struct Toolbar: View { tool.selectedPhotoItem?.image } set: { image in guard let image else { return } - tool.selectedPhotoItem = canvas.bookmarkPhoto(of: image) + tool.selectPhoto(image, for: canvas.canvasID) } CameraView(image: image, canvas: canvas) .ignoresSafeArea() From 0044fd34a3ac9675618ff8ae956b7a3d4292ab37 Mon Sep 17 00:00:00 2001 From: dscyrescotti Date: Sun, 16 Jun 2024 14:54:40 +0700 Subject: [PATCH 09/13] feat: add dimension --- Memola/Canvas/Contexts/GraphicContext.swift | 2 +- Memola/Canvas/Tool/Core/Tool.swift | 6 ++---- Memola/Features/Memo/PhotoPreview/PhotoItem.swift | 8 ++++++++ 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/Memola/Canvas/Contexts/GraphicContext.swift b/Memola/Canvas/Contexts/GraphicContext.swift index 38db14e..61e811b 100644 --- a/Memola/Canvas/Contexts/GraphicContext.swift +++ b/Memola/Canvas/Contexts/GraphicContext.swift @@ -305,7 +305,7 @@ extension GraphicContext { // MARK: - Photo extension GraphicContext { func insertPhoto(at point: CGPoint, photoItem: PhotoItem) { - let size = CGSize(width: 100, height: 100) + let size = photoItem.dimension 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(url: photoItem.id, size: size, origin: origin, bounds: bounds, createdAt: .now, bookmark: photoItem.bookmark) diff --git a/Memola/Canvas/Tool/Core/Tool.swift b/Memola/Canvas/Tool/Core/Tool.swift index 1901c9d..27556dc 100644 --- a/Memola/Canvas/Tool/Core/Tool.swift +++ b/Memola/Canvas/Tool/Core/Tool.swift @@ -126,16 +126,14 @@ public class Tool: NSObject, ObservableObject { guard let directory = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first else { return nil } - let fileName = "\(UUID().uuidString)-\(Date.now.timeIntervalSince1970)" + let fileName = "\(UUID().uuidString)-\(Int(Date.now.timeIntervalSince1970))" let folder = directory.appendingPathComponent(canvasID.uriRepresentation().lastPathComponent, conformingTo: .folder) - if folder.startAccessingSecurityScopedResource(), !fileManager.fileExists(atPath: folder.path()) { + if !fileManager.fileExists(atPath: folder.path()) { do { try fileManager.createDirectory(at: folder, withIntermediateDirectories: true) - folder.stopAccessingSecurityScopedResource() } catch { NSLog("[Memola] - \(error.localizedDescription)") - folder.stopAccessingSecurityScopedResource() return nil } } diff --git a/Memola/Features/Memo/PhotoPreview/PhotoItem.swift b/Memola/Features/Memo/PhotoPreview/PhotoItem.swift index a857da5..d69cd3b 100644 --- a/Memola/Features/Memo/PhotoPreview/PhotoItem.swift +++ b/Memola/Features/Memo/PhotoPreview/PhotoItem.swift @@ -12,4 +12,12 @@ struct PhotoItem: Identifiable, Equatable { var id: URL let image: UIImage let bookmark: Data + + var dimension: CGSize { + let size = image.size + let maxSize = max(size.width, size.height) + let width = size.width * 200 / maxSize + let height = size.height * 200 / maxSize + return CGSize(width: width, height: height) + } } From daef1670bc1413fa1e3dc14d44b3664e43a6b4b4 Mon Sep 17 00:00:00 2001 From: dscyrescotti Date: Sun, 16 Jun 2024 15:52:32 +0700 Subject: [PATCH 10/13] feat: update ordering --- Memola/Canvas/Elements/Core/Element.swift | 13 +++++++++++++ Memola/Canvas/RTree/RTree.swift | 6 +++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/Memola/Canvas/Elements/Core/Element.swift b/Memola/Canvas/Elements/Core/Element.swift index bde3e5b..5d05736 100644 --- a/Memola/Canvas/Elements/Core/Element.swift +++ b/Memola/Canvas/Elements/Core/Element.swift @@ -40,4 +40,17 @@ enum Element: Equatable, Comparable { photo.createdAt } } + + static func < (lhs: Element, rhs: Element) -> Bool { + switch (lhs, rhs) { + case let (.stroke(leftStroke), .stroke(rightStroke)): + leftStroke < rightStroke + case let (.photo(leftPhoto), .photo(rightPhoto)): + leftPhoto < rightPhoto + case let (.photo(photo), .stroke(stroke)): + photo.createdAt < stroke.value.createdAt + case let (.stroke(stroke), .photo(photo)): + stroke.value.createdAt < photo.createdAt + } + } } diff --git a/Memola/Canvas/RTree/RTree.swift b/Memola/Canvas/RTree/RTree.swift index 367a7ba..168520f 100644 --- a/Memola/Canvas/RTree/RTree.swift +++ b/Memola/Canvas/RTree/RTree.swift @@ -38,7 +38,11 @@ class RTree where T: Equatable & Comparable { .sorted(by: <) result = _merge(result, children) } else { - queue.append(contentsOf: node.children) + let nodes = node.children.sorted { + guard let first = $0.value, let second = $1.value else { return false } + return first < second + } + queue.append(contentsOf: nodes) } } return result From f0b80f3171631dabdc29ecb17c94ada8aa09d1d4 Mon Sep 17 00:00:00 2001 From: dscyrescotti Date: Mon, 17 Jun 2024 01:33:10 +0700 Subject: [PATCH 11/13] feat: implement undo and redo logic --- Memola/Canvas/Contexts/GraphicContext.swift | 17 ++++++++++++++++- Memola/Canvas/Core/Canvas.swift | 2 +- Memola/Canvas/History/History.swift | 14 ++++++++++++++ Memola/Canvas/History/HistoryEvent.swift | 1 + .../ViewController/CanvasViewController.swift | 3 ++- 5 files changed, 34 insertions(+), 3 deletions(-) diff --git a/Memola/Canvas/Contexts/GraphicContext.swift b/Memola/Canvas/Contexts/GraphicContext.swift index 61e811b..fefd095 100644 --- a/Memola/Canvas/Contexts/GraphicContext.swift +++ b/Memola/Canvas/Contexts/GraphicContext.swift @@ -70,6 +70,13 @@ final class GraphicContext: @unchecked Sendable { } } previousElement = nil + case .photo(let photo): + tree.remove(photo.element, in: photo.photoBox) + withPersistence(\.backgroundContext) { [weak photo] context in + photo?.object?.element?.graphicContext = nil + try context.saveIfNeeded() + context.refreshAllObjects() + } } } @@ -105,6 +112,13 @@ final class GraphicContext: @unchecked Sendable { } } previousElement = nil + case .photo(let photo): + tree.insert(photo.element, in: photo.photoBox) + withPersistence(\.backgroundContext) { [weak self, weak photo] context in + photo?.object?.element?.graphicContext = self?.object + try context.saveIfNeeded() + context.refreshAllObjects() + } } } } @@ -304,7 +318,7 @@ extension GraphicContext { // MARK: - Photo extension GraphicContext { - func insertPhoto(at point: CGPoint, photoItem: PhotoItem) { + func insertPhoto(at point: CGPoint, photoItem: PhotoItem) -> Photo { let size = photoItem.dimension 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] @@ -331,6 +345,7 @@ extension GraphicContext { try context.saveIfNeeded() } self.previousElement = .photo(photo) + return photo } } diff --git a/Memola/Canvas/Core/Canvas.swift b/Memola/Canvas/Core/Canvas.swift index 595fa79..4189a32 100644 --- a/Memola/Canvas/Core/Canvas.swift +++ b/Memola/Canvas/Core/Canvas.swift @@ -128,7 +128,7 @@ extension Canvas { // MARK: - Photo extension Canvas { - func insertPhoto(at point: CGPoint, photoItem: PhotoItem) { + func insertPhoto(at point: CGPoint, photoItem: PhotoItem) -> Photo { graphicContext.insertPhoto(at: point, photoItem: photoItem) } } diff --git a/Memola/Canvas/History/History.swift b/Memola/Canvas/History/History.swift index 6125381..a6d152b 100644 --- a/Memola/Canvas/History/History.swift +++ b/Memola/Canvas/History/History.swift @@ -70,6 +70,20 @@ class History: ObservableObject { try context.saveIfNeeded() } } + case .photo(let _photo): + if let url = _photo.bookmark?.getBookmarkURL() { + do { + try FileManager.default.removeItem(at: url) + } catch { + NSLog("[Memola] - \(error.localizedDescription)") + } + } + withPersistence(\.backgroundContext) { context in + if let photo = _photo.object { + context.delete(photo) + } + try context.saveIfNeeded() + } } } redoStack.removeAll() diff --git a/Memola/Canvas/History/HistoryEvent.swift b/Memola/Canvas/History/HistoryEvent.swift index 4fb811f..a24d301 100644 --- a/Memola/Canvas/History/HistoryEvent.swift +++ b/Memola/Canvas/History/HistoryEvent.swift @@ -9,4 +9,5 @@ import Foundation enum HistoryEvent { case stroke(any Stroke) + case photo(Photo) } diff --git a/Memola/Canvas/View/Bridge/ViewController/CanvasViewController.swift b/Memola/Canvas/View/Bridge/ViewController/CanvasViewController.swift index c995c08..a21f7fb 100644 --- a/Memola/Canvas/View/Bridge/ViewController/CanvasViewController.swift +++ b/Memola/Canvas/View/Bridge/ViewController/CanvasViewController.swift @@ -238,7 +238,8 @@ extension CanvasViewController { tool.selectedPhotoItem = nil } let point = gesture.location(in: drawingView) - canvas.insertPhoto(at: point.muliply(by: drawingView.ratio), photoItem: photoItem) + let photo = canvas.insertPhoto(at: point.muliply(by: drawingView.ratio), photoItem: photoItem) + history.addUndo(.photo(photo)) drawingView.draw() } } From 425d44274121518bf960ed535d0d75a8588da24a Mon Sep 17 00:00:00 2001 From: dscyrescotti Date: Mon, 17 Jun 2024 20:44:12 +0700 Subject: [PATCH 12/13] feat: optimize photo rendering --- Memola/Canvas/Core/Textures.swift | 14 ++++++++++-- Memola/Canvas/Elements/Photo/Photo.swift | 1 - Memola/Canvas/Tool/Core/Tool.swift | 22 ++++++++++++++++++- .../View/Bridge/Views/DrawingView.swift | 2 +- .../Memo/PhotoPreview/PhotoItem.swift | 4 ++-- 5 files changed, 36 insertions(+), 7 deletions(-) diff --git a/Memola/Canvas/Core/Textures.swift b/Memola/Canvas/Core/Textures.swift index 9cd1106..24a9268 100644 --- a/Memola/Canvas/Core/Textures.swift +++ b/Memola/Canvas/Core/Textures.swift @@ -20,8 +20,13 @@ class Textures { if let penTexture = penTextures[textureName] { return penTexture } + let options: [MTKTextureLoader.Option: Any] = [ + .SRGB: false, + .generateMipmaps: true, + .textureStorageMode: NSNumber(value: MTLStorageMode.private.rawValue) + ] let textureLoader = MTKTextureLoader(device: device) - let penTexture = try? textureLoader.newTexture(name: textureName, scaleFactor: 1.0, bundle: .main, options: [.SRGB: false]) + let penTexture = try? textureLoader.newTexture(name: textureName, scaleFactor: 1.0, bundle: .main, options: options) penTextures[textureName] = penTexture return penTexture } @@ -30,7 +35,12 @@ class Textures { 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]) + let options: [MTKTextureLoader.Option: Any] = [ + .SRGB: false, + .generateMipmaps: true, + .textureStorageMode: NSNumber(value: MTLStorageMode.private.rawValue) + ] + let photoTexture = try textureLoader.newTexture(URL: url, options: options) return photoTexture } catch { NSLog("[Memola] - \(error.localizedDescription)") diff --git a/Memola/Canvas/Elements/Photo/Photo.swift b/Memola/Canvas/Elements/Photo/Photo.swift index 65ffb01..3ec2741 100644 --- a/Memola/Canvas/Elements/Photo/Photo.swift +++ b/Memola/Canvas/Elements/Photo/Photo.swift @@ -77,7 +77,6 @@ extension Photo: Drawable { 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/Tool/Core/Tool.swift b/Memola/Canvas/Tool/Core/Tool.swift index 27556dc..a60c984 100644 --- a/Memola/Canvas/Tool/Core/Tool.swift +++ b/Memola/Canvas/Tool/Core/Tool.swift @@ -114,12 +114,32 @@ public class Tool: NSObject, ObservableObject { } func selectPhoto(_ image: UIImage, for canvasID: NSManagedObjectID) { - let photoItem = bookmarkPhoto(of: image, with: canvasID) + guard let resizedImage = resizePhoto(of: image) else { return } + let photoItem = bookmarkPhoto(of: resizedImage, with: canvasID) withAnimation { selectedPhotoItem = photoItem } } + private func resizePhoto(of image: UIImage) -> UIImage? { + let targetSize = CGSize(width: 768, height: 768) + let size = image.size + let widthRatio = targetSize.width / size.width + let heightRatio = targetSize.height / size.height + let newSize = CGSize( + width: size.width * min(widthRatio, heightRatio), + height: size.height * min(widthRatio, heightRatio) + ) + let rect = CGRect(origin: .zero, size: newSize) + + UIGraphicsBeginImageContextWithOptions(newSize, true, 1.0) + image.draw(in: rect) + let newImage = UIGraphicsGetImageFromCurrentImageContext() + UIGraphicsEndImageContext() + + return newImage + } + private func bookmarkPhoto(of image: UIImage, with canvasID: NSManagedObjectID) -> PhotoItem? { guard let data = image.jpegData(compressionQuality: 1) else { return nil } let fileManager = FileManager.default diff --git a/Memola/Canvas/View/Bridge/Views/DrawingView.swift b/Memola/Canvas/View/Bridge/Views/DrawingView.swift index f48f270..0bae10e 100644 --- a/Memola/Canvas/View/Bridge/Views/DrawingView.swift +++ b/Memola/Canvas/View/Bridge/Views/DrawingView.swift @@ -32,7 +32,7 @@ class DrawingView: UIView { } func updateDrawableSize(with size: CGSize) { - renderView.drawableSize = size.multiply(by: 3) + renderView.drawableSize = size.multiply(by: 2.5) } override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { diff --git a/Memola/Features/Memo/PhotoPreview/PhotoItem.swift b/Memola/Features/Memo/PhotoPreview/PhotoItem.swift index d69cd3b..fbc6875 100644 --- a/Memola/Features/Memo/PhotoPreview/PhotoItem.swift +++ b/Memola/Features/Memo/PhotoPreview/PhotoItem.swift @@ -16,8 +16,8 @@ struct PhotoItem: Identifiable, Equatable { var dimension: CGSize { let size = image.size let maxSize = max(size.width, size.height) - let width = size.width * 200 / maxSize - let height = size.height * 200 / maxSize + let width = size.width * 128 / maxSize + let height = size.height * 128 / maxSize return CGSize(width: width, height: height) } } From 90fb667e40c8abd88cc16723789d22e19d7b3dc6 Mon Sep 17 00:00:00 2001 From: dscyrescotti Date: Mon, 17 Jun 2024 20:53:10 +0700 Subject: [PATCH 13/13] refactor: clean up --- Memola/Canvas/Contexts/GraphicContext.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Memola/Canvas/Contexts/GraphicContext.swift b/Memola/Canvas/Contexts/GraphicContext.swift index fefd095..b1f8254 100644 --- a/Memola/Canvas/Contexts/GraphicContext.swift +++ b/Memola/Canvas/Contexts/GraphicContext.swift @@ -324,7 +324,8 @@ extension GraphicContext { 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(url: photoItem.id, size: size, origin: origin, bounds: bounds, createdAt: .now, bookmark: photoItem.bookmark) tree.insert(photo.element, in: photo.photoBox) - withPersistence(\.backgroundContext) { [_photo = photo, graphicContext = object] context in + withPersistence(\.backgroundContext) { [weak _photo = photo, weak graphicContext = object] context in + guard let _photo else { return } let photo = PhotoObject(\.backgroundContext) photo.imageURL = _photo.url photo.bounds = _photo.bounds