From eebf45b20a1cd1ff6e54070e843e0b5b30a5e8f2 Mon Sep 17 00:00:00 2001 From: dscyrescotti Date: Fri, 31 May 2024 21:00:41 +0700 Subject: [PATCH 1/4] refactor: save quads right after they are added --- Memola/Canvas/Contexts/GraphicContext.swift | 9 ++-- .../Geometries/Stroke/Core/Stroke.swift | 9 ---- .../SolidPointStrokeGenerator.swift | 28 +--------- .../Stroke/Strokes/EraserStroke.swift | 6 --- .../Geometries/Stroke/Strokes/PenStroke.swift | 51 +++++++++---------- 5 files changed, 31 insertions(+), 72 deletions(-) diff --git a/Memola/Canvas/Contexts/GraphicContext.swift b/Memola/Canvas/Contexts/GraphicContext.swift index 142d852..e004576 100644 --- a/Memola/Canvas/Contexts/GraphicContext.swift +++ b/Memola/Canvas/Contexts/GraphicContext.swift @@ -154,11 +154,12 @@ extension GraphicContext { func endStroke(at point: CGPoint) { guard currentPoint != nil, let currentStroke else { return } currentStroke.finish(at: point) - currentStroke.saveQuads(to: currentStroke.quads.endIndex) - withPersistence(\.backgroundContext) { context in + withPersistence(\.backgroundContext) { [currentStroke] context in + guard let stroke = currentStroke.stroke(as: PenStroke.self) else { return } + stroke.object?.bounds = stroke.bounds try context.saveIfNeeded() - if let stroke = currentStroke.stroke(as: PenStroke.self)?.object { - context.refresh(stroke, mergeChanges: false) + if let object = stroke.object { + context.refresh(object, mergeChanges: false) } } previousStroke = currentStroke diff --git a/Memola/Canvas/Geometries/Stroke/Core/Stroke.swift b/Memola/Canvas/Geometries/Stroke/Core/Stroke.swift index 968ab44..6829343 100644 --- a/Memola/Canvas/Geometries/Stroke/Core/Stroke.swift +++ b/Memola/Canvas/Geometries/Stroke/Core/Stroke.swift @@ -18,8 +18,6 @@ protocol Stroke: AnyObject, Drawable, Hashable, Equatable { var quads: [Quad] { get set } var penStyle: any PenStyle { get set } - var batchIndex: Int { get set } - var quadIndex: Int { get set } var keyPoints: [CGPoint] { get set } var movingAverage: MovingAverage { get set } @@ -32,8 +30,6 @@ protocol Stroke: AnyObject, Drawable, Hashable, Equatable { func finish(at point: CGPoint) func addQuad(at point: CGPoint, rotation: CGFloat, shape: QuadShape) - func removeQuads(from index: Int) - func saveQuads(to index: Int) } extension Stroke { @@ -78,11 +74,6 @@ extension Stroke { ) quads.append(quad) } - - func removeQuads(from index: Int) { - let dropCount = quads.endIndex - max(1, index) - quads.removeLast(dropCount) - } } extension Stroke { diff --git a/Memola/Canvas/Geometries/Stroke/Generators/SolidPointStrokeGenerator.swift b/Memola/Canvas/Geometries/Stroke/Generators/SolidPointStrokeGenerator.swift index bddd937..01bdce5 100644 --- a/Memola/Canvas/Geometries/Stroke/Generators/SolidPointStrokeGenerator.swift +++ b/Memola/Canvas/Geometries/Stroke/Generators/SolidPointStrokeGenerator.swift @@ -29,9 +29,6 @@ struct SolidPointStrokeGenerator: StrokeGenerator { let control = CGPoint.middle(p1: start, p2: end) addCurve(from: start, to: end, by: control, on: stroke) case 3: - let quadIndex = stroke.quadIndex + 1 - stroke.removeQuads(from: quadIndex) - stroke.saveQuads(to: quadIndex) 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]) @@ -42,7 +39,7 @@ struct SolidPointStrokeGenerator: StrokeGenerator { end = CGPoint.middle(p1: stroke.keyPoints[index - 1], p2: stroke.keyPoints[index]) addCurve(from: start, to: end, by: control, on: stroke) default: - smoothOutPath(on: stroke) + adjustKeyPoint(on: stroke) 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] @@ -60,29 +57,6 @@ struct SolidPointStrokeGenerator: StrokeGenerator { } } - private func smoothOutPath(on stroke: any Stroke) { - let quadIndex = stroke.quadIndex + 1 - stroke.removeQuads(from: quadIndex) - stroke.saveQuads(to: quadIndex) - adjustKeyPoint(on: stroke) - switch stroke.keyPoints.endIndex { - case 4: - 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.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.endIndex - 1 - } - private func adjustKeyPoint(on stroke: any Stroke) { let index = stroke.keyPoints.endIndex - 1 let prev = stroke.keyPoints[index - 1] diff --git a/Memola/Canvas/Geometries/Stroke/Strokes/EraserStroke.swift b/Memola/Canvas/Geometries/Stroke/Strokes/EraserStroke.swift index d36e55d..011ece5 100644 --- a/Memola/Canvas/Geometries/Stroke/Strokes/EraserStroke.swift +++ b/Memola/Canvas/Geometries/Stroke/Strokes/EraserStroke.swift @@ -18,8 +18,6 @@ final class EraserStroke: Stroke, @unchecked Sendable { var quads: [Quad] var penStyle: any PenStyle - var batchIndex: Int = 0 - var quadIndex: Int = -1 var keyPoints: [CGPoint] = [] var movingAverage: MovingAverage = MovingAverage(windowSize: 3) @@ -43,8 +41,4 @@ final class EraserStroke: Stroke, @unchecked Sendable { self.quads = quads self.penStyle = style.penStyle } - - func saveQuads(to index: Int) { - - } } diff --git a/Memola/Canvas/Geometries/Stroke/Strokes/PenStroke.swift b/Memola/Canvas/Geometries/Stroke/Strokes/PenStroke.swift index 5c8153b..c8f9d70 100644 --- a/Memola/Canvas/Geometries/Stroke/Strokes/PenStroke.swift +++ b/Memola/Canvas/Geometries/Stroke/Strokes/PenStroke.swift @@ -19,8 +19,6 @@ final class PenStroke: Stroke, @unchecked Sendable { var quads: [Quad] var penStyle: any PenStyle - var batchIndex: Int = 0 - var quadIndex: Int = -1 var keyPoints: [CGPoint] = [] var movingAverage: MovingAverage = MovingAverage(windowSize: 3) @@ -71,30 +69,31 @@ final class PenStroke: Stroke, @unchecked Sendable { } } - func saveQuads(to index: Int) { - let quads = Array(quads[batchIndex.. Date: Wed, 5 Jun 2024 20:53:34 +0700 Subject: [PATCH 2/4] feat: implement rtree --- Memola.xcodeproj/project.pbxproj | 20 ++ Memola/Canvas/RTree/Box.swift | 60 ++++++ Memola/Canvas/RTree/Node.swift | 35 ++++ Memola/Canvas/RTree/RTree.swift | 312 +++++++++++++++++++++++++++++++ 4 files changed, 427 insertions(+) create mode 100644 Memola/Canvas/RTree/Box.swift create mode 100644 Memola/Canvas/RTree/Node.swift create mode 100644 Memola/Canvas/RTree/RTree.swift diff --git a/Memola.xcodeproj/project.pbxproj b/Memola.xcodeproj/project.pbxproj index 43a30be..56d2e82 100644 --- a/Memola.xcodeproj/project.pbxproj +++ b/Memola.xcodeproj/project.pbxproj @@ -12,6 +12,9 @@ EC0D14262BF7A8C9009BFE5F /* PenObject.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC0D14252BF7A8C9009BFE5F /* PenObject.swift */; }; EC0D14282BF7BF20009BFE5F /* ContextMenuViewModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC0D14272BF7BF20009BFE5F /* ContextMenuViewModifier.swift */; }; EC1B783D2BFA0AC9005A34E2 /* Toolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC1B783C2BFA0AC9005A34E2 /* Toolbar.swift */; }; + EC2BEBF42C0F5FF7005DB0AF /* RTree.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC2BEBF32C0F5FF7005DB0AF /* RTree.swift */; }; + EC2BEBF62C0F600D005DB0AF /* Box.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC2BEBF52C0F600D005DB0AF /* Box.swift */; }; + EC2BEBF82C0F601A005DB0AF /* Node.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC2BEBF72C0F601A005DB0AF /* Node.swift */; }; EC3565522BEFC65F00A4E0BF /* NSManagedObjectContext++.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC3565512BEFC65F00A4E0BF /* NSManagedObjectContext++.swift */; }; EC3565542BEFC6AD00A4E0BF /* View++.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC3565532BEFC6AD00A4E0BF /* View++.swift */; }; EC3565562BEFC7B300A4E0BF /* NSManagedObject++.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC3565552BEFC7B300A4E0BF /* NSManagedObject++.swift */; }; @@ -95,6 +98,9 @@ EC0D14252BF7A8C9009BFE5F /* PenObject.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PenObject.swift; sourceTree = ""; }; EC0D14272BF7BF20009BFE5F /* ContextMenuViewModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextMenuViewModifier.swift; sourceTree = ""; }; EC1B783C2BFA0AC9005A34E2 /* Toolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Toolbar.swift; sourceTree = ""; }; + EC2BEBF32C0F5FF7005DB0AF /* RTree.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RTree.swift; sourceTree = ""; }; + EC2BEBF52C0F600D005DB0AF /* Box.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Box.swift; sourceTree = ""; }; + EC2BEBF72C0F601A005DB0AF /* Node.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Node.swift; sourceTree = ""; }; EC3565512BEFC65F00A4E0BF /* NSManagedObjectContext++.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSManagedObjectContext++.swift"; sourceTree = ""; }; EC3565532BEFC6AD00A4E0BF /* View++.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View++.swift"; sourceTree = ""; }; EC3565552BEFC7B300A4E0BF /* NSManagedObject++.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSManagedObject++.swift"; sourceTree = ""; }; @@ -217,6 +223,16 @@ path = Toolbar; sourceTree = ""; }; + EC2BEBF22C0F5FE1005DB0AF /* RTree */ = { + isa = PBXGroup; + children = ( + EC2BEBF32C0F5FF7005DB0AF /* RTree.swift */, + EC2BEBF52C0F600D005DB0AF /* Box.swift */, + EC2BEBF72C0F601A005DB0AF /* Node.swift */, + ); + path = RTree; + sourceTree = ""; + }; EC5050042BF65CBC00B4D86E /* Core */ = { isa = PBXGroup; children = ( @@ -362,6 +378,7 @@ ECA7387E2BE5FE4200A4542E /* Canvas */ = { isa = PBXGroup; children = ( + EC2BEBF22C0F5FE1005DB0AF /* RTree */, ECA738F92BE6130000A4542E /* Geometries */, ECA738812BE5FEEE00A4542E /* Abstracts */, ECA738992BE6018900A4542E /* Buffers */, @@ -768,6 +785,7 @@ ECA738A62BE6023F00A4542E /* GridUniforms.swift in Sources */, ECA738D72BE60FC100A4542E /* SolidPointStrokeGenerator.swift in Sources */, EC3565542BEFC6AD00A4E0BF /* View++.swift in Sources */, + EC2BEBF62C0F600D005DB0AF /* Box.swift in Sources */, ECA738832BE5FEFE00A4542E /* RenderPass.swift in Sources */, ECEC01A82BEE11BA006DA24C /* QuadShape.swift in Sources */, ECA738862BE5FF2500A4542E /* Canvas.swift in Sources */, @@ -794,6 +812,7 @@ EC4538892BEBCAE000A86FEC /* Quad.swift in Sources */, ECE883BD2C00AA170045C53D /* EraserStroke.swift in Sources */, ECA7388F2BE600DA00A4542E /* Grid.metal in Sources */, + EC2BEBF42C0F5FF7005DB0AF /* RTree.swift in Sources */, ECA738C92BE60EF700A4542E /* GraphicContext.swift in Sources */, ECA738F62BE612B700A4542E /* MTLDevice++.swift in Sources */, EC0D14212BF79C73009BFE5F /* ToolObject.swift in Sources */, @@ -812,6 +831,7 @@ ECA738C12BE60E5300A4542E /* PenStyle.swift in Sources */, ECA738DE2BE610A000A4542E /* ViewPortRenderPass.swift in Sources */, EC7F6BEC2BE5E6E300A34A7B /* MemolaApp.swift in Sources */, + EC2BEBF82C0F601A005DB0AF /* Node.swift in Sources */, ECA738A02BE601E400A4542E /* ViewPortVertex.swift in Sources */, EC0D14282BF7BF20009BFE5F /* ContextMenuViewModifier.swift in Sources */, ECA738BC2BE60E0300A4542E /* Tool.swift in Sources */, diff --git a/Memola/Canvas/RTree/Box.swift b/Memola/Canvas/RTree/Box.swift new file mode 100644 index 0000000..ed102d3 --- /dev/null +++ b/Memola/Canvas/RTree/Box.swift @@ -0,0 +1,60 @@ +// +// Box.swift +// Memola +// +// Created by Dscyre Scotti on 6/4/24. +// + +import Foundation + +struct Box: Equatable, Decodable { + var minX: Double + var minY: Double + var maxX: Double + var maxY: Double + + init(minX: Double, minY: Double, maxX: Double, maxY: Double) { + self.minX = minX + self.minY = minY + self.maxX = maxX + self.maxY = maxY + } + + var area: Double { + (maxX - minX) * (maxY - minY) + } + + var margin: Double { + (maxX - minX) + (maxY - minY) + } + + func enlargedArea(for box: Box) -> Double { + (max(box.maxX, maxX) - min(box.minX, minX)) * (max(box.maxY, maxY) - min(box.minY, minY)) + } + + func intersects(with box: Box) -> Bool { + box.minX <= maxX && box.minY <= maxY && box.maxX >= minX && box.maxY >= minY + } + + func contains(with box: Box) -> Bool { + minX <= box.minX && minY <= box.minY && box.maxX <= maxX && box.maxY <= maxY + } + + func intersectedArea(on box: Box) -> Double { + let minX = max(minX, box.minX) + let minY = max(minY, box.minY) + let maxX = min(maxX, box.maxX) + let maxY = min(maxY, box.maxY) + + return max(0, maxX - minX) * max(0, maxY - minY) + } + + mutating func enlarge(for box: Box) { + minX = min(minX, box.minX) + minY = min(minY, box.minY) + maxX = max(maxX, box.maxX) + maxY = max(maxY, box.maxY) + } + + static var infinity = Box(minX: .infinity, minY: .infinity, maxX: -.infinity, maxY: -.infinity) +} diff --git a/Memola/Canvas/RTree/Node.swift b/Memola/Canvas/RTree/Node.swift new file mode 100644 index 0000000..2321961 --- /dev/null +++ b/Memola/Canvas/RTree/Node.swift @@ -0,0 +1,35 @@ +// +// Node.swift +// Memola +// +// Created by Dscyre Scotti on 6/4/24. +// + +import Foundation + +class Node where T: Equatable & Comparable { + var box: Box + var value: T? + var isLeaf: Bool + var height: Int + var children: [Node] + + init(box: Box, value: T? = nil, isLeaf: Bool, height: Int, children: [Node] = []) { + self.box = box + self.value = value + self.isLeaf = isLeaf + self.height = height + self.children = children + } + + func updateBox() { + box = .infinity + for node in children { + box.enlarge(for: node.box) + } + } + + static func createNode(in box: Box = .infinity, for value: T? = nil, with children: [Node] = []) -> Node { + Node(box: box, value: value, isLeaf: true, height: 1, children: children) + } +} diff --git a/Memola/Canvas/RTree/RTree.swift b/Memola/Canvas/RTree/RTree.swift new file mode 100644 index 0000000..82c0351 --- /dev/null +++ b/Memola/Canvas/RTree/RTree.swift @@ -0,0 +1,312 @@ +// +// RTree.swift +// Memola +// +// Created by Dscyre Scotti on 6/4/24. +// + +import Foundation + +class RTree where T: Equatable & Comparable { + private var root: Node + private let maxEntries: Int + private let minEntries: Int + + init(maxEntries: Int = 9) { + self.maxEntries = max(4, maxEntries) + self.minEntries = max(2, Int(ceil(Double(maxEntries) * 0.4))) + self.root = Node.createNode() + } + + // MARK: - Retrival + func traverse() -> [T] { + _traverse(from: root) + } + + private func _traverse(from _root: Node) -> [T] { + var result: [T] = [] + var queue: [Node] = [_root] + while let node = queue.first { + queue.removeFirst() + if node.isLeaf { + let children = node.children + .compactMap { $0.value } + .sorted(by: <) + result = _merge(result, children) + } else { + queue.append(contentsOf: node.children) + } + } + return result + } + + func _merge(_ left: [T], _ right: [T]) -> [T] { + var mergedArray: [T] = [] + var leftIndex = 0 + var rightIndex = 0 + + while leftIndex < left.count && rightIndex < right.count { + if left[leftIndex] < right[rightIndex] { + mergedArray.append(left[leftIndex]) + leftIndex += 1 + } else { + mergedArray.append(right[rightIndex]) + rightIndex += 1 + } + } + + mergedArray.append(contentsOf: left[leftIndex...]) + mergedArray.append(contentsOf: right[rightIndex...]) + + return mergedArray + } + + // MARK: - Search + func search(box: Box) -> [T] { + guard box.intersects(with: root.box) else { return [] } + var result: [T] = [] + var queue: [Node] = [root] + while let node = queue.first { + queue.removeFirst() + for childNode in node.children { + if box.intersects(with: childNode.box) { + if node.isLeaf { + if let value = childNode.value { + result = _merge(result, [value]) + } + } else if box.contains(with: childNode.box) { + result = _merge(result, _traverse(from: childNode)) + } else { + queue.append(childNode) + } + } + } + } + return result + } + + // MARK: - Insertion + func insert(_ value: T, in box: Box) { + let node: Node = Node.createNode(in: box, for: value) + _insert(node, level: root.height - 1) + } + + private func _insert(_ node: Node, level: Int) { + let box = node.box + var path: [Node] = [] + var level = level + let leafNode = _chooseSubtree(for: box, from: root, at: level, into: &path) + leafNode.children.append(node) + leafNode.box.enlarge(for: box) + while level >= 0 { + guard path[level].children.count > maxEntries else { + break + } + _split(on: path, at: level) + level -= 1 + } + _adjustParentBoxes(with: box, through: path, at: level) + } + + private func _chooseSubtree(for box: Box, from rootNode: Node, at level: Int, into path: inout [Node]) -> Node { + var node = rootNode + while true { + path.append(node) + if node.isLeaf || path.count - 1 == level { break } + var minArea: Double = .infinity + var minEnlargement: Double = .infinity + var targetNode: Node? + for node in node.children { + let area = node.box.area + let enlargement = box.enlargedArea(for: node.box) - area + if enlargement < minEnlargement { + minEnlargement = enlargement + minArea = area < minArea ? area : minArea; + targetNode = node + } else if enlargement == minEnlargement { + if area < minArea { + minArea = area + targetNode = node + } + } + } + node = targetNode ?? node.children[0] + } + return node + } + + // MARK: - Removal + @discardableResult + func remove(_ value: T, in box: Box) -> T? { + var node: Node? = root + + var path: [Node] = [] + var indices: [Int] = [] + var parent: Node? + var i: Int = 0 + var goingUp: Bool = false + + while node != nil || !path.isEmpty { + guard let currentNode = node else { + node = path.popLast() + parent = path.last + i = indices.popLast() ?? 0 + goingUp = true + continue + } + if currentNode.isLeaf, let index = _findIndex(of: value, nodes: currentNode.children) { + let removedNode = currentNode.children.remove(at: index) + path.append(currentNode) + _condense(path) + return removedNode.value + } + if !goingUp && !currentNode.isLeaf && currentNode.box.contains(with: box) { + path.append(currentNode) + indices.append(i) + i = 0 + parent = currentNode + node = currentNode.children[0] + } else if let parent { + i += 1 + node = parent.children[safe: i] + goingUp = false + } else { + node = nil + } + } + return nil + } + + private func _findIndex(of value: T, nodes: [Node]) -> Int? { + for (index, node) in nodes.enumerated() { + if node.value == value { return index } + } + return nil + } + + private func _condense(_ path: [Node]) { + var i = path.count - 1 + while i >= 0 { + let node = path[i] + if node.children.isEmpty { + if i > 0 { + var siblings = path[i - 1].children + if let index = siblings.firstIndex(where: { $0 === node }) { + siblings.remove(at: index) + } + } else { + root = .createNode() + } + } else { + _calculateBox(of: node) + } + i -= 1 + } + } + + // MARK: - Splitting + private func _split(on path: [Node], at level: Int) { + let node = path[level] + let numOfChildren = node.children.count + let minNumOfChildren = minEntries + + _chooseSplitAxis(for: node, with: numOfChildren, by: minNumOfChildren) + let splitIndex = _chooseSplitIndex(for: node, with: numOfChildren, by: minNumOfChildren) + + let children = Array(node.children[splitIndex...createNode(with: children) + newNode.height = node.height + newNode.isLeaf = node.isLeaf + + node.updateBox() + newNode.updateBox() + + if level > 0 { + path[level - 1].children.append(newNode) + } else { + _splitRoot(with: node, and: newNode) + } + } + + private func _splitRoot(with node: Node, and newNode: Node) { + root = Node.createNode(with: [node, newNode]) + root.height = node.height + 1 + root.isLeaf = false + root.updateBox() + } + + private func _chooseSplitAxis(for node: Node, with numOfChildren: Int, by minEntries: Int) { + let comparatorX: (Node, Node) -> Bool = { $0.box.minX < $1.box.minX } + let comparatorY: (Node, Node) -> Bool = { $0.box.minY < $1.box.minY } + let xMargin = _calculateDistributionMargin(for: node, with: numOfChildren, by: minEntries, using: comparatorX) + let yMargin = _calculateDistributionMargin(for: node, with: numOfChildren, by: minEntries, using: comparatorY) + if xMargin < yMargin { + node.children.sort(by: comparatorX) + } + } + + private func _calculateDistributionMargin(for node: Node, with numOfChildren: Int, by minEntries: Int, using comparator: (Node, Node) -> Bool) -> Double { + node.children.sort(by: comparator) + let leftNode = _mergeChildNodes(of: node, from: 0, to: minEntries) + let rightNode = _mergeChildNodes(of: node, from: numOfChildren - minEntries, to: numOfChildren) + var margin = leftNode.box.margin + rightNode.box.margin + + for index in minEntries.., with numOfChildren: Int, by minEntries: Int) -> Int { + var index: Int? + var minOverlap: Double = .infinity + var minArea: Double = .infinity + for idx in minEntries...numOfChildren - minEntries { + let node1 = _mergeChildNodes(of: node, from: 0, to: idx) + let node2 = _mergeChildNodes(of: node, from: idx, to: numOfChildren) + + let overlap = node1.box.intersectedArea(on: node2.box) + let area = node1.box.area + node2.box.area + + if overlap < minOverlap { + minOverlap = overlap + index = idx + minArea = min(area, minArea) + } else if overlap == minOverlap { + if area < minArea { + minArea = area + index = idx + } + } + } + return index ?? numOfChildren - minEntries + } + + private func _adjustParentBoxes(with box: Box, through path: [Node], at level: Int) { + for index in stride(from: level, through: 0, by: -1) { + path[index].box.enlarge(for: box) + } + } + + private func _calculateBox(of node: Node) { + _mergeChildNodes(of: node, from: 0, to: node.children.count, into: node) + } + + @discardableResult + private func _mergeChildNodes(of node: Node, from start: Int, to end: Int, into newNode: Node = .createNode()) -> Node { + newNode.box = .infinity + for index in start.. Date: Wed, 5 Jun 2024 22:58:15 +0700 Subject: [PATCH 3/4] feat: replace array with rtree --- Memola/Canvas/Contexts/GraphicContext.swift | 46 +++++++++++-------- Memola/Canvas/Core/Canvas.swift | 4 -- .../Geometries/Stroke/Core/Stroke.swift | 10 +++- Memola/Canvas/History/History.swift | 6 +-- Memola/Canvas/RTree/RTree.swift | 6 ++- .../RenderPasses/GraphicRenderPass.swift | 4 +- .../ViewController/CanvasViewController.swift | 8 ++-- Memola/Extensions/CGRect++.swift | 4 ++ 8 files changed, 54 insertions(+), 34 deletions(-) diff --git a/Memola/Canvas/Contexts/GraphicContext.swift b/Memola/Canvas/Contexts/GraphicContext.swift index e004576..10fca2c 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 strokes: [any Stroke] = [] + var tree: RTree = RTree(maxEntries: 8) var object: GraphicContextObject? var currentStroke: (any Stroke)? @@ -37,20 +37,26 @@ final class GraphicContext: @unchecked Sendable { ] } - func undoGraphic() { - guard !strokes.isEmpty else { return } - let stroke = strokes.removeLast() - withPersistence(\.backgroundContext) { [stroke] context in - stroke.stroke(as: PenStroke.self)?.object?.graphicContext = nil - try context.saveIfNeeded() + func undoGraphic(for event: HistoryEvent) { + switch event { + case .stroke(let stroke): + guard let _stroke = stroke.stroke(as: PenStroke.self) else { return } + let deletedStroke = tree.remove(_stroke, in: _stroke.strokeBox) + withPersistence(\.backgroundContext) { [stroke = deletedStroke] context in + stroke?.stroke(as: PenStroke.self)?.object?.graphicContext = nil + try context.saveIfNeeded() + } + previousStroke = nil } - previousStroke = nil + } func redoGraphic(for event: HistoryEvent) { switch event { case .stroke(let stroke): - strokes.append(stroke) + if let stroke = stroke.stroke(as: PenStroke.self) { + tree.insert(stroke, in: stroke.strokeBox) + } withPersistence(\.backgroundContext) { [weak self, stroke] context in stroke.stroke(as: PenStroke.self)?.object?.graphicContext = self?.object try context.saveIfNeeded() @@ -65,9 +71,10 @@ extension GraphicContext { guard let object else { return } let queue = OperationQueue() queue.qualityOfService = .userInteractive - self.strokes = object.strokes.compactMap { stroke -> PenStroke? in - guard let stroke = stroke as? StrokeObject else { return nil } + object.strokes.forEach { stroke in + guard let stroke = stroke as? StrokeObject else { return } let _stroke = PenStroke(object: stroke) + tree.insert(_stroke, in: _stroke.strokeBox) if _stroke.isVisible(in: bounds) { let id = stroke.objectID queue.addOperation { @@ -85,14 +92,13 @@ extension GraphicContext { context.refresh(stroke, mergeChanges: false) } } - return _stroke } queue.waitUntilAllOperationsAreFinished() } func loadQuads(_ bounds: CGRect) { - for stroke in self.strokes { - guard stroke.isVisible(in: bounds), stroke.isEmpty else { continue } + for stroke in self.tree.search(box: bounds.box) { + guard stroke.isEmpty else { continue } stroke.stroke(as: PenStroke.self)?.loadQuads() } } @@ -135,7 +141,6 @@ extension GraphicContext { graphicContext?.strokes.add(stroke) _stroke.object = stroke } - strokes.append(stroke) currentStroke = stroke currentPoint = point currentStroke?.begin(at: point) @@ -152,8 +157,9 @@ extension GraphicContext { } func endStroke(at point: CGPoint) { - guard currentPoint != nil, let currentStroke else { return } + guard currentPoint != nil, let currentStroke = currentStroke?.stroke(as: PenStroke.self) else { return } currentStroke.finish(at: point) + tree.insert(currentStroke, in: currentStroke.strokeBox) withPersistence(\.backgroundContext) { [currentStroke] context in guard let stroke = currentStroke.stroke(as: PenStroke.self) else { return } stroke.object?.bounds = stroke.bounds @@ -168,10 +174,10 @@ extension GraphicContext { } func cancelStroke() { - if !strokes.isEmpty { - let stroke = strokes.removeLast() - withPersistence(\.backgroundContext) { [graphicContext = object, _stroke = stroke] context in - if let stroke = _stroke.stroke(as: PenStroke.self)?.object { + if !tree.isEmpty, let stroke = currentStroke?.stroke(as: PenStroke.self) { + let _stroke = tree.remove(stroke, in: stroke.strokeBox) + withPersistence(\.backgroundContext) { [graphicContext = object, _stroke] context in + if let stroke = _stroke?.stroke(as: PenStroke.self)?.object { graphicContext?.strokes.remove(stroke) context.delete(stroke) } diff --git a/Memola/Canvas/Core/Canvas.swift b/Memola/Canvas/Core/Canvas.swift index 477d3d7..f4ee75b 100644 --- a/Memola/Canvas/Core/Canvas.swift +++ b/Memola/Canvas/Core/Canvas.swift @@ -123,10 +123,6 @@ extension Canvas { func setGraphicRenderType(_ renderType: GraphicContext.RenderType) { graphicContext.renderType = renderType } - - func getNewlyAddedStroke() -> (any Stroke)? { - graphicContext.strokes.last - } } // MARK: - Rendering diff --git a/Memola/Canvas/Geometries/Stroke/Core/Stroke.swift b/Memola/Canvas/Geometries/Stroke/Core/Stroke.swift index 6829343..f1d959e 100644 --- a/Memola/Canvas/Geometries/Stroke/Core/Stroke.swift +++ b/Memola/Canvas/Geometries/Stroke/Core/Stroke.swift @@ -8,7 +8,7 @@ import MetalKit import Foundation -protocol Stroke: AnyObject, Drawable, Hashable, Equatable { +protocol Stroke: AnyObject, Drawable, Hashable, Equatable, Comparable { var id: UUID { get set } var bounds: [CGFloat] { get set } var color: [CGFloat] { get set } @@ -43,6 +43,10 @@ extension Stroke { return CGRect(x: x, y: y, width: width, height: height) } + var strokeBox: Box { + Box(minX: bounds[0], minY: bounds[1], maxX: bounds[2], maxY: bounds[3]) + } + func isVisible(in bounds: CGRect) -> Bool { bounds.contains(strokeBounds) || bounds.intersects(strokeBounds) } @@ -107,6 +111,10 @@ extension Stroke { func hash(into hasher: inout Hasher) { hasher.combine(id) } + + static func < (lhs: Self, rhs: Self) -> Bool { + lhs.createdAt < rhs.createdAt + } } extension Stroke { diff --git a/Memola/Canvas/History/History.swift b/Memola/Canvas/History/History.swift index 501e0af..825f357 100644 --- a/Memola/Canvas/History/History.swift +++ b/Memola/Canvas/History/History.swift @@ -23,12 +23,12 @@ class History: ObservableObject { redoStack.isEmpty } - func undo() -> Bool { + func undo() -> HistoryEvent? { guard let event = undoStack.popLast() else { - return false + return nil } addRedo(event) - return true + return event } func redo() -> HistoryEvent? { diff --git a/Memola/Canvas/RTree/RTree.swift b/Memola/Canvas/RTree/RTree.swift index 82c0351..be75479 100644 --- a/Memola/Canvas/RTree/RTree.swift +++ b/Memola/Canvas/RTree/RTree.swift @@ -18,6 +18,10 @@ class RTree where T: Equatable & Comparable { self.root = Node.createNode() } + var isEmpty: Bool { + root.children.isEmpty + } + // MARK: - Retrival func traverse() -> [T] { _traverse(from: root) @@ -40,7 +44,7 @@ class RTree where T: Equatable & Comparable { return result } - func _merge(_ left: [T], _ right: [T]) -> [T] { + private func _merge(_ left: [T], _ right: [T]) -> [T] { var mergedArray: [T] = [] var leftIndex = 0 var rightIndex = 0 diff --git a/Memola/Canvas/RenderPasses/GraphicRenderPass.swift b/Memola/Canvas/RenderPasses/GraphicRenderPass.swift index 8f68bb6..6e34684 100644 --- a/Memola/Canvas/RenderPasses/GraphicRenderPass.swift +++ b/Memola/Canvas/RenderPasses/GraphicRenderPass.swift @@ -45,7 +45,9 @@ class GraphicRenderPass: RenderPass { let graphicContext = canvas.graphicContext if renderer.redrawsGraphicRender { canvas.setGraphicRenderType(.finished) - for stroke in graphicContext.strokes { + let strokes = graphicContext.tree.search(box: canvas.bounds.box) + print(strokes.count) + for stroke in strokes { if graphicContext.previousStroke === stroke || graphicContext.currentStroke === stroke { continue } diff --git a/Memola/Canvas/View/Bridge/ViewController/CanvasViewController.swift b/Memola/Canvas/View/Bridge/ViewController/CanvasViewController.swift index 818c762..5e3bac4 100644 --- a/Memola/Canvas/View/Bridge/ViewController/CanvasViewController.swift +++ b/Memola/Canvas/View/Bridge/ViewController/CanvasViewController.swift @@ -141,8 +141,8 @@ extension CanvasViewController { func updateDocumentBounds() { var bounds = scrollView.bounds.muliply(by: drawingView.ratio / scrollView.zoomScale) - let xDelta = bounds.minX * 0.05 - let yDelta = bounds.minY * 0.05 + let xDelta = bounds.minX * 0.0 + let yDelta = bounds.minY * 0.0 bounds.origin.x -= xDelta bounds.origin.y -= yDelta bounds.size.width += xDelta * 2 @@ -323,9 +323,9 @@ extension CanvasViewController { extension CanvasViewController { func historyUndid() { - guard history.undo() else { return } + guard let event = history.undo() else { return } drawingView.disableUserInteraction() - canvas.graphicContext.undoGraphic() + canvas.graphicContext.undoGraphic(for: event) renderer.redrawsGraphicRender = true renderer.resize(on: renderView, to: renderView.drawableSize) renderView.draw() diff --git a/Memola/Extensions/CGRect++.swift b/Memola/Extensions/CGRect++.swift index ca95178..9f92c7b 100644 --- a/Memola/Extensions/CGRect++.swift +++ b/Memola/Extensions/CGRect++.swift @@ -20,4 +20,8 @@ extension CGRect { func muliply(by factor: CGFloat) -> CGRect { CGRect(origin: origin.muliply(by: factor), size: size.multiply(by: factor)) } + + var box: Box { + Box(minX: minX, minY: minY, maxX: maxX, maxY: maxY) + } } From cf126134393264686c278f10b7ec5c1dd1fa9916 Mon Sep 17 00:00:00 2001 From: dscyrescotti Date: Wed, 5 Jun 2024 23:11:38 +0700 Subject: [PATCH 4/4] feat: add any stroke wrapper --- Memola.xcodeproj/project.pbxproj | 4 +++ Memola/Canvas/Contexts/GraphicContext.swift | 18 ++++++------ .../Geometries/Stroke/Core/AnyStroke.swift | 28 +++++++++++++++++++ .../Geometries/Stroke/Core/Stroke.swift | 4 +++ .../RenderPasses/GraphicRenderPass.swift | 5 ++-- 5 files changed, 47 insertions(+), 12 deletions(-) create mode 100644 Memola/Canvas/Geometries/Stroke/Core/AnyStroke.swift diff --git a/Memola.xcodeproj/project.pbxproj b/Memola.xcodeproj/project.pbxproj index 56d2e82..921a58b 100644 --- a/Memola.xcodeproj/project.pbxproj +++ b/Memola.xcodeproj/project.pbxproj @@ -12,6 +12,7 @@ EC0D14262BF7A8C9009BFE5F /* PenObject.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC0D14252BF7A8C9009BFE5F /* PenObject.swift */; }; EC0D14282BF7BF20009BFE5F /* ContextMenuViewModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC0D14272BF7BF20009BFE5F /* ContextMenuViewModifier.swift */; }; EC1B783D2BFA0AC9005A34E2 /* Toolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC1B783C2BFA0AC9005A34E2 /* Toolbar.swift */; }; + EC2106AD2C10C2A700FBE27C /* AnyStroke.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC2106AC2C10C2A700FBE27C /* AnyStroke.swift */; }; EC2BEBF42C0F5FF7005DB0AF /* RTree.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC2BEBF32C0F5FF7005DB0AF /* RTree.swift */; }; EC2BEBF62C0F600D005DB0AF /* Box.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC2BEBF52C0F600D005DB0AF /* Box.swift */; }; EC2BEBF82C0F601A005DB0AF /* Node.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC2BEBF72C0F601A005DB0AF /* Node.swift */; }; @@ -98,6 +99,7 @@ EC0D14252BF7A8C9009BFE5F /* PenObject.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PenObject.swift; sourceTree = ""; }; EC0D14272BF7BF20009BFE5F /* ContextMenuViewModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextMenuViewModifier.swift; sourceTree = ""; }; EC1B783C2BFA0AC9005A34E2 /* Toolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Toolbar.swift; sourceTree = ""; }; + EC2106AC2C10C2A700FBE27C /* AnyStroke.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnyStroke.swift; sourceTree = ""; }; EC2BEBF32C0F5FF7005DB0AF /* RTree.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RTree.swift; sourceTree = ""; }; EC2BEBF52C0F600D005DB0AF /* Box.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Box.swift; sourceTree = ""; }; EC2BEBF72C0F601A005DB0AF /* Node.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Node.swift; sourceTree = ""; }; @@ -653,6 +655,7 @@ ECE883B92C009DCA0045C53D /* Core */ = { isa = PBXGroup; children = ( + EC2106AC2C10C2A700FBE27C /* AnyStroke.swift */, ECE883BE2C00AB440045C53D /* Stroke.swift */, ECA738D32BE60F9100A4542E /* StrokeGenerator.swift */, ECE883C02C00C9CB0045C53D /* StrokeStyle.swift */, @@ -834,6 +837,7 @@ EC2BEBF82C0F601A005DB0AF /* Node.swift in Sources */, ECA738A02BE601E400A4542E /* ViewPortVertex.swift in Sources */, EC0D14282BF7BF20009BFE5F /* ContextMenuViewModifier.swift in Sources */, + EC2106AD2C10C2A700FBE27C /* AnyStroke.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 10fca2c..8b4bb61 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 object: GraphicContextObject? var currentStroke: (any Stroke)? @@ -41,7 +41,7 @@ final class GraphicContext: @unchecked Sendable { switch event { case .stroke(let stroke): guard let _stroke = stroke.stroke(as: PenStroke.self) else { return } - let deletedStroke = tree.remove(_stroke, in: _stroke.strokeBox) + let deletedStroke = tree.remove(_stroke.anyStroke, in: _stroke.strokeBox) withPersistence(\.backgroundContext) { [stroke = deletedStroke] context in stroke?.stroke(as: PenStroke.self)?.object?.graphicContext = nil try context.saveIfNeeded() @@ -55,7 +55,7 @@ final class GraphicContext: @unchecked Sendable { switch event { case .stroke(let stroke): if let stroke = stroke.stroke(as: PenStroke.self) { - tree.insert(stroke, in: stroke.strokeBox) + tree.insert(stroke.anyStroke, in: stroke.strokeBox) } withPersistence(\.backgroundContext) { [weak self, stroke] context in stroke.stroke(as: PenStroke.self)?.object?.graphicContext = self?.object @@ -74,7 +74,7 @@ extension GraphicContext { object.strokes.forEach { stroke in guard let stroke = stroke as? StrokeObject else { return } let _stroke = PenStroke(object: stroke) - tree.insert(_stroke, in: _stroke.strokeBox) + tree.insert(_stroke.anyStroke, in: _stroke.strokeBox) if _stroke.isVisible(in: bounds) { let id = stroke.objectID queue.addOperation { @@ -97,9 +97,9 @@ extension GraphicContext { } func loadQuads(_ bounds: CGRect) { - for stroke in self.tree.search(box: bounds.box) { - guard stroke.isEmpty else { continue } - stroke.stroke(as: PenStroke.self)?.loadQuads() + for _stroke in self.tree.search(box: bounds.box) { + guard let stroke = _stroke.stroke(as: PenStroke.self), stroke.isEmpty else { continue } + stroke.loadQuads() } } } @@ -159,7 +159,7 @@ extension GraphicContext { func endStroke(at point: CGPoint) { guard currentPoint != nil, let currentStroke = currentStroke?.stroke(as: PenStroke.self) else { return } currentStroke.finish(at: point) - tree.insert(currentStroke, in: currentStroke.strokeBox) + tree.insert(currentStroke.anyStroke, in: currentStroke.strokeBox) withPersistence(\.backgroundContext) { [currentStroke] context in guard let stroke = currentStroke.stroke(as: PenStroke.self) else { return } stroke.object?.bounds = stroke.bounds @@ -175,7 +175,7 @@ extension GraphicContext { func cancelStroke() { if !tree.isEmpty, let stroke = currentStroke?.stroke(as: PenStroke.self) { - let _stroke = tree.remove(stroke, in: stroke.strokeBox) + let _stroke = tree.remove(stroke.anyStroke, in: stroke.strokeBox) withPersistence(\.backgroundContext) { [graphicContext = object, _stroke] context in if let stroke = _stroke?.stroke(as: PenStroke.self)?.object { graphicContext?.strokes.remove(stroke) diff --git a/Memola/Canvas/Geometries/Stroke/Core/AnyStroke.swift b/Memola/Canvas/Geometries/Stroke/Core/AnyStroke.swift new file mode 100644 index 0000000..c80810a --- /dev/null +++ b/Memola/Canvas/Geometries/Stroke/Core/AnyStroke.swift @@ -0,0 +1,28 @@ +// +// AnyStroke.swift +// Memola +// +// Created by Dscyre Scotti on 6/5/24. +// + +import Foundation + +struct AnyStroke: Equatable, Comparable { + var value: any Stroke + + init(_ value: any Stroke) { + self.value = value + } + + static func == (lhs: AnyStroke, rhs: AnyStroke) -> Bool { + lhs.value.id == rhs.value.id + } + + static func < (lhs: AnyStroke, rhs: AnyStroke) -> Bool { + lhs.value.createdAt < rhs.value.createdAt + } + + func stroke(as type: S.Type) -> S? { + value.stroke(as: type) + } +} diff --git a/Memola/Canvas/Geometries/Stroke/Core/Stroke.swift b/Memola/Canvas/Geometries/Stroke/Core/Stroke.swift index f1d959e..6eb7526 100644 --- a/Memola/Canvas/Geometries/Stroke/Core/Stroke.swift +++ b/Memola/Canvas/Geometries/Stroke/Core/Stroke.swift @@ -121,4 +121,8 @@ extension Stroke { func stroke(as type: S.Type) -> S? { self as? S } + + var anyStroke: AnyStroke { + AnyStroke(self) + } } diff --git a/Memola/Canvas/RenderPasses/GraphicRenderPass.swift b/Memola/Canvas/RenderPasses/GraphicRenderPass.swift index 6e34684..8e5bf1e 100644 --- a/Memola/Canvas/RenderPasses/GraphicRenderPass.swift +++ b/Memola/Canvas/RenderPasses/GraphicRenderPass.swift @@ -45,9 +45,8 @@ class GraphicRenderPass: RenderPass { let graphicContext = canvas.graphicContext if renderer.redrawsGraphicRender { canvas.setGraphicRenderType(.finished) - let strokes = graphicContext.tree.search(box: canvas.bounds.box) - print(strokes.count) - for stroke in strokes { + for _stroke in graphicContext.tree.search(box: canvas.bounds.box) { + let stroke = _stroke.value if graphicContext.previousStroke === stroke || graphicContext.currentStroke === stroke { continue }