Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions Compositor/CompositorApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down
166 changes: 166 additions & 0 deletions Compositor/Document/LayerAlign.swift
Original file line number Diff line number Diff line change
@@ -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()
}
}
15 changes: 15 additions & 0 deletions Compositor/UI/TransformInspector.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
94 changes: 94 additions & 0 deletions CompositorTests/LayerAlignTests.swift
Original file line number Diff line number Diff line change
@@ -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])
}
}
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down