diff --git a/Compositor/CompositorApp.swift b/Compositor/CompositorApp.swift index 0c171105a..2a4c372ac 100644 --- a/Compositor/CompositorApp.swift +++ b/Compositor/CompositorApp.swift @@ -333,6 +333,18 @@ struct CompositorApp: App { .disabled(!session.canTransform) Button("Flip Layer Vertical") { session.flipLayers(horizontally: false) } .disabled(!session.canTransform) + Menu("Align") { + ForEach(LayerAlignment.allCases, id: \.self) { alignment in + Button(alignment.rawValue) { session.alignLayers(alignment) } + } + } + .disabled(!session.canAlignLayers) + Menu("Distribute") { + ForEach(LayerDistribution.allCases, id: \.self) { distribution in + Button(distribution.rawValue) { session.distributeLayers(distribution) } + } + } + .disabled(!session.canDistributeLayers) } Divider() Button(session.selectedEffect != nil ? "Delete " + session.selectedEffect!.kind.rawValue : session.isMaskSelected && session.activeLayer?.mask != nil ? "Delete Layer Mask" : session.selectedLayerIDs.count > 1 ? "Delete Layers" : "Delete Layer") { diff --git a/Compositor/Document/LayerAlign.swift b/Compositor/Document/LayerAlign.swift new file mode 100644 index 000000000..3cc06a8bc --- /dev/null +++ b/Compositor/Document/LayerAlign.swift @@ -0,0 +1,166 @@ +import CoreGraphics +import Foundation + +/// The Move tool's Align and Distribute commands, as in Photoshop. +nonisolated enum LayerAlignment: String, CaseIterable, Sendable { + case left = "Left Edges", horizontalCenter = "Horizontal Centers", right = "Right Edges" + case top = "Top Edges", verticalCenter = "Vertical Centers", bottom = "Bottom Edges" + + var symbol: String { + switch self { + case .left: return "align.horizontal.left" + case .horizontalCenter: return "align.horizontal.center" + case .right: return "align.horizontal.right" + case .top: return "align.vertical.top" + case .verticalCenter: return "align.vertical.center" + case .bottom: return "align.vertical.bottom" + } + } + + /// How far `box` moves to line up with `target`. + func offset(of box: CGRect, to target: CGRect) -> CGSize { + switch self { + case .left: return CGSize(width: target.minX - box.minX, height: 0) + case .horizontalCenter: return CGSize(width: target.midX - box.midX, height: 0) + case .right: return CGSize(width: target.maxX - box.maxX, height: 0) + case .top: return CGSize(width: 0, height: target.minY - box.minY) + case .verticalCenter: return CGSize(width: 0, height: target.midY - box.midY) + case .bottom: return CGSize(width: 0, height: target.maxY - box.maxY) + } + } +} + +nonisolated enum LayerDistribution: String, CaseIterable, Sendable { + case horizontalCenters = "Horizontal Centers", verticalCenters = "Vertical Centers" + case horizontalSpacing = "Horizontal Spacing", verticalSpacing = "Vertical Spacing" + + var symbol: String { + switch self { + case .horizontalCenters: return "distribute.horizontal.center" + case .verticalCenters: return "distribute.vertical.center" + case .horizontalSpacing: return "distribute.horizontal.fill" + case .verticalSpacing: return "distribute.vertical.fill" + } + } + var isHorizontal: Bool { self == .horizontalCenters || self == .horizontalSpacing } + + /// How far each of `boxes` moves, in their order. The outermost two stay put and the rest are spread evenly + /// between them: their middles, or the gaps between them. + func offsets(of boxes: [CGRect]) -> [CGSize] { + guard boxes.count > 2 else { return boxes.map { _ in .zero } } + let start = { (box: CGRect) in isHorizontal ? box.minX : box.minY } + let length = { (box: CGRect) in isHorizontal ? box.width : box.height } + let order = boxes.indices.sorted { start(boxes[$0]) + length(boxes[$0]) / 2 < start(boxes[$1]) + length(boxes[$1]) / 2 } + let first = boxes[order.first!], last = boxes[order.last!] + var offsets = boxes.map { _ in CGSize.zero } + let steps = CGFloat(order.count - 1) + switch self { + case .horizontalCenters, .verticalCenters: + let from = start(first) + length(first) / 2, to = start(last) + length(last) / 2 + for (rank, index) in order.enumerated() { + let middle = from + (to - from) * CGFloat(rank) / steps + let delta = middle - (start(boxes[index]) + length(boxes[index]) / 2) + offsets[index] = isHorizontal ? CGSize(width: delta, height: 0) : CGSize(width: 0, height: delta) + } + case .horizontalSpacing, .verticalSpacing: + let span = start(last) + length(last) - start(first) + let gap = (span - order.reduce(0) { $0 + length(boxes[$1]) }) / steps + var position = start(first) + for index in order { + let delta = position - start(boxes[index]) + offsets[index] = isHorizontal ? CGSize(width: delta, height: 0) : CGSize(width: 0, height: delta) + position += length(boxes[index]) + gap + } + } + return offsets + } +} + +extension EditorSession { + /// What Align and Distribute move: each selected layer on its own, and each selected folder's contents as one, + /// with the upright box around them. A layer inside a selected folder moves with the folder. + var alignmentItems: [(members: [ImageLayer], box: CGRect)] { + guard let document, canEditLayers else { return [] } + let visible = document.effectiveVisibleIDs + let parents = Dictionary(uniqueKeysWithValues: document.layers.map { ($0.id, $0.parentID) }) + func selectedAncestor(of id: UUID) -> UUID? { + var current = parents[id] ?? nil + for _ in 0..<64 { + guard let parent = current else { return nil } + if selectedLayerIDs.contains(parent) { return parent } + current = parents[parent] ?? nil + } + return nil + } + // Each moving layer, under the outermost selected folder above it, or itself. + var groups: [UUID: [ImageLayer]] = [:] + var order: [UUID] = [] + for layer in document.layers where layer.asset != nil && !layer.isGroup && visible.contains(layer.id) { + var owner = selectedLayerIDs.contains(layer.id) ? layer.id : nil + var ancestor = selectedAncestor(of: layer.id) + while let folder = ancestor { owner = folder; ancestor = selectedAncestor(of: folder) } + guard let owner else { continue } + if groups[owner] == nil { order.append(owner) } + groups[owner, default: []].append(layer) + } + return order.compactMap { owner in + guard let members = groups[owner] else { return nil } + let box = members.map { layer in + let points = DistortWarp.corners(of: layer.transform) + let xs = points.map(\.x), ys = points.map(\.y) + return CGRect(x: xs.min()!, y: ys.min()!, width: xs.max()! - xs.min()!, height: ys.max()! - ys.min()!) + }.reduce(CGRect.null) { $0.union($1) } + return (members, box) + } + } + + /// Align lines things up with the selection when there is one, with the canvas when one thing is selected, and + /// otherwise with the box around them all. + /// Cheap enough for menus and buttons to ask on every redraw; the commands themselves find what moves. + var canAlignLayers: Bool { canEditLayers && !selectedLayerIDs.isEmpty } + var canDistributeLayers: Bool { canEditLayers && selectedLayerIDs.count > 2 } + + func alignLayers(_ alignment: LayerAlignment) { + commitTransform() + let items = alignmentItems + guard let document, !items.isEmpty else { return } + let target: CGRect + if let selection, !selection.isEmpty { target = selection.path.boundingBoxOfPath } + else if items.count == 1 { target = CGRect(origin: .zero, size: document.size) } + else { target = items.map(\.box).reduce(CGRect.null) { $0.union($1) } } + move(items, by: items.map { alignment.offset(of: $0.box, to: target) }, name: "Align " + alignment.rawValue) + } + + func distributeLayers(_ distribution: LayerDistribution) { + commitTransform() + let items = alignmentItems + guard items.count > 2 else { return } + move(items, by: distribution.offsets(of: items.map(\.box)), name: "Distribute " + distribution.rawValue) + } + + /// Moves each item's layers by its offset, rounded to whole pixels so nothing lands between them and has to be + /// resampled, as one undo step. Masks follow their link, as they do in a move. + private func move(_ items: [(members: [ImageLayer], box: CGRect)], by offsets: [CGSize], name: String) { + var moves: [UUID: CGSize] = [:] + for (item, offset) in zip(items, offsets) { + let whole = CGSize(width: offset.width.rounded(), height: offset.height.rounded()) + guard whole != .zero else { continue } + for layer in item.members { moves[layer.id] = whole } + } + guard !moves.isEmpty, let document else { return } + finishOpacityEdit() + beginEdit(name) + for index in document.layers.indices { + let layer = document.layers[index] + guard let offset = moves[layer.id] else { continue } + var moved = layer.transform + moved.origin.x += offset.width + moved.origin.y += offset.height + if let mask = layer.mask { + self.document?.layers[index].mask?.placement = mask.placement(movingLayer: layer.transform, to: moved) + } + self.document?.layers[index].transform = moved + } + endEdit() + } +} \ No newline at end of file diff --git a/Compositor/UI/TransformInspector.swift b/Compositor/UI/TransformInspector.swift index 79a4e7e99..9655dbb3f 100644 --- a/Compositor/UI/TransformInspector.swift +++ b/Compositor/UI/TransformInspector.swift @@ -41,6 +41,21 @@ struct TransformInspector: View { }.frame(width: 170) Button("Flip H") { change { $0.flipX.toggle() } } Button("Flip V") { change { $0.flipY.toggle() } } + Divider().frame(height: 16) + // Align to the selection, the canvas with one thing selected, or else to each other. + HStack(spacing: 2) { + ForEach(LayerAlignment.allCases, id: \.self) { alignment in + Button { session.alignLayers(alignment) } label: { Image(systemName: alignment.symbol) } + .help("Align " + alignment.rawValue).accessibilityLabel("Align " + alignment.rawValue) + } + }.buttonStyle(.borderless).disabled(!session.canAlignLayers) + HStack(spacing: 2) { + ForEach(LayerDistribution.allCases, id: \.self) { distribution in + Button { session.distributeLayers(distribution) } label: { Image(systemName: distribution.symbol) } + .help("Distribute " + distribution.rawValue + " (three or more layers)") + .accessibilityLabel("Distribute " + distribution.rawValue) + } + }.buttonStyle(.borderless).disabled(!session.canDistributeLayers) // Numbers describe an ordinary transform; while distorted, the handles are the controls. }.disabled((!session.canTransform && session.transformEdit == nil) || session.transformEdit?.corners != nil) diff --git a/CompositorTests/LayerAlignTests.swift b/CompositorTests/LayerAlignTests.swift new file mode 100644 index 000000000..64f98eb6f --- /dev/null +++ b/CompositorTests/LayerAlignTests.swift @@ -0,0 +1,94 @@ +import AppKit +import Testing +@testable import Compositor + +@MainActor +struct LayerAlignTests { + /// A 400 × 300 canvas with a solid layer for each of `rects`, bottom to top. + private func session(_ rects: [CGRect]) throws -> (EditorSession, [UUID]) { + let session = EditorSession() + session.createDocument(width: 400, height: 300) + var ids: [UUID] = [] + for rect in rects { + let context = try BrushRaster.context(width: Int(rect.width), height: Int(rect.height), mask: false) + context.setFillColor(CGColor(srgbRed: 1, green: 0, blue: 0, alpha: 1)) + context.fill(CGRect(origin: .zero, size: rect.size)) + let image = try #require(context.makeImage()) + session.insert(ImportedImage(image: image, thumbnail: image, name: "Box"), centeredAt: CGPoint(x: rect.midX, y: rect.midY)) + ids.append(try #require(session.activeLayerID)) + } + return (session, ids) + } + + private func origins(_ session: EditorSession, _ ids: [UUID]) -> [CGPoint] { + ids.compactMap { id in session.document?.layers.first { $0.id == id }?.transform.origin } + } + + @Test func severalLayersAlignToEachOther() throws { + let (session, ids) = try session([CGRect(x: 10, y: 20, width: 40, height: 30), + CGRect(x: 100, y: 60, width: 60, height: 20), + CGRect(x: 200, y: 10, width: 20, height: 50)]) + session.selectedLayerIDs = Set(ids) + session.alignLayers(.left) + #expect(origins(session, ids).map(\.x) == [10, 10, 10]) + session.alignLayers(.bottom) + #expect(origins(session, ids).map(\.y) == [50, 60, 30]) + session.undo() + #expect(origins(session, ids).map(\.y) == [20, 60, 10]) + } + + @Test func oneLayerAlignsToTheCanvasAndTheSelectionComesFirst() throws { + let (session, ids) = try session([CGRect(x: 10, y: 10, width: 101, height: 60)]) + session.selectedLayerIDs = Set(ids) + session.alignLayers(.horizontalCenter) + // 400 − 101 leaves an odd 299 to share: the layer lands on a whole pixel, not between two. + #expect(origins(session, ids).first?.x == 150) + session.alignLayers(.verticalCenter) + #expect(origins(session, ids).first?.y == 120) + session.document?.selection = DocumentSelection(path: CGPath(rect: CGRect(x: 300, y: 0, width: 50, height: 50), transform: nil)) + session.alignLayers(.right) + #expect(origins(session, ids).first?.x == 249) + } + + @Test func aSelectedFolderMovesAsOne() throws { + let (session, ids) = try session([CGRect(x: 50, y: 10, width: 20, height: 20), + CGRect(x: 90, y: 40, width: 20, height: 20), + CGRect(x: 200, y: 100, width: 20, height: 20)]) + session.selectedLayerIDs = [ids[0], ids[1]] + session.groupSelectedLayers() + let folder = try #require(session.activeLayerID) + session.selectedLayerIDs = [folder, ids[2]] + session.alignLayers(.left) + // The folder's box already starts furthest left; its layers keep their spacing, and the third joins them. + #expect(origins(session, ids).map(\.x) == [50, 90, 50]) + } + + @Test func distributeSpreadsMiddlesOrGapsEvenly() { + let boxes = [CGRect(x: 0, y: 0, width: 10, height: 10), CGRect(x: 60, y: 0, width: 30, height: 10), + CGRect(x: 100, y: 0, width: 10, height: 10)] + // Middles at 5, 75 and 105: the middle one moves to 55. + #expect(LayerDistribution.horizontalCenters.offsets(of: boxes).map(\.width) == [0, -20, 0]) + // 110 across, 50 of it boxes: gaps of 30 put the middle one at 40. + #expect(LayerDistribution.horizontalSpacing.offsets(of: boxes).map(\.width) == [0, -20, 0]) + let uneven = [CGRect(x: 0, y: 0, width: 10, height: 10), CGRect(x: 0, y: 70, width: 10, height: 10), + CGRect(x: 0, y: 20, width: 10, height: 40), CGRect(x: 0, y: 100, width: 10, height: 10)] + // Order follows position, not the list: gaps of (110 − 70) / 3. + let gap: CGFloat = 40 / 3 + let spaced = LayerDistribution.verticalSpacing.offsets(of: uneven).map(\.height) + #expect(abs(spaced[2] - (10 + gap - 20)) < 0.001) + #expect(abs(spaced[1] - (10 + gap + 40 + gap - 70)) < 0.001) + #expect(spaced[0] == 0 && spaced[3] == 0) + } + + @Test func distributeNeedsThreeAndMovesWholePixels() throws { + let (session, ids) = try session([CGRect(x: 0, y: 0, width: 10, height: 10), + CGRect(x: 50, y: 0, width: 10, height: 10), + CGRect(x: 101, y: 0, width: 10, height: 10)]) + session.selectedLayerIDs = [ids[0], ids[1]] + session.distributeLayers(.horizontalCenters) + #expect(origins(session, ids).map(\.x) == [0, 50, 101]) + session.selectedLayerIDs = Set(ids) + session.distributeLayers(.horizontalCenters) + #expect(origins(session, ids).map(\.x) == [0, 51, 101]) + } +} \ No newline at end of file diff --git a/README.md b/README.md index 9540063cf..7057d9171 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,7 @@ brew install --cask robbietilton-compositor - Snapping to canvas and layer edges and centers, with guides - Exact values for position, size, scale and angle, stepped with the arrow keys - Flip Layer and Flip Canvas, horizontal and vertical +- Align and Distribute layers to each other, the selection or the canvas, from the Move tool's bar or the Layer menu ### Selections - Rectangle and Ellipse Marquee, Freehand and Polygonal Lasso, and the Magic tool — Wand selects by color, Object traces whatever you click (Tab switches)