diff --git a/Memola/Canvas/Contexts/GraphicContext.swift b/Memola/Canvas/Contexts/GraphicContext.swift index 56a8ff1..cf6f565 100644 --- a/Memola/Canvas/Contexts/GraphicContext.swift +++ b/Memola/Canvas/Contexts/GraphicContext.swift @@ -60,17 +60,40 @@ final class GraphicContext: @unchecked Sendable { } extension GraphicContext { - func loadStrokes() { + func loadStrokes(_ bounds: CGRect) { guard let object else { return } + let queue = OperationQueue() + queue.qualityOfService = .userInteractive self.strokes = object.strokes.compactMap { stroke -> Stroke? in guard let stroke = stroke as? StrokeObject else { return nil } let _stroke = Stroke(object: stroke) - _stroke.loadQuads() - withPersistence(\.backgroundContext) { [stroke] context in - context.refresh(stroke, mergeChanges: false) + if _stroke.isVisible(in: bounds) { + let id = stroke.objectID + queue.addOperation { + withPersistenceSync(\.newBackgroundContext) { [_stroke] context in + guard let stroke = try? context.existingObject(with: id) as? StrokeObject else { return } + _stroke.loadQuads(from: stroke) + } + withPersistence(\.backgroundContext) { [stroke] context in + context.refresh(stroke, mergeChanges: false) + } + } + } else { + withPersistence(\.backgroundContext) { [stroke] context in + _stroke.loadQuads() + context.refresh(stroke, mergeChanges: false) + } } return _stroke } + queue.waitUntilAllOperationsAreFinished() + } + + func loadQuads(_ bounds: CGRect) { + for stroke in self.strokes { + guard stroke.isVisible(in: bounds), stroke.quads.isEmpty else { continue } + stroke.loadQuads() + } } } @@ -93,6 +116,7 @@ extension GraphicContext: Drawable { extension GraphicContext { func beginStroke(at point: CGPoint, pen: Pen) -> Stroke { let stroke = Stroke( + bounds: [point.x - pen.thickness, point.y - pen.thickness, point.x + pen.thickness, point.y + pen.thickness], color: pen.color, style: pen.strokeStyle.rawValue, createdAt: .now, @@ -100,6 +124,7 @@ extension GraphicContext { ) withPersistence(\.backgroundContext) { [graphicContext = object, _stroke = stroke] context in let stroke = StrokeObject(\.backgroundContext) + stroke.bounds = _stroke.bounds stroke.color = _stroke.color stroke.style = _stroke.style stroke.thickness = _stroke.thickness @@ -118,7 +143,9 @@ extension GraphicContext { func appendStroke(with point: CGPoint) { guard let currentStroke else { return } - guard let currentPoint, point.distance(to: currentPoint) > currentStroke.thickness * currentStroke.penStyle.anyPenStyle.stepRate else { return } + guard let currentPoint, point.distance(to: currentPoint) > currentStroke.thickness * currentStroke.penStyle.anyPenStyle.stepRate else { + return + } currentStroke.append(to: point) self.currentPoint = point } @@ -126,10 +153,11 @@ extension GraphicContext { func endStroke(at point: CGPoint) { guard currentPoint != nil, let currentStroke else { return } currentStroke.finish(at: point) - let saveIndex = currentStroke.batchIndex - let quads = Array(currentStroke.quads[saveIndex.. 0 else { + guard stroke.keyPoints.endIndex > 0 else { return } stroke.keyPoints.append(point) - switch stroke.keyPoints.count { + switch stroke.keyPoints.endIndex { case 2: let start = stroke.keyPoints[0] let end = stroke.keyPoints[1] @@ -28,7 +28,7 @@ struct SolidPointStrokeGenerator: StrokeGenerator { addCurve(from: start, to: end, by: control, on: stroke) case 3: stroke.removeQuads(from: stroke.quadIndex + 1) - let index = stroke.keyPoints.count - 1 + let index = stroke.keyPoints.endIndex - 1 var start = stroke.keyPoints[index - 2] var end = CGPoint.middle(p1: stroke.keyPoints[index - 2], p2: stroke.keyPoints[index - 1]) var control = CGPoint.middle(p1: start, p2: end) @@ -39,7 +39,7 @@ struct SolidPointStrokeGenerator: StrokeGenerator { addCurve(from: start, to: end, by: control, on: stroke) default: smoothOutPath(on: stroke) - let index = stroke.keyPoints.count - 1 + let index = stroke.keyPoints.endIndex - 1 let start = CGPoint.middle(p1: stroke.keyPoints[index - 2], p2: stroke.keyPoints[index - 1]) let control = stroke.keyPoints[index - 1] let end = CGPoint.middle(p1: stroke.keyPoints[index - 1], p2: stroke.keyPoints[index]) @@ -48,12 +48,12 @@ struct SolidPointStrokeGenerator: StrokeGenerator { } func finish(at point: CGPoint, on stroke: Stroke) { - switch stroke.keyPoints.count { + switch stroke.keyPoints.endIndex { case 0...1: break default: append(to: point, on: stroke) - let index = stroke.keyPoints.count - 1 + let index = stroke.keyPoints.endIndex - 1 let start = CGPoint.middle(p1: stroke.keyPoints[index - 2], p2: stroke.keyPoints[index - 1]) let end = stroke.keyPoints[index] let control = CGPoint.middle(p1: start, p2: end) @@ -64,37 +64,34 @@ struct SolidPointStrokeGenerator: StrokeGenerator { private func smoothOutPath(on stroke: Stroke) { stroke.removeQuads(from: stroke.quadIndex + 1) adjustPreviousKeyPoint(on: stroke) - switch stroke.keyPoints.count { + switch stroke.keyPoints.endIndex { case 4: - let index = stroke.keyPoints.count - 2 + let index = stroke.keyPoints.endIndex - 2 let start = stroke.keyPoints[index - 2] let end = CGPoint.middle(p1: stroke.keyPoints[index - 2], p2: stroke.keyPoints[index - 1]) let control = CGPoint.middle(p1: start, p2: end) addCurve(from: start, to: end, by: control, on: stroke) fallthrough default: - let index = stroke.keyPoints.count - 2 + let index = stroke.keyPoints.endIndex - 2 let start = CGPoint.middle(p1: stroke.keyPoints[index - 2], p2: stroke.keyPoints[index - 1]) let control = stroke.keyPoints[index - 1] let end = CGPoint.middle(p1: stroke.keyPoints[index - 1], p2: stroke.keyPoints[index]) addCurve(from: start, to: end, by: control, on: stroke) } - stroke.quadIndex = stroke.quads.count - 1 + stroke.quadIndex = stroke.quads.endIndex - 1 } private func adjustPreviousKeyPoint(on stroke: Stroke) { - let index = stroke.keyPoints.count - 1 - let prev = stroke.keyPoints[index - 1] + let index = stroke.keyPoints.endIndex - 1 + let prev = stroke.keyPoints[index - 2] + let mid = stroke.keyPoints[index - 1] let current = stroke.keyPoints[index] - let averageX = (prev.x + current.x) / 2 - let averageY = (prev.y + current.y) / 2 + let averageX = (prev.x + current.x + mid.x) / 3 + let averageY = (prev.y + current.y + mid.y) / 3 let point = CGPoint(x: averageX, y: averageY) - if index != 0 { - stroke.keyPoints[index] = point - } - if index - 1 != 0 { - stroke.keyPoints[index - 1] = point - } + stroke.keyPoints[index] = point + stroke.keyPoints[index - 1] = point } private func addPoint(_ point: CGPoint, on stroke: Stroke) { diff --git a/Memola/Canvas/Geometries/Stroke/Stroke.swift b/Memola/Canvas/Geometries/Stroke/Stroke.swift index 29d7e17..0a13a11 100644 --- a/Memola/Canvas/Geometries/Stroke/Stroke.swift +++ b/Memola/Canvas/Geometries/Stroke/Stroke.swift @@ -11,6 +11,7 @@ import Foundation final class Stroke: @unchecked Sendable { var object: StrokeObject? + var bounds: [CGFloat] var color: [CGFloat] var style: Int16 var createdAt: Date @@ -19,6 +20,7 @@ final class Stroke: @unchecked Sendable { init(object: StrokeObject) { self.object = object + self.bounds = object.bounds self.color = object.color self.style = object.style self.createdAt = object.createdAt @@ -27,12 +29,14 @@ final class Stroke: @unchecked Sendable { } init( + bounds: [CGFloat], color: [CGFloat], style: Int16, createdAt: Date, thickness: CGFloat, quads: [Quad] = [] ) { + self.bounds = bounds self.color = color self.style = style self.createdAt = createdAt @@ -59,6 +63,17 @@ final class Stroke: @unchecked Sendable { var isEraserPenStyle: Bool { penStyle == .eraser } + var strokeBounds: 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) + } + + func isVisible(in bounds: CGRect) -> Bool { + bounds.contains(strokeBounds) || bounds.intersects(strokeBounds) + } func begin(at point: CGPoint) { penStyle.anyPenStyle.generator.begin(at: point, on: self) @@ -83,6 +98,13 @@ extension Stroke { } } + func loadQuads(from object: StrokeObject) { + quads = object.quads.compactMap { quad in + guard let quad = quad as? QuadObject else { return nil } + return Quad(object: quad) + } + } + func addQuad(at point: CGPoint, rotation: CGFloat, shape: QuadShape) -> Quad { let quad = Quad( origin: point, @@ -106,6 +128,8 @@ extension Stroke { } func saveQuads(for quads: [Quad]) { + var topLeft: CGPoint = CGPoint(x: bounds[0], y: bounds[1]) + var bottomRight: CGPoint = CGPoint(x: bounds[2], y: bounds[3]) for _quad in quads { let quad = QuadObject(\.backgroundContext) quad.originX = _quad.originX.cgFloat @@ -116,7 +140,12 @@ extension Stroke { quad.color = _quad.getColor() quad.stroke = object object?.quads.add(quad) + topLeft.x = min(quad.originX, topLeft.x) + topLeft.y = min(quad.originY, topLeft.y) + bottomRight.x = max(quad.originX, bottomRight.x) + bottomRight.y = max(quad.originY, bottomRight.y) } + bounds = [topLeft.x, topLeft.y, bottomRight.x, bottomRight.y] } } diff --git a/Memola/Canvas/RenderPasses/GraphicRenderPass.swift b/Memola/Canvas/RenderPasses/GraphicRenderPass.swift index fd685de..0f8e4e5 100644 --- a/Memola/Canvas/RenderPasses/GraphicRenderPass.swift +++ b/Memola/Canvas/RenderPasses/GraphicRenderPass.swift @@ -49,6 +49,7 @@ class GraphicRenderPass: RenderPass { 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 if stroke.isEraserPenStyle { diff --git a/Memola/Canvas/RenderPasses/StrokeRenderPass.swift b/Memola/Canvas/RenderPasses/StrokeRenderPass.swift index 5d33aca..c268735 100644 --- a/Memola/Canvas/RenderPasses/StrokeRenderPass.swift +++ b/Memola/Canvas/RenderPasses/StrokeRenderPass.swift @@ -83,6 +83,7 @@ class StrokeRenderPass: RenderPass { computeEncoder.dispatchThreadgroups(numThreadgroups, threadsPerThreadgroup: threadsPerGroup) computeEncoder.endEncoding() quadCommandBuffer.commit() + quadCommandBuffer.waitUntilCompleted() } private func drawStrokeTexture(on canvas: Canvas, with renderer: Renderer) { diff --git a/Memola/Canvas/Tool/Pen/PenStyles/EraserPenStyle.swift b/Memola/Canvas/Tool/Pen/PenStyles/EraserPenStyle.swift index 25ca7c8..a18d8df 100644 --- a/Memola/Canvas/Tool/Pen/PenStyles/EraserPenStyle.swift +++ b/Memola/Canvas/Tool/Pen/PenStyles/EraserPenStyle.swift @@ -12,7 +12,7 @@ struct EraserPenStyle: PenStyle { var textureName: String = "point-texture" - var thinkness: (min: CGFloat, max: CGFloat) = (1, 120) + var thinkness: (min: CGFloat, max: CGFloat) = (0.5, 120) var color: [CGFloat] = [1, 1, 1, 0] diff --git a/Memola/Canvas/Tool/Pen/PenStyles/MarkerPenStyle.swift b/Memola/Canvas/Tool/Pen/PenStyles/MarkerPenStyle.swift index 0b821d8..ed82047 100644 --- a/Memola/Canvas/Tool/Pen/PenStyles/MarkerPenStyle.swift +++ b/Memola/Canvas/Tool/Pen/PenStyles/MarkerPenStyle.swift @@ -12,7 +12,7 @@ struct MarkerPenStyle: PenStyle { var textureName: String = "point-texture" - var thinkness: (min: CGFloat, max: CGFloat) = (1, 120) + var thinkness: (min: CGFloat, max: CGFloat) = (0.5, 120) var color: [CGFloat] = [1, 0.38, 0.38, 1] diff --git a/Memola/Canvas/View/Bridge/ViewController/CanvasViewController.swift b/Memola/Canvas/View/Bridge/ViewController/CanvasViewController.swift index 69faa94..0a37e68 100644 --- a/Memola/Canvas/View/Bridge/ViewController/CanvasViewController.swift +++ b/Memola/Canvas/View/Bridge/ViewController/CanvasViewController.swift @@ -41,13 +41,13 @@ class CanvasViewController: UIViewController { super.viewDidLoad() configureViews() configureListeners() - - loadMemo() } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) resizeDocumentView() + updateDocumentBounds() + loadMemo() } override func viewDidLayoutSubviews() { @@ -119,11 +119,11 @@ extension CanvasViewController { let newFrame = CGRect(x: 0, y: 0, width: width, height: height) drawingView.frame = newFrame - scrollView.setZoomScale(canvas.minimumZoomScale, animated: true) + scrollView.setZoomScale(canvas.defaultZoomScale, animated: true) centerDocumentView(to: newSize) - let offsetX = (newFrame.width * canvas.minimumZoomScale - view.frame.width) / 2 - let offsetY = (newFrame.height * canvas.minimumZoomScale - view.frame.height) / 2 + let offsetX = (newFrame.width * canvas.defaultZoomScale - view.frame.width) / 2 + let offsetY = (newFrame.height * canvas.defaultZoomScale - view.frame.height) / 2 let point = CGPoint(x: offsetX, y: offsetY) scrollView.setContentOffset(point, animated: true) @@ -138,6 +138,20 @@ extension CanvasViewController { let horizontalPadding = documentViewSize.width < scrollViewSize.width ? (scrollViewSize.width - documentViewSize.width) / 2 : 0 self.scrollView.contentInset = UIEdgeInsets(top: verticalPadding, left: horizontalPadding, bottom: verticalPadding, right: horizontalPadding) } + + func updateDocumentBounds() { + var bounds = scrollView.bounds.muliply(by: drawingView.ratio / scrollView.zoomScale) + let xDelta = bounds.minX * 0.05 + let yDelta = bounds.minY * 0.05 + bounds.origin.x -= xDelta + bounds.origin.y -= yDelta + bounds.size.width += xDelta * 2 + bounds.size.height += yDelta * 2 + canvas.bounds = bounds + if canvas.state == .loaded { + canvas.loadStrokes(bounds) + } + } } extension CanvasViewController { @@ -208,6 +222,7 @@ extension CanvasViewController: UIScrollViewDelegate { } func scrollViewDidEndZooming(_ scrollView: UIScrollView, with view: UIView?, atScale scale: CGFloat) { + updateDocumentBounds() centerDocumentView() magnificationEnded() } @@ -234,6 +249,7 @@ extension CanvasViewController: UIScrollViewDelegate { } func scrollViewDidEndScrolling(_ scrollView: UIScrollView) { + updateDocumentBounds() draggingEnded() } } diff --git a/Memola/Canvas/View/Bridge/Views/DrawingView.swift b/Memola/Canvas/View/Bridge/Views/DrawingView.swift index 0b798e2..84b58d5 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: 2.0) + renderView.drawableSize = size.multiply(by: 3) } override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { diff --git a/Memola/Extensions/CGRect++.swift b/Memola/Extensions/CGRect++.swift index a576e31..ca95178 100644 --- a/Memola/Extensions/CGRect++.swift +++ b/Memola/Extensions/CGRect++.swift @@ -16,4 +16,8 @@ extension CGRect { t = t.scaledBy(x: rect.width, y: rect.height) return t } + + func muliply(by factor: CGFloat) -> CGRect { + CGRect(origin: origin.muliply(by: factor), size: size.multiply(by: factor)) + } } diff --git a/Memola/Features/Memos/MemosView.swift b/Memola/Features/Memos/MemosView.swift index 1b4f079..57c6395 100644 --- a/Memola/Features/Memos/MemosView.swift +++ b/Memola/Features/Memos/MemosView.swift @@ -69,8 +69,8 @@ struct MemosView: View { memoObject.updatedAt = .now let canvasObject = CanvasObject(context: managedObjectContext) - canvasObject.width = 4_000 - canvasObject.height = 4_000 + canvasObject.width = 8_000 + canvasObject.height = 8_000 let graphicContextObject = GraphicContextObject(context: managedObjectContext) graphicContextObject.strokes = [] diff --git a/Memola/Persistence/Core/Persistence.swift b/Memola/Persistence/Core/Persistence.swift index 4cb4a9a..5ebac21 100644 --- a/Memola/Persistence/Core/Persistence.swift +++ b/Memola/Persistence/Core/Persistence.swift @@ -26,6 +26,13 @@ final class Persistence { return context }() + var newBackgroundContext: NSManagedObjectContext { + let context = persistentContainer.newBackgroundContext() + context.undoManager = nil + context.automaticallyMergesChangesFromParent = true + return context + } + lazy var persistentContainer: NSPersistentContainer = { let persistentStore = NSPersistentStoreDescription() persistentStore.shouldMigrateStoreAutomatically = true @@ -83,3 +90,14 @@ func withPersistence(_ keypath: KeyPath, _ } } } + +func withPersistenceSync(_ keypath: KeyPath, _ task: @escaping (NSManagedObjectContext) throws -> Void) { + let context = Persistence.shared[keyPath: keypath] + context.performAndWait { + do { + try task(context) + } catch { + NSLog("[Memola] - \(error.localizedDescription)") + } + } +} diff --git a/Memola/Persistence/Objects/StrokeObject.swift b/Memola/Persistence/Objects/StrokeObject.swift index ffa049b..2d9767a 100644 --- a/Memola/Persistence/Objects/StrokeObject.swift +++ b/Memola/Persistence/Objects/StrokeObject.swift @@ -10,6 +10,7 @@ import Foundation @objc(StrokeObject) final class StrokeObject: NSManagedObject { + @NSManaged var bounds: [CGFloat] @NSManaged var color: [CGFloat] @NSManaged var style: Int16 @NSManaged var createdAt: Date diff --git a/Memola/Resources/Models/MemolaModel.xcdatamodeld/MemolaModel.xcdatamodel/contents b/Memola/Resources/Models/MemolaModel.xcdatamodeld/MemolaModel.xcdatamodel/contents index 72e5132..1b277fa 100644 --- a/Memola/Resources/Models/MemolaModel.xcdatamodeld/MemolaModel.xcdatamodel/contents +++ b/Memola/Resources/Models/MemolaModel.xcdatamodeld/MemolaModel.xcdatamodel/contents @@ -26,6 +26,7 @@ +