From 4d637977e1b367c457ad6fed95c66d7c173b3959 Mon Sep 17 00:00:00 2001 From: dscyrescotti Date: Fri, 14 Jun 2024 23:19:16 +0700 Subject: [PATCH] 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 @@ + + + + + + + + + +